diff --git a/.claude/settings.json b/.claude/settings.json index ae153a96a..34af6a102 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -74,7 +74,6 @@ "Bash(pnpm -w run:*)", "Bash(pnpm -r list:*)", "Bash(pnpm --filter:*)", - "Bash(pnpm turbo:*)", "Bash(pnpm vitest:*)", "Bash(pnpx vitest:*)", "Bash(pnpx tsx:*)", diff --git a/.craft.yml b/.craft.yml index cc6287a16..ff60543cc 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,7 +1,97 @@ -changelogPolicy: none -preReleaseCommand: bash bin/bump-version.sh -targets: - - name: github - - name: npm - id: "@sentry/mcp-server" - includeNames: /^sentry-mcp-server-\d.*\.tgz$/ +minVersion: '2.29.0' +workspaces: + packages/cli: + changelog: + policy: auto + versioning: + policy: auto + preReleaseCommand: bash -c 'cd packages/cli && node --experimental-strip-types script/bump-version.ts --pre' + postReleaseCommand: bash -c 'cd packages/cli && node --experimental-strip-types script/bump-version.ts --post' + releaseBranchPrefix: release/cli + artifactProvider: + name: github + config: + artifacts: + Build: + - '/^sentry-.*$/' + - 'npm-package' + - 'vercel' + targets: + - name: npm + includeNames: '/^sentry-\d.*\.tgz$/' + - name: github + tagPrefix: 'cli@' + - name: vercel + projectId: prj_wVkOnIwI3eIQbGD1Gw7qS1MqFFIK + - name: registry + apps: + 'app:sentry': + name: 'Sentry CLI' + packageUrl: 'https://www.npmjs.com/package/sentry' + mainDocsUrl: 'https://cli.sentry.dev' + urlTemplate: 'https://github.com/getsentry/sentry-mcp/releases/download/cli@{{version}}/{{file}}' + includeNames: '/^sentry-.*\.gz$/' + checksums: + - algorithm: sha256 + format: hex + - name: brew + tap: getsentry/tools + formula: sentry + path: Formula + includeNames: '/^sentry-(darwin|linux)-(arm64|x64)\.gz$/' + template: | + class Sentry < Formula + desc "Sentry command-line tool for error monitoring and debugging" + homepage "https://cli.sentry.dev" + version "{{version}}" + license "FSL-1.1-Apache-2.0" + + if OS.mac? + if Hardware::CPU.arm? + url "https://github.com/getsentry/sentry-mcp/releases/download/cli@{{version}}/sentry-darwin-arm64.gz" + sha256 "{{checksums.sentry-darwin-arm64__gz}}" + elsif Hardware::CPU.intel? + url "https://github.com/getsentry/sentry-mcp/releases/download/cli@{{version}}/sentry-darwin-x64.gz" + sha256 "{{checksums.sentry-darwin-x64__gz}}" + else + raise "Unsupported macOS CPU architecture: #{Hardware::CPU.type}" + end + elsif OS.linux? + if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? + url "https://github.com/getsentry/sentry-mcp/releases/download/cli@{{version}}/sentry-linux-arm64.gz" + sha256 "{{checksums.sentry-linux-arm64__gz}}" + elsif Hardware::CPU.intel? && Hardware::CPU.is_64_bit? + url "https://github.com/getsentry/sentry-mcp/releases/download/cli@{{version}}/sentry-linux-x64.gz" + sha256 "{{checksums.sentry-linux-x64__gz}}" + else + raise "Unsupported Linux CPU architecture: #{Hardware::CPU.type} (only 64-bit arm and x86_64 are supported)" + end + else + raise "Unsupported operating system" + end + + def install + bin.install Dir["sentry-*"].first => "sentry" + end + + def post_install + system bin/"sentry", "cli", "setup", "--method", "brew", "--no-modify-path" + end + + test do + assert_match version.to_s, shell_output("#{bin}/sentry --version").chomp + end + end + packages/mcp-server: + changelogPolicy: none + preReleaseCommand: >- + bash -c 'set -e; for package in packages/mcp-* packages/smoke-tests; do + (cd "$package" && npm version "$CRAFT_NEW_VERSION" --no-git-tag-version); + done' + releaseBranchPrefix: release/mcp + targets: + - name: github + includeNames: '/^sentry-mcp-server-\d.*\.tgz$/' + - name: npm + id: '@sentry/mcp-server' + includeNames: '/^sentry-mcp-server-\d.*\.tgz$/' diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml index 92fc30ea5..82760906c 100644 --- a/.github/dependency-review-config.yml +++ b/.github/dependency-review-config.yml @@ -1,4 +1,8 @@ # React Router's advisory only affects applications using its unstable RSC APIs. # This application uses the stable browser router and has no RSC integration. +# provider-utils 2.2.8 retains APIs required by ui-utils 1.2.11. Its checked-in +# pnpm patch backports the upstream bounded-response fix, and check:patches +# exercises oversized-body rejection and cancellation against the nested copy. allow-ghsas: - GHSA-qwww-vcr4-c8h2 + - GHSA-866g-f22w-33x8 diff --git a/.github/workflows/cli-build.yml b/.github/workflows/cli-build.yml new file mode 100644 index 000000000..d0e72f6fe --- /dev/null +++ b/.github/workflows/cli-build.yml @@ -0,0 +1,246 @@ +name: Build + +on: + push: + branches: + - main + - release/cli/** + workflow_dispatch: + +concurrency: + group: cli-build-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + packages: write + +env: + SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} + NODE_VERSION_20: "20.20.2" + NODE_VERSION_22: "22.23.1" + NODE_VERSION_24: "24.18.0" + +jobs: + build-binary: + name: Build Binary (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + environment: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/cli/')) && 'production' || '' }} + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-latest + - target: darwin-x64 + os: ubuntu-latest + - target: linux-x64 + os: ubuntu-latest + - target: linux-arm64 + os: ubuntu-latest + - target: windows-x64 + os: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_22 }} + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Setup codesign dependencies + env: + APPLE_CERT_DATA: ${{ secrets.APPLE_CERT_DATA }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + shell: bash + run: | + if [[ "$RUNNER_OS" == "macOS" && "$RUNNER_ARCH" == "ARM64" ]]; then + RCODESIGN_ARCHIVE="apple-codesign-0.29.0-aarch64-apple-darwin.tar.gz" + RCODESIGN_SHA256="d1a532150adaf90048260d76359261aa716abafc45c53c5dc18845029184334a" + elif [[ "$RUNNER_OS" == "macOS" ]]; then + RCODESIGN_ARCHIVE="apple-codesign-0.29.0-x86_64-apple-darwin.tar.gz" + RCODESIGN_SHA256="14ef11bedd51a8d95eafd767939ae96d5900e5a61511bef75bb21db6e7c74140" + else + RCODESIGN_ARCHIVE="apple-codesign-0.29.0-x86_64-unknown-linux-musl.tar.gz" + RCODESIGN_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002" + fi + if [[ "$RUNNER_OS" == "macOS" ]]; then + BASE64_DECODE="-D" + else + BASE64_DECODE="--decode" + fi + curl -fsSL "https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.29.0/${RCODESIGN_ARCHIVE}" -o rcodesign.tar.gz + echo "${RCODESIGN_SHA256} rcodesign.tar.gz" | shasum -a 256 -c + tar -xzf rcodesign.tar.gz --strip-components=1 + sudo mv rcodesign /usr/local/bin/rcodesign + rm rcodesign.tar.gz + if [[ -n "$APPLE_CERT_DATA" ]]; then + echo "$APPLE_CERT_DATA" | base64 "$BASE64_DECODE" > /tmp/certs.p12 + { + echo 'APPLE_CERT_PATH=/tmp/certs.p12' + } >> "$GITHUB_ENV" + fi + if [[ -n "$APPLE_API_KEY" ]]; then + echo "$APPLE_API_KEY" | base64 "$BASE64_DECODE" > /tmp/apple_key.json + jq -r .private_key /tmp/apple_key.json > /tmp/apple_key.pem + { + echo "APPLE_API_KEY_ISSUER_ID=$(jq -r .issuer_id /tmp/apple_key.json | tr -d '\n\r')" + echo "APPLE_API_KEY_ID=$(jq -r .key_id /tmp/apple_key.json | tr -d '\n\r')" + echo 'APPLE_API_KEY_P8_PATH=/tmp/apple_key.pem' + echo 'APPLE_API_KEY_PATH=/tmp/apple_key.json' + } >> "$GITHUB_ENV" + fi + - name: Build + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RELEASE_BUILD: "1" + FOSSILIZE_SIGN: ${{ (github.ref_name == 'main' || startsWith(github.ref_name, 'release/cli/')) && 'y' || 'n' }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: pnpm --filter sentry run build -- --target ${{ matrix.target }} + - name: Smoke test + if: matrix.target == 'linux-x64' + env: + SENTRY_AUTH_TOKEN: "" + SENTRY_TOKEN: "" + SENTRY_CONFIG_DIR: ${{ runner.temp }}/.sentry-smoke + run: | + packages/cli/dist-bin/sentry-linux-x64 --help + output=$(packages/cli/dist-bin/sentry-linux-x64 auth status 2>&1) && status=$? || status=$? + test "$status" -eq 10 + printf '%s\n' "$output" | grep -qi 'not authenticated' + - name: Upload binary artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: sentry-${{ matrix.target }} + path: | + packages/cli/dist-bin/sentry-* + !packages/cli/dist-bin/*.gz + - name: Upload compressed artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: sentry-${{ matrix.target }}-gz + path: packages/cli/dist-bin/*.gz + + build-npm: + name: Build npm Package + runs-on: ubuntu-latest + environment: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/cli/')) && 'production' || '' }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_22 }} + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Bundle + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: pnpm --filter sentry run bundle + - name: Smoke test + env: + SENTRY_AUTH_TOKEN: "" + SENTRY_TOKEN: "" + SENTRY_CONFIG_DIR: ${{ runner.temp }}/.sentry-smoke + run: node packages/cli/dist/bin.cjs --help + - name: Pack + working-directory: packages/cli + run: npm pack + - name: Upload npm artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: npm-package + path: packages/cli/*.tgz + + test-npm: + name: Test npm Package (Node ${{ matrix.node }}) + needs: build-npm + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node == 24 && env.NODE_VERSION_24 || matrix.node == 20 && env.NODE_VERSION_20 || env.NODE_VERSION_22 }} + - name: Download npm artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: npm-package + path: packages/cli + - name: Test packaged CLI + env: + SENTRY_AUTH_TOKEN: "" + SENTRY_TOKEN: "" + SENTRY_CONFIG_DIR: ${{ runner.temp }}/.sentry-smoke + run: | + package_file=$(find packages/cli -maxdepth 1 -name 'sentry-*.tgz' -print -quit) + test -n "$package_file" + npm install --ignore-scripts --prefix "$RUNNER_TEMP/npm-smoke" "$GITHUB_WORKSPACE/$package_file" + node "$RUNNER_TEMP/npm-smoke/node_modules/sentry/dist/bin.cjs" --help + + build-docs: + name: Build Docs + needs: build-binary + runs-on: ubuntu-latest + environment: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/cli/')) && 'production' || '' }} + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + PUBLIC_SENTRY_ENVIRONMENT: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/cli/')) && 'production' || 'development' }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_24 }} + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Generate CLI docs + run: pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs + - name: Set docs release + run: | + release=$(node -p 'require("./packages/cli/package.json").version') + { + echo "PUBLIC_SENTRY_RELEASE=$release" + echo "SENTRY_RELEASE=$release" + } >> "$GITHUB_ENV" + - name: Build docs + run: pnpm --filter sentry-cli-docs run build + - name: Download Linux binary + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: sentry-linux-x64 + path: dist-bin + - name: Inject debug IDs and upload sourcemaps + if: github.event_name == 'push' && env.SENTRY_AUTH_TOKEN != '' + env: + SENTRY_ORG: sentry + SENTRY_PROJECT: cli-website + run: | + chmod +x dist-bin/sentry-linux-x64 + ./dist-bin/sentry-linux-x64 sourcemap inject apps/cli-docs/dist/ + # shellcheck disable=SC2088 + ./dist-bin/sentry-linux-x64 sourcemap upload apps/cli-docs/dist/ \ + --release "$PUBLIC_SENTRY_RELEASE" \ + --url-prefix "~/" + - name: Remove sourcemaps + run: find apps/cli-docs/dist -name '*.map' -delete + - name: Package docs + run: | + output_dir="$RUNNER_TEMP/vercel-output" + rm -rf "$output_dir" + mkdir -p "$output_dir/.vercel/output/static" + cp -R apps/cli-docs/dist/. "$output_dir/.vercel/output/static/" + printf '%s\n' '{"version":3}' > "$output_dir/.vercel/output/config.json" + (cd "$output_dir" && zip -qr "$GITHUB_WORKSPACE/vercel.zip" .vercel) + - name: Upload docs artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: vercel + path: vercel.zip diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 28e7eafd7..de4296c77 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,7 +17,9 @@ jobs: deploy: name: Deploy to Cloudflare runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + # Keep the production Worker unchanged while the CLI/docs import lands. + # Restore deployments only through a separately reviewed workflow change. + if: ${{ false }} steps: - uses: actions/checkout@v4 @@ -36,7 +38,7 @@ jobs: - name: Get pnpm store directory shell: bash run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" - uses: actions/cache@v4 name: Setup pnpm cache @@ -51,7 +53,7 @@ jobs: # === BUILD AND DEPLOY CANARY WORKER === - name: Build - run: pnpm -w run build + run: pnpm --filter '@sentry/mcp-cloudflare...' run build env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index 578edcf7c..6d54abbab 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -5,13 +5,13 @@ on: push: branches: [main] paths: - - "packages/mcp-core/src/tools*" + - "packages/mcp-core/src/tools/**" - "packages/mcp-server-evals/**" - "packages/mcp-server-mocks/**" - ".github/workflows/eval.yml" pull_request: paths: - - "packages/mcp-core/src/tools*" + - "packages/mcp-core/src/tools/**" - "packages/mcp-server-evals/**" - "packages/mcp-server-mocks/**" - ".github/workflows/eval.yml" @@ -54,7 +54,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run build - run: pnpm build + run: pnpm --filter '@sentry/mcp-server-evals...' --if-present run build - name: Run evals run: pnpm eval:ci evals diff --git a/.github/workflows/mcp-registry.yml b/.github/workflows/mcp-registry.yml index 5b5abe3ac..4cff8e1c3 100644 --- a/.github/workflows/mcp-registry.yml +++ b/.github/workflows/mcp-registry.yml @@ -24,8 +24,8 @@ concurrency: jobs: publish: if: >- - github.repository == 'getsentry/sentry-mcp' && - ((github.event_name == 'release' && !github.event.release.prerelease) || + (github.repository == 'getsentry/sentry-mcp' || github.repository == 'getsentry/toolkit') && + ((github.event_name == 'release' && !github.event.release.prerelease && !startsWith(github.event.release.tag_name, 'cli@')) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/mcp-server-package.yml b/.github/workflows/mcp-server-package.yml index f3ca47fc9..93dc4da2f 100644 --- a/.github/workflows/mcp-server-package.yml +++ b/.github/workflows/mcp-server-package.yml @@ -8,11 +8,11 @@ on: - ".github/workflows/mcp-server-package.yml" - "packages/mcp-core/**" - "packages/mcp-server/**" + - "packages/mcp-server-mocks/**" - "packages/mcp-server-tsconfig/**" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" - - "turbo.json" permissions: contents: read @@ -52,11 +52,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build package dependencies - run: pnpm --filter @sentry/mcp-core build - - - name: Build stdio package - run: pnpm --filter @sentry/mcp-server build + - name: Build stdio package and dependencies + run: pnpm --filter '@sentry/mcp-server...' --if-present run build - name: Pack stdio package run: pnpm --dir packages/mcp-server pack --pack-destination "$RUNNER_TEMP" diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index 19afd1def..b60c43073 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -2,7 +2,7 @@ name: Post-merge tasks on: push: - branches: ["main", "release/*"] + branches: ["main", "release/*", "release/mcp/**"] workflow_dispatch: jobs: @@ -26,7 +26,7 @@ jobs: run: pnpm install - name: Build - run: pnpm build + run: pnpm --filter '@sentry/mcp-server...' --if-present run build - name: Run linter run: pnpm lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8a547203..2cd738c77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,14 @@ name: Release on: workflow_dispatch: inputs: + product: + description: Product to release + required: true + type: choice + default: mcp + options: + - cli + - mcp bump: description: Version bump type required: true @@ -31,13 +39,13 @@ jobs: private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" @@ -46,19 +54,46 @@ jobs: with: run_install: false + - name: Resolve release workspace + id: release + env: + PRODUCT: ${{ inputs.product }} + run: | + case "$PRODUCT" in + cli) + workspace=packages/cli + package_path=packages/cli + ;; + mcp) + workspace=packages/mcp-server + package_path=packages/mcp-server + ;; + *) + echo "::error::Unsupported product: $PRODUCT" + exit 1 + ;; + esac + + { + echo "workspace=$workspace" + echo "package_path=$package_path" + } >> "$GITHUB_OUTPUT" + - name: Calculate version id: version env: BUMP: ${{ inputs.bump }} + PACKAGE_PATH: ${{ steps.release.outputs.package_path }} run: | - CURRENT=$(node -p "require('./packages/mcp-server/package.json').version") - NEW=$(npx semver -i "$BUMP" "$CURRENT") + CURRENT=$(node -p "require('./$PACKAGE_PATH/package.json').version") + NEW=$(npm exec --yes --package=semver@7.7.2 -- semver -i "$BUMP" "$CURRENT") echo "version=$NEW" >> "$GITHUB_OUTPUT" - name: Prepare release - uses: getsentry/action-prepare-release@v1 + uses: getsentry/craft@25028d0646040cc0a669ae3314cbf884f4c4347a # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: + workspace: ${{ steps.release.outputs.workspace }} version: ${{ steps.version.outputs.version }} force: ${{ inputs.force && 'true' || '' }} diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 519fda3eb..84147a204 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -8,6 +8,16 @@ on: push: branches: [main] pull_request: + paths: + - ".github/workflows/smoke-tests.yml" + - "packages/mcp-cloudflare/**" + - "packages/mcp-core/**" + - "packages/mcp-server-mocks/**" + - "packages/mcp-server-tsconfig/**" + - "packages/smoke-tests/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" jobs: smoke-tests: @@ -45,7 +55,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build - run: pnpm build + run: pnpm --filter '@sentry/mcp-cloudflare...' --if-present run build - name: Start local dev server working-directory: packages/mcp-cloudflare diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08c9fb1c5..76d92d69d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,63 +4,276 @@ on: push: branches: [main] pull_request: + merge_group: + +permissions: + contents: read + +env: + NODE_VERSION_20: "20.20.2" + NODE_VERSION_22: "22.23.1" + NODE_VERSION_24: "24.18.0" jobs: - test: + discover-projects: + name: Discover projects runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.projects.outputs.matrix }} + count: ${{ steps.projects.outputs.count }} + runtime-matrix: ${{ steps.projects.outputs.runtime-matrix }} + runtime-count: ${{ steps.projects.outputs.runtime-count }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + fetch-depth: 0 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_22 }} + - name: Build project matrix + id: projects + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + matrix="$(node scripts/ci-projects.mjs --event "$GITHUB_EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA")" + count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).include.length))' "$matrix")" + runtime_matrix="$(node -e 'const { include } = JSON.parse(process.argv[1]); process.stdout.write(JSON.stringify({ include: include.filter((project) => project.npmRuntime !== "") }))' "$matrix")" + runtime_count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).include.length))' "$runtime_matrix")" + { + echo "matrix=$matrix" + echo "count=$count" + echo "runtime-matrix=$runtime_matrix" + echo "runtime-count=$runtime_count" + } >> "$GITHUB_OUTPUT" - - name: Setup Node.js - uses: actions/setup-node@v4 + install: + name: Install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: "22" + node-version: ${{ env.NODE_VERSION_22 }} + cache: pnpm + - run: pnpm install --frozen-lockfile - # pnpm/action-setup@v4 - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda - name: Install pnpm + quality: + name: Repository quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - run_install: false + node-version: ${{ env.NODE_VERSION_22 }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test:ci-projects + - run: pnpm run docs:check + - run: pnpm run lint + - run: pnpm run check:generated + - name: Check generated files + run: git diff --exit-code - - name: Get pnpm store directory - shell: bash + project: + name: ${{ matrix.name }} + needs: discover-projects + if: needs.discover-projects.outputs.count != '0' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover-projects.outputs.matrix) }} + env: + BRAINTRUST_API_KEY: ci-test-key + GITHUB_TOKEN: ci-test-token + HONEYCOMB_API_KEY: ci-test-key + SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} + SENTRY_OAUTH_CLIENT_SECRET: ci-test-secret + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_22 }} + cache: pnpm + - run: cp .env.example .env.local + - run: pnpm install --frozen-lockfile + - name: Build project dependencies + env: + PROJECT_NAME: ${{ matrix.name }} + run: pnpm --filter "${PROJECT_NAME}^..." --if-present run build + - name: Prepare project + if: matrix.prepare != '' + env: + PROJECT_NAME: ${{ matrix.name }} + CHECK_SCRIPT: ${{ matrix.prepare }} + run: pnpm --filter "$PROJECT_NAME" run "$CHECK_SCRIPT" + - name: Build project + if: matrix.build != '' + env: + PROJECT_NAME: ${{ matrix.name }} + CHECK_SCRIPT: ${{ matrix.build }} + run: pnpm --filter "$PROJECT_NAME" run "$CHECK_SCRIPT" + - name: Lint project + if: matrix.lint != '' + env: + PROJECT_NAME: ${{ matrix.name }} + CHECK_SCRIPT: ${{ matrix.lint }} + run: pnpm --filter "$PROJECT_NAME" run "$CHECK_SCRIPT" + - name: Typecheck project + if: matrix.typecheck != '' + env: + PROJECT_NAME: ${{ matrix.name }} + CHECK_SCRIPT: ${{ matrix.typecheck }} + run: pnpm --filter "$PROJECT_NAME" run "$CHECK_SCRIPT" + - name: Clear stale JUnit + if: matrix.junit != '' + env: + JUNIT_FILE: ${{ matrix.junit }} + run: rm -f "$JUNIT_FILE" + - name: Test project + if: matrix.test != '' + env: + PROJECT_NAME: ${{ matrix.name }} + CHECK_SCRIPT: ${{ matrix.test }} + run: pnpm --filter "$PROJECT_NAME" run "$CHECK_SCRIPT" + - name: Verify JUnit + if: matrix.junit != '' + env: + JUNIT_FILE: ${{ matrix.junit }} run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache + test -s "$JUNIT_FILE" + python3 -c 'import sys, xml.etree.ElementTree as ET; ET.parse(sys.argv[1])' "$JUNIT_FILE" + - name: Check project policies + if: matrix.policy != '' + env: + PROJECT_NAME: ${{ matrix.name }} + CHECK_SCRIPT: ${{ matrix.policy }} + run: pnpm --filter "$PROJECT_NAME" run "$CHECK_SCRIPT" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: matrix.e2e != '' with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Check lockfile is up to date + node-version: ${{ env.NODE_VERSION_24 }} + cache: pnpm + - name: Run E2E tests on Node 24 + if: matrix.e2e != '' + env: + PROJECT_NAME: ${{ matrix.name }} + CHECK_SCRIPT: ${{ matrix.e2e }} + SENTRY_CLI_BINARY: ${{ github.workspace }}/packages/cli/dist-bin/sentry-linux-x64 + run: pnpm --filter "$PROJECT_NAME" run "$CHECK_SCRIPT" + - name: Verify coverage + if: matrix.coverage != '' + env: + COVERAGE_FILE: ${{ matrix.coverage }} + run: test -s "$COVERAGE_FILE" + - name: Check generated files run: | - pnpm install --lockfile-only - git diff --exit-code pnpm-lock.yaml || (echo "pnpm-lock.yaml is out of sync with manifest files. Run 'pnpm install' and commit the result." && exit 1) + git diff --exit-code + [[ -z "$(git status --porcelain --untracked-files=all)" ]] - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run build - run: pnpm build - - - name: Check generated definitions - run: pnpm run check:generated - - - name: Run type checking - run: pnpm -w run tsc - - - name: Run linter - run: pnpm lint - - - name: Run tests - run: pnpm test:ci - - - name: Publish Test Report - uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857 - if: ${{ !cancelled() }} + npm-runtime: + name: NPM package (${{ matrix.name }}) + needs: discover-projects + if: needs.discover-projects.outputs.runtime-count != '0' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover-projects.outputs.runtime-matrix) }} + env: + SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} + SENTRY_CLI_NO_TELEMETRY: "1" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - report_paths: "**/*.junit.xml" - comment: false + node-version: ${{ env.NODE_VERSION_22 }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build package dependencies + env: + PROJECT_NAME: ${{ matrix.name }} + run: pnpm --filter "${PROJECT_NAME}^..." --if-present run build + - name: Build and pack once on Node 22 + env: + BUILD_SCRIPT: ${{ matrix.npmRuntime }} + PROJECT_NAME: ${{ matrix.name }} + run: | + pnpm --filter "$PROJECT_NAME" run "$BUILD_SCRIPT" + mkdir -p "$RUNNER_TEMP/npm-package" + pnpm --filter "$PROJECT_NAME" pack --pack-destination "$RUNNER_TEMP/npm-package" + shopt -s nullglob + packages=("$RUNNER_TEMP/npm-package"/*.tgz) + [[ "${#packages[@]}" == 1 ]] + test -s "${packages[0]}" + [[ "$(stat -c %s "${packages[0]}")" -le 52428800 ]] + mv "${packages[0]}" "$RUNNER_TEMP/npm-package/package.tgz" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_20 }} + - name: Validate packaged runtime on Node 20 + env: + RUNTIME_BIN: ${{ matrix.runtimeBin }} + RUNTIME_PACKAGE: ${{ matrix.name }} + run: bash scripts/validate-npm-runtime.sh + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_22 }} + - name: Validate packaged runtime on Node 22 + env: + RUNTIME_BIN: ${{ matrix.runtimeBin }} + RUNTIME_PACKAGE: ${{ matrix.name }} + run: bash scripts/validate-npm-runtime.sh + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION_24 }} + - name: Validate packaged runtime on Node 24 + env: + RUNTIME_BIN: ${{ matrix.runtimeBin }} + RUNTIME_PACKAGE: ${{ matrix.name }} + run: bash scripts/validate-npm-runtime.sh + + test: + name: test + if: always() + needs: [discover-projects, install, quality, project, npm-runtime] + runs-on: ubuntu-latest + steps: + - name: Require all workspace checks + env: + DISCOVER_RESULT: ${{ needs.discover-projects.result }} + INSTALL_RESULT: ${{ needs.install.result }} + QUALITY_RESULT: ${{ needs.quality.result }} + PROJECT_RESULT: ${{ needs.project.result }} + PROJECT_COUNT: ${{ needs.discover-projects.outputs.count }} + RUNTIME_RESULT: ${{ needs.npm-runtime.result }} + RUNTIME_COUNT: ${{ needs.discover-projects.outputs.runtime-count }} + run: | + [[ "$DISCOVER_RESULT" == success ]] + [[ "$INSTALL_RESULT" == success ]] + [[ "$QUALITY_RESULT" == success ]] + expected=success + if [[ "$PROJECT_COUNT" == 0 ]]; then + expected=skipped + fi + [[ "$PROJECT_RESULT" == "$expected" ]] + expected=success + if [[ "$RUNTIME_COUNT" == 0 ]]; then + expected=skipped + fi + [[ "$RUNTIME_RESULT" == "$expected" ]] diff --git a/.github/workflows/token-cost.yml b/.github/workflows/token-cost.yml index abfc38b7d..40217e299 100644 --- a/.github/workflows/token-cost.yml +++ b/.github/workflows/token-cost.yml @@ -3,7 +3,35 @@ name: Token Cost on: push: branches: [main] + paths: + - ".github/workflows/token-cost.yml" + - "packages/mcp-core/src/tools/**" + - "packages/mcp-core/src/schema.ts" + - "packages/mcp-core/src/constants.ts" + - "packages/mcp-core/src/skills.ts" + - "packages/mcp-core/src/toolDefinitions.json" + - "packages/mcp-core/src/skillDefinitions.json" + - "packages/mcp-core/src/internal/tool-helpers/tool-call-formatting.ts" + - "packages/mcp-core/scripts/measure-token-cost.ts" + - "packages/mcp-core/scripts/generate-definitions.ts" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" pull_request: + paths: + - ".github/workflows/token-cost.yml" + - "packages/mcp-core/src/tools/**" + - "packages/mcp-core/src/schema.ts" + - "packages/mcp-core/src/constants.ts" + - "packages/mcp-core/src/skills.ts" + - "packages/mcp-core/src/toolDefinitions.json" + - "packages/mcp-core/src/skillDefinitions.json" + - "packages/mcp-core/src/internal/tool-helpers/tool-call-formatting.ts" + - "packages/mcp-core/scripts/measure-token-cost.ts" + - "packages/mcp-core/scripts/generate-definitions.ts" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" permissions: contents: read @@ -44,7 +72,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build tool definitions - run: pnpm -w run build + run: pnpm --filter '@sentry/mcp-core...' --if-present run build - name: Measure token cost id: measure diff --git a/.gitignore b/.gitignore index 8365d7f22..7e000454f 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,10 @@ coverage packages/mcp-server/src/toolDefinitions.json packages/mcp-server/src/skillDefinitions.json packages/mcp-server-evals/eval-results.json +apps/cli-docs/.astro +apps/cli-docs/src/content/docs/commands/ +apps/cli-docs/src/content/docs/configuration.md +apps/cli-docs/public/.well-known/skills/index.json # Auto-generated by dotagents — do not commit these files. .agents/.gitignore .warden/ diff --git a/apps/cli-docs/astro.config.mjs b/apps/cli-docs/astro.config.mjs new file mode 100644 index 000000000..8afc67060 --- /dev/null +++ b/apps/cli-docs/astro.config.mjs @@ -0,0 +1,257 @@ +import starlight from "@astrojs/starlight"; +import sentry from "@sentry/astro"; +import sentryStarlightTheme, { + monochromeCodeTheme, + sentryAgentMarkdown, +} from "@sentry/starlight-theme"; +import { defineConfig } from "astro/config"; + +// Allow base path override via environment variable for PR previews +const base = process.env.DOCS_BASE_PATH || "/"; + +export default defineConfig({ + site: "https://cli.sentry.dev", + base, + markdown: { + smartypants: false, + shikiConfig: { + theme: monochromeCodeTheme, + }, + }, + // Generate sourcemaps for Sentry. "hidden" produces .map files without + // adding //# sourceMappingURL comments to the output (the debug IDs + // injected post-build by `sentry sourcemap inject` are used instead). + // + // Astro 6 / Vite 7 reads `sourcemap` from + // `vite.environments.{client,ssr}.build.sourcemap` (Environments API), + // not the legacy top-level `vite.build.sourcemap`. + vite: { + environments: { + client: { build: { sourcemap: "hidden" } }, + ssr: { build: { sourcemap: "hidden" } }, + }, + }, + integrations: [ + sentry({ + project: "cli-website", + org: "sentry", + environment: process.env.PUBLIC_SENTRY_ENVIRONMENT ?? "development", + // Note: @sentry/astro v10 does not support the `release` build-time + // option (todo(v11) in the source). Release is set in Sentry.init() + // via PUBLIC_SENTRY_RELEASE / SENTRY_RELEASE env vars instead. + // + // Disable the plugin's sourcemap upload — it pulls in @sentry/cli + // (20+ MB binary download). We use our own CLI post-build instead + // (see CI workflow: `sentry sourcemap inject` + `sentry sourcemap upload`). + sourceMapsUploadOptions: { enabled: false }, + }), + starlight({ + title: "Sentry CLI", + favicon: "/favicon.svg", + logo: { + // The site is dark-only (the theme maps light + dark to the same dark + // palette), so a single white logo is used for both — a dark-marks + // variant would be invisible on the always-dark header. + src: "./src/assets/logo.svg", + replacesTitle: true, + }, + social: [ + { + icon: "github", + label: "GitHub", + href: "https://github.com/getsentry/cli", + }, + ], + plugins: [sentryStarlightTheme(), sentryAgentMarkdown()], + components: { + PageFrame: "./src/components/PageFrame.astro", + Header: "./src/components/Header.astro", + // The custom landing page should not render the docs theme footer. + Footer: "./src/components/Footer.astro", + PageTitle: "./src/components/PageTitle.astro", + }, + head: [ + // PNG favicon fallback for browsers without SVG-favicon support. + // (Starlight's `favicon` option emits the SVG link; this adds the raster.) + { + tag: "link", + attrs: { + rel: "icon", + href: "/favicon.png", + type: "image/png", + sizes: "256x256", + }, + }, + // Overscroll easter egg - bottom of page, only on /cli route + { + tag: "script", + content: ` + (function() { + let overscrollEl; + let pullDistance = 0; + let touchStartY = 0; + let isAtBottom = false; + + function isLandingPage() { + const path = window.location.pathname; + // Works with both / (prod) and /pr-preview/pr-XX (preview) + return path === '/' || + /^\\/\\_preview\\/pr-(\\d+|main)\\/?$/.test(path); + } + + function checkAtBottom() { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollHeight = document.documentElement.scrollHeight; + const clientHeight = document.documentElement.clientHeight; + return scrollTop + clientHeight >= scrollHeight - 5; + } + + function createOverscrollMessage() { + if (!isLandingPage()) return; + var command = 'curl https://cli.sentry.dev/install -fsS | bash'; + overscrollEl = document.createElement('div'); + overscrollEl.className = 'overscroll-message'; + overscrollEl.innerHTML = 'You made it to the end. Might as well give it a try → ' + command + ''; + document.body.appendChild(overscrollEl); + var codeEl = overscrollEl.querySelector('.overscroll-copy-cmd'); + codeEl.addEventListener('click', function() { + if (!navigator.clipboard) return; + navigator.clipboard.writeText(command).then(function() { + codeEl.textContent = 'copied!'; + setTimeout(function() { codeEl.textContent = command; }, 1500); + }).catch(function() {}); + }); + } + + function updateOverscroll(distance) { + if (!overscrollEl) return; + const clampedDistance = Math.min(Math.max(distance, 0), 50); + const opacity = Math.min(clampedDistance / 15, 1); + const translateY = Math.min(clampedDistance * 2.5, 120); + overscrollEl.style.opacity = opacity; + overscrollEl.style.transform = 'translateX(-50%) translateY(-' + translateY + 'px)'; + } + + function handleTouchStart(e) { + if (!isLandingPage()) return; + touchStartY = e.touches[0].clientY; + isAtBottom = checkAtBottom(); + } + + function handleTouchMove(e) { + if (!isLandingPage() || !isAtBottom) return; + const touchY = e.touches[0].clientY; + pullDistance = touchStartY - touchY; + if (pullDistance > 0 && checkAtBottom()) { + updateOverscroll(pullDistance); + } + } + + function handleTouchEnd() { + pullDistance = 0; + updateOverscroll(0); + } + + function handleWheel(e) { + if (!isLandingPage()) return; + if (checkAtBottom() && e.deltaY > 0) { + pullDistance = Math.min(pullDistance + e.deltaY * 0.8, 50); + updateOverscroll(pullDistance); + clearTimeout(window.overscrollTimeout); + window.overscrollTimeout = setTimeout(function() { + pullDistance = 0; + updateOverscroll(0); + }, 5000); + } else if (e.deltaY < 0) { + clearTimeout(window.overscrollTimeout); + pullDistance = 0; + updateOverscroll(0); + } + } + + document.addEventListener('DOMContentLoaded', function() { + createOverscrollMessage(); + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + document.addEventListener('touchmove', handleTouchMove, { passive: true }); + document.addEventListener('touchend', handleTouchEnd, { passive: true }); + document.addEventListener('wheel', handleWheel, { passive: true }); + }); + })(); + `, + }, + // Add fonts + { + tag: "link", + attrs: { + rel: "preconnect", + href: "https://fonts.googleapis.com", + }, + }, + { + tag: "link", + attrs: { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossorigin: true, + }, + }, + { + tag: "link", + attrs: { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap", + }, + }, + // Plausible analytics — defer keeps it off the critical path + { + tag: "script", + attrs: { + defer: true, + "data-domain": "cli.sentry.dev", + src: "https://plausible.io/js/script.js", + }, + }, + // Open Graph images for social sharing + { + tag: "meta", + attrs: { + property: "og:image", + content: "https://cli.sentry.dev/og-image.png", + }, + }, + { + tag: "meta", + attrs: { + name: "twitter:image", + content: "https://cli.sentry.dev/og-image-twitter.png", + }, + }, + ], + sidebar: [ + { + label: "Getting Started", + items: [ + { label: "Introduction", slug: "" }, + { label: "Installation", slug: "getting-started" }, + { label: "Migrating from v3", slug: "migrating-from-v3" }, + { label: "Self-Hosted", slug: "self-hosted" }, + { label: "Configuration", slug: "configuration" }, + { label: "Library Usage", slug: "library-usage" }, + ], + }, + { + label: "Commands", + items: [{ autogenerate: { directory: "commands" } }], + }, + { + label: "Resources", + items: [ + { label: "Agentic Usage", slug: "agentic-usage" }, + { label: "Contributing", slug: "contributing" }, + ], + }, + ], + customCss: ["./src/styles/cli.css"], + }), + ], +}); diff --git a/apps/cli-docs/package.json b/apps/cli-docs/package.json new file mode 100644 index 000000000..459b0ed96 --- /dev/null +++ b/apps/cli-docs/package.json @@ -0,0 +1,29 @@ +{ + "name": "sentry-cli-docs", + "version": "1.0.0", + "private": true, + "sentryCi": { + "dependencies": ["sentry"] + }, + "scripts": { + "ci:prepare": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs", + "dev": "astro dev", + "build": "astro build", + "test": "node --test test/*.test.mjs", + "preview": "astro preview", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@astrojs/starlight": "^0.41.5", + "@sentry/astro": "^10.38.0", + "@sentry/starlight-theme": "^0.8.0", + "@vercel/analytics": "^2.0.1", + "@vercel/speed-insights": "^2.0.0", + "astro": "^7.1.1", + "sharp": "^0.35.0", + "shiki": "^3.21.0" + }, + "devDependencies": { + "wrangler": "^4.118.0" + } +} diff --git a/apps/cli-docs/public/.well-known/skills/sentry-cli/SKILL.md b/apps/cli-docs/public/.well-known/skills/sentry-cli/SKILL.md new file mode 120000 index 000000000..2aa8fe71c --- /dev/null +++ b/apps/cli-docs/public/.well-known/skills/sentry-cli/SKILL.md @@ -0,0 +1 @@ +../../../../../../packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md \ No newline at end of file diff --git a/apps/cli-docs/public/.well-known/skills/sentry-cli/references b/apps/cli-docs/public/.well-known/skills/sentry-cli/references new file mode 120000 index 000000000..1af90a90f --- /dev/null +++ b/apps/cli-docs/public/.well-known/skills/sentry-cli/references @@ -0,0 +1 @@ +../../../../../../packages/cli/plugins/sentry-cli/skills/sentry-cli/references \ No newline at end of file diff --git a/apps/cli-docs/public/CNAME b/apps/cli-docs/public/CNAME new file mode 100644 index 000000000..47fd68022 --- /dev/null +++ b/apps/cli-docs/public/CNAME @@ -0,0 +1 @@ +cli.sentry.dev diff --git a/apps/cli-docs/public/favicon.png b/apps/cli-docs/public/favicon.png new file mode 100644 index 000000000..05b150b10 Binary files /dev/null and b/apps/cli-docs/public/favicon.png differ diff --git a/apps/cli-docs/public/favicon.svg b/apps/cli-docs/public/favicon.svg new file mode 100644 index 000000000..7ad47f504 --- /dev/null +++ b/apps/cli-docs/public/favicon.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/cli-docs/public/glyph.svg b/apps/cli-docs/public/glyph.svg new file mode 100644 index 000000000..6c930cf79 --- /dev/null +++ b/apps/cli-docs/public/glyph.svg @@ -0,0 +1 @@ + diff --git a/apps/cli-docs/public/install b/apps/cli-docs/public/install new file mode 120000 index 000000000..c6850ca5e --- /dev/null +++ b/apps/cli-docs/public/install @@ -0,0 +1 @@ +../../../packages/cli/install \ No newline at end of file diff --git a/apps/cli-docs/public/og-image-twitter.png b/apps/cli-docs/public/og-image-twitter.png new file mode 100644 index 000000000..7d3f9506e Binary files /dev/null and b/apps/cli-docs/public/og-image-twitter.png differ diff --git a/apps/cli-docs/public/og-image.png b/apps/cli-docs/public/og-image.png new file mode 100644 index 000000000..792a201fd Binary files /dev/null and b/apps/cli-docs/public/og-image.png differ diff --git a/apps/cli-docs/public/wordmark-light.svg b/apps/cli-docs/public/wordmark-light.svg new file mode 100644 index 000000000..e124651b6 --- /dev/null +++ b/apps/cli-docs/public/wordmark-light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/cli-docs/public/wordmark.svg b/apps/cli-docs/public/wordmark.svg new file mode 100644 index 000000000..74e2b4731 --- /dev/null +++ b/apps/cli-docs/public/wordmark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/cli-docs/sentry.client.config.js b/apps/cli-docs/sentry.client.config.js new file mode 100644 index 000000000..48b259b4b --- /dev/null +++ b/apps/cli-docs/sentry.client.config.js @@ -0,0 +1,23 @@ +import * as Sentry from "@sentry/astro"; + +Sentry.init({ + dsn: "https://2aca5fe97c71868bc3aa7fb48620dc39@o1.ingest.us.sentry.io/4510798755856384", + environment: import.meta.env.PUBLIC_SENTRY_ENVIRONMENT ?? "development", + release: import.meta.env.PUBLIC_SENTRY_RELEASE || undefined, + sendDefaultPii: true, + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration(), + ], + enableLogs: true, + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + // Enable in all environments (including development) + enabled: true, + // Uncomment to debug Sentry initialization + // debug: true, +}); + +// Expose globally for inline scripts +window.Sentry = Sentry; diff --git a/apps/cli-docs/sentry.server.config.js b/apps/cli-docs/sentry.server.config.js new file mode 100644 index 000000000..1fc3dca01 --- /dev/null +++ b/apps/cli-docs/sentry.server.config.js @@ -0,0 +1,12 @@ +import * as Sentry from "@sentry/astro"; + +Sentry.init({ + dsn: "https://2aca5fe97c71868bc3aa7fb48620dc39@o1.ingest.us.sentry.io/4510798755856384", + environment: process.env.PUBLIC_SENTRY_ENVIRONMENT ?? "development", + release: process.env.SENTRY_RELEASE || undefined, + sendDefaultPii: true, + enableLogs: true, + tracesSampleRate: 1.0, + // Enable in all environments (including development) + enabled: true, +}); diff --git a/apps/cli-docs/src/assets/bg.png b/apps/cli-docs/src/assets/bg.png new file mode 100644 index 000000000..9af3d0002 Binary files /dev/null and b/apps/cli-docs/src/assets/bg.png differ diff --git a/apps/cli-docs/src/assets/logo.svg b/apps/cli-docs/src/assets/logo.svg new file mode 100644 index 000000000..74e2b4731 --- /dev/null +++ b/apps/cli-docs/src/assets/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/cli-docs/src/assets/section-bg-1.png b/apps/cli-docs/src/assets/section-bg-1.png new file mode 100644 index 000000000..c3540bdaa Binary files /dev/null and b/apps/cli-docs/src/assets/section-bg-1.png differ diff --git a/apps/cli-docs/src/assets/section-bg-2.png b/apps/cli-docs/src/assets/section-bg-2.png new file mode 100644 index 000000000..7b4d21ae4 Binary files /dev/null and b/apps/cli-docs/src/assets/section-bg-2.png differ diff --git a/apps/cli-docs/src/assets/section-bg-3.png b/apps/cli-docs/src/assets/section-bg-3.png new file mode 100644 index 000000000..d71854f3c Binary files /dev/null and b/apps/cli-docs/src/assets/section-bg-3.png differ diff --git a/apps/cli-docs/src/components/FeatureTerminal.astro b/apps/cli-docs/src/components/FeatureTerminal.astro new file mode 100644 index 000000000..753d4b22e --- /dev/null +++ b/apps/cli-docs/src/components/FeatureTerminal.astro @@ -0,0 +1,155 @@ +--- +interface Props { + title?: string; +} + +const { title = "Terminal" } = Astro.props; +--- + +
+
+
+ + + +
+ {title} +
+
+
+ +
+
+ + diff --git a/apps/cli-docs/src/components/FeatureVisual.astro b/apps/cli-docs/src/components/FeatureVisual.astro new file mode 100644 index 000000000..3a9582806 --- /dev/null +++ b/apps/cli-docs/src/components/FeatureVisual.astro @@ -0,0 +1,24 @@ +--- +import { getImage } from 'astro:assets'; + +interface Props { + /** Image import from src/assets/ — processed by Astro's image pipeline */ + image: ImageMetadata; + class?: string; +} + +const { image, class: className } = Astro.props; + +const optimized = await getImage({ + src: image, + format: 'webp', + quality: 80, +}); +--- + +
+ +
diff --git a/apps/cli-docs/src/components/Footer.astro b/apps/cli-docs/src/components/Footer.astro new file mode 100644 index 000000000..4eac5f6a7 --- /dev/null +++ b/apps/cli-docs/src/components/Footer.astro @@ -0,0 +1,7 @@ +--- +import StarlightThemeFooter from "@sentry/starlight-theme/components/Footer.astro"; + +const isHomepage = Astro.locals.starlightRoute.id === ""; +--- + +{!isHomepage && } diff --git a/apps/cli-docs/src/components/Header.astro b/apps/cli-docs/src/components/Header.astro new file mode 100644 index 000000000..cffb606fe --- /dev/null +++ b/apps/cli-docs/src/components/Header.astro @@ -0,0 +1,80 @@ +--- +import config from "virtual:starlight/user-config"; + +import Search from "virtual:starlight/components/Search"; +import SiteTitle from "virtual:starlight/components/SiteTitle"; +import SocialIcons from "virtual:starlight/components/SocialIcons"; + +const isHomepage = Astro.locals.starlightRoute.id === ""; +const shouldRenderSearch = + config.pagefind || + config.components.Search !== "@astrojs/starlight/components/Search.astro"; + +const baseUrl = import.meta.env.BASE_URL; +const base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; +--- + +
+
+ +
+ +
+ {!isHomepage && shouldRenderSearch && } + {isHomepage && Docs} + {!isHomepage && Docs} + +
+
+ + diff --git a/apps/cli-docs/src/components/InstallSelector.astro b/apps/cli-docs/src/components/InstallSelector.astro new file mode 100644 index 000000000..8d9d62991 --- /dev/null +++ b/apps/cli-docs/src/components/InstallSelector.astro @@ -0,0 +1,541 @@ +--- +import { codeToTokens } from 'shiki'; + +interface InstallOption { + label: string; + command: string; +} + +interface Props { + options: InstallOption[]; + defaultIndex?: number; +} + +const { options, defaultIndex = 0 } = Astro.props; + +/** + * Highlights a shell command using Shiki and returns inline HTML. + */ +async function highlightCommand(cmd: string): Promise { + const { tokens } = await codeToTokens(cmd, { + lang: 'bash', + theme: 'github-dark', + }); + + // Convert tokens to inline HTML spans (single line command) + return tokens[0] + .map(token => { + const escaped = token.content + .replace(/&/g, '&') + .replace(//g, '>'); + return `${escaped}`; + }) + .join(''); +} + +// Pre-highlight all options server-side for client-side switching +const highlightedOptions = await Promise.all( + options.map(async (opt) => ({ + label: opt.label, + command: opt.command, + highlighted: await highlightCommand(opt.command), + })) +); + +const defaultOption = highlightedOptions[defaultIndex]; +--- + +
+
+ + +
+ + +
+ + +
+
+ + + + diff --git a/apps/cli-docs/src/components/PackageManagerCode.astro b/apps/cli-docs/src/components/PackageManagerCode.astro new file mode 100644 index 000000000..30d2a47d8 --- /dev/null +++ b/apps/cli-docs/src/components/PackageManagerCode.astro @@ -0,0 +1,232 @@ +--- +interface Props { + npm: string; + pnpm: string; + yarn: string; + bun: string; +} + +const { npm, pnpm, yarn, bun } = Astro.props; +const id = `pm-${Math.random().toString(36).substr(2, 9)}`; +--- + +
+
+
+ + + + +
+
+
+
{npm}
+ + + + +
+
+ + + + diff --git a/apps/cli-docs/src/components/PageFrame.astro b/apps/cli-docs/src/components/PageFrame.astro new file mode 100644 index 000000000..43fc52a37 --- /dev/null +++ b/apps/cli-docs/src/components/PageFrame.astro @@ -0,0 +1,15 @@ +--- +import Default from "@astrojs/starlight/components/PageFrame.astro"; +import Analytics from "@vercel/analytics/astro"; +import SpeedInsights from "@vercel/speed-insights/astro"; +--- + + + + + + + + + + diff --git a/apps/cli-docs/src/components/PageTitle.astro b/apps/cli-docs/src/components/PageTitle.astro new file mode 100644 index 000000000..c592d40d7 --- /dev/null +++ b/apps/cli-docs/src/components/PageTitle.astro @@ -0,0 +1,60 @@ +--- +import type { StarlightRouteData } from '@astrojs/starlight/route-data'; + +const PAGE_TITLE_ID = '_top'; +const { entry, sidebar } = Astro.locals.starlightRoute; + +// Find the parent group for the current page +function findParentGroup(sidebar: StarlightRouteData['sidebar']): string | null { + for (const item of sidebar) { + if (item.type === 'group' && item.entries) { + for (const child of item.entries) { + if (child.type === 'link' && child.isCurrent) { + return item.label; + } + // Check nested groups + if (child.type === 'group' && child.entries) { + for (const nested of child.entries) { + if (nested.type === 'link' && nested.isCurrent) { + return item.label; + } + } + } + } + } + } + return null; +} + +const parentGroup = findParentGroup(sidebar); +const title = entry.data.title; +--- + +
+ {parentGroup && } +

{title}

+
+ + diff --git a/apps/cli-docs/src/components/Terminal.astro b/apps/cli-docs/src/components/Terminal.astro new file mode 100644 index 000000000..e2a694e5c --- /dev/null +++ b/apps/cli-docs/src/components/Terminal.astro @@ -0,0 +1,289 @@ +--- +import { getImage } from 'astro:assets'; +import bgImage from '../assets/bg.png'; + +interface Props { + title?: string; + background?: 'custom'; +} + +const { title = "Terminal", background } = Astro.props; +const bgClass = background ? `bg-${background}` : ''; + +let bgStyle = ''; +if (background) { + const optimized = await getImage({ src: bgImage, format: 'webp', quality: 80 }); + bgStyle = `--bg-image: url('${optimized.src}');`; +} +--- + +
+
+
+
+ + + +
+ {title} +
+
+
+
+ $ + sentry auth +
+
+ ● + Opening browser for authentication... +
+
+ ✓ + Authenticated as john@example.com +
+ +
+ +
+ $ + sentry issue list +
+
Issues from 3 projects:
+
+
╭─────────┬───────┬───────────────┬────────┬──────┬────────────┬──────────────────────────────────────────╮
+│ LEVEL   │ ALIAS │ SHORT ID      │ COUNT  │ SEEN │ FIXABILITY │ TITLE                                    │
+├─────────┼───────┼───────────────┼────────┼──────┼────────────┼──────────────────────────────────────────┤
+│ ERROR   │ f-79  │ FRONTEND-79   │   2.4k │   2h │        92% │ TypeError: Cannot read property 'map'... │
+│ WARN    │ a-2f  │ API-2F        │    891 │   1d │        67% │ ReferenceError: user is not defined      │
+│ ERROR   │ m-4d  │ MOBILE-4D     │    456 │   5d │        23% │ NetworkError: Failed to fetch            │
+╰─────────┴───────┴───────────────┴────────┴──────┴────────────┴──────────────────────────────────────────╯
+ +
+ +
+ $ + sentry issue explain f-79 +
+
+
Root Cause Analysis Complete
+
══════════════════════════════
+
+
+ Cause: + The userData response from /api/users returns +
+
+ null when user session expires, but the component +
+
+ assumes it's always an array and calls .map() directly. +
+
+
+ → + Add null check before mapping: userData?.map() +
+
+
+
+ + diff --git a/apps/cli-docs/src/content.config.ts b/apps/cli-docs/src/content.config.ts new file mode 100644 index 000000000..7fbcf2c33 --- /dev/null +++ b/apps/cli-docs/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from "astro:content"; +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md new file mode 100644 index 000000000..d9b4eb20e --- /dev/null +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -0,0 +1,335 @@ +--- +title: Agent Guidance +description: Operational guidance for AI coding agents using the Sentry CLI +--- + +Best practices and operational guidance for AI coding agents using the Sentry CLI. + +## Key Principles + +- **Just run the command** — the CLI handles authentication and org/project detection automatically. Don't pre-authenticate or look up org/project before running commands. The CLI prompts for login if needed. +- **Prefer CLI commands over raw API calls** — the CLI has dedicated commands for most tasks. Reach for `sentry issue view`, `sentry issue list`, `sentry trace view`, etc. before constructing API calls manually or fetching external documentation. +- **Use `sentry docs` for setup questions** — if you need to know how to configure a Sentry SDK or feature, run `sentry docs "your question"` to query the documentation directly. This is faster and more accurate than fetching docs externally. +- **Use `sentry schema` to explore the API** — if you need to discover API endpoints, run `sentry schema` to browse interactively or `sentry schema ` to search. This is faster than fetching OpenAPI specs externally. +- **Use `sentry issue view` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. Multiple IDs can be passed in one invocation: `sentry issue view A B C --json` returns an array of the same objects. +- **Use `--json` for machine-readable output** — pipe through `jq` for filtering. Human-readable output includes formatting that is hard to parse. +- **The CLI auto-detects org/project — don't discover it yourself** — most commands work without explicit targets by checking `.sentryclirc` config files, scanning for DSNs in `.env` files and source code, and matching directory names. Do **not** run `sentry org list` and then `sentry project list` to figure out which project this checkout belongs to — that manual fan-out just replicates the detection the CLI already runs on every command. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. + +## Design Principles + +The `sentry` CLI follows conventions from well-known tools — if you're familiar with them, that knowledge transfers directly: + +- **`gh` (GitHub CLI) conventions**: The `sentry` CLI uses the same ` ` command pattern (e.g., `sentry issue list`, `sentry org view`). Flags follow `gh` conventions: `--json` for machine-readable output, `--fields` to select specific fields, `-w`/`--web` to open in browser, `-q`/`--query` for filtering, `-n`/`--limit` for result count. +- **`sentry api` mimics `curl`**: The `sentry api` command provides direct API access with a `curl`-like interface — `--method` for HTTP method, `--data` for request body, `--header` for custom headers. It handles authentication automatically. If you know how to call a REST API with `curl`, the same patterns apply. + +## Context Window Tips + +- Use `--json --fields` to select specific fields and reduce output size. Run ` --help` to see available fields. Example: `sentry issue list --json --fields shortId,title,priority,level,status` +- Use `--json` when piping output between commands or processing programmatically +- Use `--limit` to cap the number of results (default is usually 10–100) +- Prefer `sentry issue view PROJECT-123` over listing and filtering manually +- Pass multiple issue IDs in one call (`sentry issue view A B C --json`) instead of looping `issue view` per ID +- Analyze multiple issues in one call (`sentry issue explain A B C --json`) instead of looping `issue explain` per ID +- Use `sentry api` for endpoints not covered by dedicated commands + +## Safety Rules + +- Always confirm with the user before running destructive commands: `project delete`, `trial start` +- For mutations, verify the org/project context looks correct in the command output before proceeding with further changes +- Never store or log authentication tokens — the CLI manages credentials automatically +- If the CLI reports the wrong org/project, override with explicit `/` arguments + +## Exit Codes + +The CLI uses semantic exit codes. Key ranges for agents: + +| Range | Meaning | Agent Action | +|-------|---------|-------------| +| 0 | Success | Proceed normally | +| 10–19 | Auth error | Prompt user to run `sentry auth login` | +| 20–29 | Input error | Check command arguments and retry | +| 30–39 | API error | Retry or report to user | +| 40–49 | Feature unavailable | Inform user about plan/settings | +| 50–59 | Operation error | Report to user | +| 60–69 | Command-specific | Check stderr for details | + +See [Exit Codes](/exit-codes/) for the complete reference. + +## Workflow Patterns + +### Investigate an Issue + +```bash +# 1. Find the issue (auto-detects org/project from DSN or config) +sentry issue list --query "is:unresolved" --limit 5 + +# 2. Get details. For agents, prefer --json — it includes the full issue plus +# the latest event under `event`, so you get everything in one call. +sentry issue view PROJECT-123 --json + +# 3. Get AI root cause analysis +sentry issue explain PROJECT-123 + +# 4. Get a fix plan +sentry issue plan PROJECT-123 +``` + +`sentry issue view --json` is the fastest way to get an agent up to +speed on an issue. Select just the fields you need with `--fields` instead of +consuming the whole payload — the latest event's `request` entry can carry live +session data (cookies, headers, body), so extract named fields rather than +dumping the entire object: + +```bash +# Top-level issue fields +sentry issue view PROJECT-123 --json --fields shortId,title,culprit,count,userCount,permalink + +# Named fields from the latest event — avoids pulling the full request/session blob +sentry issue view PROJECT-123 --json --fields event.id,event.title,event.dateCreated + +# Just the request URL and method (not the whole request entry). Event data +# lives under event.entries[], each tagged with a `type` and `data` payload. +sentry issue view PROJECT-123 --json | jq '.event.entries[] | select(.type == "request") | .data | {url, method}' +``` + +### Explore Traces and Performance + +```bash +# 1. List recent traces (auto-detects org/project) +sentry trace list --limit 5 + +# 2. View a specific trace with span tree +sentry trace view abc123def456... + +# 3. View spans for a trace +sentry span list abc123def456... + +# 4. View logs associated with a trace +sentry trace logs abc123def456... +``` + +### Stream Logs + +```bash +# Stream logs in real-time (auto-detects org/project) +sentry log list --follow + +# Filter logs by severity +sentry log list --query "severity:error" +``` + +### Capture Events Locally (Spotlight) + +```bash +# Run the app with the local server auto-enabled; tail errors/traces/logs. +# No DSN needed — with no DSN, events go ONLY to the local server (nothing +# reaches the user's Sentry org, no production quota). With a DSN set, the +# SDK sends to both. +sentry local run -- npm run dev # or: python manage.py runserver, etc. + +# From a CLI source checkout, run the Local UI in a second terminal, then open it. +pnpm --filter local dev +sentry local run --open -- npm run dev + +# Watch only AI/agent (gen_ai, mcp) spans while iterating on an agent. +sentry local -f ai + +# Server-side SDKs read SENTRY_SPOTLIGHT automatically. The CLI also injects +# the URL under every framework client prefix (NEXT_PUBLIC_, VITE_, PUBLIC_, +# NUXT_PUBLIC_, REACT_APP_, VUE_APP_, GATSBY_). Until the browser SDK reads +# these automatically (getsentry/sentry-javascript#18198), reference the var +# matching your framework in the client config: +# Sentry.init({ spotlight: process.env.NEXT_PUBLIC_SENTRY_SPOTLIGHT ?? false }) +``` + +### Query Sentry Documentation + +```bash +# Ask a documentation question +sentry docs "How do I configure tracing in Next.js?" + +# Search the documentation index +sentry docs list "source maps" +``` + +### Check Sentry Service Status + +```bash +# Show current status of Sentry services +sentry status + +# Machine-readable status +sentry status --json +``` + +### Explore the API Schema + +```bash +# Browse all API resource categories +sentry schema + +# Search for endpoints related to a resource +sentry schema issues + +# Get details about a specific endpoint +sentry schema "GET /api/0/organizations/{organization_id_or_slug}/issues/" +``` + +### Manage Releases + +```bash +# Create a release — version must match Sentry.init({ release }) exactly +sentry release create my-org/1.0.0 --project my-project + +# Associate commits via repository integration (needs local git checkout) +sentry release set-commits my-org/1.0.0 --auto + +# Or read commits from local git history (no integration needed) +sentry release set-commits my-org/1.0.0 --local + +# Mark the release as finalized +sentry release finalize my-org/1.0.0 + +# Record a production deploy +sentry release deploy my-org/1.0.0 production +``` + +**Key details:** +- The positional is `/`. In `sentry release create sentry/1.0.0`, `sentry` is the org and `1.0.0` is the version — the slash separates org from version, it is not part of the version string. +- The **version** must match the `release` value in `Sentry.init()`. If your SDK uses `"1.0.0"`, the command must use `org/1.0.0`. +- `--auto` requires a Sentry repository integration (GitHub/GitLab/Bitbucket) **and** a local git checkout. It matches your `origin` remote against Sentry's repo list. Without a checkout, use `--local`. +- With no flag, `set-commits` tries `--auto` first and falls back to `--local` on failure. + +### View Agent Conversations + +```bash +# List recent agent conversations (org auto-detected) +sentry agent-conversation list + +# Explicit org, last 24 hours +sentry agent-conversation list my-org --period 24h + +# View a conversation transcript +sentry agent-conversation view my-org/conv-123 + +# JSON output for programmatic access +sentry agent-conversation view conv-123 --json +``` + +### Process WebAssembly Modules + +```bash +# Add a build id to a wasm module (no auth required) +sentry wasm-split app.wasm + +# Capture the build id for a later debug-files upload +BUILD_ID=$(sentry wasm-split app.wasm) + +# Split debug data into a companion and strip the shipped binary +sentry wasm-split app.wasm --debug-out app.debug.wasm --strip +``` + +### Arbitrary API Access + +```bash +# GET request (default) +sentry api /api/0/organizations/my-org/ + +# POST request with data +sentry api /api/0/organizations/my-org/projects/ --method POST --data '{"name":"new-project","platform":"python"}' +``` + +## Dashboard Layout + +Sentry dashboards use a **6-column grid**. When adding widgets, aim to fill complete rows (widths should sum to 6). + +Display types with default sizes: + +| Display Type | Width | Height | Category | Notes | +|---|---|---|---|---| +| `big_number` | 2 | 1 | common | Compact KPI — place 3 per row (2+2+2=6) | +| `line` | 3 | 2 | common | Half-width chart — place 2 per row (3+3=6) | +| `area` | 3 | 2 | common | Half-width chart — place 2 per row | +| `bar` | 3 | 2 | common | Half-width chart — place 2 per row | +| `table` | 6 | 2 | common | Full-width — always takes its own row | +| `stacked_area` | 3 | 2 | specialized | Stacked area chart | +| `top_n` | 3 | 2 | specialized | Top N ranked list | +| `categorical_bar` | 3 | 2 | specialized | Categorical bar chart | +| `text` | 3 | 2 | specialized | Static text/markdown widget | +| `details` | 3 | 2 | internal | Detail view | +| `wheel` | 3 | 2 | internal | Pie/wheel chart | +| `rage_and_dead_clicks` | 3 | 2 | internal | Rage/dead click visualization | +| `server_tree` | 3 | 2 | internal | Hierarchical tree display | +| `agents_traces_table` | 3 | 2 | internal | Agents traces table | + +Use **common** types for general dashboards. Use **specialized** only when specifically requested. Avoid **internal** types unless the user explicitly asks. + +Available datasets: `spans` (default), `errors`, `metrics`, `issue`, `logs`. Run `sentry dashboard widget --help` for dataset descriptions, query formats, and examples. + +**Row-filling examples:** + +```bash +# 3 KPIs filling one row (2+2+2 = 6) +sentry dashboard widget add "Error Count" --display big_number --query count +sentry dashboard widget add "P95 Duration" --display big_number --query p95:span.duration +sentry dashboard widget add "Throughput" --display big_number --query epm + +# 2 charts filling one row (3+3 = 6) +sentry dashboard widget add "Errors Over Time" --display line --query count +sentry dashboard widget add "Latency Over Time" --display line --query p95:span.duration + +# Full-width table (6 = 6) +sentry dashboard widget add "Top Endpoints" --display table \ + --query count --query p95:span.duration \ + --group-by transaction --sort -count --limit 10 +``` + +## Quick Reference + +### Time filtering + +Use `--period` (alias: `-t`) to filter by time window: + +```bash +sentry trace list --period 1h +sentry span list --period 24h +sentry span list -t 7d +``` + +### Scoping to an org or project + +Org and project are positional arguments following `gh` CLI conventions: + +```bash +sentry trace list my-org/my-project +sentry issue list my-org/my-project +sentry span list my-org/my-project/abc123def456... +``` + +### Listing spans in a trace + +Pass the trace ID as a positional argument to `span list`: + +```bash +sentry span list abc123def456... +sentry span list my-org/my-project/abc123def456... +``` + +### Dataset names for the Events API + +When querying the Events API (directly or via `sentry api`), valid dataset values are: `spans`, `logs`, `errors`, `tracemetrics`, `profile_functions`, and `uptime_results`. + +## Common Mistakes + +- **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix. +- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` if you need to switch accounts. +- **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically. +- **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. +- **Manually discovering the project before running a command**: Don't list the orgs you belong to, then list every project in each, to match the local checkout to a project — the CLI already does exactly this resolution internally on each command. Skip the fan-out and run the command directly; correct the target afterwards only if the output shows the wrong org/project. +- **Confusing `--query` syntax**: The `--query` flag uses Sentry search syntax (e.g., `is:unresolved`, `assigned:me`), not free text search. +- **Not using `--web`**: View commands support `-w`/`--web` to open the resource in the browser — useful for sharing links. +- **Fetching API schemas instead of using the CLI**: Prefer `sentry schema` to browse the API and `sentry api` to make requests — the CLI handles authentication and endpoint resolution, so there's rarely a need to download OpenAPI specs separately. +- **Fetching Sentry docs externally**: Use `sentry docs "your question"` to query Sentry's documentation from the CLI — this returns concise answers with source links, without needing to fetch or parse documentation pages. +- **Release version mismatch**: The `org/version` positional is `/`, where `org/` is the org, not part of the version. `sentry release create sentry/1.0.0` creates version `1.0.0` in org `sentry`. If your `Sentry.init()` uses `release: "1.0.0"`, this is correct. Don't double-prefix like `sentry/myapp/1.0.0`. +- **Running `set-commits --auto` without a git checkout**: `--auto` needs a local git repo to discover the origin remote URL and HEAD commit. In CI, ensure `actions/checkout` with `fetch-depth: 0` runs before `set-commits --auto`. +- **Using `sentry api` when CLI commands suffice**: `sentry issue list --json` and `sentry issue view --json` already include `shortId`, `title`, `count`, `userCount`, `priority`, `level`, `status`, `permalink`, and other fields at the top level. When using `--fields` to select specific fields like `count` or `userCount`, the CLI automatically ensures these fields are present in the API response. Use `--fields` to select specific fields and `--help` to see all available fields. Only fall back to `sentry api` for data the CLI doesn't expose. diff --git a/apps/cli-docs/src/content/docs/agentic-usage.md b/apps/cli-docs/src/content/docs/agentic-usage.md new file mode 100644 index 000000000..8caf49980 --- /dev/null +++ b/apps/cli-docs/src/content/docs/agentic-usage.md @@ -0,0 +1,82 @@ +--- +title: Agentic Usage +description: Enable AI coding agents to use the Sentry CLI +--- + +AI coding agents can use the Sentry CLI through the skill system. The CLI detects and supports Claude Code (including Cowork), Cursor, Windsurf, GitHub Copilot, Gemini CLI, OpenAI Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, and any agent that reads skills from `~/.agents`. This allows agents to interact with Sentry directly from your development environment. + +## Automatic Installation + +The curl installer and Homebrew run `sentry cli setup`, which installs agent skills into detected agent root directories (`~/.claude`, `~/.agents`). After a package-manager install, run `sentry cli setup` yourself. `sentry cli upgrade` also refreshes skills. No network fetch is needed — skill files are embedded in the binary. + +This uses the same `~/.agents` convention as [dotagents](https://github.com/getsentry/dotagents), Sentry's first-party tool for installing agent skills. See [Manual Installation](#manual-installation) to add the skill with dotagents yourself. + +To skip automatic skill installation, pass `--no-agent-skills` to `sentry cli setup`. + +## Manual Installation + +### dotagents (recommended) + +[dotagents](https://github.com/getsentry/dotagents) is Sentry's first-party tool for installing agent skills. It configures Claude, Cursor, Codex, Grok, VS Code, OpenCode, and more from a single `agents.toml`. + +Add the Sentry CLI skill from its well-known source: + +```bash +npx @sentry/dotagents add https://cli.sentry.dev sentry-cli +``` + +This installs the skill globally under `~/.agents/` and makes it available across all your projects. The first `add` bootstraps `~/.agents/agents.toml` for you — no `init` step is required. + +To install the skill for a single repository instead, initialize project scope first, then add it: + +```bash +npx @sentry/dotagents --project init +npx @sentry/dotagents --project add https://cli.sentry.dev sentry-cli +``` + +### skills + +Alternatively, use the `skills` CLI: + +```bash +npx skills add https://cli.sentry.dev +``` + +Either command registers the Sentry CLI as a skill that your agent can invoke when needed. + +## Capabilities + +With this skill, agents can: + +- **View issues** - List and inspect Sentry issues from your projects +- **Inspect events** - Look at specific error events and their details +- **AI analysis** - Get root cause analysis and fix plans via Seer AI +- **Browse projects** - List projects and organizations you have access to +- **Explore the API** - Browse API endpoints with `sentry schema` and make arbitrary requests with `sentry api` +- **Query documentation** - Ask questions about Sentry setup and configuration with `sentry docs` +- **Make API calls** - Execute arbitrary Sentry API requests +- **Authenticate** - Help you set up CLI authentication +- **View agent conversations** - List and inspect AI agent conversation transcripts with `sentry agent-conversation` +- **Check service status** - Query the Sentry status page with `sentry status` (no auth required) +- **Process WebAssembly** - Add build IDs and split debug data from wasm modules with `sentry wasm-split` (no auth required) + +## How It Works + +When you ask your agent about Sentry errors or want to investigate an issue, the agent uses CLI commands to fetch real data from your Sentry account. For example: + +- "Show me the latest issues in my project" → `sentry issue list` +- "What's the stack trace for ISSUE-123?" → `sentry issue view ISSUE-123` +- "List all projects in my organization" → `sentry project list my-org` +- "What API endpoints exist for releases?" → `sentry schema releases` +- "How do I set up source maps for Next.js?" → `sentry docs "source maps Next.js"` +- "What is Sentry's status right now?" → `sentry status` +- "Show me recent agent conversations" → `sentry agent-conversation list` + +The CLI has dedicated commands for most Sentry tasks, so agents should prefer `sentry` commands over constructing raw API calls. The `sentry docs` command queries Sentry's documentation directly from the terminal, the `sentry schema` command provides built-in API exploration, and `sentry api` handles authenticated requests for anything not covered by a dedicated command. + +The skill uses your existing CLI authentication, so you'll need to run `sentry auth login` first if you haven't already. + +## Requirements + +- An authenticated Sentry CLI installation (`sentry auth login`) +- An AI coding agent that supports the skills system (e.g., Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini CLI, Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, or any agent that reads from `~/.agents`) diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md new file mode 100644 index 000000000..3e932f7cc --- /dev/null +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -0,0 +1,171 @@ +--- +title: Contributing +description: How to contribute to the Sentry CLI +--- + +We welcome contributions to the Sentry CLI! This guide will help you get started. + +## Development Setup + +### Prerequisites + + +- [Node.js](https://nodejs.org) (v22.15 or later) +- [pnpm](https://pnpm.io) (v10.11 or later) + +- Git + +### Getting Started + +```bash +# Clone the repository +git clone https://github.com/getsentry/cli.git +cd cli + +# Install dependencies +pnpm install + +# Run CLI in development mode +pnpm run cli -- --help + +# Run tests +pnpm run test +``` + +### Environment Variables + +Create a `.env.local` file for development: + +```bash +cp .env.example .env.local +``` + +Edit `.env.local` with your development credentials. + +## Project Structure + + +``` +cli/ +├── src/ +│ ├── bin.ts # Entry point +│ ├── app.ts # Stricli application setup +│ ├── context.ts # Dependency injection context +│ ├── commands/ # CLI commands +│ │ ├── agent-conversation/# list, view +│ │ ├── alert/ # create, delete, edit, list, view +│ │ ├── auth/ # login, logout, refresh, status, token, whoami +│ │ ├── build/ # download, upload +│ │ ├── cli/ # completion, defaults, feedback, fix, import, setup, uninstall, upgrade +│ │ ├── code-mappings/# upload +│ │ ├── dart-symbol-map/# upload +│ │ ├── dashboard/ # add, create, delete, edit, list, restore, revisions, view +│ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload +│ │ ├── docs/ # list, query +│ │ ├── event/ # list, send, view +│ │ ├── feedback/ # list, view +│ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view +│ │ ├── local/ # run, serve +│ │ ├── log/ # list, view +│ │ ├── monitor/ # list, run +│ │ ├── org/ # list, view +│ │ ├── platform/ # list +│ │ ├── proguard/ # upload, uuid +│ │ ├── project/ # create, delete, list, view +│ │ ├── react-native/# gradle, xcode +│ │ ├── release/ # archive, create, delete, deploy, deploys, finalize, list, propose-version, restore, set-commits, view +│ │ ├── replay/ # list, view +│ │ ├── repo/ # list +│ │ ├── snapshots/ # diff, download, upload +│ │ ├── sourcemap/ # inject, resolve, upload +│ │ ├── span/ # list, view +│ │ ├── status/ # show +│ │ ├── team/ # list +│ │ ├── trace/ # list, logs, view +│ │ ├── trial/ # list, start +│ │ ├── api.ts # Make an authenticated API request +│ │ ├── explore.ts # Query aggregate event data (Explore) +│ │ ├── help.ts # Help command +│ │ ├── info.ts # Print configuration and verify authentication +│ │ ├── init.ts # Initialize Sentry in your project (experimental) +│ │ ├── schema.ts # Browse the Sentry API schema +│ │ └── wasm-split.ts# Add build ids to WebAssembly modules and split out debug data +│ ├── lib/ # Shared utilities +│ └── types/ # TypeScript types and Valibot schemas +├── test/ # Test files (mirrors src/ structure) +├── script/ # Build and utility scripts +├── plugins/ # Agent skill files +└── docs/ # Documentation site (Astro + Starlight) +``` + + +## Building + + +```bash +# Build for current platform (uses esbuild + fossilize for Node SEA packaging) +pnpm run build + +# Build for all platforms +pnpm run build:all + +# Create npm bundle +pnpm run bundle +``` + + +## Testing + +```bash +# Run all tests +pnpm run test + +# Run specific test file +pnpm run test -- test/path/to/test.ts + +# Run with watch mode +pnpm run test -- --watch + +# Run with coverage +pnpm run test -- --coverage +``` + +## Code Style + +The project uses [Ultracite](https://github.com/getsentry/ultracite) for linting and formatting: + +```bash +# Check for issues +pnpm run lint + +# Auto-fix issues +pnpm run lint:fix + +# Type checking +pnpm run typecheck +``` + +## Submitting Changes + +1. Fork the repository +2. Create a feature branch: `git checkout -b feat/my-feature` +3. Make your changes +4. Run tests and linting: `pnpm run test && pnpm run lint` +5. Commit with [conventional commits](https://www.conventionalcommits.org/): `git commit -m "feat: add new feature"` +6. Push and create a pull request + +## Conventional Commits + +We use conventional commits for automatic changelog generation: + +- `feat:` - New features +- `fix:` - Bug fixes +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `test:` - Test changes +- `chore:` - Maintenance tasks + +## Getting Help + +- [GitHub Issues](https://github.com/getsentry/cli/issues) - Bug reports and feature requests +- [GitHub Discussions](https://github.com/getsentry/cli/discussions) - Questions and discussions diff --git a/apps/cli-docs/src/content/docs/exit-codes.md b/apps/cli-docs/src/content/docs/exit-codes.md new file mode 100644 index 000000000..715268c69 --- /dev/null +++ b/apps/cli-docs/src/content/docs/exit-codes.md @@ -0,0 +1,100 @@ +--- +title: Exit Codes +description: Exit code reference for scripting and automation with the Sentry CLI +--- + +The CLI uses semantic exit codes so scripts, CI pipelines, and AI agents can +react to failure categories without parsing stderr. + +## Exit Code Ranges + +| Range | Category | Description | +|-------|----------|-------------| +| 0 | Success | Command completed successfully | +| 1 | General | Unexpected or unclassified error | +| 10–19 | Auth | Authentication and authorization failures | +| 20–29 | Input | Configuration, validation, and resolution errors | +| 30–39 | API | Sentry API and network errors | +| 40–49 | Feature | Feature availability and billing issues | +| 50–59 | Operations | Upgrade and OAuth flow errors | +| 60–69 | Command | Command-specific non-standard exits | + +## Complete Reference + +| Code | Name | Description | +|------|------|-------------| +| 0 | Success | Command completed successfully | +| 1 | General Error | Unexpected error or unclassified failure | +| 10 | Not Authenticated | No credentials found — run `sentry auth login` | +| 11 | Token Expired | Auth token expired — re-authenticate | +| 12 | Token Invalid | Auth token rejected by the server | +| 13 | Host Scope | Request blocked — credentials don't match the target host | +| 20 | Config Error | Configuration or DSN problem | +| 21 | Validation Error | Invalid input (malformed ID, bad flag value, etc.) | +| 22 | Missing Context | Required context (org, project) could not be determined | +| 23 | Not Found | A user-provided identifier could not be resolved | +| 30 | API Error | Sentry API returned an error response | +| 31 | Timeout | Operation exceeded its time limit | +| 40 | Seer Not Enabled | Seer is not enabled for the organization | +| 41 | Seer No Budget | Seer requires a paid plan | +| 42 | AI Disabled | AI features disabled by organization admin | +| 50 | Upgrade Error | CLI upgrade operation failed | +| 51 | Device Flow Error | OAuth device authorization flow failed | +| 60 | Output Error | Command produced output but the operation failed | +| 61 | Wizard Error | Interactive setup wizard encountered an error | +| 62 | Wizard Deps | Wizard dependency installation failed | +| 63 | Wizard Codemod | Wizard codemod plan or apply failed | +| 64 | Wizard Verify | User stopped wizard after verification step | + +## Scripting Examples + +### Bash + +```bash +sentry issue list my-org/ +code=$? + +case $code in + 0) echo "Success" ;; + 1?) echo "Auth problem (code $code) — run: sentry auth login" ;; + 2?) echo "Input/config problem (code $code)" ;; + 3?) echo "API/network error (code $code)" ;; + 4?) echo "Feature not available (code $code)" ;; + *) echo "Failed with exit code $code" ;; +esac +``` + +### Python + +```python +import subprocess + +result = subprocess.run(["sentry", "issue", "list", "my-org/"], capture_output=True) + +if result.returncode == 0: + print("Success") +elif 10 <= result.returncode <= 19: + print("Auth error — run: sentry auth login") +elif 20 <= result.returncode <= 29: + print("Input/config error") +elif 30 <= result.returncode <= 39: + print("API/network error") +elif 40 <= result.returncode <= 49: + print("Feature not available") +``` + +## Notes + +- Exit codes below 128 are safe from collision with Unix signal exits (128+N). +- The `sentry api` command renders API error responses to stdout and exits + with code 60 (Output Error), not 30 (API Error). This matches the `gh api` + convention — the error response body is useful output. Parse the HTTP status + from `--verbose` output or the JSON error body if you need to distinguish + API error categories. +- The `sentry init` wizard maps its internal workflow exit codes to CLI + exit codes: platform not detected → 20 (Config), dependency install + failed → 62 (Wizard Deps), codemod failed → 63 (Wizard Codemod), + verification stopped → 64 (Wizard Verify), other → 61 (Wizard). +- [Stricli](https://bloomberg.github.io/stricli/) (the CLI framework) uses + negative exit codes (-5 to -1) for framework-level errors like unknown + commands or invalid arguments. These appear as 251–255 in unsigned form. diff --git a/apps/cli-docs/src/content/docs/features.md b/apps/cli-docs/src/content/docs/features.md new file mode 100644 index 000000000..1e9b0f8aa --- /dev/null +++ b/apps/cli-docs/src/content/docs/features.md @@ -0,0 +1,242 @@ +--- +title: Features +description: Advanced features of the Sentry CLI +--- + +The Sentry CLI includes several features designed to streamline your workflow, especially in complex project setups. + +## DSN Auto-Detection + +The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify the target for every command. DSN detection is one part of the [resolution priority chain](./configuration/#resolution-priority) — it runs after checking for explicit arguments, environment variables, and `.sentryclirc` config files. + +### How It Works + +DSN detection follows this priority order (highest first): + +1. **Source code** - Explicit DSN in `Sentry.init()` calls +2. **Environment files** - `.env.local`, `.env`, etc. +3. **Environment variable** - `SENTRY_DSN` + +When a DSN is found, the CLI resolves it to your organization and project, then caches the result for fast subsequent lookups. + +:::tip +For monorepos or when DSN detection picks up the wrong project, use a [`.sentryclirc` config file](./configuration/#configuration-file-sentryclirc) to pin your org/project explicitly. +::: + +### Supported Languages + +The CLI scans source files for DSN URLs (the `https://…@….ingest.sentry.io/…` pattern) using a universal regex — no language-specific parsing is needed. Any text file with a recognized extension is scanned, including: + +| Language Family | File Extensions | +|----------------|-----------------| +| JavaScript/TypeScript | `.js`, `.ts`, `.jsx`, `.tsx`, `.mjs`, `.cjs`, `.astro`, `.vue`, `.svelte` | +| Python | `.py` | +| Go | `.go` | +| JVM (Java, Kotlin, Scala, Groovy) | `.java`, `.kt`, `.kts`, `.scala`, `.groovy` | +| .NET (C#, F#, VB) | `.cs`, `.fs`, `.vb` | +| Ruby | `.rb`, `.erb` | +| PHP | `.php` | +| Swift/Objective-C | `.swift`, `.m`, `.mm` | +| Rust | `.rs` | +| Dart/Flutter | `.dart` | +| Elixir/Erlang | `.ex`, `.exs`, `.erl` | +| Config files | `.json`, `.yaml`, `.yml`, `.toml`, `.xml`, `.properties` | + +### Caching + +To avoid scanning your codebase on every command, the CLI caches: + +- **DSN location** - Which file contains the DSN +- **Resolved project** - The org/project slugs from the API + +The cache is validated on each run by checking if the source file still contains the same DSN. If the DSN changes or the file is deleted, a full scan is triggered. + +### Usage + +Once your project has a DSN configured, commands automatically use it: + +```bash +# Instead of: +sentry issue list my-org/my-project + +# Just run: +sentry issue list +``` + +The CLI will show which project was detected: + +``` +Detected project: my-app (from .env) + +ID SHORT ID TITLE COUNT +123456789 MYAPP-ABC TypeError: Cannot read prop... 142 +``` + +## Monorepo Support & Alias System + +In monorepos with multiple Sentry projects, the CLI generates short aliases for each project, making it easy to work with issues across projects. + +### How Aliases Work + +When you run `sentry issue list`, the CLI: + +1. Scans for DSNs in monorepo directories (`packages/`, `apps/`, etc.) +2. Generates unique short aliases for each project +3. Caches the aliases for use with other commands + +Aliases are the shortest unique prefix of each project slug. For example: + +| Project Slug | Alias | +|--------------|-------| +| `frontend` | `f` | +| `functions` | `fu` | +| `backend` | `b` | + +For projects with a common prefix (like `spotlight-electron`, `spotlight-website`), the prefix is stripped first: + +| Project Slug | Alias | +|--------------|-------| +| `spotlight-electron` | `e` | +| `spotlight-website` | `w` | +| `spotlight-backend` | `b` | + +### Using Alias-Suffix Format + +After running `issue list`, you can reference issues using the `alias-suffix` format: + +```bash +# List issues - note the ALIAS column +sentry issue list +``` + +``` +ALIAS SHORT ID TITLE COUNT +e SPOTLIGHT-ELEC-4Y TypeError: Cannot read prop... 142 +w SPOTLIGHT-WEB-ABC Failed to fetch user data 89 +b SPOTLIGHT-BACK-XYZ Connection timeout 34 +``` + +```bash +# View issue using alias-suffix +sentry issue view e-4Y + +# Explain using alias-suffix +sentry issue explain w-ABC + +# Works with any issue command +sentry issue plan b-XYZ +``` + +### Cross-Organization Support + +If you work with multiple organizations that have projects with the same slug, the CLI uses org-prefixed aliases: + +``` +ALIAS SHORT ID TITLE +o1:api ORG1-API-123 Error in API handler +o2:api ORG2-API-456 Database connection failed +``` + +## Issue ID Formats + +The CLI accepts several formats for identifying issues: + +### Numeric ID + +The internal Sentry issue ID: + +```bash +sentry issue view 123456789 +sentry issue explain 987654321 +``` + +### Full Short ID + +The project-prefixed short ID shown in Sentry UI: + +```bash +sentry issue view MYPROJECT-ABC +sentry issue explain FRONTEND-XYZ +``` + +### Short Suffix + +Just the suffix portion when project context is provided via the `/` prefix: + +```bash +sentry issue view my-org/myproject-ABC +``` + +### GitHub-Style (`#` separator) + +A `#` may be used in place of the final slash, matching how issues are referenced +on GitHub. This is handy for AI agents and tooling that emit `org/project#SHORTID`: + +```bash +# Equivalent to my-org/my-project/PROJ-123 +sentry issue view my-org/my-project#PROJ-123 + +# Project context only (org auto-detected) +sentry issue view my-project#PROJ-123 +``` + +### Alias-Suffix + +The short alias plus suffix, available after running `issue list`: + +```bash +# First, list issues to populate the alias cache +sentry issue list + +# Then use alias-suffix format +sentry issue view e-4Y +sentry issue explain w-ABC +sentry issue plan b-XYZ +``` + +This format is especially useful in monorepos where you're working across multiple projects. + +## AI-Powered Analysis with Seer + +The CLI integrates with Sentry's Seer AI to provide root cause analysis and fix plans directly in your terminal. + +### Root Cause Analysis + +Use `sentry issue explain` to understand why an issue is happening: + +```bash +sentry issue explain MYPROJECT-ABC +``` + +Seer analyzes: +- Stack traces and error messages +- Related events and patterns +- Your codebase (via GitHub integration) + +And provides: +- Detailed root cause explanation +- Reproduction steps +- Relevant code locations + +### Fix Plans + +After understanding the root cause, use `sentry issue plan` to get actionable fix steps: + +```bash +sentry issue plan MYPROJECT-ABC +``` + +The plan includes: +- Specific files to modify +- Code changes to make +- Implementation guidance + +### Requirements + +For Seer integration to work, you need: + +1. **Seer enabled** for your organization +2. **GitHub integration** configured with repository access +3. **Code mappings** set up to link stack frames to source files + +See [Sentry's Seer documentation](https://docs.sentry.io/product/issues/issue-details/ai-suggested-solution/) for setup instructions. diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx new file mode 100644 index 000000000..b9dc05975 --- /dev/null +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -0,0 +1,201 @@ +--- +title: Installation +description: How to install and set up the Sentry CLI +--- + +import PackageManagerCode from "../../components/PackageManagerCode.astro"; + +## Install Script + +Install the latest stable release: + +```bash +curl https://cli.sentry.dev/install -fsS | bash +``` + +Install the nightly build (built from `main`, updated on every commit): + +```bash +curl https://cli.sentry.dev/install -fsS | bash -s -- --version nightly +``` + +You can also use the `SENTRY_VERSION` environment variable to pin a version, +which is especially useful in CI/CD pipelines and Dockerfiles: + +```bash +# Pin to a specific stable version +curl https://cli.sentry.dev/install -fsS | SENTRY_VERSION=0.42.2 bash + +# Pin to nightly +curl https://cli.sentry.dev/install -fsS | SENTRY_VERSION=nightly bash +``` + +The `--version` flag takes precedence over `SENTRY_VERSION` if both are set. +The chosen channel is persisted so that `sentry cli upgrade` automatically +tracks the same channel on future updates. + +### Installer Flags + +The install script accepts additional flags to customize behavior: + +```bash +# Skip shell config modifications (~/.zshrc, ~/.bashrc, etc.) +curl https://cli.sentry.dev/install -fsS | bash -s -- --no-modify-path + +# Skip shell completion installation +curl https://cli.sentry.dev/install -fsS | bash -s -- --no-completions + +# Skip AI agent skill installation +curl https://cli.sentry.dev/install -fsS | bash -s -- --no-agent-skills +``` + +You can also set `SENTRY_INSTALL_DIR` to override the binary installation directory: + +```bash +curl https://cli.sentry.dev/install -fsS | SENTRY_INSTALL_DIR="$HOME/.local/bin" bash +``` + +### Supported Platforms + +{/* GENERATED:START platform-support */} + + + + + + + + + + + + + + + + + + + + + + + + + + +
OSArchitecturesNotes
macOSx64, arm64 (Apple Silicon)
Linuxx64, arm64Standalone binary requires glibc
Windowsx64Via Git Bash, MSYS2, or WSL
+ +{/* GENERATED:END platform-support */} + +On Alpine and other musl-based Linux systems, install Node.js and use +`npm install -g sentry` instead of the standalone installer. + +## Homebrew + +```bash +brew install getsentry/tools/sentry +``` + +## Package Managers + +Install globally with your preferred package manager (the npm packages require **Node.js 20+**; on **22.15+** the built-in `node:sqlite` is used, on 20–22.14 a bundled WASM fallback is used transparently): + + + +Unlike the install script and Homebrew, package manager installs don't set up shell completions or agent skills. Run `sentry cli setup` once to enable them: + +```bash +sentry cli setup +``` + +Or run directly without installing: + + + +## Authentication + +### OAuth Device Flow (Recommended) + +On a fresh installation, the install script starts OAuth login automatically +when running in an interactive terminal with no detected AI agent or existing +credentials. Upgrades and non-interactive installations skip this first-login step. + +The easiest way to authenticate is via OAuth device flow: + +```bash +sentry auth +``` + +You'll be given a URL and a code to enter. Once you authorize the application +in your browser, the CLI stores the OAuth credentials. When the server provides +a refresh token, the CLI refreshes the access token automatically. Persist the +Sentry CLI configuration directory (`$XDG_CONFIG_HOME/sentry/`, defaulting to +`~/.config/sentry/`, overridable with `SENTRY_CONFIG_DIR`) across runs to keep +automatic refresh working. + +### API Token + +Alternatively, you can use an API token directly: + +```bash +sentry auth --token YOUR_SENTRY_API_TOKEN +``` + +You can create API tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). + +API tokens are also useful for ephemeral CI jobs and sandboxes that cannot +persist the CLI configuration directory. + +### Check Auth Status + +Verify your authentication status: + +```bash +sentry auth status +``` + +### Logout + +To remove stored credentials: + +```bash +sentry auth logout +``` + +## Self-Hosted Sentry + +Using a self-hosted Sentry instance? Create a public OAuth application and pass +its client ID and instance URL when you log in: + +```bash +SENTRY_CLIENT_ID=your-client-id sentry auth login --url https://sentry.example.com +``` + +See the [Self-Hosted](../self-hosted/) guide for full setup details. + +## Configuration + +Credentials are stored in a SQLite database under `$XDG_CONFIG_HOME/sentry/` (defaulting to `~/.config/sentry/`) with restricted file permissions (mode 600) for security. See [Configuration](../configuration/) for environment variables and customization options. + +## Next Steps + +Once authenticated, you can start using the CLI: + +- [Initialize Sentry](../commands/init/) - Set up Sentry in your project with the guided wizard +- [Organization commands](../commands/org/) - List and view organizations +- [Project commands](../commands/project/) - Manage projects +- [Issue commands](../commands/issue/) - Track and manage issues +- [Event commands](../commands/event/) - Inspect events +- [API commands](../commands/api/) - Direct API access +- [Agentic Usage](../agentic-usage/) - Enable AI coding agents to use the CLI diff --git a/apps/cli-docs/src/content/docs/index.mdx b/apps/cli-docs/src/content/docs/index.mdx new file mode 100644 index 000000000..4fbff5831 --- /dev/null +++ b/apps/cli-docs/src/content/docs/index.mdx @@ -0,0 +1,107 @@ +--- +title: The CLI for developers and agents +description: A CLI for developers and agents +template: splash +prev: false +next: false +hero: + title: | + The CLI for + developers and agents + tagline: No more flags. No more guessing. Just type what you mean and let the CLI figure out the rest. Built by humans and agents, for humans and agents. +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; +import InstallSelector from '../../components/InstallSelector.astro'; +import Terminal from '../../components/Terminal.astro'; +import FeatureTerminal from '../../components/FeatureTerminal.astro'; +import FeatureVisual from '../../components/FeatureVisual.astro'; +import sectionBg1 from '../../assets/section-bg-1.png'; +import sectionBg2 from '../../assets/section-bg-2.png'; +import sectionBg3 from '../../assets/section-bg-3.png'; + + + + + +{/* ============================================ + Features Section - Horizontal Rows with Terminals + ============================================ */} + +
+
+
+

It Knows Your Project

+

No config files. No flags. The CLI reads your .env, detects your project from the codebase, and just works. Monorepos, multiple orgs, complex setups — all handled automatically.

+

Stop memorizing project slugs and DSNs. Start typing commands that make sense.

+
+ + +
$ sentry issue list
+
Detected project: my-app (from .env)
+
+
╭─────────┬────────────┬──────────────────────────────────────────┬───────╮
+│ LEVEL   │ SHORT ID   │ TITLE                                    │ COUNT │
+├─────────┼────────────┼──────────────────────────────────────────┼───────┤
+│ ERROR   │ MYAPP-WQ   │ TypeError: Cannot read property 'map'... │   142 │
+│ WARN    │ MYAPP-X3   │ Failed to fetch user data from API       │    89 │
+│ ERROR   │ MYAPP-R7   │ Connection timeout after 30s             │    34 │
+╰─────────┴────────────┴──────────────────────────────────────────┴───────╯
+
+
+
+
+ +
+
+
+

Ask Seer Why

+

Get AI-powered root cause analysis right in your terminal. Seer analyzes stack traces, related events, and your codebase to explain exactly what went wrong and why.

+

Then run sentry issue plan to get a step-by-step fix you can apply immediately.

+
+ + +
$ sentry issue explain WQ
+
Analyzing MYAPP-WQ...
+
+
Root Cause: The user object is undefined when
+
accessed before the auth check completes in useEffect.
+
+
Affected: src/hooks/useUser.ts:42
+
Run `sentry issue plan` for a fix.
+
+
+
+
+ +
+
+
+

Works With Everything

+

Structured JSON output for scripts and pipelines. Open issues directly in your browser. Pipe to jq, fzf, or your favorite tools.

+

Built for humans and AI agents alike — every command is predictable, composable, and automation-ready.

+
+ + +
$ sentry org list --json | jq '.[0]'
+
+
{"{"}
+
"slug": "my-org",
+
"name": "My Organization",
+
"projects": 12,
+
"members": 8
+
{"}"}
+
+
+
+
\ No newline at end of file diff --git a/apps/cli-docs/src/content/docs/library-usage.md b/apps/cli-docs/src/content/docs/library-usage.md new file mode 100644 index 000000000..c72bba3cd --- /dev/null +++ b/apps/cli-docs/src/content/docs/library-usage.md @@ -0,0 +1,272 @@ +--- +title: Library Usage +description: Use the Sentry CLI programmatically in Node.js +--- + +The Sentry CLI can be used as a JavaScript/TypeScript library, running commands +in-process without spawning a subprocess. This is useful for AI coding agents, +build tools, CI scripts, and other tools that want structured Sentry data. + +## Installation + +```bash +npm install sentry +``` + +## Quick Start + +```typescript +import createSentrySDK from "sentry"; + +const sdk = createSentrySDK({ token: "sntrys_..." }); + +// Typed methods for every CLI command +const orgs = await sdk.org.list(); +const issues = await sdk.issue.list({ orgProject: "acme/frontend", limit: 5 }); +``` + +## Typed SDK + +`createSentrySDK()` returns an object with typed methods for **every** CLI command, +organized by the CLI route hierarchy: + +```typescript +import createSentrySDK from "sentry"; + +const sdk = createSentrySDK({ token: "sntrys_..." }); +``` + +### Organizations + +```typescript +const orgs = await sdk.org.list(); +const org = await sdk.org.view({ org: "acme" }); +``` + +### Projects + +```typescript +const projects = await sdk.project.list({ orgProject: "acme/" }); +const project = await sdk.project.view({ orgProject: "acme/frontend" }); +``` + +### Issues + +```typescript +const issues = await sdk.issue.list({ + orgProject: "acme/frontend", + limit: 10, + query: "is:unresolved", + sort: "date", +}); + +const issue = await sdk.issue.view({}, "ACME-123"); +``` + +### Events, Traces, Spans + +```typescript +const event = await sdk.event.view({}, "abc123..."); + +const traces = await sdk.trace.list({ orgProject: "acme/frontend" }); +const trace = await sdk.trace.view({}, "abc123..."); + +const spans = await sdk.span.list({}, "acme/frontend"); +``` + +### Dashboards + +```typescript +const dashboards = await sdk.dashboard.list({}, "acme/"); +const dashboard = await sdk.dashboard.view({}, "acme/", "my-dashboard"); + +// Nested widget commands +await sdk.dashboard.widget.add( + { display: "line", query: ["count"] }, + "acme/", "my-dashboard", "Errors over time" +); +``` + +### Teams + +```typescript +const teams = await sdk.team.list({ orgProject: "acme/" }); +``` + +### Authentication + +```typescript +await sdk.auth.login(); +await sdk.auth.status(); +const whoami = await sdk.auth.whoami(); +``` + +The typed SDK invokes command handlers directly — bypassing CLI string parsing +for zero overhead beyond the command's own logic. + +## Escape Hatch: `run()` + +For commands not easily expressed through the typed API, or when you want to +pass raw CLI flags, use `sdk.run()`: + +```typescript +// Run any CLI command — returns parsed JSON by default +const version = await sdk.run("--version"); +const issues = await sdk.run("issue", "list", "-l", "5"); +const help = await sdk.run("help", "issue"); +``` + +## Authentication + +The `token` option provides an auth token for the current invocation. When +omitted, it falls back to stored credentials and environment variables: + +1. `token` option (highest priority) +2. Stored OAuth token from `sentry auth login` (if not expired) +3. `SENTRY_AUTH_TOKEN` environment variable +4. `SENTRY_TOKEN` environment variable + +Set `SENTRY_FORCE_ENV_TOKEN=1` to make environment variable tokens take priority over stored OAuth. + +```typescript +// Explicit token +const sdk = createSentrySDK({ token: "sntrys_..." }); + +// Or set the env var — it's picked up automatically +process.env.SENTRY_AUTH_TOKEN = "sntrys_..."; +const sdk = createSentrySDK(); +``` + +## Options + +All options are optional. Pass them when creating the SDK: + +```typescript +const sdk = createSentrySDK({ token: "...", text: true, cwd: "/my/project" }); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `token` | `string` | Auto-detected | Auth token for this invocation | +| `url` | `string` | `sentry.io` | Sentry instance URL for self-hosted | +| `org` | `string` | Auto-detected | Default organization slug | +| `project` | `string` | Auto-detected | Default project slug | +| `text` | `boolean` | `false` | Return human-readable text instead of parsed JSON (`run()` only) | +| `cwd` | `string` | `process.cwd()` | Working directory for DSN auto-detection | +| `headers` | `Record` | — | Extra HTTP headers for self-hosted instances behind a reverse proxy (same as [`SENTRY_CUSTOM_HEADERS`](./configuration/#sentry_custom_headers)); ignored for sentry.io | +| `signal` | `AbortSignal` | — | Abort signal for cancelling streaming commands | + +## Return Values + +Typed SDK methods return **parsed JavaScript objects** with zero serialization +overhead (via zero-copy capture). The `run()` escape hatch returns parsed JSON +by default, or a trimmed string for commands without JSON support. + +```typescript +// Typed methods → typed return +const issues = await sdk.issue.list({ orgProject: "acme/frontend" }); +// IssueListResult type with known fields + +// run() → parsed JSON or string +const version = await sdk.run("--version"); +// "sentry 0.21.0" +``` + +## Error Handling + +Commands that fail throw a `SentryError`: + +```typescript +import createSentrySDK, { SentryError } from "sentry"; + +const sdk = createSentrySDK(); + +try { + await sdk.issue.view({}, "NONEXISTENT-1"); +} catch (err) { + if (err instanceof SentryError) { + console.error(err.message); // Clean error message (no ANSI codes) + console.error(err.exitCode); // Non-zero exit code + console.error(err.stderr); // Raw stderr output + } +} +``` + +## Environment Isolation + +The library never mutates `process.env`. Each invocation creates an isolated +copy of the environment. This means: + +- Your application's env vars are never touched +- Multiple sequential calls are safe +- Auth tokens passed via `token` don't leak to subsequent calls + +:::note +Concurrent calls are not supported in the current version. +Calls should be sequential (awaited one at a time). +::: + +## Comparison with Subprocess + +| | Library (`createSentrySDK()`) | Subprocess (`child_process`) | +|---|---|---| +| **Startup** | ~0ms (in-process) | ~200ms (process spawn + init) | +| **Output** | Parsed object (zero-copy) | String (needs JSON.parse) | +| **Errors** | `SentryError` with typed fields | Exit code + stderr string | +| **Auth** | `token` option or env vars | Env vars only | +| **Node.js** | >=20 required | Any version | + +## Requirements + +- **Node.js >= 20**. On Node.js 22.15+ the built-in `node:sqlite` module is used; on Node.js 20–22.14 the CLI transparently falls back to a bundled WASM SQLite driver, so no native module or extra install step is required. + +:::caution +The WASM SQLite fallback (Node.js 20–22.14) does not support [WAL mode](https://www.sqlite.org/wal.html), so concurrent access from multiple processes is slower and its local cache reads/writes are less efficient. For the best performance and reliability we **strongly recommend** the [standalone binary](/installation/) (which bundles a modern runtime) or running on **Node.js 22.15+** so the native `node:sqlite` driver is used. +::: + +## Streaming Commands + +Two commands support real-time streaming: `log list --follow` and `dashboard view --refresh`. +When using streaming flags, methods return an `AsyncIterable` instead of a `Promise`: + +```typescript +const sdk = createSentrySDK({ token: "sntrys_..." }); + +// Stream logs as they arrive (polls every 5 seconds) +for await (const log of sdk.log.list({ follow: "5" }, "acme/backend")) { + console.log(log); +} + +// Auto-refresh dashboard (polls every 30 seconds) +for await (const snapshot of sdk.run("dashboard", "view", "123", "--refresh", "30")) { + console.log(snapshot); +} + +// Stop streaming by breaking out of the loop +for await (const log of sdk.log.list({ follow: "2" })) { + if (someCondition) break; // Streaming stops immediately +} +``` + +### Cancellation + +`break` in a `for await...of` loop immediately signals the streaming command to stop. +You can also pass an `AbortSignal` via `SentryOptions` for programmatic cancellation: + +```typescript +const controller = new AbortController(); +const sdk = createSentrySDK({ token: "...", signal: controller.signal }); + +// Cancel after 30 seconds +setTimeout(() => controller.abort(), 30_000); + +for await (const log of sdk.log.list({ follow: "5" })) { + console.log(log); +} +// Loop exits when signal fires +``` + +:::note +Concurrent streaming calls are not supported. Each streaming invocation +uses an isolated environment — only one can be active at a time. +::: diff --git a/apps/cli-docs/src/content/docs/migrating-from-v3.md b/apps/cli-docs/src/content/docs/migrating-from-v3.md new file mode 100644 index 000000000..588464f97 --- /dev/null +++ b/apps/cli-docs/src/content/docs/migrating-from-v3.md @@ -0,0 +1,616 @@ +--- +title: Migrating from v3 (sentry-cli) +description: Upgrade guide from the legacy sentry-cli (v3) to the new sentry CLI (v4), including the sentry-cli → sentry rename, command changes, and copy-paste compatibility shims. +--- + +Version 4 is a ground-up rewrite of the Sentry CLI. The most visible change is +the name: the tool is now called **`sentry`** (not `sentry-cli`), shipped from +the **`sentry`** npm package (not `@sentry/cli`). Most of your commands keep +working — many old names are kept as hidden aliases — but a handful moved, and +the output/exit-code behavior was modernized. + +This guide covers everything that changed and gives you **copy-paste shims** so +existing scripts and muscle memory keep working while you migrate. + +:::tip[In a hurry?] +Add the [compatibility shim](#drop-in-compatibility-shim) to your shell profile +and most `sentry-cli …` invocations keep working unchanged. Then migrate at your +own pace. +::: + +## What changed at a glance + +| Area | v3 (`sentry-cli`) | v4 (`sentry`) | +|------|-------------------|---------------| +| Binary name | `sentry-cli` | `sentry` | +| npm package | `@sentry/cli` | `sentry` | +| Command groups | plural (`releases`, `projects`) | singular (`release`, `project`) | +| Output | plain text | Markdown on a TTY, plain when piped, `--json` for machines | +| Exit codes | mostly `1` | [semantic ranges](/exit-codes/) (auth=1x, input=2x, API=3x…) | +| Auth | token-only | OAuth device flow (`sentry auth login`) **or** token | +| Some commands | `login`, `update`, `upload-dif`, `deploys` | moved (see [table](#command-changes)) | +| Flags | per-command `--auth-token`/`--url`/`--header`/… | `--org`/`--project`/`--log-level`/`-v` kept; others → env vars (see [Global flags](#global-flags-and-options)) | + +Your **environment variables** (`SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, +`SENTRY_PROJECT`, `SENTRY_DSN`, `SENTRY_URL`) and your **`.sentryclirc`** file +are still read, so CI credentials keep working as-is. + +## Installation + +Uninstall the old package/binary and install the new one. + +```bash +# npm / pnpm / yarn / bun +npm uninstall -g @sentry/cli +npm install -g sentry # or: pnpm add -g sentry / yarn global add sentry / bun add -g sentry + +# Homebrew +brew uninstall sentry-cli +brew install getsentry/tools/sentry + +# Install script +curl https://cli.sentry.dev/install -fsS | bash + +# One-off, no install +npx sentry@latest --help +``` + +Verify: + +```bash +sentry --version +sentry auth status +``` + +## The `sentry-cli` → `sentry` rename + +If you only ever call the top-level binary, the simplest bridge is a plain +alias so old commands and scripts resolve to the new binary: + +```bash +# ~/.bashrc or ~/.zshrc +alias sentry-cli='sentry' +``` + +This is enough **if** you only used commands whose names didn't move (see the +[table below](#command-changes)). For the commands that did move, use the +[compatibility shim](#drop-in-compatibility-shim) instead of a plain alias. + +For CI, either update your install step to `npm install -g sentry` and call +`sentry`, or add a one-line shim in your job: + +```bash +# GitHub Actions / any CI shell +npm install -g sentry +sentry-cli() { sentry "$@"; } # if you didn't use any moved commands +``` + +## Command changes + +### Still work unchanged + +These are identical, or the old plural form still works as a shortcut: + +| v3 | v4 | +|----|----| +| `sentry-cli info` | `sentry info` | +| `sentry-cli send-event …` | `sentry send-event …` | +| `sentry-cli bash-hook` | `sentry bash-hook` | +| `sentry-cli sourcemaps …` | `sentry sourcemaps …` (or `sentry sourcemap …`) | +| `sentry-cli debug-files …` | `sentry debug-files …` | +| `sentry-cli react-native gradle` | `sentry react-native gradle` | +| `sentry-cli react-native xcode` | `sentry react-native xcode` | + +### Renamed groups (plural → singular) + +Command **groups** are singular now. The plural name still works as a shortcut +for the bare **list** (`sentry releases` → lists releases), but any +**subcommand** must use the singular form: + +| v3 | v4 | +|----|----| +| `sentry-cli organizations …` | `sentry org …` | +| `sentry-cli projects …` | `sentry project …` | +| `sentry-cli releases …` | `sentry release …` | +| `sentry-cli issues …` | `sentry issue …` | +| `sentry-cli monitors …` | `sentry monitor …` | +| `sentry-cli repos …` | `sentry repo …` | +| `sentry-cli events …` | `sentry event …` | + +```bash +# v3 +sentry-cli releases new 1.0.0 +# v4 +sentry release new 1.0.0 +``` + +### Moved commands + +These live under a different group now: + +| v3 | v4 | +|----|----| +| `sentry-cli login` | `sentry auth login` | +| `sentry-cli logout` | `sentry auth logout` | +| `sentry-cli update` | `sentry cli upgrade` | +| `sentry-cli uninstall` | `sentry cli uninstall` | +| `sentry-cli deploys new …` | `sentry release deploy …` (create) | +| `sentry-cli deploys list …` | `sentry release deploys …` (list) | +| `sentry-cli upload-dif …` | `sentry debug-files upload …` | +| `sentry-cli upload-dsym …` | `sentry debug-files upload …` | +| `sentry-cli difutil check …` | `sentry debug-files check …` | +| `sentry-cli upload-proguard …` | `sentry proguard upload …` | + +Most of these were already soft-deprecated in v3 (hidden from `--help` in favor +of `debug-files` / `proguard`); v4 simply drops the legacy top-level spellings. + +`sentry-cli send-envelope` also has no direct v4 equivalent. To send an existing +envelope file, use `sentry event send ./envelope-file --raw` and update any v3 +flags in the call. The shim below cannot translate `send-envelope` for you. + +## Drop-in compatibility shim + +Paste this shell function into your `~/.bashrc` / `~/.zshrc` (or a CI step). It +transparently translates every moved/renamed command to its v4 equivalent, so +existing `sentry-cli …` calls keep working. Anything it doesn't special-case is +passed straight through to `sentry`. + +```bash +sentry-cli() { + # v3 GLOBAL flags that became environment variables in v4 (see the "Global + # flags" section below). They precede the command, so translate only the + # LEADING run and stop at the first command word — this leaves command-level + # flags untouched (notably `--url`, which `release create`/`deploy` use for + # the release/deploy URL, not the Sentry host). Other still-valid v4 globals + # are collected into `lead` and re-applied before the command. + local envs=() lead=() headers="" allow_failure="" login_url="" login_args=() + while [ "$#" -gt 0 ]; do + case "${1:-}" in + --auth-token) envs+=("SENTRY_AUTH_TOKEN=$2" "SENTRY_FORCE_ENV_TOKEN=1"); shift 2 2>/dev/null || shift ;; + --auth-token=*) envs+=("SENTRY_AUTH_TOKEN=${1#*=}" "SENTRY_FORCE_ENV_TOKEN=1"); shift ;; + --url) login_url="$2"; shift 2 2>/dev/null || shift ;; + --url=*) login_url="${1#*=}"; shift ;; + # Multiple --header flags merge into one semicolon-separated var. + --header) headers="${headers:+$headers; }$2"; shift 2 2>/dev/null || shift ;; + --header=*) headers="${headers:+$headers; }${1#*=}"; shift ;; + # Still-valid v4 globals: keep them (value-taking ones consume a value). + --org|--project|--log-level|--fields) lead+=("$1" "$2"); shift 2 2>/dev/null || shift ;; + --org=*|--project=*|--log-level=*|--fields=*) lead+=("$1"); shift ;; + -v|--verbose|--json) lead+=("$1"); shift ;; + # v3 `--allow-failure` (and SENTRY_ALLOW_FAILURE) is gone in v4 and would + # be rejected as unknown. Strip it and emulate its "never fail" behavior. + --allow-failure) allow_failure=1; shift ;; + *) break ;; + esac + done + + # v3 accepts auth, headers and allow-failure after the command too. Keep + # --url on releases/deploys: there it can name the release or deploy URL. + local command="${1:-}" remaining=() + if [ -n "$login_url" ]; then + if [ "$command" = login ]; then login_args=(--url "$login_url") + else envs+=("SENTRY_HOST=$login_url" "SENTRY_URL=$login_url"); fi + fi + while [ "$#" -gt 0 ]; do + case "$1" in + --auth-token) + [ "$#" -ge 2 ] || { remaining+=("$1"); shift; continue; } + envs+=("SENTRY_AUTH_TOKEN=$2" "SENTRY_FORCE_ENV_TOKEN=1"); shift 2 ;; + --auth-token=*) envs+=("SENTRY_AUTH_TOKEN=${1#*=}" "SENTRY_FORCE_ENV_TOKEN=1"); shift ;; + --header) + [ "$#" -ge 2 ] || { remaining+=("$1"); shift; continue; } + headers="${headers:+$headers; }$2"; shift 2 ;; + --header=*) headers="${headers:+$headers; }${1#*=}"; shift ;; + --allow-failure) allow_failure=1; shift ;; + --url) + if [ "$command" = login ] || [ "$command" = releases ] || [ "$command" = deploys ] || [ "$#" -lt 2 ]; then + remaining+=("$1"); shift + else + envs+=("SENTRY_HOST=$2" "SENTRY_URL=$2"); shift 2 + fi ;; + --url=*) + if [ "$command" = login ] || [ "$command" = releases ] || [ "$command" = deploys ]; then + remaining+=("$1") + else + envs+=("SENTRY_HOST=${1#*=}" "SENTRY_URL=${1#*=}") + fi + shift ;; + *) remaining+=("$1"); shift ;; + esac + done + set -- "${remaining[@]}" + [ "${SENTRY_ALLOW_FAILURE:-}" = "1" ] && allow_failure=1 + [ -n "$headers" ] && envs+=("SENTRY_CUSTOM_HEADERS=$headers") + + # `env` runs the real `sentry` (bypassing this function → no recursion), + # re-applying any leading global flags before the translated command. + local run=(env "${envs[@]}" sentry "${lead[@]}") + + _scli_set_host() { + local word inserted="" updated=() + for word in "${run[@]}"; do + if [ -z "$inserted" ] && [ "$word" = sentry ]; then + updated+=("SENTRY_HOST=$1" "SENTRY_URL=$1") + inserted=1 + fi + updated+=("$word") + done + run=("${updated[@]}") + } + + # `deploys` handling (top-level or nested under `releases`). Bare/`list` map to + # `release deploys` (list); only `new` (create) can't be shimmed — v4 takes the + # environment/name as positionals, not v3's `-e`/`-n` flags — so flag those. + local deploy_msg='sentry-cli: `deploys new` changed in v4 — environment/name are positionals now:\n sentry release deploy [name] [--url … --started … --finished …]\n' + _scli_deploys() { + local release="" listed="" dargs=() + while [ "$#" -gt 0 ]; do + case "$1" in + --url) + [ "$#" -ge 2 ] || break + _scli_set_host "$2"; shift 2 ;; + --url=*) _scli_set_host "${1#*=}"; shift ;; + *) break ;; + esac + done + while [ "$#" -gt 0 ]; do + case "$1" in + list) listed=1; shift ;; + -r|--release) + release="${2:-}" + shift 2 2>/dev/null || shift + ;; + --release=*) release="${1#*=}"; shift ;; + new) + if [ -z "$listed" ]; then printf '%b' "$deploy_msg" >&2; return 64; fi + dargs+=("$1"); shift ;; + *) dargs+=("$1"); shift ;; + esac + done + [ -n "$release" ] && dargs=("$release" "${dargs[@]}") + "${run[@]}" release deploys "${dargs[@]}" + } + + _scli_group() { + local grp="$1"; shift + # v3 also accepts global flags between a plural group and its subcommand. + while [ "$#" -gt 0 ]; do + case "$1" in + --org|--project|--log-level|--fields) + [ "$#" -ge 2 ] || break + run+=("$1" "$2"); shift 2 ;; + --org=*|--project=*|--log-level=*|--fields=*|-v|--verbose|--json) + run+=("$1"); shift ;; + --url) + [ "$#" -ge 2 ] || break + _scli_set_host "$2"; shift 2 ;; + --url=*) _scli_set_host "${1#*=}"; shift ;; + *) break ;; + esac + done + + if [ "$grp" = releases ]; then + if [ "${1:-}" = deploys ]; then shift; _scli_deploys "$@"; return; fi + if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then "${run[@]}" release list "$@"; + else "${run[@]}" release "$@"; fi + return + fi + + case "$grp" in + organizations) grp=org ;; + projects) grp=project ;; + issues) grp=issue ;; + monitors) grp=monitor ;; + repos) grp=repo ;; + events) grp=event ;; + esac + # Some groups default to `view`, so an empty group must explicitly list. + if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then "${run[@]}" "$grp" list "$@"; + else "${run[@]}" "$grp" "$@"; fi + } + + _scli_dispatch() { + case "${1:-}" in + # Moved commands + login) shift; "${run[@]}" auth login "${login_args[@]}" "$@" ;; + logout) shift; "${run[@]}" auth logout "$@" ;; + send-envelope) printf 'sentry-cli: use sentry event send --raw and update v3 flags\n' >&2; return 64 ;; + update) shift; "${run[@]}" cli upgrade "$@" ;; + uninstall) shift; "${run[@]}" cli uninstall "$@" ;; + deploys) shift; _scli_deploys "$@" ;; + upload-dif|upload-dsym) shift; "${run[@]}" debug-files upload "$@" ;; + upload-proguard) shift; "${run[@]}" proguard upload "$@" ;; + difutil) shift; "${run[@]}" debug-files "$@" ;; + + # Renamed groups accept v3 globals before the subcommand. + releases|organizations|projects|issues|monitors|repos|events) + _scli_group "$@" ;; + + # Everything else is unchanged + *) "${run[@]}" "$@" ;; + esac + } + + # v3's `--allow-failure`/`SENTRY_ALLOW_FAILURE` made any command exit 0. v4 + # dropped it, so emulate here: swallow a non-zero status when it was set. + if [ -n "$allow_failure" ]; then + _scli_dispatch "$@" || { printf 'sentry-cli: command failed, but --allow-failure/SENTRY_ALLOW_FAILURE was set — exiting 0\n' >&2; return 0; } + else + _scli_dispatch "$@" + fi +} +``` + +:::note +`env` runs the real `sentry` binary, bypassing the function (no infinite +recursion), and applies the translated `--auth-token`/`--url`/`--header` values +for that one call only. In `fish`, port the same preprocessing + `switch`/`case` +into a `function sentry-cli … end`. +::: + +## Global flags and options + +v3 accepted many of the same flags on (nearly) every command. v4 keeps a small +set as **global flags** and moves the rest to environment variables — so the +biggest migration gotcha is flags that silently no longer exist. + +### Still global (work on every command) + +| v3 flag | v4 | +|---------|----| +| `--org ` | `--org` (accepted; also `SENTRY_ORG`) | +| `--project ` | `--project` (accepted; also `SENTRY_PROJECT`, or `org/project` combo) | +| `--log-level ` | `--log-level` | +| — | `-v` / `--verbose` | +| — | `--json`, `--fields` (new — structured output) | + +### Replaced by environment variables + +| v3 flag | v4 replacement | +|---------|----------------| +| `--auth-token ` | `SENTRY_AUTH_TOKEN` plus `SENTRY_FORCE_ENV_TOKEN=1` to override stored OAuth credentials (or `sentry auth login`) | +| `--url ` (self-hosted) | `SENTRY_URL` / `SENTRY_HOST`, or pass the URL as a command argument | +| `--header "K: V"` | `SENTRY_CUSTOM_HEADERS` | + +The [compatibility shim](#drop-in-compatibility-shim) above translates +`--auth-token`, `--url`, and `--header` into these env vars automatically. For +`--auth-token`, it sets both `SENTRY_AUTH_TOKEN` and +`SENTRY_FORCE_ENV_TOKEN=1` so the explicit flag overrides stored OAuth +credentials as it did in v3. + +### Dropped + +- `--quiet` (and its `--silent` alias) has no direct replacement — pipe the + output (non-TTY is plain) or use `--log-level error`. +- `--allow-failure` (and the `SENTRY_ALLOW_FAILURE` env var), which made any + command exit `0` even on error, is **not implemented in v4** and will be + rejected as an unknown flag. Handle failures in your own script (e.g. + `sentry … || true`); the [shim](#drop-in-compatibility-shim) above emulates + the old behavior when it sees the flag or env var. +- `SENTRY_API_KEY` (the v3 `apiKey` option) and `SENTRY_VCS_REMOTE` (the + `vcsRemote` option) are no longer honored — use `SENTRY_AUTH_TOKEN` for auth. +- `SENTRY_CUSTOM_HEADERS` only applies to self-hosted Sentry; custom headers are + ignored for `sentry.io`. + +:::caution +Command-specific flags changed more than the global ones, and some v3 flags +don't exist in v4 yet. A few notable ones: + +- `release set-commits` drops `--ignore-missing` and `--ignore-empty`. +- `release deploy` takes separate positional arguments for the release, + environment, and optional name (`[/] [name]`), + not `--env`/`--name`. +- `sourcemap upload`/`inject` drop several flags (see [Sourcemaps](#sourcemaps)). + +Before relying on a flag, confirm it with `sentry --help` — that's the +authoritative list for v4. If a flag you depend on is missing, please +[open an issue](https://github.com/getsentry/cli/issues). +::: + +## Node.js wrapper (`SentryCli` class) + +v3's `@sentry/cli` package exported a `SentryCli` class for programmatic use: + +```js +// v3 +const SentryCli = require("@sentry/cli"); +const cli = new SentryCli(null, { authToken: process.env.SENTRY_AUTH_TOKEN }); +await cli.releases.new("1.0.0"); +await cli.releases.uploadSourceMaps("1.0.0", { include: ["./dist"] }); +await cli.releases.setCommits("1.0.0", { auto: true }); +await cli.releases.finalize("1.0.0"); +``` + +v4 does **not** ship the `SentryCli` class. Instead, the `sentry` package is +itself usable as a library via `createSentrySDK()`, which exposes a typed method +for **every** command (full reference: [Library Usage](/library-usage/)): + +```js +// v4 +import createSentrySDK from "sentry"; +const sdk = createSentrySDK({ token: process.env.SENTRY_AUTH_TOKEN }); +await sdk.release.create({ orgVersion: "1.0.0" }); +await sdk.sourcemap.upload({ directory: "./dist", release: "1.0.0" }); +await sdk.release["set-commits"]({ orgVersion: "1.0.0", auto: true }); +await sdk.release.finalize({ orgVersion: "1.0.0" }); +``` + +:::note +The `sentry` npm package requires **Node.js 20+** (v3's `@sentry/cli` supported +older runtimes). On Node.js 22.15+ it uses the built-in `node:sqlite` module; on +Node.js 20–22.14 it transparently falls back to a bundled WASM SQLite driver, so +no native module or extra install step is needed. The standalone binary bundles +its own runtime and is unaffected by your installed Node.js version. + +For best performance we strongly recommend the standalone binary or Node.js +22.15+ — the WASM fallback can't use SQLite WAL mode, making its local cache +slower and less concurrency-friendly. +::: + +Mapping: + +| v3 (`@sentry/cli`) | v4 (`sentry`) | +|--------------------|---------------| +| `new SentryCli(configFile, { authToken })` | `createSentrySDK({ token })` (`configFile` dropped) | +| `cli.releases.new(v)` | `sdk.release.create({ orgVersion: v })` | +| `cli.releases.finalize(v)` | `sdk.release.finalize({ orgVersion: v })` | +| `cli.releases.setCommits(v, o)` | `sdk.release["set-commits"]({ orgVersion: v, ...o })` | +| `cli.releases.uploadSourceMaps(v, { include })` | `sdk.sourcemap.upload({ directory, release: v })` | +| `cli.releases.newDeploy(v, { env, name, url })` | `sdk.release.deploy({ orgVersion: v, environment: env, name, url })` | +| `cli.releases.proposeVersion()` | `sdk.release["propose-version"]()` | +| `cli.execute(args)` | `sdk.run(...args)` | + +Note the argument reshaping: the v3 `uploadSourceMaps` `include` array becomes +the `directory` argument of `sourcemap.upload`, and the release is optional +(sourcemap upload is keyed by debug ID, as it has been since v2). + +### Codemod + +To automate the mechanical parts of this migration, run the +[Codemod](https://codemod.com) from your project root (it rewrites the import, +constructor, and method chain, and inserts `// TODO(sentry-v4): …` comments where +option shapes changed and need a manual check). + +For now, run it from the CLI repo's source — clone the repo, then point the +Codemod runner at the transform and your project directory: + +```bash +git clone --depth 1 https://github.com/getsentry/cli /tmp/sentry-cli-src + +# From your project root (-t . targets the current directory) +npx codemod@latest jssg run --language typescript \ + /tmp/sentry-cli-src/codemods/sentry-v3-to-v4/scripts/codemod.ts -t . +``` + +:::note +Once the codemod is published to the [Codemod registry](https://codemod.com), the +shorter form below will work — until then, use the run-from-source command above: + +```bash +npx codemod@latest @sentry/cli-v3-to-v4 +``` +::: + +It rewrites `.js`/`.ts` files in place. Review the diff afterward — argument +shapes differ (especially for `uploadSourceMaps` and `newDeploy`), so the codemod +flags those rather than guessing. See +[`codemods/sentry-v3-to-v4`](https://github.com/getsentry/cli/tree/main/codemods/sentry-v3-to-v4) +for the source and test fixtures. + +## Output and scripting + +v4 produces richer human output (Markdown, rendered on a TTY) but stays +**script-friendly**: + +- **Piping / non-TTY** automatically emits plain text — no ANSI codes. +- **`--json`** on any command emits stable JSON for machines; combine with + `--fields` to select columns. +- Color respects `NO_COLOR`, `FORCE_COLOR`, and `SENTRY_PLAIN_OUTPUT`. + +```bash +# v3: parse text with grep/awk +sentry-cli releases list | awk '{print $1}' + +# v4: query structured JSON +sentry release list --json | jq -r '.[].version' +``` + +If a script parsed the old plain-text tables, switch it to `--json` — it's far +more robust than screen-scraping. + +## Authentication + +v4 adds a browser-based **OAuth device flow** for interactive use, while still +honoring tokens for CI: + +```bash +# Interactive (opens a browser, no token needed) +sentry auth login + +# Check who you are / token validity +sentry auth status +sentry auth whoami # or the top-level: sentry whoami + +# CI / non-interactive — unchanged from v3 +export SENTRY_AUTH_TOKEN=sntrys_… +sentry auth status +``` + +Stored OAuth credentials take precedence over `SENTRY_AUTH_TOKEN` by default. +Set `SENTRY_FORCE_ENV_TOKEN=1` when an environment token must override a stored +login. (v4 also accepts `SENTRY_TOKEN` as an alias for it.) + +Note: there is **no `--auth-token` flag** in v4 — authentication comes from +`sentry auth login`, `SENTRY_AUTH_TOKEN`, or `.sentryclirc`. See +[Global flags](#global-flags-and-options) below. + +## Exit codes + +v3 mostly exited `1` on any error. v4 uses [semantic exit +codes](/exit-codes/) so scripts and CI can branch on the failure category +(`1x` auth, `2x` input/config, `3x` API/network, `4x` feature/billing, …). + +If a script did `sentry-cli … || handle_error`, it still works — any non-zero +code triggers the fallback. Only update it if you were matching the **specific** +value `1`. + +## Sourcemaps + +The command maps `sourcemaps` → `sourcemap` (the plural is aliased, so existing +invocations keep working): + +```bash +# v3 +sentry-cli sourcemaps upload ./dist + +# v4 (either form works) +sentry sourcemap upload ./dist +``` + +Two behavioral differences to be aware of: + +- **Positional args:** v3 accepted multiple paths (`[PATHS]...`); v4 takes a + single `` (both `upload` and `inject`). Run one invocation per + directory. +- **Flags:** v4 `sourcemap upload` keeps `--release`, `--dist`, `--url-prefix`, + `--ext`, `--ignore`, `--ignore-file`, `--strip-prefix`, + `--strip-common-prefix`, `--no-rewrite`, `--allow-empty`. These v3 flags are + **not present** in v4: `--url-suffix`, `--note`, `--validate`, `--decompress`, + `--wait`, `--wait-for`, `--no-sourcemap-reference`, `--debug-id-reference`, + `--bundle`, `--bundle-sourcemap`, `--strict`. `sourcemap inject` also drops + `--release`. Run `sentry sourcemap upload --help` for the current set. +- **`--debug-id-reference` is now automatic:** in v3 that flag let `sourcemaps + upload` take the debug ID from the linked sourcemap when it couldn't verify + one in the bundle itself, for example in binary bundles. + v4 does this by default. If a sourcemap already carries `debug_id` + — from a bundler plugin configured with `sourcemaps.disable: + 'disable-upload'`, or copied across by a tool like React Native's + `copy-debugid.js` — `inject` and `upload` adopt that ID rather than generating a + new one. + +See [`sourcemap`](/commands/sourcemap/) for details. + +## Configuration + +Configuration precedence is unchanged in spirit and fully backward compatible: + +1. CLI flags +2. `SENTRY_ORG` / `SENTRY_PROJECT` env vars (`SENTRY_PROJECT` accepts + `org/project`) +3. Stored defaults (`sentry auth login` / `sentry cli defaults`) +4. DSN auto-detection from your source and `.env` files +5. Directory-name inference + +Your existing **`.sentryclirc`** and `SENTRY_*` environment variables are still +read. See [Configuration](/configuration/) for the full list. + +## Getting help + +- `sentry --help` — top-level command list +- `sentry --help` — details and flags for any command +- [Command reference](/commands/) · [Exit codes](/exit-codes/) · + [Configuration](/configuration/) + +If a command you relied on isn't covered here, please +[open an issue](https://github.com/getsentry/cli/issues) — we want the +migration to be painless. diff --git a/apps/cli-docs/src/content/docs/self-hosted.md b/apps/cli-docs/src/content/docs/self-hosted.md new file mode 100644 index 000000000..0de9b5b48 --- /dev/null +++ b/apps/cli-docs/src/content/docs/self-hosted.md @@ -0,0 +1,113 @@ +--- +title: Self-Hosted Sentry +description: Using the Sentry CLI with a self-hosted Sentry instance +--- + +The CLI works with self-hosted Sentry instances. Set the `SENTRY_HOST` (or `SENTRY_URL`) environment variable to point at your instance: + +```bash +export SENTRY_HOST=https://sentry.example.com +``` + +## Authenticating + +### With OAuth (Sentry 26.1.0+) + +The OAuth device flow requires **Sentry 26.1.0 or later** and a public OAuth application registered on your instance. + +#### 1. Create a Public OAuth Application + +1. In your Sentry instance, go to **Settings → Developer Settings → Applications → Create New Application** (or visit `https://sentry.example.com/settings/account/api/applications/`) +2. Select **Public** as the application type +3. Fill in the required fields (name, redirect URL — can be any placeholder URL) +3. Save the application and copy the **Client ID** + +#### 2. Log In + +Use the `--url` flag to authenticate against your instance (recommended — this registers the host as trusted): + +```bash +SENTRY_CLIENT_ID=your-client-id sentry auth login --url https://sentry.example.com +``` + +Or pass the instance URL via environment variable: + +```bash +SENTRY_HOST=https://sentry.example.com SENTRY_CLIENT_ID=your-client-id sentry auth login --url https://sentry.example.com +``` + +:::tip +You can export both variables in your shell profile so every CLI invocation picks them up: + +```bash +export SENTRY_HOST=https://sentry.example.com +export SENTRY_CLIENT_ID=your-client-id +``` +::: + +:::note +The `--url` flag is the most secure way to authenticate with a new host — it is the only way to register a trust anchor for that host. Without it, the CLI refuses to log in to an instance URL that was picked up from an untrusted channel (e.g. a `.sentryclirc` file), protecting you from credential leaks and OAuth phishing. +::: + +### With an API Token + +If your instance is on an older version or you prefer not to create an OAuth application, you can use an API token instead: + +1. Go to **Settings → Developer Settings → Personal Tokens** in your Sentry instance (or visit `https://sentry.example.com/settings/account/api/auth-tokens/new-token/`) +2. Create a new token with the following scopes: + +`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` + +3. Pass it to the CLI: + +```bash +SENTRY_HOST=https://sentry.example.com sentry auth login --token YOUR_TOKEN --url https://sentry.example.com +``` + +## After Login + +Once authenticated, the CLI stores your instance URL — you don't need to set `SENTRY_URL` on every command. All subsequent commands automatically use the correct instance: + +```bash +sentry issue list +sentry org list +``` + +If you pass a self-hosted Sentry URL as a command argument (e.g., an issue or event URL), the CLI detects the instance automatically. + +## TLS / Corporate Proxies + +If your self-hosted instance sits behind a private CA certificate (common with corporate TLS-intercepting proxies like Zscaler or Netskope), point `NODE_EXTRA_CA_CERTS` at your CA bundle: + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem +``` + +You can also persist this so you don't need the env var on every invocation: + +```bash +sentry cli defaults ca-cert /path/to/corporate-ca.pem +``` + +If your proxy requires custom HTTP headers (e.g. an IAP token), set them with `SENTRY_CUSTOM_HEADERS` or persist them: + +```bash +sentry cli defaults headers "X-IAP: token" +``` + +## Relevant Environment Variables + + +| Variable | Description | +|----------|-------------| +| `SENTRY_HOST` | Base URL of your Sentry instance (takes precedence over `SENTRY_URL`) | +| `SENTRY_URL` | Alias for `SENTRY_HOST` | +| `SENTRY_CLIENT_ID` | Client ID of your public OAuth application | +| `SENTRY_CUSTOM_HEADERS` | Custom HTTP headers for proxy/IAP (semicolon-separated `Name: Value` pairs) | +| `SENTRY_FORCE_ENV_TOKEN` | Force env token over stored OAuth token | +| `SENTRY_ORG` | Default organization slug | +| `SENTRY_PROJECT` | Default project slug (supports `org/project` format) | +| `NODE_EXTRA_CA_CERTS` | Path to PEM file with additional CA certificates (for corporate proxies) | + + +See [Configuration](./configuration/) for the full environment variable reference. diff --git a/apps/cli-docs/src/fonts/dammit-sans-v0.3-bold.otf b/apps/cli-docs/src/fonts/dammit-sans-v0.3-bold.otf new file mode 100755 index 000000000..44cf6cfec Binary files /dev/null and b/apps/cli-docs/src/fonts/dammit-sans-v0.3-bold.otf differ diff --git a/apps/cli-docs/src/fragments/commands/agent-conversation.md b/apps/cli-docs/src/fragments/commands/agent-conversation.md new file mode 100644 index 000000000..9ae15932f --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/agent-conversation.md @@ -0,0 +1,36 @@ + + + +## Examples + +### List conversations + +```bash +# List recent agent conversations +sentry agent-conversation list + +# Explicit organization +sentry agent-conversation list my-org + +# Show more, last 24 hours +sentry agent-conversation list --limit 50 --period 24h + +# Filter conversations +sentry agent-conversation list -q "has:errors" + +# Paginate through results +sentry agent-conversation list my-org -c next +``` + +### View a conversation transcript + +```bash +# View full transcript (org auto-detected) +sentry agent-conversation view conv-123 + +# Explicit org (slash-separated) +sentry agent-conversation view my-org/conv-123 + +# JSON output +sentry agent-conversation view my-org/conv-123 --json +``` diff --git a/apps/cli-docs/src/fragments/commands/alert.md b/apps/cli-docs/src/fragments/commands/alert.md new file mode 100644 index 000000000..ee32b001f --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/alert.md @@ -0,0 +1,91 @@ + + +## Examples + +### Create an issue alert rule + +```bash +# Create an issue alert rule with inline JSON condition/action +sentry alert issues create my-org/my-project \ + --name "Error Spike" \ + --condition '{"type":"first_seen_event","comparison":true,"conditionResult":true}' \ + --action '{"type":"email","data":{},"config":{"targetType":"team","targetIdentifier":"1"}}' +``` + +### List issue alert rules + +```bash +# List issue alert rules for a project +sentry alert issues list my-org/my-project + +# Filter rules by name +sentry alert issues list my-org/my-project --query "spike" +``` + +### View an issue alert rule + +```bash +# View by ID +sentry alert issues view my-org/my-project/12345 + +# View by name +sentry alert issues view my-org/my-project/"Error Spike" +``` + +### Edit an issue alert rule + +```bash +# Edit issue alert name/status +sentry alert issues edit my-org/my-project/12345 --name "Prod Error Spike" --status disabled +``` + +### Delete an issue alert rule + +```bash +# Delete with preview +sentry alert issues delete my-org/my-project/12345 --dry-run +``` + +### Create a metric alert rule + +```bash +# Create an organization metric alert rule +sentry alert metrics create my-org \ + --name "P95 Latency" \ + --query "environment:prod" \ + --aggregate "p95(span.duration)" \ + --dataset spans \ + --time-window 5 \ + --trigger '{"alertThreshold":500,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}' +``` + +### List metric alert rules + +```bash +# List metric alert rules for an organization +sentry alert metrics list my-org/ +``` + +### View a metric alert rule + +```bash +# View by ID +sentry alert metrics view my-org/67890 + +# View by name +sentry alert metrics view my-org/"P95 latency alert" +``` + +### Edit a metric alert rule + +```bash +# Edit metric alert query/window +sentry alert metrics edit my-org/67890 --query "environment:prod event.type:error" --time-window 15 +``` + +### Delete a metric alert rule + +```bash +# Delete without prompt +sentry alert metrics delete my-org/67890 --yes +``` diff --git a/apps/cli-docs/src/fragments/commands/api.md b/apps/cli-docs/src/fragments/commands/api.md new file mode 100644 index 000000000..e42ad8600 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/api.md @@ -0,0 +1,77 @@ + + +## Examples + +Endpoints are relative to `/api/0/` — the prefix is added automatically. Absolute HTTP(S) Sentry URLs also work; their origin is validated against your authenticated host. + +### GET requests + +```bash +# List organizations +sentry api organizations/ + +# Get a specific issue +sentry api issues/123456789/ +``` + +### POST requests + +```bash +# Create a release +sentry api organizations/my-org/releases/ \ + -X POST -F version=1.0.0 + +# With inline JSON body +sentry api issues/123456789/ \ + -X POST -d '{"status": "resolved"}' +``` + +### PUT requests + +```bash +# Update an issue status +sentry api issues/123456789/ \ + -X PUT -F status=resolved + +# Assign an issue +sentry api issues/123456789/ \ + -X PUT --field assignedTo="user@example.com" +``` + +### DELETE requests + +```bash +sentry api projects/my-org/my-project/ -X DELETE +``` + +### Advanced usage + +```bash +# Add custom headers +sentry api organizations/ -H "X-Custom: value" + +# Read body from a file +sentry api projects/my-org/my-project/releases/ -X POST --input release.json + +# Verbose mode (shows full HTTP request/response) +sentry api organizations/ --verbose + +# Preview the request without sending +sentry api organizations/ --dry-run +``` + +### Dataset Names + +When querying the Events API (`/events/` endpoint), valid dataset values are: `spans`, `logs`, `errors`, `tracemetrics`, `profile_functions`, and `uptime_results`. + +### Binary responses + +Endpoints that return binary data — image attachments, minidumps, debug files — are streamed to stdout as raw bytes, so you can redirect them straight to a file: + +```bash +sentry api "https://sentry.io/api/0/projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1" > screenshot.png +``` + +When the response is a PNG or JPEG image **and** you're on a graphics-capable terminal, the image is rendered inline instead of dumping raw bytes into your session. Terminals that speak the newer kitty graphics protocol (kitty, WezTerm, Ghostty, recent Konsole) are used in preference to sixel, which remains the fallback for older terminals. Redirecting or piping stdout always keeps the raw bytes. Set `SENTRY_NO_GRAPHICS=1` (or run `sentry cli defaults graphics off`) to disable inline rendering; `SENTRY_NO_SIXEL` is still honored as a deprecated alias. + +For full API documentation, see the [Sentry API Reference](https://docs.sentry.io/api/). diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md new file mode 100644 index 000000000..bc2f3afaf --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -0,0 +1,147 @@ + + +## Examples + +### OAuth login (recommended) + +```bash +sentry auth +``` + +Bare `sentry auth` logs in when you're logged out and shows status when you're +already authenticated. `sentry auth login` always starts the login flow. + +1. A URL and device code will be displayed +2. Open the URL in your browser +3. Enter the code when prompted +4. Authorize the application +5. The CLI stores the OAuth credentials and, when the server provides a refresh + token, automatically refreshes the access token + +### Token login + +```bash +sentry auth --token YOUR_SENTRY_API_TOKEN +``` + +### Read-only OAuth login + +Request only read-only scopes — useful for tokens handed to AI agents or +CI jobs that should not mutate Sentry state: + +```bash +sentry auth --read-only +``` + +### Custom OAuth scopes + +Request specific scopes (repeatable, comma-separated): + +```bash +sentry auth --scope project:read --scope org:read +sentry auth --scope project:read,event:read +``` + +### Self-hosted Sentry + +Use `--url` (recommended) or the `SENTRY_URL` environment variable: + +```bash +sentry auth --url https://sentry.example.com +SENTRY_URL=https://sentry.example.com sentry auth +``` + +For token-based auth with self-hosted: + +```bash +sentry auth --token YOUR_TOKEN --url https://sentry.example.com +``` + +See [Self-Hosted Sentry](../self-hosted/) for details. + +### Logout + +```bash +sentry auth logout +``` + +### Refresh the OAuth access token + +```bash +sentry auth refresh + +# Refresh with read-only scopes +sentry auth refresh --read-only + +# Refresh with specific scopes +sentry auth refresh --scope project:read --scope org:read +``` + +### Print stored token + +```bash +sentry auth token +``` + +### Check auth status + +```bash +sentry auth status +``` + +``` +✓ Authenticated +User: username +Access token expires: in 4 weeks +Automatic refresh: enabled +``` + +```bash +# Show the raw token +sentry auth status --show-token + +# View current user +sentry auth whoami +``` + +## Credential Storage + +Auth tokens are stored in the Sentry CLI configuration directory +(`$XDG_CONFIG_HOME/sentry/`, defaulting to `~/.config/sentry/`, overridable +with `SENTRY_CONFIG_DIR`) with restricted file permissions. A pre-existing +legacy `~/.sentry/` directory is still honored. + +OAuth access tokens expire. When the server provides a refresh token, the CLI +stores it and refreshes the access token automatically. Persist the +configuration directory across runs to keep automatic refresh working. For +ephemeral CI jobs or sandboxes that cannot persist stored credentials, provide +an API token with `sentry auth login --token` or `SENTRY_AUTH_TOKEN`. + +## Token Precedence + +By default, the CLI checks for auth tokens in the following order: + +1. The stored credential from `sentry auth login` +2. `SENTRY_AUTH_TOKEN` environment variable +3. `SENTRY_TOKEN` environment variable (legacy alias) + +The stored credential takes priority. Stored OAuth credentials support +automatic refresh; manually provided API tokens do not use a refresh token. To +override this precedence and force environment tokens to win, set +`SENTRY_FORCE_ENV_TOKEN=1`. + +When a token comes from an environment variable, the CLI skips expiry checks and automatic refresh. + +## Invalid Token Formatting + +Tokens must be a single line of printable ASCII characters, without spaces. +When preparing an access token for storage or an authenticated request, the CLI +removes surrounding whitespace and ASCII control characters, then rejects any +remaining whitespace, control characters, and non-ASCII characters. It does not +join split lines. + +If you see "Invalid authentication token", copy the complete token again into +the configuration that supplies it. For environment tokens, check +`SENTRY_AUTH_TOKEN` (or the legacy `SENTRY_TOKEN`). For stored credentials, run +`sentry auth login` to replace them. A token rejected for formatting exits with +code `12` (`AUTH_INVALID`). diff --git a/apps/cli-docs/src/fragments/commands/build.md b/apps/cli-docs/src/fragments/commands/build.md new file mode 100644 index 000000000..ffa76e914 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/build.md @@ -0,0 +1,55 @@ + + +## Examples + +```bash +# Upload an Android build (APK or AAB) for size analysis +sentry build upload ./app-release.apk + +# Upload an iOS build (XCArchive directory or IPA) +sentry build upload ./MyApp.xcarchive +sentry build upload ./MyApp.ipa + +# Upload with a build configuration and release notes +sentry build upload ./app.aab --build-configuration Release --release-notes "Nightly" + +# Tag a build with install groups (repeatable) +sentry build upload ./app.aab --install-group qa --install-group beta + +# Attach explicit git metadata (otherwise auto-collected in CI) +sentry build upload ./app.aab --head-sha "$GIT_SHA" --pr-number 42 --base-ref main + +# Download a build artifact by ID +sentry build download 1234567890 + +# Download to a specific path +sentry build download 1234567890 --output ./app.ipa + +# Output the result as JSON +sentry build download 1234567890 --json +``` + +## Important Notes + +- `build upload` supports **Android APK/AAB** and **iOS XCArchive/IPA**. An + XCArchive is a directory; an IPA is converted to an XCArchive layout for + upload. **Sentry SaaS only.** +- iOS caveat: `Assets.car` asset catalogs are **not** parsed into per-asset + images (that required native macOS frameworks), so the server sees the raw + `.car` rather than a per-image breakdown. XCArchive symlinks and Unix file + permissions are preserved. +- Multiple paths may be uploaded at once; the command exits non-zero if any + build fails to upload. +- Git metadata (commit, branch, PR number, repo) is **auto-collected in CI** + (GitHub Actions env vars + the local git repo). Use `--no-git-metadata` to + disable it, `--force-git-metadata` to collect outside CI, or the explicit + `--head-sha` / `--base-sha` / `--head-ref` / `--base-ref` / `--pr-number` / + `--vcs-provider` / `--head-repo-name` / `--base-repo-name` flags to override. +- `build download` fetches a mobile build artifact (APK or IPA) previously + uploaded to Sentry's preprod system for size analysis. **Sentry SaaS only.** +- The build must be installable. Builds that are still processing — or that have + no downloadable artifact — cannot be downloaded. +- Without `--output`, the artifact is saved as + `preprod_artifact_.` in the current directory. +- The organization is resolved from `--org`, `SENTRY_ORG`, config defaults, or a + detected DSN. diff --git a/apps/cli-docs/src/fragments/commands/cli.md b/apps/cli-docs/src/fragments/commands/cli.md new file mode 100644 index 000000000..9053b9483 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/cli.md @@ -0,0 +1,170 @@ + + +## Examples + +### Check for updates + +```bash +sentry cli upgrade --check +``` + +``` +Installation method: curl +Current version: 0.4.0 +Channel: stable +Latest version: 0.5.0 + +Run 'sentry cli upgrade' to update. +``` + +### Upgrade + +```bash +# Upgrade to latest stable +sentry cli upgrade + +# Upgrade to a specific version +sentry cli upgrade 0.5.0 + +# Force re-download +sentry cli upgrade --force +``` + +### Release Channels + +```bash +# Switch to nightly builds +sentry cli upgrade nightly + +# Switch back to stable +sentry cli upgrade stable +``` + +After switching, bare `sentry cli upgrade` will continue tracking that channel. + +| Channel | Description | +|---------|-------------| +| `stable` | Latest stable release (default) | +| `nightly` | Built from `main`, updated on every commit | + +### Installation Detection + +The CLI detects how it was installed and uses the appropriate upgrade method: + +| Method | Detection | +|--------|-----------| +| curl | Binary installed via cli.sentry.dev (XDG: `~/.local/bin`; legacy: `~/.sentry/bin`) | +| brew | Binary in a Homebrew Cellar (`brew install getsentry/tools/sentry`) | +| npm | Globally installed via `npm install -g sentry` | +| pnpm | Globally installed via `pnpm add -g sentry` | +| bun | Globally installed via `bun install -g sentry` | + +Nightly builds are only available as standalone binaries (via the curl install method). Switching to nightly from a package manager install will automatically migrate to a standalone binary. + +### View and manage defaults + +```bash +# Show all current defaults +sentry cli defaults + +# Set default organization +sentry cli defaults org my-org + +# Set default project +sentry cli defaults project my-project + +# Set default Sentry URL (self-hosted) +sentry cli defaults url https://sentry.example.com + +# Set custom HTTP headers (self-hosted, e.g. for IAP/proxies) +sentry cli defaults headers "X-IAP: token" + +# Set a custom CA certificate (self-hosted, behind a TLS proxy) +sentry cli defaults ca-cert /path/to/ca.pem + +# Disable telemetry +sentry cli defaults telemetry off + +# Clear a single default +sentry cli defaults org --clear + +# Clear all defaults +sentry cli defaults --clear +``` + +### Import legacy settings + +Import settings from `.sentryclirc` files used by the legacy `sentry-cli`: + +```bash +# Auto-detect and import .sentryclirc +sentry cli import + +# Preview what would be imported +sentry cli import --dry-run + +# Skip confirmation prompt +sentry cli import --yes + +# Explicitly trust a self-hosted URL +sentry cli import --url https://sentry.example.com + +# Skip API validation of the imported token +sentry cli import --skip-validation +``` + +### Send feedback + +```bash +# Send positive feedback +sentry cli feedback i love this tool + +# Report an issue +sentry cli feedback the issue view is confusing +``` + +Feedback is sent via Sentry's telemetry system. If telemetry is disabled (`SENTRY_CLI_NO_TELEMETRY=1`), feedback cannot be sent. + +### Fix configuration issues + +```bash +sentry cli fix +``` + +### Print shell completions + +```bash +# Print completions for your current shell (auto-detected from $SHELL) +sentry cli completion + +# Generate for a specific shell +sentry cli completion zsh > ~/.local/share/zsh/site-functions/_sentry +eval "$(sentry cli completion bash)" +sentry cli completion fish > ~/.config/fish/completions/sentry.fish +``` + +### Configure shell integration + +```bash +# Run full setup (PATH, completions, agent skills) +sentry cli setup + +# Skip agent skill installation +sentry cli setup --no-agent-skills + +# Skip PATH and completion modifications +sentry cli setup --no-modify-path --no-completions +``` + +### Uninstall + +```bash +# Show what would be removed (dry run) +sentry cli uninstall --dry-run + +# Uninstall, keeping config directory +sentry cli uninstall --yes --keep-config + +# Full uninstall with confirmation +sentry cli uninstall +``` diff --git a/apps/cli-docs/src/fragments/commands/code-mappings.md b/apps/cli-docs/src/fragments/commands/code-mappings.md new file mode 100644 index 000000000..8d47cb6f0 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/code-mappings.md @@ -0,0 +1,36 @@ + + + +## Examples + +```bash +# Upload code mappings from a JSON file +sentry code-mappings upload mappings.json + +# Specify repository explicitly +sentry code-mappings upload mappings.json --repo owner/repo + +# Specify repository and default branch +sentry code-mappings upload mappings.json --repo owner/repo --default-branch develop + +# Output as JSON +sentry code-mappings upload mappings.json --json +``` + +## Input Format + +The JSON file must contain an array of objects with `stackRoot` and `sourceRoot`: + +```json +[ + { "stackRoot": "com/example/module", "sourceRoot": "src/main/java/com/example/module" }, + { "stackRoot": "com/example/other", "sourceRoot": "src/main/java/com/example/other" } +] +``` + +## Important Notes + +- Repository name and default branch are **auto-detected** from git remotes if + not provided via `--repo` and `--default-branch`. +- Requires an Organization Token with `org:ci` scope. +- Mappings are uploaded in batches of 300 per API request. diff --git a/apps/cli-docs/src/fragments/commands/dart-symbol-map.md b/apps/cli-docs/src/fragments/commands/dart-symbol-map.md new file mode 100644 index 000000000..9ff976fcb --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/dart-symbol-map.md @@ -0,0 +1,22 @@ + + +## Examples + +```bash +# Upload a dart symbol map with a debug ID +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json + +# Validate without uploading +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json --no-upload + +# Output as JSON +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json --json +``` + +## Important Notes + +- The `--debug-id` flag is **required** — it associates the map with a native + debug file (dSYM/ELF). The sentry-dart-plugin extracts this automatically. +- The mapping file must be a **JSON array of strings** with an even number of + entries (alternating obfuscated/original name pairs). +- Supported on Sentry SaaS and self-hosted >= 25.8.0. diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md new file mode 100644 index 000000000..6977cc957 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/dashboard.md @@ -0,0 +1,157 @@ + + +## Examples + +### List dashboards + +```bash +# List all dashboards +sentry dashboard list + +# Filter by name pattern +sentry dashboard list "Backend*" + +# Open dashboard list in browser +sentry dashboard list -w +``` + +``` +ID TITLE WIDGETS CREATED +12345 General 4 2024-01-15 +12346 Frontend Performance 6 2024-02-20 +12347 Backend Errors 3 2024-03-10 +``` + +### View a dashboard + +```bash +# View by title +sentry dashboard view 'Frontend Performance' + +# View by ID +sentry dashboard view 12345 + +# Auto-refresh every 30 seconds +sentry dashboard view "Backend Performance" --refresh 30 + +# Open in browser +sentry dashboard view 12345 -w +``` + +``` +Dashboard: Frontend Performance (ID: 12345) +URL: https://my-org.sentry.io/dashboard/12345/ + +Widgets: + #0 Error Count big_number count() + #1 Errors Over Time line count() + #2 Errors by Browser bar count() group by browser.name + #3 Top Endpoints table count(), p95(span.duration) group by transaction +``` + +### Create a dashboard + +```bash +sentry dashboard create 'Frontend Performance' +``` + +``` +Created dashboard: Frontend Performance (ID: 12348) +URL: https://my-org.sentry.io/dashboard/12348/ +``` + +### Add widgets + +```bash +# Simple counter widget +sentry dashboard widget add 'My Dashboard' "Error Count" \ + --display big_number --query count + +# Line chart with group-by +sentry dashboard widget add 'My Dashboard' "Errors by Browser" \ + --display line --query count --group-by browser.name + +# Table with multiple aggregates, sorted descending +sentry dashboard widget add 'My Dashboard' "Top Endpoints" \ + --display table \ + --query count --query p95:span.duration \ + --group-by transaction \ + --sort -count --limit 10 + +# With search filter +sentry dashboard widget add 'My Dashboard' "Slow Requests" \ + --display bar --query p95:span.duration \ + --where "span.op:http.client" \ + --group-by span.description +``` + +### Edit widgets + +```bash +# Change display type +sentry dashboard widget edit 12345 --title 'Error Count' --display bar + +# Rename a widget +sentry dashboard widget edit 'My Dashboard' --index 0 --new-title 'Total Errors' + +# Change the query +sentry dashboard widget edit 12345 --title 'Error Rate' --query p95:span.duration +``` + +### Delete widgets + +```bash +# Delete by title +sentry dashboard widget delete 'My Dashboard' --title 'Error Count' + +# Delete by index +sentry dashboard widget delete 12345 --index 2 +``` + +### View revision history + +```bash +# List revisions by dashboard title +sentry dashboard revisions 'Frontend Performance' + +# List revisions by dashboard ID +sentry dashboard revisions 12345 + +# With explicit org +sentry dashboard revisions my-org 12345 +``` + +### Restore a previous revision + +```bash +# Restore by dashboard title and revision number +sentry dashboard restore 'Frontend Performance' --revision 3 + +# Restore by dashboard ID +sentry dashboard restore 12345 --revision 1 + +# With explicit org +sentry dashboard restore my-org 12345 --revision 1 +``` + +:::tip +Use `sentry dashboard revisions` to find the revision number before restoring. +::: + +## Query Shorthand + +The `--query` flag supports shorthand for aggregate functions: + +| Input | Expands to | +|-------|-----------| +| `count` | `count()` | +| `p95:span.duration` | `p95(span.duration)` | +| `avg:span.duration` | `avg(span.duration)` | +| `count()` | `count()` (passthrough) | + +## Sort Shorthand + +| Input | Meaning | +|-------|---------| +| `count` | Sort by `count()` ascending | +| `-count` | Sort by `count()` descending | diff --git a/apps/cli-docs/src/fragments/commands/debug-files.md b/apps/cli-docs/src/fragments/commands/debug-files.md new file mode 100644 index 000000000..979839c15 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/debug-files.md @@ -0,0 +1,113 @@ + + + +## Examples + +```bash +# Inspect a debug information file (auto-detects the format) +sentry debug-files check ./libexample.so +sentry debug-files check MyApp.dSYM/Contents/Resources/DWARF/MyApp +sentry debug-files check ./app.pdb --json + +# List the source files a debug file references (and whether they're available) +sentry debug-files print-sources ./libexample.so +sentry debug-files print-sources ./app.pdb --json + +# Locate debug files for one or more debug identifiers on disk +sentry debug-files find +sentry debug-files find --type dsym --path ./build +sentry debug-files find --no-cwd --no-well-known -p /symbols --json + +# Bundle a debug file's referenced source files (run on the build machine) +sentry debug-files bundle-sources ./libexample.so +sentry debug-files bundle-sources ./app.pdb --output ./app.src.zip + +# Bundle JVM sources with a debug ID +sentry debug-files bundle-jvm --output ./out --debug-id ./src + +# Exclude additional directories +sentry debug-files bundle-jvm --output ./out --debug-id --exclude generated --exclude build-tools ./src + +# Output as JSON +sentry debug-files bundle-jvm --output ./out --debug-id --json ./src + +# Upload debug information files (scans directories recursively) +sentry debug-files upload ./build +sentry debug-files upload ./libexample.so --include-sources + +# .zip archives are scanned in place; use --no-zips to skip them +sentry debug-files upload ./symbols.zip +sentry debug-files upload ./build --no-zips + +# Restrict by type or debug id, and wait for server-side processing +sentry debug-files upload ./dsyms --type dsym --wait +sentry debug-files upload ./build --id --require-all + +# Unity: upload IL2CPP line mappings (optionally with referenced C# sources) +sentry debug-files upload ./build --il2cpp-mapping +sentry debug-files upload ./build --il2cpp-mapping --include-sources + +# Preview what would be uploaded without uploading (no credentials needed) +sentry debug-files upload ./build --no-upload +``` + +## Notes on `find` + +- `debug-files find` locates debug files **locally** by debug identifier — it + makes no API calls. It searches Xcode's `DerivedData` (for dSYMs, unless + `--no-well-known`), the current directory (unless `--no-cwd`), and any + `--path`/`-p` directories, recursively. +- Restrict the search with `--type`/`-t` (repeatable): `dsym`, `elf`, `pe`, + `pdb`, `portablepdb`, `sourcebundle`, `breakpad`, `proguard`, `jvm`. +- A debug identifier must match exactly, including any PE/PDB age suffix. A + Breakpad symbol file is listed when it matches, but does **not** satisfy the + request (the id is still reported as missing). +- Exits non-zero if any requested identifier could not be located. + +## Important Notes + +- `check`, `print-sources`, `bundle-sources`, and `bundle-jvm` are **local-only** + — they make no network requests. They parse object files in-process + (Mach-O/dSYM, ELF, PE/PDB, Portable PDB, WebAssembly, Breakpad, source bundles) + via a bundled `symbolic` WASM module. +- `check` exits non-zero if the file is not usable for symbolication (no debug + id or no useful features). +- `print-sources` lists the source files each object references, reporting for + each whether the source is embedded in the debug file, available via a source + link, or present on the local disk. It is a read-only preview of what + `bundle-sources` would collect and always exits zero on a parseable file. +- `bundle-sources` reads source files from the paths recorded in the debug info, + so it is normally run on the build machine right after compiling. Referenced + files that are not present locally are skipped; it exits non-zero (writing + nothing) when none are found. The bundle defaults to `.src.zip` and is + uploaded via `sentry debug-files upload`. +- `upload` scans each path (files or directories, walked recursively) for + native debug information files, parses them in-process, and uploads matching + files via the chunk-upload protocol. Use `--type`/`--id` to restrict which + files are sent, `--no-debug`/`--no-unwind`/`--no-sources` to drop files whose + only useful feature is the named one, and `--include-sources` to attach a + source bundle per file. `.zip` archives are scanned in place by default (their + entries run through the same filters; nested archives are not recursed) — pass + `--no-zips` to skip them. `--derived-data` additionally scans Xcode's + `~/Library/Developer/Xcode/DerivedData` folder (macOS only). `--no-upload` + previews the selection without credentials; `--wait`/`--wait-for` block on + server-side processing and exit non-zero if any file fails. `--require-all` + fails if a requested `--id` was not found. The server-advertised maximum file + size and maximum processing wait are honored automatically (oversized files + are skipped with a warning). BCSymbolMap resolution (the legacy + `--symbol-maps` flag) is intentionally unsupported — it only applies to Apple + Bitcode, which Apple has deprecated and the App Store no longer accepts. Use + the legacy Rust `sentry-cli` if you still need it. +- Managed .NET PE assemblies that embed a Portable PDB have it extracted and + uploaded automatically as a separate `.pdb` debug file (no flag needed). +- `--il2cpp-mapping` computes Unity IL2CPP C++→C# line mappings from each file's + referenced generated C++ sources and uploads them as separate `il2cpp` debug + files. Combine with `--include-sources` to also bundle the referenced C# + source files. +- Upload a JVM bundle separately via `sentry debug-files upload --type jvm`. +- Supported JVM source file extensions: `.java`, `.kt`, `.scala`, `.sc`, + `.groovy`, `.gvy`, `.gy`, `.gsh`, `.clj`, `.cljc` +- Build output directories (`build/`, `target/`, `out/`, `bin/`) are + automatically excluded unless they appear under a `src/` ancestor. +- Source-set prefixes (e.g., `src/main/java/`) are stripped to produce + package-relative paths matching JVM stack traces. diff --git a/apps/cli-docs/src/fragments/commands/docs.md b/apps/cli-docs/src/fragments/commands/docs.md new file mode 100644 index 000000000..c1b073749 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/docs.md @@ -0,0 +1,24 @@ +## Examples + +### Ask a documentation question + +```bash +sentry docs "How do I configure tracing in Next.js?" +``` + +The answer includes inline links and a Sources section containing the Sentry +documentation pages used to answer the question. + +### Search the documentation index + +```bash +sentry docs list "source maps" +``` + +``` +TITLE DESCRIPTION URL +Source Maps Upload source maps for JavaScript https://docs.sentry.io/... +``` + +Use `--limit` to restrict the number of results or `--json` for structured +output. diff --git a/apps/cli-docs/src/fragments/commands/event.md b/apps/cli-docs/src/fragments/commands/event.md new file mode 100644 index 000000000..1cb7e39fb --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/event.md @@ -0,0 +1,146 @@ + + + +## Examples + +### Sending Events + +```bash +# Send an error event (default level) +sentry event send -m "Something went wrong" + +# Specify level, release, and environment +sentry event send -m "Deploy check" -l info -r 1.0.0 -E production + +# Add tags and extra data +sentry event send -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 + +# Set user context +sentry event send -m "Login error" --user id:42 --user email:alice@example.com + +# Custom fingerprint to group related events together +sentry event send -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} +``` + +### Send from a JSON file + +```bash +# Send a serialized Sentry Event object +sentry event send ./crash.json + +# Send without re-parsing (raw mode — also supports pre-built envelopes) +sentry event send --raw ./crash.json +sentry event send --raw ./captured.envelope +``` + +Prefix relative file paths with `./` to distinguish them from a project target. + +### DSN authentication + +`sentry event send` authenticates with ingest via a **DSN**, not a user token. +An explicit DSN, `SENTRY_DSN`, and project auto-detection do not require login. +Passing `` or `/` does: the CLI uses your session to +resolve the project and fetch its active DSN, then sends to ingest. If a project +has multiple active DSNs, pass the desired DSN explicitly. + +The DSN is resolved in priority order: + +1. A leading ``, ``, or `/` positional +2. `SENTRY_DSN` environment variable +3. Auto-detection from the current project (`.env`, source code, env files) + +```bash +# Explicit DSN +sentry event send "https://key@o123.ingest.us.sentry.io/456" -m "Test" + +# Via environment variable +export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" +sentry event send -m "Test" + +# Project target (logged-in session; CLI fetches its sole active DSN) +sentry event send cli -m "Test" + +# Org/project target +sentry event send sentry/cli -m "Test" + +# Auto-detect from the current project +sentry event send -m "Test" +``` + +### Listing Events + +```bash +# List events for an issue (using short ID) +sentry event list PROJ-ABC + +# List events for an issue (using numeric ID) +sentry event list 123456789 + +# Filter by search query +sentry event list PROJ-ABC --query "browser:Chrome" + +# Include full event bodies (stacktraces) +sentry event list PROJ-ABC --full + +# Limit results and time range +sentry event list PROJ-ABC --limit 50 --period 24h + +# Paginate through results +sentry event list PROJ-ABC -c next +sentry event list PROJ-ABC -c prev + +# Output as JSON +sentry event list PROJ-ABC --json +``` + +### Viewing Events + +```bash +sentry event view abc123def456abc123def456abc12345 +``` + +``` +Event: abc123def456abc123def456abc12345 +Issue: FRONT-ABC +Timestamp: 2024-01-20 14:22:00 + +Exception: + TypeError: Cannot read property 'foo' of undefined + at processData (app.js:123:45) + at handleClick (app.js:89:12) + at HTMLButtonElement.onclick (app.js:45:8) + +Tags: + browser: Chrome 120 + os: Windows 10 + environment: production + release: 1.2.3 + +Context: + url: https://example.com/app + user_id: 12345 +``` + +```bash +# Open in browser +sentry event view abc123def456abc123def456abc12345 -w + +# Download an attachment listed by `sentry event view --json` +sentry api "https://sentry.io/api/0/projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1" > screenshot.png +``` + +## Finding Event IDs + +Event IDs can be found: + +1. In the Sentry UI when viewing an issue's events +2. In the output of `sentry issue view` commands +3. In error reports sent to Sentry (as `event_id`) + +## Backward compatibility + +The old sentry-cli top-level command is available as a hidden alias: + +```bash +sentry send-event # same as: sentry event send +``` diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md new file mode 100644 index 000000000..70adfa6c9 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -0,0 +1,116 @@ + + +## Examples + +### Top errors (default) + +```bash +# Top errors in the last 24 hours, scoped to a project +sentry explore my-org/cli + +# All projects in an org +sentry explore my-org/ + +# Bare project slug (searches across orgs) +sentry explore cli + +# Auto-detect from DSN/config +sentry explore +``` + +### Spike analysis + +```bash +# Errors with user impact for a specific UTC window +sentry explore my-org/cli -F title -F "count()" -F "count_unique(user)" \ + --period "2024-01-15T00:00:00Z/2024-01-16T00:00:00Z" + +# Filter by specific error type (combines with auto-injected project filter) +sentry explore my-org/cli -F title -F "count()" \ + -q "error.type:TypeError" --period 1h +``` + +### Span queries (performance) + +```bash +# Span operation latency by route +sentry explore my-org/cli -F span.op -F "p50(span.duration)" \ + -F "p95(span.duration)" --dataset spans --period 1h + +# Top spans by count +sentry explore my-org/cli -F span.op -F "count()" \ + --dataset spans --sort "-count()" +``` + +### Metrics + +Use `--metric` (`-m`) to query metrics by name. The CLI auto-resolves the metric's type and unit. + +```bash +# Sum a custom metric (e.g., LLM token usage) across an org +sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d + +# Break down by a tag column (e.g., model name) +sentry explore my-org/seer -F gen_ai.request.model \ + -m llm.token_usage --dataset metrics --period 7d + +# Use a different aggregation (default is sum) +sentry explore my-org/ -m cache.hit_rate --agg avg --dataset metrics +``` + +You can also use the raw tracemetrics format: `aggregation(value,metric_name,metric_type,unit)`. + +```bash +sentry explore my-org/ \ + -F "sum(value,llm.token_usage,distribution,none)" \ + --dataset metrics --period 7d +``` + +### Replays + +```bash +# List recent replays +sentry explore my-org/cli --dataset replays --period 24h + +# Filter replays by activity level +sentry explore my-org/cli --dataset replays -F activity -F duration \ + -F "count_errors" --period 7d +``` + +### Logs + +```bash +# Log severity counts in the last hour +sentry explore my-org/cli -F severity -F "count()" \ + --dataset logs --period 1h +``` + +### JSON output for scripting + +```bash +# Pipe to jq for filtering +sentry explore my-org/cli -F title -F "count()" --json | jq '.data[:5]' + +# Get raw data for analysis +sentry explore my-org/cli -F title -F "count()" -F "count_unique(user)" \ + --json --limit 100 +``` + +## Datasets + +| Dataset | Description | +|---------|-------------| +| `errors` | Error events (default) | +| `spans` | Span/transaction data for performance analysis | +| `metrics` | Custom and built-in metrics | +| `logs` | Structured log entries | +| `replays` | Session replay recordings | + +## Target Patterns + +| Target | Behavior | +|--------|----------| +| `/` | Auto-adds `project:` to query | +| `/` | All projects in org (no project filter) | +| `` | Searches for project across all accessible orgs | +| (omitted) | Auto-detect from DSN/config | diff --git a/apps/cli-docs/src/fragments/commands/feedback.md b/apps/cli-docs/src/fragments/commands/feedback.md new file mode 100644 index 000000000..b98ffb417 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/feedback.md @@ -0,0 +1,62 @@ +## Examples + +### List User Feedback + +Modern User Feedback is stored as Feedback issues. The command always limits +issue searches to `issue.category:feedback`; it does not use the legacy User +Reports API. + +```bash +# Auto-detect the organization from the current project +sentry feedback list + +# List Feedback for one project +sentry feedback list my-org/frontend + +# List Feedback across every project in an organization +sentry feedback list my-org/ + +# Search for a project across accessible organizations +sentry feedback list frontend +``` + +The unresolved inbox from the last 14 days is shown by default. Select another +mailbox or expand the time range with flags: + +```bash +sentry feedback list my-org/frontend --status resolved +sentry feedback list my-org/frontend --status spam +sentry feedback list my-org/frontend --status all --period 90d +sentry feedback list my-org/frontend --query "message:*checkout*" +``` + +Use `--json` for the standard paginated envelope. Navigate pages in either +direction with `--cursor next` and `--cursor prev`. + +### View User Feedback + +```bash +# Most recent unresolved Feedback, with detected or explicit organization +sentry feedback view @latest +sentry feedback view my-org/@latest + +# Short ID or numeric ID +sentry feedback view FRONTEND-2SDJ +sentry feedback view 5146636313 + +# Explicit organization +sentry feedback view my-org/FRONTEND-2SDJ + +# `view` is the default command; `show` is an alias +sentry feedback my-org/FRONTEND-2SDJ +sentry feedback show my-org/FRONTEND-2SDJ + +# Open the Feedback item in Sentry +sentry feedback view my-org/FRONTEND-2SDJ --web +``` + +`@latest` selects the most recently active unresolved Feedback. + +The detail view includes the complete message and, when available, its latest +event, linked error, Session Replays, and attachment metadata. If the supplied +ID belongs to another issue category, use `sentry issue view` instead. diff --git a/apps/cli-docs/src/fragments/commands/index.md b/apps/cli-docs/src/fragments/commands/index.md new file mode 100644 index 000000000..d1ab7faf7 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/index.md @@ -0,0 +1,26 @@ + + +## Global Options + +All commands support the following global options: + +- `--help` - Show help for the command +- `--version` - Show CLI version +- `--log-level ` - Set log verbosity (`error`, `warn`, `log`, `info`, `debug`, `trace`). Overrides `SENTRY_LOG_LEVEL` +- `--verbose` - Shorthand for `--log-level debug` + +## JSON Output + +Most list and view commands support `--json` flag for JSON output, making it easy to integrate with other tools: + +```bash +sentry org list --json | jq '.[] | .slug' +``` + +## Opening in Browser + +View commands support `-w` or `--web` flag to open the resource in your browser: + +```bash +sentry issue view PROJ-123 -w +``` diff --git a/apps/cli-docs/src/fragments/commands/info.md b/apps/cli-docs/src/fragments/commands/info.md new file mode 100644 index 000000000..de341e208 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/info.md @@ -0,0 +1,25 @@ +## Examples + +```bash +# Print the resolved config and verify authentication +sentry info + +# Verify only authentication (don't require a default org/project) +sentry info --no-defaults + +# Machine-readable status for external tooling (always exits 0) +sentry info --config-status-json +``` + +## Important Notes + +- `info` prints the resolved Sentry server URL and the default organization and + project, then verifies your credentials against the server. +- The server URL comes from `SENTRY_URL`, the stored default, or the SaaS + default; org/project come from `SENTRY_ORG`/`SENTRY_PROJECT` or stored + defaults. `have_dsn` reflects whether `SENTRY_DSN` is set. +- Exits non-zero when authentication fails, or (unless `--no-defaults`) when no + default organization/project is configured. +- `--config-status-json` emits a JSON status object (`config`, `auth`, + `have_dsn`) for external tooling and **always exits 0** — it is a status + report, not a check. For general machine-readable output use `--json`. diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md new file mode 100644 index 000000000..12cf0eff5 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -0,0 +1,70 @@ + + +> **Experimental:** `sentry init` is experimental and may modify your source files. Always review changes before committing. + +**Authentication:** Interactive runs start the OAuth login flow automatically when credentials are missing or the stored session has expired. Non-interactive and CI runs must authenticate before running `sentry init`. + +## Examples + +```bash +# Interactive setup +sentry init + +# Non-interactive agent/CI setup +sentry init --yes --features errors,tracing,replay + +# Dry run to preview changes +sentry init --dry-run + +# Target a subdirectory +sentry init ./my-app + +# Use a specific org (auto-detect project) +sentry init acme/ + +# Use a specific org and project +sentry init acme/my-app + +# Assign a team when creating a new project +sentry init acme/ --team backend + +# Enable specific features +sentry init --features profiling,replay +``` + +## Target Syntax + +| Syntax | Meaning | +|--------|---------| +| _(omitted)_ | Auto-detect org and project | +| `acme/` | Use org `acme`, auto-detect or create project | +| `acme/my-app` | Use org `acme` and project `my-app` | +| `my-app` | Search for project `my-app` across all accessible orgs | + +Path-like arguments (starting with `.`, `/`, or `~`) are always treated as the directory. The order of target and directory can be swapped — the CLI will auto-correct with a warning. + +## Available Features + +| Feature | Description | +|---------|-------------| +| `errors` | Error monitoring | +| `tracing` | Performance tracing | +| `logs` | Log integration | +| `replay` | Session replay | +| `profiling` | Profiling | +| `crons` | Cron job monitoring | +| `agent-tracing` | Agent tracing for AI/LLM apps | +| `mcp-observability` | MCP (Model Context Protocol) observability | + +## What the Wizard Does + +1. **Detects your framework** — scans your project files to identify the platform and framework +2. **Installs the SDK** — adds the appropriate Sentry SDK package to your project +3. **Instruments your code** — configures error monitoring, tracing, and any selected features + +### Supported Platforms + +- **JavaScript / TypeScript** — Next.js, Express, SvelteKit, React +- **Python** — Flask, FastAPI + +More platforms and frameworks are coming soon. diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md new file mode 100644 index 000000000..531e2c2be --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -0,0 +1,320 @@ + + +## Examples + +### List issues + +```bash +# List issues in a specific project +sentry issue list my-org/frontend + +# All projects in an org +sentry issue list my-org/ + +# Search for a project across organizations +sentry issue list frontend +``` + +``` +ID SHORT ID TITLE COUNT USERS +123456789 FRONT-ABC TypeError: Cannot read prop... 1.2k 234 +987654321 FRONT-DEF ReferenceError: x is not de... 456 89 +``` + +**Filter by status and search:** + +```bash +# Show only unresolved issues +sentry issue list my-org/frontend --query "is:unresolved" + +# Show resolved issues +sentry issue list my-org/frontend --query "is:resolved" + +# Sort by frequency +sentry issue list my-org/frontend --sort freq --limit 20 + +# Sort by Sentry's "recommended" relevance ranking (the default on sentry.io; +# self-hosted instances default to "date" and require a recent Sentry version +# to accept --sort recommended) +sentry issue list my-org/frontend --sort recommended + +# Multiple filters (space-separated = implicit AND) +sentry issue list --query "is:unresolved level:error assigned:me" + +# Negation and wildcards +sentry issue list --query "!browser:Chrome message:*timeout*" + +# Match multiple values for one key (in-list syntax) +sentry issue list --query "browser:[Chrome,Firefox]" +``` + +:::caution[Search syntax] +Sentry search uses **implicit AND** — space-separated terms are all required. +**AND/OR operators are not supported** for issue search. Use alternatives: +- `key:[val1,val2]` — in-list syntax (matches val1 OR val2 for one key) +- Run separate queries for different terms +- `*term*` — wildcard matching + +Full syntax reference: [Sentry Search Docs](https://docs.sentry.io/concepts/search/) +::: + +### Magic selectors + +Use `@latest` and `@most_frequent` to target issues without knowing their ID: + +```bash +# View the most recent issue +sentry issue view @latest + +# Explain the most frequently occurring issue +sentry issue explain @most_frequent + +# Generate a fix plan for the latest issue +sentry issue plan @latest +``` + +### Common mistakes + +- **Don't use the issue command name as an issue prefix** — `sentry issue explain issue-1` is parsed as project `issue` + suffix `1`. Use a real project prefix: `sentry issue explain FRONT-1` or a numeric ID: `sentry issue explain 123456789`. +- **Issue short IDs use uppercase prefixes** — `CLI-G`, not `cli-g`. The CLI tolerates lowercase input in most commands, but agents should prefer the canonical form. +- **`org/project` targets use slugs, not numeric IDs** — `sentry issue list my-org/6775615880` fails when `6775615880` is a project ID copied from a URL. Run `sentry project list my-org/` to find the slug (e.g. `frontend`). +- **Prefer auto-detect over guessing slugs** — omit the target when possible; the CLI resolves org/project from DSNs, `.env` files, and your working directory. + +### List events for an issue + +```bash +# List recent events for an issue +sentry issue events FRONT-ABC + +# Filter events by search query +sentry issue events FRONT-ABC --query "browser:Chrome" + +# Show full event details +sentry issue events FRONT-ABC --full + +# Limit results and filter by time period +sentry issue events FRONT-ABC --limit 50 --period 24h + +# Paginate through results +sentry issue events FRONT-ABC -c next +``` + +### View an issue + +```bash +sentry issue view FRONT-ABC + +# Multiple issues in one invocation (space-separated, not commas) +sentry issue view FRONT-ABC BACK-2 +``` + +``` +Issue: TypeError: Cannot read property 'foo' of undefined +Short ID: FRONT-ABC +Status: unresolved +First seen: 2024-01-15 10:30:00 +Last seen: 2024-01-20 14:22:00 +Events: 1,234 +Users affected: 234 + +Latest event: + Browser: Chrome 120 + OS: Windows 10 + URL: https://example.com/app +``` + +```bash +# Open one or more issues in the browser (up to 5 tabs by default) +sentry issue view FRONT-ABC BACK-2 -w + +# Explicitly allow more than 5 tabs +sentry issue view FRONT-ABC BACK-2 API-3 WEB-4 IOS-5 OPS-6 -w --force +``` + +```bash +# GitHub-style identifiers work too (the "#" replaces the final slash) +sentry issue view my-org/my-project#FRONT-ABC +sentry issue view my-project#FRONT-ABC +``` + +**JSON output for agents and scripting:** + +`--json` returns the issue fields at the top level plus the latest event under +`event`, the resolved `org` slug, related `replayIds`, and `trace` context. +Prefer this over the human output when parsing programmatically. One issue ID +still returns a single object; multiple IDs return an array of those objects. + +```bash +# Full JSON (issue fields + latest event + trace/replay context) +sentry issue view FRONT-ABC --json + +# Multiple issues: JSON is an array of the same objects +sentry issue view FRONT-ABC BACK-2 --json + +# Select specific top-level fields to keep output small +sentry issue view FRONT-ABC --json --fields shortId,title,culprit,count,userCount,permalink + +# Pull named fields off the latest event instead of the whole `event` object — +# the event's `request` entry can include live session data (cookies, headers, +# body), so extract only what you need +sentry issue view FRONT-ABC --json --fields event.id,event.title,event.dateCreated +``` + +Common `jq` shapes for the `--json` output. The latest event's data lives under +`event.entries[]`, each tagged with a `type` (`"exception"`, `"request"`, +`"breadcrumbs"`, …) and a `data` payload — not as top-level `event.request` / +`event.exception` keys: + +```bash +# Issue summary +sentry issue view FRONT-ABC --json | jq '{shortId, title, count, userCount, permalink}' + +# Latest event id + culprit +sentry issue view FRONT-ABC --json | jq '{event: .event.id, culprit}' + +# Just the request URL and method (avoids the full request/session blob) +sentry issue view FRONT-ABC --json | jq '.event.entries[] | select(.type == "request") | .data | {url, method}' + +# Exception type and value from the latest event +sentry issue view FRONT-ABC --json | jq '.event.entries[] | select(.type == "exception") | .data.values[0] | {type, value}' +``` + +### Explain issues with Seer AI + +```bash +# Analyze root cause (may take a few minutes for new issues) +sentry issue explain 123456789 + +# By short ID with org prefix +sentry issue explain my-org/MYPROJECT-ABC + +# Analyze multiple issues in one invocation +sentry issue explain FRONT-ABC BACK-2 + +# Force a fresh analysis +sentry issue explain 123456789 --force +``` + +With `--json`, one explained issue preserves the existing array of root causes. +Multiple issues return an array of labeled objects containing `issue`, `org`, +`issueId`, and `rootCauses`. + +### Generate a plan with Seer AI + +```bash +# Generate a fix plan (automatically runs explain if needed) +sentry issue plan 123456789 + +# Force a fresh plan even if one already exists +sentry issue plan 123456789 --force +``` + +**Requirements:** + +- Seer AI enabled for your organization +- GitHub integration configured with repository access +- Code mappings set up to link stack frames to source files +- Root cause analysis is run automatically if needed (the `plan` command triggers `explain` first) + +### Resolve and reopen issues + +```bash +# Resolve immediately (no regression tracking) +sentry issue resolve CLI-G5 + +# Resolve in a specific release — future events on newer releases are +# regression-flagged +sentry issue resolve CLI-G5 --in 0.26.1 + +# Monorepo-style releases work too (no special parsing) +sentry issue resolve CLI-G5 --in spotlight@1.2.3 + +# Resolve in the next release (tied to current HEAD) +sentry issue resolve CLI-G5 --in @next +sentry issue resolve CLI-G5 -i @next + +# Resolve in the current git HEAD — auto-detects the Sentry repo from +# your git origin remote (hard-errors if it can't) +sentry issue resolve CLI-G5 --in @commit + +# Explicit commit + repo (no git inspection; repo must be registered in Sentry) +sentry issue resolve CLI-G5 --in @commit:getsentry/cli@abc123def + +# Reopen a resolved issue +sentry issue unresolve CLI-G5 +sentry issue reopen CLI-G5 # alias +``` + +:::note[How `@commit` auto-detects] +`--in @commit` reads `HEAD` and the `origin` remote, parses the remote as +`owner/repo`, then looks it up in your org's Sentry repositories (cached +locally for 7 days). If any step fails, the command stops with a clear +error pointing you at `--in @commit:@` or `sentry repo list /` +— no silent fallback to a different resolution mode. +::: + +### Merge fragmented issues + +Consolidate multiple issues (e.g. same logical error split by Sentry's +default stack-trace grouping) into a single canonical group: + +```bash +# Let Sentry auto-pick the parent (typically the largest by event count) +sentry issue merge CLI-K9 CLI-15H CLI-15N + +# Pin the canonical parent explicitly — accepts the same formats as +# positional args, including org-qualified and project-alias forms +sentry issue merge CLI-K9 CLI-15H CLI-15N --into CLI-K9 +sentry issue merge my-org/CLI-K9 my-org/CLI-15H --into my-org/CLI-K9 +sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form + +# Cross-org merges are rejected — all issues must share an organization +# Non-error issue types (performance, info, etc.) cannot be merged +``` + +### Archive and ignore issues + +Archive an issue to suppress alerts. Without `--until`, the issue is archived +forever. Use `--until` to set a condition for automatic unarchival: + +```bash +# Archive forever (fully silenced) +sentry issue archive CLI-G5 + +# Smart detection — unarchives when Sentry detects a spike in event frequency +sentry issue archive CLI-G5 --until auto + +# Duration-based +sentry issue archive CLI-G5 --until 1h # 1 hour +sentry issue archive CLI-G5 --until 7d # 7 days +sentry issue archive CLI-G5 --until 2026-12-31 # specific date + +# Count-based — unarchive after N more events +sentry issue archive CLI-G5 --until 100x + +# User-based — unarchive after N more users affected +sentry issue archive CLI-G5 --until 10u + +# Compound — count within a time window +sentry issue archive CLI-G5 --until 100x/1h # 100 events within 1 hour +sentry issue archive CLI-G5 --until 10u/1d # 10 users within 1 day + +# Verbose forms also work +sentry issue archive CLI-G5 --until 10events/2hours + +# 'ignore' is an alias for 'archive' +sentry issue ignore CLI-G5 --until auto +``` + +:::tip[`--until` syntax reference] +| Format | Meaning | +|--------|---------| +| `auto` | Unarchive on event frequency spike (recommended) | +| `30m`, `1h`, `7d`, `1w` | Duration (minutes, hours, days, weeks) | +| `2026-05-15` | Absolute date (computed as time delta) | +| `10x` or `10events` | After 10 more events | +| `10u` or `10users` | After 10 more users affected | +| `10x/5m` | 10 events within 5 minutes | +| `10users/2hours` | 10 users within 2 hours | +| *(omitted)* | Archive forever | +::: diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md new file mode 100644 index 000000000..92a571f8f --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -0,0 +1,187 @@ + + +`sentry local` runs a local development server that captures Sentry SDK envelopes from your dev stack and surfaces errors, traces, and logs in real time — right in your terminal. No authentication required. + +No DSN is required either. If your app has no DSN configured, events flow **only** to the local server — nothing reaches your Sentry organization and no production quota is used. If a DSN *is* set, the SDK sends to both Sentry and the local server. + +If a server is already running on the port, the command attaches as an SSE consumer instead of starting a duplicate. + +## Examples + +```bash +# Start the server and tail events (default) +sentry local + +# Run your app with the local server auto-enabled +sentry local run -- npm run dev +sentry local run -- python manage.py runserver + +# Use a custom port +sentry local --port 9000 + +# Only show errors and logs (filter out transactions) +sentry local -f error -f log + +# Run quietly (suppress per-envelope tail output) +sentry local --quiet +``` + +## `sentry local run` + +Runs a command with `SENTRY_SPOTLIGHT` injected into the environment. The Sentry SDK automatically detects this variable and sends envelopes to the local server. No code changes needed. + +If nothing is listening on the port, a server is started in the background and shut down when your command exits. If something already is — the Spotlight desktop app's own sidecar, or a `sentry local serve` in another terminal — the command attaches to it as an SSE consumer, so events still tail to your terminal either way. + +Env vars injected into the child process: + +| Variable | Value | +|----------|-------| +| `SENTRY_SPOTLIGHT` | `http://localhost:/stream` | +| `SENTRY_SPOTLIGHT` | `http://localhost:/stream` | +| `SENTRY_TRACES_SAMPLE_RATE` | `1` (unless already set) | +| `SENTRY_RELEASE` | `sentry-cli-local` (unless already set) | + +The `` variants cover every common framework client prefix so the spotlight URL is inlined into your browser bundle no matter which bundler you use: `PUBLIC_` (SvelteKit, Astro, Qwik), `NEXT_PUBLIC_` (Next.js), `VITE_` (Vite), `NUXT_PUBLIC_` (Nuxt), `REACT_APP_` (Create React App), `VUE_APP_` (Vue CLI), and `GATSBY_` (Gatsby). + +**Server vs. client.** Server-side SDKs (`@sentry/node`, Python, and friends) read `SENTRY_SPOTLIGHT` automatically — no code changes needed. + +**Cloudflare Workers.** Wrangler does not expose inherited process environment variables to Worker code. When `local run` detects `wrangler dev` together with a `wrangler.json`, `wrangler.jsonc`, or `wrangler.toml`, it automatically adds `--var SENTRY_SPOTLIGHT:http://localhost:/stream`. + +This creates an ephemeral Worker binding that `@sentry/cloudflare` reads from its `env` object, so you do not need `spotlight: true` in `withSentry()` and no project file is modified. An explicit `--var SENTRY_SPOTLIGHT:...` is preserved. + +For browser/client events, the CLI exposes the spotlight URL under every framework client prefix above. Once the [browser SDK reads these variables automatically](https://github.com/getsentry/sentry-javascript/pull/18198), client-side capture will be zero-config too. **Until then**, reference the variable matching your framework in your client config: + +```ts +// Next.js example — other frameworks use their own env access pattern +// (e.g. import.meta.env.VITE_SENTRY_SPOTLIGHT for Vite-based frameworks). +Sentry.init({ spotlight: process.env.NEXT_PUBLIC_SENTRY_SPOTLIGHT ?? false }); +``` + +## Browser UI + +Use `--open` to launch the Sentry Local UI ([local.sentry.dev](https://local.sentry.dev)) in your browser. The UI connects to the local receiver via the loopback stream endpoint and provides a visual workspace for browsing errors, traces, logs, and AI spans captured during the session. + +```bash +# Start the server and open the UI +sentry local --open + +# Run your app with the UI +sentry local run --open -- npm run dev +``` + +The `--open` flag requires a loopback `--host` (localhost, 127.0.0.1, or ::1). The UI is read-only — it reads the SSE stream but cannot ingest or clear data. Session data stays in memory for the duration of the server; nothing is sent to sentry.io unless a DSN is configured. + +## Endpoints + +| Method | Path | Description | +|--------|---------------------------------|----------------------------------------------------| +| `POST` | `/stream` | Envelope ingest | +| `POST` | `/api/{projectId}/envelope/` | Sentry SDK ingest path | +| `GET` | `/stream` | Server-Sent Events feed of incoming envelopes | +| `GET` | `/health` | Liveness check (returns `OK`) | + +## Tail output + +By default, incoming envelopes are pretty-printed to the terminal: + +``` +14:32:01 [ERROR] [SERVER] TypeError: x is not a function [app.ts:42:5] [handleRequest] +14:32:02 [TRACE] [BROWSER] [http.client] GET /api/users [245ms] [3 spans] +14:32:03 [INFO] [SERVER] User logged in [user_id=1234] [region=us] +``` + +Errors show the exception type, message, and top stack frame. Transactions show the operation, duration, and span count. Logs show the severity level, message, and custom attributes. + +Use `--filter` / `-f` to narrow the output to specific event types (repeatable): + +```bash +sentry local -f error -f log # only errors and logs +``` + +Use `--quiet` to suppress tail output entirely if you only need the SSE stream. + +## Agent tracing + +`sentry local` shows rich output for AI agent spans when your SDK instruments with [OpenTelemetry semantic attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/): + +``` +14:32:01 [TRACE] [SERVER] [gen_ai] chat anthropic/claude-4-sonnet [1200ms] [5 spans] +14:32:02 [TRACE] [SERVER] [mcp] tools/call search_files [320ms] +14:32:03 [TRACE] [SERVER] [db] SELECT users [postgresql] [12ms] +14:32:04 [ERROR] [SERVER] RateLimitError: API quota exceeded [api_client.py:42] +``` + +GenAI operations show the model name, MCP tool calls show the tool being invoked, and database queries show the system and query summary. This works automatically when your Sentry SDK is configured with AI/LLM integrations. + +To watch only agent activity, filter to the `ai` item type: + +```bash +sentry local -f ai # only AI/agent spans +sentry local -f ai -f error # agent spans and errors +``` + +## JSON output + +Use `--format json` (or `-F json`) for machine-readable NDJSON output, one JSON object per envelope item: + +```bash +sentry local --format json +``` + +`local run` supports the same JSON, attribute, and filter options while it +starts your app and injects the receiver URL. For an agent-friendly stream +without SDK housekeeping envelopes, use: + +```bash +sentry local run --format json \ + --filter error --filter transaction --filter log --filter ai \ + -- npm run dev +``` + +```json +{"type":"transaction","timestamp":1700000001,"op":"gen_ai","label":"chat anthropic/claude-4-sonnet","duration_ms":1200,"span_count":5,"source":"server"} +{"type":"error","timestamp":1700000002,"error_type":"RateLimitError","message":"API quota exceeded","source":"server"} +{"type":"log","timestamp":1700000003,"level":"info","message":"User logged in","attributes":{"user_id":1234},"source":"server"} +``` + +This is useful for AI coding agents and automation tools that need to consume Sentry events programmatically. + +In JSON mode, event records are versioned NDJSON on standard output. Startup, +connection, and shutdown messages stay on standard error, so an agent can pipe +the evidence stream without parsing terminal status text. Records include +`schema_version`, `trace_id`, and, when supplied by the SDK, `event_id` and +`envelope_id` for exact correlation. In `local run --format json`, the wrapped +app's standard output is also forwarded to standard error, leaving standard +output exclusively for NDJSON observations. + +## Agent-debugging fixture + +The repository includes a small Hono server that produces a normal database +request, an agent/MCP trace, and an intentional failure. It sends only to the +local server unless you explicitly set `SENTRY_DSN`. + +In one terminal, start the local receiver: + +```bash +sentry local serve --format json --attributes +``` + +In another, run the fixture with Spotlight pointed at that receiver: + +```bash +SENTRY_SPOTLIGHT=http://localhost:8969/stream \ + pnpm --filter sentry exec tsx test/fixtures/local-agent-server.ts +``` + +Then exercise each telemetry shape: + +```bash +curl http://127.0.0.1:3030/api/users/42 +curl -X POST http://127.0.0.1:3030/api/agent/run \ + -H 'content-type: application/json' \ + -d '{"prompt":"Where is the rate limit configured?"}' +curl -i http://127.0.0.1:3030/api/broken +``` + +The final request intentionally returns HTTP 500. The fixture is for local +experimentation only; do not run it with production credentials. diff --git a/apps/cli-docs/src/fragments/commands/log.md b/apps/cli-docs/src/fragments/commands/log.md new file mode 100644 index 000000000..0159faa3f --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/log.md @@ -0,0 +1,105 @@ + +## Examples + +### List logs + +```bash +# List last 100 logs (default) +sentry log list +``` + +``` +TIMESTAMP LEVEL MESSAGE +2024-01-20 14:22:01 info User login successful +2024-01-20 14:22:03 debug Processing request for /api/users +2024-01-20 14:22:05 error Database connection timeout +2024-01-20 14:22:06 warn Retry attempt 1 of 3 + +Showing 4 logs. +``` + +**Filter logs:** + +```bash +# Show only error logs +sentry log list -q 'severity:error' + +# Filter by message content +sentry log list -q 'database' + +# Limit results +sentry log list --limit 50 +``` + +### Stream logs in real-time + +```bash +# Stream with default 2-second poll interval +sentry log list -f + +# Stream with custom 5-second poll interval +sentry log list -f 5 + +# Stream error logs from a specific project +sentry log list my-org/backend -f -q 'severity:error' +``` + +### View a log entry + +```bash +sentry log view 968c763c740cfda8b6728f27fb9e9b01 +``` + +``` +Log 968c763c740c... +════════════════════ + +ID: 968c763c740cfda8b6728f27fb9e9b01 +Timestamp: 2024-01-20 14:22:05 +Severity: ERROR + +Message: + Database connection timeout after 30s + +─── Context ─── + +Project: backend +Environment: production +Release: 1.2.3 + +─── Trace ─── + +Trace ID: abc123def456abc123def456abc12345 +Span ID: 1234567890abcdef + +─── Source Location ─── + +Function: connect_to_database +File: src/db/connection.py:142 +``` + +```bash +# With explicit project +sentry log view my-org/backend 968c763c740cfda8b6728f27fb9e9b01 + +# Open in browser +sentry log view 968c763c740cfda8b6728f27fb9e9b01 -w +``` + +## Finding Log IDs + +Log IDs can be found: + +1. In the output of `sentry log list` (the ID column) +2. In the Sentry UI when viewing log entries +3. In the `sentry.item_id` field of JSON output + +## JSON Output + +Use `--json` for machine-readable output: + +```bash +sentry log list --json | jq '.data[] | select(.severity == "error")' +``` + +In streaming mode with `--json`, each log entry is output as a separate JSON object (newline-delimited JSON), making it suitable for piping to other tools. diff --git a/apps/cli-docs/src/fragments/commands/monitor.md b/apps/cli-docs/src/fragments/commands/monitor.md new file mode 100644 index 000000000..fd2f16782 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/monitor.md @@ -0,0 +1,33 @@ + + +## Examples + +```bash +# Wrap a command with cron monitor check-ins (DSN-based) +SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 \ + sentry monitor run nightly-job -- python manage.py cron + +# The -- separator is optional when the command has no flags +sentry monitor run nightly-job npm run task + +# Create/update the monitor on the first check-in via --schedule (crontab) +sentry monitor run nightly-job -s "0 0 * * *" --max-runtime 30 --timezone UTC -- ./backup.sh + +# List cron monitors in an org +sentry monitor list my-org/ + +# Paginate through monitors +sentry monitor list my-org/ -c next + +# Output as JSON +sentry monitor list --json +``` + +## Check-in lifecycle + +`monitor run` sends an `in_progress` check-in when the wrapped command starts, +then an `ok` or `error` check-in (with duration) when it finishes, based on the +exit code. The wrapped command inherits stdio, has `SIGINT`/`SIGTERM` +forwarded, receives the `SENTRY_MONITOR_SLUG` environment variable, and its +exit code is preserved. Check-in delivery failures are non-fatal — the wrapped +command still runs and exits with its own code. diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md new file mode 100644 index 000000000..d102dfde4 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -0,0 +1,36 @@ + + +## Examples + +```bash +# List organizations +sentry org list +``` + +``` +SLUG NAME ROLE +my-org My Organization owner +another-org Another Org member +``` + +```bash +# View organization details +sentry org view my-org +``` + +``` +Organization: My Organization +Slug: my-org +Role: owner +Projects: 5 +Teams: 3 +Members: 12 +``` + +```bash +# Open in browser +sentry org view my-org -w + +# JSON output +sentry org list --json +``` diff --git a/apps/cli-docs/src/fragments/commands/platform.md b/apps/cli-docs/src/fragments/commands/platform.md new file mode 100644 index 000000000..f08f8543c --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/platform.md @@ -0,0 +1,17 @@ + + +## Examples + +```bash +# List all valid Sentry platform identifiers +sentry platform list + +# Filter by substring +sentry platform list --search python + +# Shortcut for `sentry platform list` +sentry platforms + +# Output as JSON +sentry platform list --json +``` diff --git a/apps/cli-docs/src/fragments/commands/proguard.md b/apps/cli-docs/src/fragments/commands/proguard.md new file mode 100644 index 000000000..5f0bf843c --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/proguard.md @@ -0,0 +1,37 @@ + + +## Examples + +### Upload mapping files + +```bash +# Upload a ProGuard/R8 mapping file +sentry proguard upload ./app/build/outputs/mapping/release/mapping.txt + +# Upload multiple mapping files +sentry proguard upload mapping-release.txt mapping-debug.txt + +# Validate without uploading (dry-run) +sentry proguard upload mapping.txt --no-upload + +# Fail if no mapping files are provided (useful in CI) +sentry proguard upload ./mapping/ --require-one +``` + +### Compute UUID + +```bash +# Compute the UUID for a ProGuard/R8 mapping file +sentry proguard uuid ./app/build/outputs/mapping/release/mapping.txt + +# Output as JSON (includes the file path) +sentry proguard uuid mapping.txt --json +``` + +## Important Notes + +- The UUID is **deterministically derived from the mapping file contents** — + identical files always produce the same UUID. This is the same value + Sentry uses to associate a mapping with obfuscated Android stack traces. +- This matches the UUID computed by the legacy `sentry-cli proguard uuid` + command byte-for-byte. diff --git a/apps/cli-docs/src/fragments/commands/project.md b/apps/cli-docs/src/fragments/commands/project.md new file mode 100644 index 000000000..7756c6324 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/project.md @@ -0,0 +1,63 @@ + + +## Examples + +```bash +# List all projects in an org +sentry project list my-org/ +``` + +``` +ORG SLUG PLATFORM TEAM +my-org frontend javascript web-team +my-org backend python api-team +my-org mobile-ios cocoa mobile-team +``` + +```bash +# Filter by platform +sentry project list my-org/ --platform javascript + +# View project details +sentry project view my-org/frontend +``` + +``` +Project: frontend +Organization: my-org +Platform: javascript +Team: web-team +DSN: https://abc123@sentry.io/123456 +``` + +```bash +# Open project in browser +sentry project view my-org/frontend -w +``` + +### Create a project + +```bash +# Every project is a name:platform pair; project names cannot contain whitespace +# Create a new project +sentry project create my-new-app:javascript-nextjs + +# Create several projects with their own platforms +sentry project create web:javascript api:python-django worker:node + +# Create under a specific org and team +sentry project create my-org/my-new-app:python --team backend-team + +# Preview without creating +sentry project create my-new-app:node --dry-run +``` + +### Delete a project + +```bash +# Delete a project (will prompt for confirmation) +sentry project delete my-org/old-project + +# Delete without confirmation +sentry project delete my-org/old-project --yes +``` diff --git a/apps/cli-docs/src/fragments/commands/react-native.md b/apps/cli-docs/src/fragments/commands/react-native.md new file mode 100644 index 000000000..2d8e5d035 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/react-native.md @@ -0,0 +1,50 @@ +## Examples + +```bash +# Upload a bundle + sourcemap by debug ID (called by the Gradle plugin) +sentry react-native gradle \ + --bundle index.android.bundle \ + --sourcemap index.android.bundle.map + +# Also associate with a release and distribution(s) +sentry react-native gradle \ + --bundle index.android.bundle \ + --sourcemap index.android.bundle.map \ + --release com.example.app@1.0.0 \ + --dist 1000 + +# Xcode build phase (usually added automatically to your build script) +sentry react-native xcode +``` + +## Xcode build step (`react-native xcode`) + +`react-native xcode` runs inside an Xcode "Bundle React Native code and images" +build phase. It has three modes: + +- **release build** — wraps the RN build script (standing in for + `NODE_BINARY`/`HERMES_CLI_PATH`) to capture the produced bundle + sourcemap + (including the Hermes combined sourcemap), then uploads them. +- **simulator build with `--allow-fetch`** — downloads the bundle + sourcemap + from the running packager, then uploads. +- **debug build** — just runs the build script. + +Release/distribution come from `SENTRY_RELEASE`/`SENTRY_DIST` or the app's +`Info.plist` (`@+`), +unless `--no-auto-release` is set. When run outside an Xcode build phase the +release is discovered via `xcodebuild`; `--allow-xcode-infoplist-preprocessing` +enables `cc`-based `INFOPLIST_PREPROCESS` handling. Pass extra build-script +arguments after the flags. + +## Important Notes + +- `react-native gradle` is normally invoked automatically by the + [sentry-android-gradle-plugin](https://docs.sentry.io/platforms/react-native/sourcemaps/); + you rarely run it by hand. +- It injects a debug ID into both the bundle and its sourcemap, then uploads + them under the `~/` convention. Without `--release` the files are + matched by debug ID; with `--release` they are also uploaded for each + `--dist`. +- `--wait`/`--wait-for` block until the server finishes processing the upload. +- Indexed/file RAM bundles (a pre-Hermes format that React Native has since + deprecated) are not supported — use a plain or Hermes bundle. diff --git a/apps/cli-docs/src/fragments/commands/release.md b/apps/cli-docs/src/fragments/commands/release.md new file mode 100644 index 000000000..a09cfe71e --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/release.md @@ -0,0 +1,67 @@ + + +## Examples + +```bash +# List releases (auto-detect org) +sentry release list + +# List releases in a specific org +sentry release list my-org/ + +# View release details +sentry release view 1.0.0 +sentry release view my-org/1.0.0 + +# Create and finalize a release +sentry release create 1.0.0 --finalize + +# Create a release, then finalize separately +sentry release create 1.0.0 +sentry release set-commits 1.0.0 --auto +sentry release finalize 1.0.0 + +# Set commits from local git history +sentry release set-commits 1.0.0 --local + +# Create a deploy +sentry release deploy 1.0.0 production +sentry release deploy 1.0.0 staging "Deploy #42" + +# Propose a version from git HEAD +sentry release create $(sentry release propose-version) + +# List deploys for a release +sentry release deploys 1.0.0 +sentry release deploys my-org/1.0.0 + +# Archive a release (hide it from the default list, but keep it) +sentry release archive 1.0.0 +sentry release archive my-org/1.0.0 --dry-run # Preview without archiving + +# Restore a previously archived release +sentry release restore 1.0.0 +sentry release restore my-org/1.0.0 + +# Delete a release +sentry release delete my-org/1.0.0 +sentry release delete my-org/1.0.0 --yes # Skip confirmation +sentry release delete my-org/1.0.0 --dry-run # Preview without deleting + +# Output as JSON +sentry release list --json +sentry release view 1.0.0 --json + +# Full release workflow with explicit org +sentry release create my-org/1.0.0 --project my-project +sentry release set-commits my-org/1.0.0 --auto +sentry release finalize my-org/1.0.0 +sentry release deploy my-org/1.0.0 production +``` + +## Important Notes + +- **Version matching**: The release version must match the `release` value in your `Sentry.init()` call. If your SDK uses `"1.0.0"`, create the release as `sentry release create org/1.0.0` (version = `1.0.0`), **not** `sentry release create org/myapp/1.0.0`. +- **The `org/` prefix is the org slug**: In `sentry release create sentry/1.0.0`, `sentry` is the org slug and `1.0.0` is the version. The `/` separates org from version — it is not part of the version string. +- **`--auto` needs a git checkout**: The `--auto` flag lists repos from the Sentry API and matches against your local `origin` remote URL. Without a local git repo, use `--local` instead. +- **Default mode tries `--auto` first**: When neither `--auto` nor `--local` is specified, `set-commits` tries auto-discovery first and falls back to local git history if the integration isn't configured. diff --git a/apps/cli-docs/src/fragments/commands/replay.md b/apps/cli-docs/src/fragments/commands/replay.md new file mode 100644 index 000000000..71ea5a672 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/replay.md @@ -0,0 +1,41 @@ + +## Examples + +### List replays + +```bash +# List recent replays for a project +sentry replay list my-org/frontend + +# Search across all projects in an org +sentry replay list my-org/ --query "environment:production" + +# Change the time window and sort +sentry replay list my-org/frontend --period 24h --sort errors + +# Paginate through results +sentry replay list my-org/frontend -c next +sentry replay list my-org/frontend -c prev + +# Output machine-readable data +sentry replay list my-org/frontend --json +``` + +### View a replay + +```bash +# View a replay by ID using auto-detected org/project context +sentry replay view 346789a703f6454384f1de473b8b9fcc + +# View a replay with an explicit org +sentry replay view my-org/346789a703f6454384f1de473b8b9fcc + +# View a replay with explicit org/project context +sentry replay view my-org/frontend/346789a703f6454384f1de473b8b9fcc + +# Open a replay in the browser +sentry replay view my-org/346789a703f6454384f1de473b8b9fcc --web + +# View the replay linked to a trace +sentry replay view my-org/frontend/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +``` diff --git a/apps/cli-docs/src/fragments/commands/repo.md b/apps/cli-docs/src/fragments/commands/repo.md new file mode 100644 index 000000000..e62b2a747 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/repo.md @@ -0,0 +1,14 @@ + + +## Examples + +```bash +# List repositories (auto-detect org) +sentry repo list + +# List repos in a specific org with pagination +sentry repo list my-org/ -c next + +# Output as JSON +sentry repo list --json +``` diff --git a/apps/cli-docs/src/fragments/commands/schema.md b/apps/cli-docs/src/fragments/commands/schema.md new file mode 100644 index 000000000..79ae96ee9 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/schema.md @@ -0,0 +1,26 @@ + + +## Examples + +```bash +# List all API resources +sentry schema + +# Browse issue endpoints +sentry schema issues + +# View details for a specific operation +sentry schema issues list + +# Look up an endpoint by its exact operation ID +sentry schema listOrganizationEvents + +# Look up an endpoint by HTTP method and path +sentry schema "GET /api/0/organizations/{organization_id_or_slug}/issues/" + +# Search for monitoring-related endpoints +sentry schema --search monitor + +# Flat list of every endpoint +sentry schema --all +``` diff --git a/apps/cli-docs/src/fragments/commands/snapshots.md b/apps/cli-docs/src/fragments/commands/snapshots.md new file mode 100644 index 000000000..ee58e6f1f --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/snapshots.md @@ -0,0 +1,56 @@ + + +## Examples + +```bash +# Upload a folder of screenshots as a snapshot for an app +sentry snapshots upload ./screenshots --app-id com.example.app + +# Upload only a subset of images (removals/renames not inferred on PRs) +sentry snapshots upload ./screenshots --app-id my-app --selective + +# Only flag images that differ by more than 1% +sentry snapshots upload ./screenshots --app-id my-app --diff-threshold 0.01 + +# Compare two directories of snapshot images locally +sentry snapshots diff ./baseline ./head + +# Fail (non-zero exit) if any images changed, with a custom threshold +sentry snapshots diff ./baseline ./head --fail-on-diff --threshold 0.02 + +# Download a specific baseline snapshot by ID +sentry snapshots download --snapshot-id 1234567890 + +# Download the latest baseline for an app, filtered by branch +sentry snapshots download --app-id my-app --branch main + +# Extract images to a specific directory +sentry snapshots download --app-id my-app --output ./baseline/ +``` + +## Important Notes + +- `snapshots upload` scans a folder for PNG/JPEG images (skipping hidden files), + uploads each to Sentry's object store — images already present are skipped by + content hash — and creates a snapshot. **Sentry SaaS only.** A companion + `.json` sidecar adds per-image metadata; `--all-image-file-names` + (or `--all-image-file-names-file`) lists the full suite for selective uploads. + Each image must be at most 40,000,000 pixels. Git metadata is auto-collected + in CI (see `build upload`); a `--pr-number` requires a resolvable base SHA. +- `snapshots diff` compares two local image directories (PNG/JPEG) perceptually + — anti-aliasing aware, with a per-pixel `--threshold` (0.0–1.0) — and writes a + PNG diff mask per changed image. It makes **no network requests**. Use + `--fail-on-diff` to exit non-zero when any images changed/added/removed, and + `--selective` to treat images missing from head as skipped rather than removed. +- `snapshots download` fetches baseline snapshot images from Sentry's preprod + system and extracts them to a local directory. **Sentry SaaS only.** +- Provide exactly one of `--snapshot-id` (a direct artifact ID) or `--app-id` + (resolves the latest baseline). `--branch` only applies with `--app-id`. +- With org auth tokens, resolving by `--app-id` requires `--project` (a project + ID or slug). +- If the downloadable archive has not been built yet, the command triggers a + build and waits for it (up to 5 minutes). +- Images are extracted to `./snapshots-base/` by default; override with + `--output`. +- The organization is resolved from `--org`, `SENTRY_ORG`, config defaults, or a + detected DSN. diff --git a/apps/cli-docs/src/fragments/commands/sourcemap.md b/apps/cli-docs/src/fragments/commands/sourcemap.md new file mode 100644 index 000000000..77c8aa307 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/sourcemap.md @@ -0,0 +1,79 @@ + + +## Examples + +### Inject debug IDs + +`sentry sourcemap inject` is a purely local file operation — it does not make any API calls and does not require authentication. You can run it in CI before authenticating. + +Existing debug IDs from JavaScript comments or sourcemaps are preserved. If a +bundler emits an ID without Sentry's runtime registration snippet, `inject` +adds the snippet and adjusts the sourcemap mappings. Bundles that already +register their ID are left unchanged. `sourcemap upload` applies the same +injection by default, unless `--no-rewrite` is passed. +Binary bundles with an existing debug ID in their sourcemap are left unchanged. + +```bash +# Inject debug IDs into all JS files in dist/ +sentry sourcemap inject ./dist + +# Preview changes without writing +sentry sourcemap inject ./dist --dry-run + +# Only process specific extensions +sentry sourcemap inject ./build --ext .js,.mjs +``` + +### Upload sourcemaps + +```bash +# Upload sourcemaps from dist/ +sentry sourcemap upload ./dist + +# Associate with a release +sentry sourcemap upload ./dist --release 1.0.0 + +# Set a custom URL prefix +sentry sourcemap upload ./dist --url-prefix '~/static/js/' +``` + +### Resolve sourcemap linkage + +```bash +# Report how each JS file's sourcemap resolves and whether a debug ID +# has been injected (read-only — never modifies files) +sentry sourcemap resolve ./dist + +# Machine-readable output +sentry sourcemap resolve ./dist --json +``` + +Use `sentry sourcemap resolve` to debug why `sentry sourcemap upload` may +not find the expected sourcemaps. It reports, for each JavaScript file, +whether the companion `.map` was located (by convention or via a +`sourceMappingURL` directive), whether the map is inline (`data:` URL) or +remote, and whether a Sentry debug ID is present. + +## Error handling + +Both `sentry sourcemap inject` and `sentry sourcemap upload` exit with an +error if zero JS + sourcemap pairs are discovered in the target +directory. This catches silent bundler misconfigurations — the most +common cause is a bundler that isn't emitting `.map` files: + +``` +# Vite / Astro: set `vite.build.sourcemap: "hidden"` (Astro 5) or +# `vite.environments.client.build.sourcemap: "hidden"` (Astro 6+). + +# webpack: set `devtool: "hidden-source-map"`. + +# esbuild: set `sourcemap: true` or `sourcemap: "linked"`. +``` + +For CI steps that may run against legitimately-empty directories (e.g., +library-only repos, conditional release skips), pass `--allow-empty` to +suppress the error: + +```bash +sentry sourcemap upload ./dist --allow-empty +``` diff --git a/apps/cli-docs/src/fragments/commands/span.md b/apps/cli-docs/src/fragments/commands/span.md new file mode 100644 index 000000000..2cece9e9f --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/span.md @@ -0,0 +1,48 @@ + + +## Examples + +### List spans + +```bash +# List recent spans in the current project +sentry span list + +# Find all DB spans +sentry span list -q "op:db" + +# Slow spans in the last 24 hours +sentry span list -q "duration:>100ms" --period 24h + +# List spans within a specific trace +sentry span list abc123def456abc123def456abc12345 + +# Paginate through results +sentry span list -c next +``` + +### Filter by project in a trace + +```bash +# Show only spans from one project within a trace +sentry span list my-org/cli-server/abc123def456abc123def456abc12345 + +# Or use --query to filter by project +sentry span list abc123def456abc123def456abc12345 -q "project:cli-server" + +# Multiple projects at once +sentry span list abc123def456abc123def456abc12345 -q "project:[cli-server,api]" +``` + +### View spans + +```bash +# View a single span +sentry span view abc123def456abc123def456abc12345 a1b2c3d4e5f67890 + +# View multiple spans at once +sentry span view abc123def456abc123def456abc12345 a1b2c3d4e5f67890 b2c3d4e5f6789012 + +# With explicit org/project +sentry span view my-org/backend/abc123def456abc123def456abc12345 a1b2c3d4e5f67890 +``` diff --git a/apps/cli-docs/src/fragments/commands/status.md b/apps/cli-docs/src/fragments/commands/status.md new file mode 100644 index 000000000..40b5e6639 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/status.md @@ -0,0 +1,29 @@ + + +## Examples + +```bash +# Show the current status of Sentry's services +sentry status +``` + +``` +✓ All Systems Operational + +### Components + +● Dashboard — Operational +● US Error Ingestion — Operational + +See https://status.sentry.io for full details. +``` + +```bash +# Get machine-readable status (useful in scripts) +sentry status --json +``` + +```bash +# Check a self-hosted or regional status page (Statuspage CNAME) +sentry status --url https://status.acme.com +``` diff --git a/apps/cli-docs/src/fragments/commands/team.md b/apps/cli-docs/src/fragments/commands/team.md new file mode 100644 index 000000000..3606123f4 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/team.md @@ -0,0 +1,14 @@ + + +## Examples + +```bash +# List teams +sentry team list my-org/ + +# Paginate through teams +sentry team list my-org/ -c next + +# Output as JSON +sentry team list --json +``` diff --git a/apps/cli-docs/src/fragments/commands/trace.md b/apps/cli-docs/src/fragments/commands/trace.md new file mode 100644 index 000000000..cd2924ea2 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/trace.md @@ -0,0 +1,61 @@ + + +## Examples + +### List traces + +```bash +# List last 20 traces (default) +sentry trace list + +# Sort by slowest first +sentry trace list --sort duration + +# Filter by transaction name, last 24 hours +sentry trace list -q "transaction:GET /api/users" --period 24h + +# Paginate through results +sentry trace list my-org/backend -c next +``` + +### View a trace + +```bash +# View trace details with span tree +sentry trace view abc123def456abc123def456abc12345 + +# Open trace in browser +sentry trace view abc123def456abc123def456abc12345 -w + +# Auto-recover from an issue short ID +sentry trace view PROJ-123 +``` + +### Cross-project traces + +```bash +# Filter trace view to one project's spans +sentry trace view my-org/cli-server/abc123def456abc123def456abc12345 + +# Full trace across all projects (default) +sentry trace view my-org/abc123def456abc123def456abc12345 + +# Filter trace logs by project +sentry trace logs my-org/cli-server/abc123def456abc123def456abc12345 + +# Multiple projects via --query +sentry trace logs abc123def456abc123def456abc12345 -q "project:[cli-server,api]" +``` + +### View trace logs + +```bash +# View logs for a trace +sentry trace logs abc123def456abc123def456abc12345 + +# Search with a longer time window +sentry trace logs --period 30d abc123def456abc123def456abc12345 + +# Filter logs within a trace +sentry trace logs -q 'severity:error' abc123def456abc123def456abc12345 +``` diff --git a/apps/cli-docs/src/fragments/commands/trial.md b/apps/cli-docs/src/fragments/commands/trial.md new file mode 100644 index 000000000..0847a9be6 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/trial.md @@ -0,0 +1,20 @@ + + +## Examples + +```bash +# List all trials for the current org +sentry trial list + +# List trials for a specific org +sentry trial list my-org + +# Start a Seer trial +sentry trial start seer + +# Start a trial for a specific org +sentry trial start replays my-org + +# Start a Business plan trial (opens browser) +sentry trial start plan +``` diff --git a/apps/cli-docs/src/fragments/commands/wasm-split.md b/apps/cli-docs/src/fragments/commands/wasm-split.md new file mode 100644 index 000000000..3b768a444 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/wasm-split.md @@ -0,0 +1,41 @@ + +## Examples + +```bash +# Add a build id to a module, in place, and print it +sentry wasm-split app.wasm + +# Capture the build id for a later upload +BUILD_ID=$(sentry wasm-split app.wasm) + +# Split debug data into a companion and ship a stripped binary +sentry wasm-split app.wasm --debug-out app.debug.wasm --strip + +# Also drop function names from the shipped binary +sentry wasm-split app.wasm -d app.debug.wasm --strip --strip-names + +# Point browsers at a companion served from a CDN +sentry wasm-split app.wasm -o dist/app.wasm -d dist/app.debug.wasm --strip \ + --external-dwarf-url https://cdn.example.com/debug/app.debug.wasm +``` + +## Important Notes + +- This is a **drop-in replacement for Symbolicator's `wasm-split` binary** — + same flags, same behaviour, same output. +- **Only the build id is printed**, as lowercase hex, so it can be captured in + a shell variable. Pass `--quiet` to print nothing. `--quiet` cannot be + combined with `--json`. +- A build id the module **already carries is reused**. Rewriting it would + orphan debug files uploaded against the old one. `--build-id` applies only + when the module has none. +- The debug companion is a **complete copy** of the module, captured before + stripping. DWARF offsets are relative to the code section, so a companion + missing it cannot be symbolicated. Expect it to be about the size of the + input. +- `--strip-names` takes effect **only alongside `--strip`**. +- `external_debug_info` is written when `--external-dwarf-url` is given, or + else from the basename of `--debug-out`. A bare filename resolves relative to + the main wasm file, which is how Emscripten reads it. +- The file is **left untouched when nothing changed** — no new build id, no + stripping, no `external_debug_info` — so reruns do not disturb build caches. diff --git a/apps/cli-docs/src/fragments/configuration.md b/apps/cli-docs/src/fragments/configuration.md new file mode 100644 index 000000000..b9673a933 --- /dev/null +++ b/apps/cli-docs/src/fragments/configuration.md @@ -0,0 +1,126 @@ + + +## Configuration File (`.sentryclirc`) + +The CLI supports a `.sentryclirc` config file using standard INI syntax. This is the same format used by the legacy `sentry-cli` tool, so existing config files are automatically picked up. + +### How It Works + +The CLI looks for `.sentryclirc` files by walking up from your current directory toward the filesystem root. If multiple files are found, values from the closest file take priority, with `~/.sentryclirc` serving as a global fallback. + +```ini +[defaults] +org = my-org +project = my-project + +[auth] +token = sntrys_... +``` + +### Supported Fields + +| Section | Key | Description | +|---------|-----|-------------| +| `[defaults]` | `org` | Default organization slug | +| `[defaults]` | `project` | Default project slug | +| `[defaults]` | `url` | Sentry base URL (for self-hosted) | +| `[auth]` | `token` | Auth token (mapped to `SENTRY_AUTH_TOKEN`) | + +### Monorepo Setup + +In monorepos, place a `.sentryclirc` at the repo root with your org, then add per-package configs with just the project: + +``` +my-monorepo/ + .sentryclirc # [defaults] org = my-company + packages/ + frontend/ + .sentryclirc # [defaults] project = frontend-web + backend/ + .sentryclirc # [defaults] project = backend-api +``` + +When you run a command from `packages/frontend/`, the CLI resolves `org = my-company` from the root and `project = frontend-web` from the closest file. + +### Resolution Priority + +When the CLI needs to determine your org and project, it checks these sources in order: + +1. **Explicit CLI arguments** — `sentry issue list my-org/my-project` +2. **Environment variables** — `SENTRY_ORG` / `SENTRY_PROJECT` +3. **`.sentryclirc` config file** — walked up from CWD, merged with `~/.sentryclirc` +4. **Persistent defaults** — set via `sentry cli defaults` +5. **DSN auto-detection** — scans source code and `.env` files +6. **Directory name inference** — matches your directory name against project slugs + +The first source that provides both org and project wins. For org-only commands, only the org is needed. + +### Backward Compatibility + +If you previously used the legacy `sentry-cli` and have a `~/.sentryclirc` file, the new CLI reads it automatically. The `[defaults]` and `[auth]` sections are fully compatible. The `[auth] token` value is mapped to the `SENTRY_AUTH_TOKEN` environment variable internally (only if the env var is not already set). + +## Persistent Defaults + +Use `sentry cli defaults` to set persistent defaults for organization, project, URL, and telemetry. These are stored in the CLI's local database and apply to all commands. + +```bash +sentry cli defaults org my-org # Set default organization +sentry cli defaults project my-project # Set default project +sentry cli defaults url https://... # Set Sentry URL (self-hosted) +sentry cli defaults telemetry off # Disable telemetry +sentry cli defaults # Show all current defaults +sentry cli defaults org --clear # Clear a specific default +``` + +See [`sentry cli defaults`](./commands/cli/#sentry-cli-defaults) for full usage. + +## Global Options + +These flags are accepted by every command. They are not shown in individual command `--help` output, but are always available. + +### `--log-level ` + +Set the log verbosity level. Accepts: `error`, `warn`, `log`, `info`, `debug`, `trace`. + +```bash +sentry issue list --log-level debug +sentry --log-level=trace cli upgrade +``` + +Overrides `SENTRY_LOG_LEVEL` when both are set. + +### `--verbose` + +Shorthand for `--log-level debug`. Enables debug-level diagnostic output. + +```bash +sentry issue list --verbose +``` + +:::note +The `sentry api` command also uses `--verbose` to show full HTTP request/response details. When used with `sentry api`, it serves both purposes (debug logging + HTTP output). +::: + +## Credential Storage + +We store credentials and caches in a SQLite database (`cli.db`) inside the config directory. The location follows the [XDG Base Directory specification](https://specifications.freedesktop.org/basedir/latest/): by default the CLI uses `$XDG_CONFIG_HOME/sentry` (i.e. `~/.config/sentry/` when `XDG_CONFIG_HOME` is unset), and you can override it with `SENTRY_CONFIG_DIR`. For backward compatibility, if a legacy `~/.sentry/` directory already exists it continues to be used. The database file and its WAL side-files are created with restricted permissions (mode 600) so that only the current user can read them. The database also caches: + +- Organization and project defaults +- DSN resolution results +- Region URL mappings +- Project aliases (for monorepo support) + +See [Credential Storage](./commands/auth/#credential-storage) in the auth command docs for more details. + +## Binary Install Location + +When installed via the install script, the CLI binary is placed in an XDG-aligned directory. `sentry cli setup` resolves the location in this order: + +1. `SENTRY_INSTALL_DIR` — explicit override +2. `$XDG_BIN_HOME` — used when set to an absolute path, per the XDG spec +3. `~/.local/bin` or `~/bin` — when either already exists and is on your `PATH` +4. `~/.local/bin` — default fallback + +Older installs placed the binary in `~/.sentry/bin`. Running `sentry cli setup` moves an existing `~/.sentry/bin` binary into the resolved install directory (updating your `PATH` and recorded install metadata to match) and migrates any legacy `~/.sentry` config data (`cli.db`, `config.json`) into the XDG config directory. Both migrations are skipped when a binary or config already exists at the target. + +`sentry upgrade` runs `setup` on the new binary, so it migrates too — but conservatively, because upgrade never edits your `PATH`. A legacy `~/.sentry/bin` binary is relocated to the XDG install directory **only when that directory is already on your `PATH`**, so the moved binary stays discoverable. If the XDG directory isn't on `PATH`, upgrade leaves the binary in place (a mislocated binary that vanished from `PATH` would break the command); run `sentry cli setup` explicitly to relocate it and update `PATH`. Legacy config data is migrated on upgrade regardless. diff --git a/apps/cli-docs/src/styles/cli.css b/apps/cli-docs/src/styles/cli.css new file mode 100644 index 000000000..ea247d8e2 --- /dev/null +++ b/apps/cli-docs/src/styles/cli.css @@ -0,0 +1,640 @@ +/* ========================================================================== + Sentry CLI Docs — Project-specific overrides + Base theme provided by @sentry/starlight-theme + ========================================================================== */ + +/* Brand fonts: Dammit Sans (headings, self-hosted below) + Rubik (body, self- + hosted by @sentry/starlight-theme as "Rubik Variable") + JetBrains Mono + (code). Dammit Sans is a display face with a limited glyph set, so headings + fall back to Rubik for any missing glyph (e.g. # / * \ | ^ _ ` ~). */ +@font-face { + font-family: "Dammit Sans"; + /* Relative URL so Vite bundles + hashes it and respects the site `base` + (root-absolute paths break under the PR-preview base, e.g. /_preview/pr-N). */ + src: url("../fonts/dammit-sans-v0.3-bold.otf") format("opentype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +/* Headings use the Dammit Sans display font; body/code are unaffected. */ +.sl-markdown-content :is(h1, h2, h3, h4, h5, h6):not(:where(.not-content *)), +.page-title-wrapper h1, +.hero h1 { + font-family: var(--sl-font-headings); +} + +:root, +:root[data-theme="dark"], +:root[data-theme="light"] { + /* Compat: existing components reference this var */ + /* Sentry brand purple (#7553FF), the mid-anchor of the CLI banner gradient */ + --sl-color-accent-rgb: 117 83 255; + + /* Body uses the theme's self-hosted Rubik Variable; headings use Dammit Sans; + code stays JetBrains Mono. */ + --sl-font: "Rubik Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + --sl-font-headings: "Dammit Sans", "Rubik Variable", -apple-system, + BlinkMacSystemFont, "Segoe UI", sans-serif; + --sl-font-mono: "JetBrains Mono", ui-monospace, monospace; + + /* Match the original CLI docs header sizing. */ + --sl-nav-height: 4rem !important; + --sl-nav-pad-x: 1.5rem !important; + --sl-nav-gap: 2rem !important; +} + +/* Subtle background gradient glow */ +.main-frame::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 100vh; + background: + radial-gradient( + ellipse 80% 50% at 50% -20%, + rgb(var(--sl-color-accent-rgb) / 0.12) 0%, + transparent 50% + ), + radial-gradient( + ellipse 60% 40% at 100% 0%, + rgba(59, 130, 246, 0.08) 0%, + transparent 40% + ); + pointer-events: none; + z-index: -1; +} + +/* GitHub icon - white in header */ +.social-icons a, +.social-icons a svg, +a[href*="github.com"], +a[href*="github.com"] svg { + color: #fff !important; + fill: #fff !important; +} + +.social-icons a:hover, +.social-icons a:hover svg, +a[href*="github.com"]:hover, +a[href*="github.com"]:hover svg { + color: rgba(255, 255, 255, 0.8) !important; + fill: rgba(255, 255, 255, 0.8) !important; +} + +/* Header / Navigation */ +header.header { + background: rgba(10, 10, 15, 0.95); + backdrop-filter: blur(12px); + border-bottom: none; +} + +header.header > .header { + display: flex; + gap: 1rem; + justify-content: space-between; + align-items: center; + height: 100%; + width: 100%; + padding: 0; + background: transparent; + backdrop-filter: none; +} + +.header-left { + flex: 0 0 auto; +} + +.header-right { + flex: 0 0 auto; + gap: 1rem; + align-items: center; +} + +.site-title img { + height: 2.3rem !important; + width: auto !important; +} + +starlight-menu-button button { + background-color: #1a1a1f !important; + color: #fff !important; + border: 1px solid rgba(255, 255, 255, 0.2) !important; + box-shadow: none !important; +} + +site-search { + --sl-search-width: 22rem; +} + +site-search button { + background: var(--sl-color-bg) !important; + border: 1px solid rgba(255, 255, 255, 0.25) !important; + border-radius: 6px !important; + padding: 0.35rem 0.75rem !important; + font-size: 0.8rem !important; + height: auto !important; + min-height: unset !important; + gap: 0.5rem !important; + width: 22rem !important; + min-width: 22rem !important; + justify-content: space-between !important; + position: fixed !important; + left: 50% !important; + transform: translateX(-50%) !important; + top: 0.75rem !important; + z-index: 1000 !important; +} + +site-search button:hover { + border-color: rgba(255, 255, 255, 0.4) !important; +} + +site-search button svg { + width: 14px !important; + height: 14px !important; + color: rgba(255, 255, 255, 0.5) !important; +} + +site-search button span { + font-size: 0.8rem !important; + color: rgba(255, 255, 255, 0.5) !important; +} + +site-search button kbd { + background: transparent !important; + border: none !important; + color: rgba(255, 255, 255, 0.5) !important; + font-size: 0.75rem !important; + padding: 0 !important; + box-shadow: none !important; +} + +/* Docs section heading accent - keep it below the text, not through it. */ +html[data-has-sidebar] .sl-markdown-content h2:not(:where(.not-content *))::before { + bottom: 0.02em; + height: 0.12em; + left: 0; + right: var(--sl-anchor-icon-space, 0); + transform: none; +} + +/* ========================================================================== + Splash / Landing Page Styles + ========================================================================== */ + +/* Hero section */ +.hero { + padding: 10rem 0 0.5rem !important; + text-align: left !important; + max-width: none !important; +} + +.hero > img, +.hero > .hero-image { + display: none !important; +} + +/* Hero title - make it bold and impactful */ +.hero h1 { + font-size: clamp(2.5rem, 8vw, 4rem) !important; + font-weight: 700 !important; + line-height: 1 !important; + letter-spacing: -0.03em !important; + margin-bottom: 1.5rem !important; +} + +/* Hero tagline */ +.hero .tagline { + font-size: 1.125rem !important; + line-height: 1.7 !important; + color: rgba(255, 255, 255, 0.6) !important; + max-width: 540px !important; + margin: 0 !important; +} + +/* Hide default hero actions */ +.hero .action, +.hero .sl-flex.actions { + display: none !important; +} + +/* Command box in hero - tight to tagline, space below */ +.hero-command { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + margin-top: 0.5rem; + margin-bottom: 4rem; +} + +.hero-docs-link { + color: rgba(255, 255, 255, 0.7) !important; + text-decoration: none !important; + font-weight: 400; + font-size: 0.9rem; + transition: color 0.2s ease; + margin-top: 0.75rem; + position: relative; + display: inline-block; +} + +.hero-docs-link::after { + content: ""; + position: absolute; + bottom: -3px; + right: 0; + width: 7.5em; /* Width of "documentation." */ + height: 1px; + background: currentColor; + transition: width 0.3s ease; +} + +.hero-docs-link:hover { + color: #fff !important; +} + +.hero-docs-link:hover::after { + width: 100%; +} + +/* Remove the static underline from the span since pseudo-element handles it */ +.hero-docs-link .underline { + text-decoration: none; +} + +/* Stack container - page layout for splash. + Keep the landing page at the pre-theme width; normal docs pages use the + shared Sentry Starlight theme defaults. */ +html[data-has-hero]:not([data-has-sidebar]) { + --sl-content-width: 67.5rem !important; + --sl-content-pad-x: 2rem !important; +} + +@media (max-width: 49.999rem) { + html[data-has-hero]:not([data-has-sidebar]) { + --sl-nav-height: 3.5rem !important; + --sl-nav-pad-x: 1rem !important; + } +} + +@media (min-width: 50rem) { + html[data-has-hero]:not([data-has-sidebar]) { + --sl-nav-height: 4rem !important; + --sl-nav-pad-x: 1.5rem !important; + } +} + +html[data-has-hero]:not([data-has-sidebar]) header.header { + background: rgba(10, 10, 15, 0.95); + border-bottom: none; +} + +html[data-has-hero]:not([data-has-sidebar]) header.header > .header { + background: transparent; + backdrop-filter: none; +} + +html[data-has-hero]:not([data-has-sidebar]) .header-homepage { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + height: 100%; + width: 100%; + max-width: var(--sl-content-width); + margin: 0 auto; + padding: 0; +} + +html[data-has-hero]:not([data-has-sidebar]) .header-left { + flex: 0 0 auto; +} + +html[data-has-hero]:not([data-has-sidebar]) .header-right { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 1rem; + margin-left: auto; +} + +html[data-has-hero]:not([data-has-sidebar]) .content-panel { + padding: 1.5rem var(--sl-content-pad-x); +} + +html[data-has-hero]:not([data-has-sidebar]) .sl-container { + max-width: var(--sl-content-width); + margin: 0 auto; + padding: 0; +} + +html[data-has-hero]:not([data-has-sidebar]) main > .content-panel:first-child .sl-container > * + * { + margin-top: 1.5rem; +} + +html[data-has-hero]:not([data-has-sidebar]) .sl-markdown-content { + line-height: 1.8; +} + +html[data-has-hero]:not([data-has-sidebar]) .command-text { + font-size: 0.875em; + font-weight: 500; + line-height: 1; + padding: 0.2em 0.5em; +} + +html[data-has-hero]:not([data-has-sidebar]) main { + padding-top: 0; +} + +/* ========================================================================== + Features Section - Horizontal Rows with Terminals + ========================================================================== */ + +.feature-section { + margin: 2rem 0; + background: rgba(255, 255, 255, 0.02); + border-radius: 16px; + overflow: hidden; +} + +.feature-section-inner { + display: flex; + align-items: stretch; +} + +.feature-section-inner.reverse { + flex-direction: row-reverse; +} + +.feature-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 2.5rem; +} + +.feature-text h3 { + font-size: 1.75rem; + font-weight: 600; + color: #fff; + margin: 0 0 0.75rem 0; + line-height: 1.2; +} + +.feature-text p { + font-size: 1.05rem; + line-height: 1.7; + color: rgba(255, 255, 255, 0.55); + margin: 0 0 1rem 0; +} + +.feature-text p:last-child { + margin-bottom: 0; + color: rgba(255, 255, 255, 0.45); +} + +.feature-text code { + background: rgba(255, 255, 255, 0.1); + padding: 0.15em 0.4em; + border-radius: 4px; + font-size: 0.9em; +} + +.feature-visual { + flex: 0 0 55%; + min-width: 0; + min-height: 420px; + margin: 0.5rem 0.75rem 0.75rem 0; + padding: 10%; + background-size: cover; + background-position: center; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + overflow: hidden; +} + +.feature-section-inner.reverse .feature-visual { + margin: 0.75rem 0 0.75rem 0.75rem; +} + +/* Responsive for feature sections */ +@media (max-width: 1000px) { + .feature-section-inner, + .feature-section-inner.reverse { + flex-direction: column; + } + + .feature-text { + padding: 2rem; + text-align: center; + } + + .feature-visual, + .feature-section-inner.reverse .feature-visual { + flex: none; + width: calc(100% - 1.5rem); + min-height: 380px; + margin: 0 0.75rem 0.75rem 0.75rem; + } +} + +@media (max-width: 600px) { + .feature-section { + margin: 1.5rem 0; + } + + .feature-text { + padding: 1.5rem; + } + + .feature-visual { + min-height: 340px; + margin: 0 0.5rem 0.5rem 0.5rem; + padding: 8%; + } + + .feature-text h3 { + font-size: 1.4rem; + } +} + +@media (max-width: 480px) { + .feature-visual { + margin: 0 0.5rem 0.5rem 0.5rem; + min-height: 300px; + padding: 6%; + } + + .feature-text { + padding: 1.25rem; + } + + .feature-text h3 { + font-size: 1.25rem; + } +} + +/* ========================================================================== + PackageManagerCode - pm-pre no-border treatment + ========================================================================== */ + +pre.pm-pre, +.pm-pre { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + margin: 0 !important; + padding: 0 !important; +} + +/* ASCII art tables inside terminals - no chrome, consistent monospace */ +pre.table-box { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + margin: 0 !important; + padding: 0 !important; +} + +pre.table-box, +pre.table-box * { + font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, + "DejaVu Sans Mono", monospace !important; + font-size: inherit !important; + font-weight: 400 !important; + font-style: normal !important; + font-variant: normal !important; + font-variant-ligatures: none !important; + font-feature-settings: normal !important; + font-stretch: normal !important; + letter-spacing: 0 !important; + word-spacing: 0 !important; + text-decoration: none !important; + text-transform: none !important; + -webkit-text-size-adjust: none !important; +} + +/* ========================================================================== + Mobile Responsive + ========================================================================== */ + +@media (max-width: 768px) { + /* Reset search bar to flow naturally in the compact docs header. */ + site-search button { + position: static !important; + left: auto !important; + top: auto !important; + transform: none !important; + z-index: auto !important; + width: auto !important; + min-width: auto !important; + padding: 0.5rem !important; + } + + site-search button span, + site-search button kbd { + display: none !important; + } + + .hero { + text-align: center !important; + } + + .hero h1 { + font-size: 2rem !important; + } + + .hero .tagline { + font-size: 1rem !important; + margin: 0 auto !important; + } + + .hero-command { + align-items: center; + } + + .hero-command .command-box-wrapper { + justify-content: center; + } + + .hero-command .install-selector { + max-width: 100%; + } + + .hero-command .install-box { + flex-shrink: 0; + } + + .hero-command .command-area { + font-size: 0.7rem; + padding: 0.5rem 0.75rem; + } +} + +/* ========================================================================== + Overscroll Easter Egg + ========================================================================== */ + +.overscroll-message { + position: fixed; + bottom: -60px; + left: 50%; + transform: translateX(-50%) translateY(0); + z-index: 9999; + pointer-events: none; + opacity: 0; + transition: + opacity 0.15s ease-out, + transform 0.15s ease-out; +} + +.overscroll-message span { + display: inline-block; + padding: 0.6rem 1.5rem; + background: rgb(var(--sl-color-accent-rgb) / 0.12); + border: 1px solid rgb(var(--sl-color-accent-rgb) / 0.25); + border-radius: 24px; + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; + font-weight: 500; + font-family: var(--sl-font); + backdrop-filter: blur(12px); + white-space: nowrap; +} + +.overscroll-message code { + background: rgba(255, 255, 255, 0.1); + padding: 0.2em 0.5em; + border-radius: 6px; + font-family: var(--sl-font-mono); + font-size: 0.85em; + color: #9e86ff; + cursor: pointer; + pointer-events: auto; +} + +.overscroll-message code:hover { + background: rgba(255, 255, 255, 0.18); +} + +@media (max-width: 640px) { + .overscroll-message span { + white-space: normal; + text-align: center; + max-width: calc(100vw - 2rem); + font-size: 0.8rem; + padding: 0.5rem 1rem; + } +} diff --git a/apps/cli-docs/test/install-selector.test.mjs b/apps/cli-docs/test/install-selector.test.mjs new file mode 100644 index 000000000..4b99c1e76 --- /dev/null +++ b/apps/cli-docs/test/install-selector.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const component = readFileSync(fileURLToPath(new URL("../src/components/InstallSelector.astro", import.meta.url)), "utf8"); +const script = component.match(/ + + diff --git a/packages/cli/test/init-eval/templates/react-vite-app/package.json b/packages/cli/test/init-eval/templates/react-vite-app/package.json new file mode 100644 index 000000000..669dc4753 --- /dev/null +++ b/packages/cli/test/init-eval/templates/react-vite-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "react-vite-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5", + "vite": "^6.0.0" + } +} diff --git a/packages/cli/test/init-eval/templates/react-vite-app/src/app.tsx b/packages/cli/test/init-eval/templates/react-vite-app/src/app.tsx new file mode 100644 index 000000000..be4a1202e --- /dev/null +++ b/packages/cli/test/init-eval/templates/react-vite-app/src/app.tsx @@ -0,0 +1,5 @@ +function App() { + return

Hello World

; +} + +export default App; diff --git a/packages/cli/test/init-eval/templates/react-vite-app/src/main.tsx b/packages/cli/test/init-eval/templates/react-vite-app/src/main.tsx new file mode 100644 index 000000000..74ab28da8 --- /dev/null +++ b/packages/cli/test/init-eval/templates/react-vite-app/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./app"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/cli/test/init-eval/templates/react-vite-app/tsconfig.json b/packages/cli/test/init-eval/templates/react-vite-app/tsconfig.json new file mode 100644 index 000000000..9e82e3ffe --- /dev/null +++ b/packages/cli/test/init-eval/templates/react-vite-app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/cli/test/init-eval/templates/react-vite-app/vite.config.ts b/packages/cli/test/init-eval/templates/react-vite-app/vite.config.ts new file mode 100644 index 000000000..58676f788 --- /dev/null +++ b/packages/cli/test/init-eval/templates/react-vite-app/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/.gitignore b/packages/cli/test/init-eval/templates/sveltekit-app/.gitignore new file mode 100644 index 000000000..31fda85b7 --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.svelte-kit/ +build/ diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/package.json b/packages/cli/test/init-eval/templates/sveltekit-app/package.json new file mode 100644 index 000000000..e58b151fa --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "sveltekit-app", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/src/app.d.ts b/packages/cli/test/init-eval/templates/sveltekit-app/src/app.d.ts new file mode 100644 index 000000000..da08e6da5 --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/src/app.html b/packages/cli/test/init-eval/templates/sveltekit-app/src/app.html new file mode 100644 index 000000000..f273cc58f --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/src/routes/+page.svelte b/packages/cli/test/init-eval/templates/sveltekit-app/src/routes/+page.svelte new file mode 100644 index 000000000..f3e333e80 --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/src/routes/+page.svelte @@ -0,0 +1 @@ +

Hello World

diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/svelte.config.js b/packages/cli/test/init-eval/templates/sveltekit-app/svelte.config.js new file mode 100644 index 000000000..6bfb3c408 --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/tsconfig.json b/packages/cli/test/init-eval/templates/sveltekit-app/tsconfig.json new file mode 100644 index 000000000..feea18bf2 --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/packages/cli/test/init-eval/templates/sveltekit-app/vite.config.ts b/packages/cli/test/init-eval/templates/sveltekit-app/vite.config.ts new file mode 100644 index 000000000..80864b9de --- /dev/null +++ b/packages/cli/test/init-eval/templates/sveltekit-app/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [sveltekit()], +}); diff --git a/packages/cli/test/lib/agent-skills.test.ts b/packages/cli/test/lib/agent-skills.test.ts new file mode 100644 index 000000000..80f621c8b --- /dev/null +++ b/packages/cli/test/lib/agent-skills.test.ts @@ -0,0 +1,334 @@ +/** + * Agent Skills Tests + * + * Unit tests for Claude Code detection, shared path construction, and + * embedded skill installation across detected agent roots. + */ + +import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { readFile, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + detectClaudeCode, + getSkillInstallPath, + installAgentSkills, +} from "../../src/lib/agent-skills.js"; + +// Wrap node:fs/promises so individual functions can be observed (to prove the +// atomic temp-file + rename mechanism is actually used) and selectively made to +// fail (to exercise the cleanup/rollback path). Every function delegates to the +// real implementation by default, so the rest of the suite hits the real FS. +// `mockReset: false` (vitest.config.ts) keeps these delegating implementations +// across tests; only the `*Once` overrides below are transient. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeFile: vi.fn(actual.writeFile), + rename: vi.fn(actual.rename), + rm: vi.fn(actual.rm), + }; +}); + +describe("agent-skills", () => { + describe("detectClaudeCode", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `agent-skills-detect-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns true when ~/.claude directory exists", () => { + mkdirSync(join(testDir, ".claude"), { recursive: true }); + expect(detectClaudeCode(testDir)).toBe(true); + }); + + test("returns false when ~/.claude directory does not exist", () => { + expect(detectClaudeCode(testDir)).toBe(false); + }); + }); + + describe("getSkillInstallPath", () => { + test("defaults to the Claude Code path", () => { + const path = getSkillInstallPath("/home/user"); + expect(path).toBe("/home/user/.claude/skills/sentry-cli/SKILL.md"); + }); + + test("returns correct path under ~/.agents/skills", () => { + const path = getSkillInstallPath("/home/user", ".agents"); + expect(path).toBe("/home/user/.agents/skills/sentry-cli/SKILL.md"); + }); + }); + + describe("installAgentSkills", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `agent-skills-install-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + // Clear recorded calls (and any leftover `*Once` overrides) between tests + // without dropping the delegating implementations set in the factory. + vi.clearAllMocks(); + for (const dir of [ + testDir, + join(testDir, ".agents"), + join(testDir, ".claude"), + ]) { + try { + if (existsSync(dir)) { + chmodSync(dir, 0o755); + } + } catch { + // Ignore cleanup races for directories that never existed. + } + } + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns null when no supported agent root is detected", async () => { + const result = await installAgentSkills(testDir); + expect(result).toBeNull(); + }); + + test("installs to ~/.agents/ when the shared agent root exists", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + + const result = await installAgentSkills(testDir); + + expect(result).not.toBeNull(); + expect(result!.created).toBe(true); + expect(result!.path).toBe( + join(testDir, ".agents", "skills", "sentry-cli", "SKILL.md") + ); + expect(existsSync(result!.path)).toBe(true); + + const content = await readFile(result!.path, "utf-8"); + expect(content).toContain("sentry-cli"); + + expect(result!.referenceCount).toBeGreaterThan(0); + const refsDir = join( + testDir, + ".agents", + "skills", + "sentry-cli", + "references" + ); + expect(existsSync(refsDir)).toBe(true); + expect(existsSync(join(refsDir, "issue.md"))).toBe(true); + + expect( + existsSync(join(testDir, ".claude", "skills", "sentry-cli", "SKILL.md")) + ).toBe(false); + }); + + test("publishes every file via rename from a temp path (atomic write guard)", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + + const result = await installAgentSkills(testDir); + expect(result).not.toBeNull(); + + // The whole point of the change: content is staged to a hidden `.tmp` + // file and then atomically renamed into place. If this regresses to a + // direct in-place `writeFile`, both assertions below fail — which is what + // makes this a real guard rather than a tautology. + const writeTargets = vi + .mocked(writeFile) + .mock.calls.map((call) => String(call[0])); + expect(writeTargets.length).toBeGreaterThan(0); + expect(writeTargets.every((target) => target.endsWith(".tmp"))).toBe( + true + ); + + const renameDestinations = vi + .mocked(rename) + .mock.calls.map((call) => String(call[1])); + expect(renameDestinations).toContain(result!.path); + }); + + test("leaves no temp files behind on success", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + + const result = await installAgentSkills(testDir); + expect(result).not.toBeNull(); + + // A leftover `.tmp` would mean the rename/cleanup regressed. + const skillDir = join(testDir, ".agents", "skills", "sentry-cli"); + const leftovers = [ + ...readdirSync(skillDir), + ...readdirSync(join(skillDir, "references")), + ].filter((name) => name.endsWith(".tmp")); + expect(leftovers).toEqual([]); + }); + + test("rolls back cleanly when rename fails (no partial dest, no temp orphan)", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + + // Force the first publish to fail at the rename step. The temp file has + // already been written at this point, so this exercises the catch/cleanup + // branch in atomicWriteFile (rm of the temp) and the null-returning + // failure handler in writeSkillFiles. + vi.mocked(rename).mockRejectedValueOnce( + new Error("simulated rename failure") + ); + + const result = await installAgentSkills(testDir); + + // A non-atomic in-place writer never calls rename, so forcing rename to + // fail would have no effect and the install would still succeed — this + // assertion only holds because the rename is on the critical path. + expect(result).toBeNull(); + + const skillDir = join(testDir, ".agents", "skills", "sentry-cli"); + const leftovers = readdirSync(skillDir).filter((name) => + name.endsWith(".tmp") + ); + expect(leftovers).toEqual([]); + expect(existsSync(join(skillDir, "SKILL.md"))).toBe(false); + }); + + test("still fails to null when temp cleanup also fails after a rename error", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + + // Both the rename and the subsequent best-effort `rm` of the temp file + // fail. The cleanup error must be swallowed (captured, not thrown) so the + // original rename error is what propagates — exercising the inner + // catch of atomicWriteFile. + vi.mocked(rename).mockRejectedValueOnce(new Error("rename failed")); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup failed")); + + const result = await installAgentSkills(testDir); + + expect(result).toBeNull(); + expect(vi.mocked(rm)).toHaveBeenCalled(); + }); + + test("installs to both ~/.agents/ and ~/.claude/ when both roots exist", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + mkdirSync(join(testDir, ".claude"), { recursive: true }); + + const result = await installAgentSkills(testDir); + + expect(result).not.toBeNull(); + expect(result!.path).toBe( + join(testDir, ".agents", "skills", "sentry-cli", "SKILL.md") + ); + expect( + existsSync(join(testDir, ".agents", "skills", "sentry-cli", "SKILL.md")) + ).toBe(true); + expect( + existsSync(join(testDir, ".claude", "skills", "sentry-cli", "SKILL.md")) + ).toBe(true); + expect( + existsSync( + join( + testDir, + ".agents", + "skills", + "sentry-cli", + "references", + "issue.md" + ) + ) + ).toBe(true); + expect( + existsSync( + join( + testDir, + ".claude", + "skills", + "sentry-cli", + "references", + "issue.md" + ) + ) + ).toBe(true); + }); + + test("reports created: false when updating existing file", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + + const first = await installAgentSkills(testDir); + expect(first!.created).toBe(true); + + const second = await installAgentSkills(testDir); + expect(second!.created).toBe(false); + expect(second!.path).toBe(first!.path); + }); + + test("reports the fresh claude path when shared skills already exist", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + + const first = await installAgentSkills(testDir); + expect(first!.created).toBe(true); + expect(first!.path).toBe( + join(testDir, ".agents", "skills", "sentry-cli", "SKILL.md") + ); + + mkdirSync(join(testDir, ".claude"), { recursive: true }); + + const second = await installAgentSkills(testDir); + expect(second).not.toBeNull(); + expect(second!.created).toBe(true); + expect(second!.path).toBe( + join(testDir, ".claude", "skills", "sentry-cli", "SKILL.md") + ); + }); + + test("claude install succeeds even if ~/.agents is not writable", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + chmodSync(join(testDir, ".agents"), 0o444); + mkdirSync(join(testDir, ".claude"), { recursive: true }); + + const result = await installAgentSkills(testDir); + expect(result).not.toBeNull(); + expect(result!.path).toBe( + join(testDir, ".claude", "skills", "sentry-cli", "SKILL.md") + ); + expect(existsSync(result!.path)).toBe(true); + expect( + existsSync(join(testDir, ".agents", "skills", "sentry-cli", "SKILL.md")) + ).toBe(false); + }); + + test("agents install succeeds even if ~/.claude is not writable", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + mkdirSync(join(testDir, ".claude"), { recursive: true }); + chmodSync(join(testDir, ".claude"), 0o444); + + const result = await installAgentSkills(testDir); + expect(result).not.toBeNull(); + expect(result!.path).toBe( + join(testDir, ".agents", "skills", "sentry-cli", "SKILL.md") + ); + expect(existsSync(result!.path)).toBe(true); + expect( + existsSync(join(testDir, ".claude", "skills", "sentry-cli", "SKILL.md")) + ).toBe(false); + }); + + test("returns null when all detected targets are not writable", async () => { + mkdirSync(join(testDir, ".agents"), { recursive: true }); + mkdirSync(join(testDir, ".claude"), { recursive: true }); + chmodSync(join(testDir, ".agents"), 0o444); + chmodSync(join(testDir, ".claude"), 0o444); + + const result = await installAgentSkills(testDir); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/cli/test/lib/alias.property.test.ts b/packages/cli/test/lib/alias.property.test.ts new file mode 100644 index 000000000..fa01328a0 --- /dev/null +++ b/packages/cli/test/lib/alias.property.test.ts @@ -0,0 +1,480 @@ +/** + * Property-Based Tests for Alias Generation + * + * Uses fast-check to verify properties that should always hold true + * for the alias generation functions, regardless of input. + */ + +import { + array, + constantFrom, + assert as fcAssert, + property, + tuple, + uniqueArray, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + buildOrgAwareAliases, + findCommonWordPrefix, + findShortestUniquePrefixes, + type OrgProjectPair, +} from "../../src/lib/alias.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Generate valid slug characters */ +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + +/** Generate simple slugs (no hyphens) */ +const simpleSlugArb = array(constantFrom(...slugChars.split("")), { + minLength: 1, + maxLength: 15, +}).map((chars) => chars.join("")); + +/** Generate slugs that may contain hyphens */ +const slugWithHyphensArb = array(constantFrom(...`${slugChars}-`.split("")), { + minLength: 2, + maxLength: 20, +}) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-") || s.includes("--"))); + +/** Generate org slugs */ +const orgSlugArb = simpleSlugArb; + +/** Generate project slugs (may have hyphens like "spotlight-electron") */ +const projectSlugArb = slugWithHyphensArb; + +/** Generate org/project pairs */ +const orgProjectPairArb = tuple(orgSlugArb, projectSlugArb).map( + ([org, project]): OrgProjectPair => ({ org, project }) +); + +/** Generate arrays of unique strings */ +const uniqueStringsArb = uniqueArray(simpleSlugArb, { + minLength: 1, + maxLength: 10, + comparator: (a, b) => a.toLowerCase() === b.toLowerCase(), +}); + +/** Generate strings with common word prefix (like spotlight-*) */ +const commonPrefixStringsArb = tuple( + simpleSlugArb, + uniqueArray(simpleSlugArb, { minLength: 2, maxLength: 5 }) +).map(([prefix, suffixes]) => suffixes.map((s) => `${prefix}-${s}`)); + +// Properties for findShortestUniquePrefixes + +describe("property: findShortestUniquePrefixes", () => { + test("every input string gets a prefix", () => { + fcAssert( + property(uniqueStringsArb, (strings) => { + const prefixes = findShortestUniquePrefixes(strings); + expect(prefixes.size).toBe(strings.length); + + for (const str of strings) { + expect(prefixes.has(str)).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("prefixes are unique within the set", () => { + fcAssert( + property(uniqueStringsArb, (strings) => { + const prefixes = findShortestUniquePrefixes(strings); + const prefixValues = [...prefixes.values()]; + const uniquePrefixValues = new Set(prefixValues); + + // All prefixes should be unique + expect(uniquePrefixValues.size).toBe(prefixValues.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("prefix is always a prefix of the original string", () => { + fcAssert( + property(uniqueStringsArb, (strings) => { + const prefixes = findShortestUniquePrefixes(strings); + + for (const [str, prefix] of prefixes) { + expect(str.toLowerCase().startsWith(prefix)).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("prefix is lowercase", () => { + fcAssert( + property(uniqueStringsArb, (strings) => { + const prefixes = findShortestUniquePrefixes(strings); + + for (const prefix of prefixes.values()) { + expect(prefix).toBe(prefix.toLowerCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("prefix is minimal (can't be shorter and still unique)", () => { + fcAssert( + property(uniqueStringsArb, (strings) => { + if (strings.length < 2) return; + + const prefixes = findShortestUniquePrefixes(strings); + + for (const [str, prefix] of prefixes) { + if (prefix.length <= 1) continue; + + // Check that a shorter prefix would NOT be unique + const shorterPrefix = prefix.slice(0, -1); + const wouldCollide = strings.some( + (other) => + other !== str && other.toLowerCase().startsWith(shorterPrefix) + ); + + expect(wouldCollide).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("single string gets first character as prefix", () => { + fcAssert( + property(simpleSlugArb, (str) => { + const prefixes = findShortestUniquePrefixes([str]); + expect(prefixes.get(str)).toBe(str.charAt(0).toLowerCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("prefixes never end with dash or underscore", () => { + // Use slugs that may contain hyphens to test the extension logic + fcAssert( + property( + uniqueArray(slugWithHyphensArb, { + minLength: 1, + maxLength: 10, + comparator: (a, b) => a.toLowerCase() === b.toLowerCase(), + }), + (strings) => { + const prefixes = findShortestUniquePrefixes(strings); + + for (const prefix of prefixes.values()) { + expect(prefix.endsWith("-")).toBe(false); + expect(prefix.endsWith("_")).toBe(false); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty array returns empty map", () => { + const prefixes = findShortestUniquePrefixes([]); + expect(prefixes.size).toBe(0); + }); +}); + +// Properties for findCommonWordPrefix + +describe("property: findCommonWordPrefix", () => { + test("returns common prefix for strings with shared word boundary", () => { + fcAssert( + property(commonPrefixStringsArb, (strings) => { + const prefix = findCommonWordPrefix(strings); + + // Should find the common prefix + expect(prefix.length).toBeGreaterThan(0); + + // All strings with boundaries should start with the prefix + for (const str of strings) { + expect(str.toLowerCase().startsWith(prefix)).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty string for strings without common boundary prefix", () => { + fcAssert( + property( + uniqueArray(simpleSlugArb, { minLength: 2, maxLength: 5 }), + (strings) => { + // Simple slugs have no hyphens, so no common word prefix + const prefix = findCommonWordPrefix(strings); + expect(prefix).toBe(""); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty string for single element arrays", () => { + fcAssert( + property(slugWithHyphensArb, (str) => { + const prefix = findCommonWordPrefix([str]); + expect(prefix).toBe(""); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty string for empty arrays", () => { + const prefix = findCommonWordPrefix([]); + expect(prefix).toBe(""); + }); + + test("prefix ends with boundary character if found", () => { + fcAssert( + property(commonPrefixStringsArb, (strings) => { + const prefix = findCommonWordPrefix(strings); + + if (prefix.length > 0) { + // Should end with hyphen or underscore + const lastChar = prefix.at(-1); + expect(lastChar === "-" || lastChar === "_").toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("prefix is lowercase", () => { + fcAssert( + property(commonPrefixStringsArb, (strings) => { + const prefix = findCommonWordPrefix(strings); + expect(prefix).toBe(prefix.toLowerCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for buildOrgAwareAliases + +describe("property: buildOrgAwareAliases", () => { + test("every input pair gets an alias", () => { + fcAssert( + property( + array(orgProjectPairArb, { minLength: 1, maxLength: 10 }), + (pairs) => { + const { aliasMap } = buildOrgAwareAliases(pairs); + + // Each unique org/project pair should have an alias + const uniqueKeys = new Set(pairs.map((p) => `${p.org}/${p.project}`)); + expect(aliasMap.size).toBe(uniqueKeys.size); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("aliases are unique within the result", () => { + fcAssert( + property( + array(orgProjectPairArb, { minLength: 1, maxLength: 10 }), + (pairs) => { + const { aliasMap } = buildOrgAwareAliases(pairs); + const aliases = [...aliasMap.values()]; + const uniqueAliases = new Set(aliases); + + expect(uniqueAliases.size).toBe(aliases.length); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("aliases are lowercase", () => { + fcAssert( + property( + array(orgProjectPairArb, { minLength: 1, maxLength: 10 }), + (pairs) => { + const { aliasMap } = buildOrgAwareAliases(pairs); + + for (const alias of aliasMap.values()) { + expect(alias).toBe(alias.toLowerCase()); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("aliases are non-empty", () => { + fcAssert( + property( + array(orgProjectPairArb, { minLength: 1, maxLength: 10 }), + (pairs) => { + const { aliasMap } = buildOrgAwareAliases(pairs); + + for (const alias of aliasMap.values()) { + expect(alias.length).toBeGreaterThan(0); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("aliases never end with dash or underscore", () => { + fcAssert( + property( + array(orgProjectPairArb, { minLength: 1, maxLength: 10 }), + (pairs) => { + const { aliasMap } = buildOrgAwareAliases(pairs); + + for (const alias of aliasMap.values()) { + expect(alias.endsWith("-")).toBe(false); + expect(alias.endsWith("_")).toBe(false); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty input returns empty map", () => { + const { aliasMap } = buildOrgAwareAliases([]); + expect(aliasMap.size).toBe(0); + }); + + test("colliding slugs get org-prefixed aliases", () => { + // Create pairs with same project slug in different orgs + fcAssert( + property( + tuple(simpleSlugArb, simpleSlugArb, simpleSlugArb), + ([org1, org2, project]) => { + if (org1 === org2) return; // Need different orgs + + const pairs: OrgProjectPair[] = [ + { org: org1, project }, + { org: org2, project }, + ]; + + const { aliasMap } = buildOrgAwareAliases(pairs); + + // Both should have aliases + expect(aliasMap.has(`${org1}/${project}`)).toBe(true); + expect(aliasMap.has(`${org2}/${project}`)).toBe(true); + + // Aliases should be different + const alias1 = aliasMap.get(`${org1}/${project}`); + const alias2 = aliasMap.get(`${org2}/${project}`); + expect(alias1).not.toBe(alias2); + + // Colliding aliases should contain "/" (org prefix) + expect(alias1?.includes("/")).toBe(true); + expect(alias2?.includes("/")).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-colliding slugs get simple prefixes (no org)", () => { + fcAssert( + property( + tuple( + simpleSlugArb, + uniqueArray(simpleSlugArb, { minLength: 2, maxLength: 5 }) + ), + ([org, projects]) => { + const pairs: OrgProjectPair[] = projects.map((p) => ({ + org, + project: p, + })); + const { aliasMap } = buildOrgAwareAliases(pairs); + + // All aliases should be simple (no "/") + for (const alias of aliasMap.values()) { + expect(alias.includes("/")).toBe(false); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("deterministic results for same input", () => { + fcAssert( + property( + array(orgProjectPairArb, { minLength: 1, maxLength: 10 }), + (pairs) => { + const result1 = buildOrgAwareAliases(pairs); + const result2 = buildOrgAwareAliases(pairs); + + expect(result1.aliasMap.size).toBe(result2.aliasMap.size); + + for (const [key, alias1] of result1.aliasMap) { + expect(result2.aliasMap.get(key)).toBe(alias1); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Cross-function Properties + +describe("property: cross-function invariants", () => { + test("common prefix stripping improves alias brevity", () => { + fcAssert( + property(commonPrefixStringsArb, (projects) => { + // All in same org, all share prefix + const pairs: OrgProjectPair[] = projects.map((p) => ({ + org: "acme", + project: p, + })); + + const { aliasMap } = buildOrgAwareAliases(pairs); + + // Aliases should be shorter than full project slugs + for (const [key, alias] of aliasMap) { + const project = key.split("/")[1]!; + expect(alias.length).toBeLessThanOrEqual(project.length); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("findShortestUniquePrefixes is consistent with buildOrgAwareAliases for unique slugs", () => { + fcAssert( + property( + tuple( + simpleSlugArb, + uniqueArray(simpleSlugArb, { minLength: 2, maxLength: 5 }) + ), + ([org, projects]) => { + // Build aliases through the full function + const pairs: OrgProjectPair[] = projects.map((p) => ({ + org, + project: p, + })); + const { aliasMap } = buildOrgAwareAliases(pairs); + + // Get prefixes directly + const directPrefixes = findShortestUniquePrefixes(projects); + + // Aliases should match direct prefixes for simple unique slugs + for (const [key, alias] of aliasMap) { + const project = key.split("/")[1]!; + expect(directPrefixes.get(project)).toBe(alias); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/alias.test.ts b/packages/cli/test/lib/alias.test.ts new file mode 100644 index 000000000..cecd95115 --- /dev/null +++ b/packages/cli/test/lib/alias.test.ts @@ -0,0 +1,209 @@ +/** + * Tests for alias generation utilities + * + * Note: Core invariants (uniqueness, prefix correctness, case handling) are tested + * via property-based tests in alias.property.test.ts. These tests focus on + * specific expected outputs and integration scenarios. + */ + +import { describe, expect, test } from "vitest"; +import { + buildOrgAwareAliases, + findCommonWordPrefix, + findShortestUniquePrefixes, +} from "../../src/lib/alias.js"; + +describe("alias generation integration", () => { + test("generates short aliases for spotlight projects", () => { + const projectSlugs = [ + "spotlight-electron", + "spotlight-website", + "spotlight", + ]; + + // Strip common prefix + const commonPrefix = findCommonWordPrefix(projectSlugs); + expect(commonPrefix).toBe("spotlight-"); + + // Create remainders + const slugToRemainder = new Map(); + for (const slug of projectSlugs) { + const remainder = slug.slice(commonPrefix.length); + slugToRemainder.set(slug, remainder || slug); + } + + expect(slugToRemainder.get("spotlight-electron")).toBe("electron"); + expect(slugToRemainder.get("spotlight-website")).toBe("website"); + expect(slugToRemainder.get("spotlight")).toBe("spotlight"); // No prefix to strip + + // Find unique prefixes for remainders + const remainders = [...slugToRemainder.values()]; + const prefixes = findShortestUniquePrefixes(remainders); + + expect(prefixes.get("electron")).toBe("e"); + expect(prefixes.get("website")).toBe("w"); + expect(prefixes.get("spotlight")).toBe("s"); + }); + + test("generates aliases without stripping for unrelated projects", () => { + const projectSlugs = ["frontend", "backend", "worker"]; + + const commonPrefix = findCommonWordPrefix(projectSlugs); + expect(commonPrefix).toBe(""); + + const prefixes = findShortestUniquePrefixes(projectSlugs); + expect(prefixes.get("frontend")).toBe("f"); + expect(prefixes.get("backend")).toBe("b"); + expect(prefixes.get("worker")).toBe("w"); + }); +}); + +describe("buildOrgAwareAliases specific outputs", () => { + // These tests verify specific expected alias outputs for documentation + // and to catch any changes to the alias generation algorithm. + + test("single org multiple projects - expected aliases", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "frontend" }, + { org: "acme", project: "backend" }, + ]); + expect(result.aliasMap.get("acme/frontend")).toBe("f"); + expect(result.aliasMap.get("acme/backend")).toBe("b"); + }); + + test("collision with distinct org names - expected format", () => { + const result = buildOrgAwareAliases([ + { org: "acme-corp", project: "api" }, + { org: "bigco", project: "api" }, + ]); + + // Org prefixes should be unique: "a" vs "b" + expect(result.aliasMap.get("acme-corp/api")).toBe("a/a"); + expect(result.aliasMap.get("bigco/api")).toBe("b/a"); + }); + + test("preserves common word prefix stripping for unique projects", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "spotlight-electron" }, + { org: "acme", project: "spotlight-website" }, + ]); + // Common prefix "spotlight-" is stripped internally, resulting in short aliases + expect(result.aliasMap.get("acme/spotlight-electron")).toBe("e"); + expect(result.aliasMap.get("acme/spotlight-website")).toBe("w"); + }); + + test("collision with similar org names uses longer prefixes", () => { + const result = buildOrgAwareAliases([ + { org: "organization1", project: "app" }, + { org: "organization2", project: "app" }, + ]); + + const alias1 = result.aliasMap.get("organization1/app"); + const alias2 = result.aliasMap.get("organization2/app"); + + // Both orgs start with "organization", so prefixes need to be longer + expect(alias1).not.toBe(alias2); + // Should include enough of the org to be unique + expect(alias1).toMatch(/\/a$/); // ends with project prefix + expect(alias2).toMatch(/\/a$/); + }); + + test("multiple collisions across same orgs - all unique", () => { + const result = buildOrgAwareAliases([ + { org: "org1", project: "api" }, + { org: "org2", project: "api" }, + { org: "org1", project: "web" }, + { org: "org2", project: "web" }, + ]); + + // All four should have org-prefixed aliases with slash + expect(result.aliasMap.get("org1/api")).toContain("/"); + expect(result.aliasMap.get("org2/api")).toContain("/"); + expect(result.aliasMap.get("org1/web")).toContain("/"); + expect(result.aliasMap.get("org2/web")).toContain("/"); + + // All should be unique + const aliases = [...result.aliasMap.values()]; + const uniqueAliases = new Set(aliases); + expect(uniqueAliases.size).toBe(aliases.length); + }); + + test("collision with same-letter project slugs uses unique project prefixes", () => { + // Both "api" and "app" start with "a" - need unique project prefixes + const result = buildOrgAwareAliases([ + { org: "org1", project: "api" }, + { org: "org2", project: "api" }, + { org: "org1", project: "app" }, + { org: "org2", project: "app" }, + ]); + + // All four should be unique + const aliases = [...result.aliasMap.values()]; + const uniqueAliases = new Set(aliases); + expect(uniqueAliases.size).toBe(4); + + // api and app should have different project prefixes (not both "a") + const org1Api = result.aliasMap.get("org1/api"); + const org1App = result.aliasMap.get("org1/app"); + expect(org1Api).not.toBe(org1App); + + // Project prefixes should distinguish api vs app + // e.g., "o1/api" vs "o1/app" + expect(org1Api).toMatch(/^o.*\/api$/); + expect(org1App).toMatch(/^o.*\/app$/); + }); + + test("handles slug that is prefix of another slug", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "cli" }, + { org: "acme", project: "cli-website" }, + ]); + // "cli" is a prefix of "cli-website", so cli-website uses "website" as remainder + expect(result.aliasMap.get("acme/cli")).toBe("c"); + expect(result.aliasMap.get("acme/cli-website")).toBe("w"); + }); + + test("handles nested prefix relationships", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "api" }, + { org: "acme", project: "api-server" }, + { org: "acme", project: "api-server-v2" }, + ]); + // api → api, api-server → server, api-server-v2 → v2 + expect(result.aliasMap.get("acme/api")).toBe("a"); + expect(result.aliasMap.get("acme/api-server")).toBe("s"); + expect(result.aliasMap.get("acme/api-server-v2")).toBe("v"); + }); + + test("no alias ends with dash or underscore", () => { + const result = buildOrgAwareAliases([ + { org: "acme", project: "cli" }, + { org: "acme", project: "cli-website" }, + { org: "acme", project: "cli-app" }, + ]); + for (const alias of result.aliasMap.values()) { + expect(alias.endsWith("-")).toBe(false); + expect(alias.endsWith("_")).toBe(false); + } + }); + + test("avoids collision when prefix-stripped remainder matches another slug", () => { + // Edge case: "cli-website" would become "website" after stripping "cli-", + // but "website" is already a project slug - must avoid duplicate aliases + const result = buildOrgAwareAliases([ + { org: "acme", project: "cli" }, + { org: "acme", project: "cli-website" }, + { org: "acme", project: "website" }, + ]); + + // All aliases must be unique + const aliases = [...result.aliasMap.values()]; + const uniqueAliases = new Set(aliases); + expect(uniqueAliases.size).toBe(aliases.length); + + // No alias should end with dash + for (const alias of aliases) { + expect(alias.endsWith("-")).toBe(false); + } + }); +}); diff --git a/packages/cli/test/lib/api-client.coverage.test.ts b/packages/cli/test/lib/api-client.coverage.test.ts new file mode 100644 index 000000000..cc050a216 --- /dev/null +++ b/packages/cli/test/lib/api-client.coverage.test.ts @@ -0,0 +1,2351 @@ +/** + * API Client Coverage Tests + * + * Comprehensive tests for all domain modules under src/lib/api/ to + * reach 80%+ line coverage on each module. Follows the same mock + * pattern as api-client.seer.test.ts. + */ + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { number, object, string } from "valibot"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { resolveEventInOrg } from "../../src/lib/api/events.js"; +import { unwrapResult } from "../../src/lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + addMemberToTeam, + apiRequest, + apiRequestToRegion, + createProject, + createProjectWithAutoTeam, + createTeam, + getCurrentUser, + getDetailedTrace, + getEvent, + getIssue, + getIssueByShortId, + getIssueInOrg, + getLatestEvent, + getLogs, + getProjectKeys, + listEventAttachments, + listIssuesAllPages, + listLogs, + listProjects, + listProjectsAllPages, + listProjectsPaginated, + listProjectTeams, + listRepositories, + listRepositoriesPaginated, + listTeams, + listTeamsPaginated, + listTraceLogs, + listTransactions, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, + rawApiRequest, + tryGetPrimaryDsn, + updateIssueStatus, +} from "../../src/lib/api-client.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { ApiError, AuthError } from "../../src/lib/errors.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +// --- Shared test setup --- + +useTestConfigDir("test-api-coverage-"); +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", "https://sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// --- Helpers --- + +/** Build a mock issue response */ +function mockIssue(overrides: Record = {}) { + return { + id: "12345", + shortId: "TEST-1", + title: "Test Issue", + status: "unresolved", + level: "error", + permalink: "https://sentry.io/organizations/test-org/issues/12345/", + ...overrides, + }; +} + +/** Build a mock project response */ +function mockProject(overrides: Record = {}) { + return { + id: "1", + slug: "test-project", + name: "Test Project", + ...overrides, + }; +} + +/** Build a mock team response */ +function mockTeam(overrides: Record = {}) { + return { + id: "1", + slug: "test-team", + name: "Test Team", + ...overrides, + }; +} + +/** Create a Link header for pagination */ +function linkHeader(cursor: string, hasResults: boolean): string { + return `; rel="next"; results="${hasResults}"; cursor="${cursor}"`; +} + +// ============================================================================= +// issues.ts +// ============================================================================= + +describe("issues.ts", () => { + describe("getIssue", () => { + test("fetches issue by numeric ID via legacy endpoint", async () => { + const issue = mockIssue(); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/api/0/issues/12345/"); + return new Response(JSON.stringify(issue), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getIssue("12345"); + expect(result.id).toBe("12345"); + expect(result.title).toBe("Test Issue"); + }); + + test("passes collapse query params when provided", async () => { + const issue = mockIssue(); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.getAll("collapse")).toEqual([ + "stats", + "lifetime", + ]); + return new Response(JSON.stringify(issue), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await getIssue("12345", { collapse: ["stats", "lifetime"] }); + }); + }); + + describe("getIssueInOrg", () => { + test("fetches issue scoped to organization", async () => { + const issue = mockIssue(); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/organizations/test-org/issues/12345/"); + return new Response(JSON.stringify(issue), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getIssueInOrg("test-org", "12345"); + expect(result.id).toBe("12345"); + }); + + test("passes collapse query params when provided", async () => { + const issue = mockIssue(); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.pathname).toContain("/organizations/test-org/issues/12345/"); + expect(url.searchParams.getAll("collapse")).toEqual([ + "stats", + "filtered", + ]); + return new Response(JSON.stringify(issue), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await getIssueInOrg("test-org", "12345", { + collapse: ["stats", "filtered"], + }); + }); + }); + + describe("getIssueByShortId", () => { + test("resolves short ID to issue (uppercases input)", async () => { + const issue = mockIssue({ shortId: "TEST-1" }); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/shortids/TEST-1/"); + return new Response(JSON.stringify({ group: issue }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getIssueByShortId("test-org", "test-1"); + expect(result.shortId).toBe("TEST-1"); + }); + + test("passes collapse query params when provided", async () => { + const issue = mockIssue({ shortId: "TEST-1" }); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.pathname).toContain("/shortids/TEST-1/"); + expect(url.searchParams.getAll("collapse")).toEqual([ + "stats", + "lifetime", + "filtered", + "unhandled", + ]); + return new Response(JSON.stringify({ group: issue }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await getIssueByShortId("test-org", "test-1", { + collapse: ["stats", "lifetime", "filtered", "unhandled"], + }); + }); + + test("throws ApiError 404 when group is missing", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + try { + await getIssueByShortId("test-org", "test-1"); + expect(true).toBe(false); // Should not reach + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(404); + } + }); + + test("enriches 404 errors with actionable hints", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + + try { + await getIssueByShortId("test-org", "CLI-G5"); + expect(true).toBe(false); // Should not reach + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(404); + expect(apiErr.message).toContain("CLI-G5"); + expect(apiErr.message).toContain("test-org"); + } + }); + }); + + describe("updateIssueStatus", () => { + test("sends PUT request with status body", async () => { + const issue = mockIssue({ status: "resolved" }); + let capturedBody: unknown; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedBody = await req.json(); + expect(req.method).toBe("PUT"); + expect(req.url).toContain("/api/0/issues/12345/"); + return new Response(JSON.stringify(issue), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await updateIssueStatus("12345", "resolved"); + expect(result.status).toBe("resolved"); + expect(capturedBody).toEqual({ status: "resolved" }); + }); + }); + + describe("listIssuesAllPages", () => { + test("returns single page when limit <= page size", async () => { + const issues = [mockIssue({ id: "1" }), mockIssue({ id: "2" })]; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(issues), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("cursor-abc", false), + }, + }) + ); + + const result = await listIssuesAllPages("test-org", "test-project", { + limit: 10, + }); + expect(result.issues).toHaveLength(2); + // No more pages → nextCursor should be undefined + expect(result.nextCursor).toBeUndefined(); + }); + + test("follows cursor across multiple pages", async () => { + let callCount = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + callCount += 1; + const req = new Request(input!, init); + const url = new URL(req.url); + + if (callCount === 1) { + // First page: return items with cursor + return new Response( + JSON.stringify([mockIssue({ id: "1" }), mockIssue({ id: "2" })]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("page2-cursor", true), + }, + } + ); + } + // Second page: return items without cursor + expect(url.searchParams.get("cursor")).toBe("page2-cursor"); + return new Response(JSON.stringify([mockIssue({ id: "3" })]), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("end", false), + }, + }); + }); + + const result = await listIssuesAllPages("test-org", "test-project", { + limit: 200, + }); + expect(result.issues).toHaveLength(3); + expect(callCount).toBe(2); + }); + + test("trims when overshooting limit (no nextCursor)", async () => { + // Return 5 items but we only want 3 + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify([ + mockIssue({ id: "1" }), + mockIssue({ id: "2" }), + mockIssue({ id: "3" }), + mockIssue({ id: "4" }), + mockIssue({ id: "5" }), + ]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("next", true), + }, + } + ) + ); + + const result = await listIssuesAllPages("test-org", "test-project", { + limit: 3, + }); + expect(result.issues).toHaveLength(3); + // Trimmed — no nextCursor to prevent skipping + expect(result.nextCursor).toBeUndefined(); + }); + + test("throws when limit < 1", async () => { + await expect( + listIssuesAllPages("test-org", "test-project", { limit: 0 }) + ).rejects.toThrow("limit must be at least 1"); + }); + + test("calls onPage callback after each page", async () => { + const onPageCalls: [number, number][] = []; + + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify([mockIssue({ id: "1" }), mockIssue({ id: "2" })]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("end", false), + }, + } + ) + ); + + await listIssuesAllPages("test-org", "test-project", { + limit: 10, + onPage: (fetched, limit) => { + onPageCalls.push([fetched, limit]); + }, + }); + + expect(onPageCalls).toHaveLength(1); + expect(onPageCalls[0]).toEqual([2, 10]); + }); + + test("returns nextCursor when last page has more results but limit reached", async () => { + // Return exactly limit items with a next cursor + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify([mockIssue({ id: "1" }), mockIssue({ id: "2" })]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("has-more", true), + }, + } + ) + ); + + const result = await listIssuesAllPages("test-org", "test-project", { + limit: 2, + }); + expect(result.issues).toHaveLength(2); + // Exactly at limit with more pages → keep nextCursor + expect(result.nextCursor).toBe("has-more"); + }); + + test("uses the remaining limit on the final page and preserves its cursor", async () => { + const requestedLimits: string[] = []; + let callCount = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + callCount += 1; + const req = new Request(input!, init); + const url = new URL(req.url); + requestedLimits.push(url.searchParams.get("limit") ?? ""); + const count = callCount === 3 ? 50 : 100; + const issues = Array.from({ length: count }, (_, index) => + mockIssue({ id: `${callCount}-${index}` }) + ); + return new Response(JSON.stringify(issues), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader(`cursor-${callCount + 1}`, true), + }, + }); + }); + + const result = await listIssuesAllPages("test-org", "test-project", { + limit: 250, + }); + + expect(result.issues).toHaveLength(250); + expect(requestedLimits).toEqual(["100", "100", "50"]); + expect(result.nextCursor).toBe("cursor-4"); + }); + + test("accepts startCursor option", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify([mockIssue({ id: "1" })]), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); + }); + + await listIssuesAllPages("test-org", "test-project", { + limit: 10, + startCursor: "start-here", + }); + expect(capturedUrl).toContain("cursor=start-here"); + }); + }); +}); + +// ============================================================================= +// teams.ts +// ============================================================================= + +describe("teams.ts", () => { + describe("listTeams", () => { + test("lists teams in organization", async () => { + const teams = [mockTeam({ id: "1" }), mockTeam({ id: "2" })]; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/organizations/test-org/teams/"); + return new Response(JSON.stringify(teams), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listTeams("test-org"); + expect(result).toHaveLength(2); + }); + }); + + describe("listProjectTeams", () => { + test("lists teams for a specific project", async () => { + const teams = [mockTeam()]; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/projects/test-org/test-project/teams/"); + return new Response(JSON.stringify(teams), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listProjectTeams("test-org", "test-project"); + expect(result).toHaveLength(1); + expect(result[0]!.slug).toBe("test-team"); + }); + }); + + describe("createTeam", () => { + test("creates team and adds current user as member", async () => { + const team = mockTeam({ slug: "new-team" }); + const requestUrls: string[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + requestUrls.push(req.url); + + if ( + req.method === "POST" && + req.url.includes("/organizations/test-org/teams/") + ) { + return new Response(JSON.stringify(team), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + if (req.url.includes("/member")) { + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }); + + const result = await createTeam("test-org", "new-team"); + expect(result.slug).toBe("new-team"); + // Should have made at least 2 calls (create + add member) + expect(requestUrls.length).toBeGreaterThanOrEqual(2); + }); + + test("returns team even when member-add fails", async () => { + const team = mockTeam({ slug: "new-team" }); + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + + if ( + req.method === "POST" && + req.url.includes("/organizations/test-org/teams/") + ) { + return new Response(JSON.stringify(team), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + // addMemberToTeam fails + return new Response(JSON.stringify({ detail: "Permission denied" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createTeam("test-org", "new-team"); + expect(result.slug).toBe("new-team"); + }); + }); + + describe("addMemberToTeam", () => { + test("adds member to team", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/members/me/teams/test-team/"); + expect(req.method).toBe("POST"); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + // Should not throw + await addMemberToTeam("test-org", "test-team", "me"); + }); + }); +}); + +// ============================================================================= +// projects.ts +// ============================================================================= + +describe("projects.ts", () => { + describe("listProjects", () => { + test("returns all projects from single page", async () => { + const projects = [mockProject({ id: "1" }), mockProject({ id: "2" })]; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(projects), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }) + ); + + const result = await listProjects("test-org"); + expect(result).toHaveLength(2); + }); + + test("auto-paginates through multiple pages", async () => { + let callCount = 0; + + globalThis.fetch = mockFetch(async () => { + callCount += 1; + if (callCount === 1) { + return new Response(JSON.stringify([mockProject({ id: "1" })]), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("page2", true), + }, + }); + } + return new Response(JSON.stringify([mockProject({ id: "2" })]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listProjects("test-org"); + expect(result).toHaveLength(2); + expect(callCount).toBe(2); + }); + }); + + describe("listProjectsPaginated", () => { + test("returns single page with pagination metadata", async () => { + const projects = [mockProject()]; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(projects), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("next-cursor", true), + }, + }) + ); + + const result = await listProjectsPaginated("test-org"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("next-cursor"); + }); + + test("passes cursor and perPage options", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("cursor")).toBe("my-cursor"); + expect(url.searchParams.get("per_page")).toBe("50"); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listProjectsPaginated("test-org", { + cursor: "my-cursor", + perPage: 50, + }); + }); + + test("caps perPage at API_MAX_PER_PAGE", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("per_page")).toBe(String(API_MAX_PER_PAGE)); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listProjectsPaginated("test-org", { perPage: 500 }); + }); + }); + + describe("listProjectsAllPages", () => { + test("auto-paginates when limit exceeds API_MAX_PER_PAGE", async () => { + const perPageValues: number[] = []; + let callCount = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + const perPage = Number(url.searchParams.get("per_page")); + perPageValues.push(perPage); + callCount += 1; + + const page = Array.from({ length: API_MAX_PER_PAGE }, (_, i) => + mockProject({ + id: String((callCount - 1) * API_MAX_PER_PAGE + i + 1), + slug: `proj-${(callCount - 1) * API_MAX_PER_PAGE + i}`, + }) + ); + + const hasMore = callCount === 1; + return new Response(JSON.stringify(page), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("page2", hasMore), + }, + }); + }); + + const result = await listProjectsAllPages("test-org", { limit: 200 }); + expect(result.data).toHaveLength(200); + expect(callCount).toBe(2); + expect(perPageValues).toEqual([API_MAX_PER_PAGE, API_MAX_PER_PAGE]); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single request when limit fits in one API page", async () => { + let callCount = 0; + globalThis.fetch = mockFetch(async (input, init) => { + callCount += 1; + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("per_page")).toBe("25"); + return new Response(JSON.stringify([mockProject()]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listProjectsAllPages("test-org", { limit: 25 }); + expect(result.data).toHaveLength(1); + expect(callCount).toBe(1); + }); + }); + + describe("createProject", () => { + test("creates project under team", async () => { + const project = mockProject({ slug: "new-project" }); + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/teams/test-org/test-team/projects/"); + expect(req.method).toBe("POST"); + return new Response(JSON.stringify(project), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createProject("test-org", "test-team", { + name: "New Project", + }); + expect(result.slug).toBe("new-project"); + }); + }); + + describe("createProjectWithAutoTeam", () => { + const autoTeamProject = { + id: "99", + slug: "auto-proj", + name: "Auto Project", + platform: "node", + dateCreated: "2026-01-01T00:00:00Z", + team_slug: "team-testuser", + }; + const dsnKeys = [ + { + id: "k1", + isActive: true, + dsn: { public: "https://key@o1.ingest.sentry.io/99", secret: "" }, + }, + ]; + + test("POSTs to /organizations/{org}/projects/ and returns project with DSN and team_slug", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + if (req.url.includes("/projects/") && req.method === "POST") { + return new Response(JSON.stringify(autoTeamProject), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + // DSN key fetch + return new Response(JSON.stringify(dsnKeys), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createProjectWithAutoTeam("test-org", { + name: "Auto Project", + }); + expect(result.project.slug).toBe("auto-proj"); + expect(result.team_slug).toBe("team-testuser"); + expect(result.dsn).toContain("key"); + expect(result.url).toContain("auto-proj"); + }); + + test("propagates 403 when org has disabled member project creation", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ detail: MEMBER_PROJECT_CREATION_DISABLED_DETAIL }), + { status: 403, headers: { "Content-Type": "application/json" } } + ) + ); + + await expect( + createProjectWithAutoTeam("test-org", { name: "Blocked" }) + ).rejects.toMatchObject({ status: 403 }); + }); + + test("returns dsn:null when DSN fetch fails", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + if (req.method === "POST") { + return new Response(JSON.stringify(autoTeamProject), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createProjectWithAutoTeam("test-org", { + name: "Auto Project", + }); + expect(result.dsn).toBeNull(); + }); + }); + + describe("getProjectKeys", () => { + test("returns project client keys", async () => { + const keys = [ + { + id: "key-1", + name: "Default", + isActive: true, + dsn: { public: "https://abc@sentry.io/1", secret: "secret" }, + }, + ]; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/projects/test-org/test-project/keys/"); + expect(new URL(req.url).searchParams.get("status")).toBe("active"); + return new Response(JSON.stringify(keys), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getProjectKeys("test-org", "test-project", { + status: "active", + }); + expect(result).toHaveLength(1); + expect(result[0]!.dsn.public).toBe("https://abc@sentry.io/1"); + }); + }); + + describe("tryGetPrimaryDsn", () => { + test("returns DSN of first active key", async () => { + const keys = [ + { + id: "key-1", + name: "Default", + isActive: true, + dsn: { public: "https://abc@sentry.io/1", secret: "s" }, + }, + { + id: "key-2", + name: "Other", + isActive: false, + dsn: { public: "https://def@sentry.io/2", secret: "s" }, + }, + ]; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(keys), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const dsn = await tryGetPrimaryDsn("test-org", "test-project"); + expect(dsn).toBe("https://abc@sentry.io/1"); + }); + + test("returns first key DSN when no active key", async () => { + const keys = [ + { + id: "key-1", + name: "Default", + isActive: false, + dsn: { public: "https://abc@sentry.io/1", secret: "s" }, + }, + ]; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(keys), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const dsn = await tryGetPrimaryDsn("test-org", "test-project"); + expect(dsn).toBe("https://abc@sentry.io/1"); + }); + + test("returns null when no keys", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const dsn = await tryGetPrimaryDsn("test-org", "test-project"); + expect(dsn).toBeNull(); + }); + + test("returns null on API error", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + + const captureSpy = vi.spyOn(Sentry, "captureException"); + try { + const dsn = await tryGetPrimaryDsn("test-org", "test-project"); + expect(dsn).toBeNull(); + // 404 is expected user/API noise — silenced, not an issue. + expect(captureSpy).not.toHaveBeenCalled(); + } finally { + captureSpy.mockRestore(); + } + }); + + test("reports unexpected DSN fetch failures to Sentry", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Internal error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + ); + + const captureSpy = vi.spyOn(Sentry, "captureException"); + const withScopeSpy = vi.spyOn(Sentry, "withScope"); + withScopeSpy.mockImplementation((fn: (scope: unknown) => void) => { + fn({ + setTag() { + /* noop */ + }, + setContext() { + /* noop */ + }, + setFingerprint() { + /* noop */ + }, + }); + }); + try { + const dsn = await tryGetPrimaryDsn("test-org", "test-project"); + expect(dsn).toBeNull(); + expect(captureSpy).toHaveBeenCalledTimes(1); + } finally { + captureSpy.mockRestore(); + withScopeSpy.mockRestore(); + } + }); + }); +}); + +// ============================================================================= +// users.ts +// ============================================================================= + +describe("users.ts", () => { + describe("getCurrentUser", () => { + test("fetches authenticated user from /auth/ endpoint", async () => { + const user = { + id: "123", + name: "Test User", + email: "test@example.com", + username: "testuser", + }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/api/0/auth/"); + return new Response(JSON.stringify(user), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getCurrentUser(); + expect(result.id).toBe("123"); + expect(result.name).toBe("Test User"); + expect(result.email).toBe("test@example.com"); + }); + + test("validates against SentryUserSchema (rejects invalid)", async () => { + // Missing required "id" field + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ name: "no-id" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(getCurrentUser()).rejects.toThrow(ApiError); + }); + }); +}); + +// ============================================================================= +// events.ts +// ============================================================================= + +describe("events.ts", () => { + describe("getLatestEvent", () => { + test("fetches latest event for an issue", async () => { + const event = { eventID: "evt-abc", title: "Error" }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/organizations/test-org/issues/"); + expect(req.url).toContain("/events/latest/"); + return new Response(JSON.stringify(event), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getLatestEvent("test-org", "12345"); + expect(result.eventID).toBe("evt-abc"); + }); + }); + + describe("getEvent", () => { + test("fetches specific event by ID", async () => { + const event = { eventID: "evt-abc" }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain( + "/projects/test-org/test-project/events/evt-abc/" + ); + return new Response(JSON.stringify(event), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getEvent("test-org", "test-project", "evt-abc"); + expect(result.eventID).toBe("evt-abc"); + }); + }); + + describe("listEventAttachments", () => { + test("returns attachment metadata from every API page", async () => { + let requestCount = 0; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.pathname).toContain( + "/projects/test-org/test-project/events/evt-abc/attachments/" + ); + expect(url.searchParams.get("per_page")).toBe("100"); + + requestCount += 1; + const firstPage = url.searchParams.get("cursor") === null; + if (firstPage) { + return new Response( + JSON.stringify([{ id: "attachment-1", name: "first.png" }]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("attachments-next", true), + }, + } + ); + } + + expect(url.searchParams.get("cursor")).toBe("attachments-next"); + return new Response( + JSON.stringify([{ id: "attachment-2", name: "second.png" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }); + + const result = await listEventAttachments( + "test-org", + "test-project", + "evt-abc" + ); + + expect(result.map((attachment) => attachment.id)).toEqual([ + "attachment-1", + "attachment-2", + ]); + expect(requestCount).toBe(2); + }); + }); + + describe("resolveEventInOrg", () => { + test("returns resolved event on success", async () => { + const resolved = { + organizationSlug: "test-org", + projectSlug: "test-project", + event: { eventID: "evt-abc" }, + }; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(resolved), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await resolveEventInOrg("test-org", "evt-abc"); + expect(result).not.toBeNull(); + expect(result!.org).toBe("test-org"); + expect(result!.project).toBe("test-project"); + }); + + test("returns null on 404", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await resolveEventInOrg("test-org", "evt-abc"); + expect(result).toBeNull(); + }); + + test("re-throws non-404 errors", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Internal Server Error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(resolveEventInOrg("test-org", "evt-abc")).rejects.toThrow(); + }); + }); +}); + +// ============================================================================= +// traces.ts +// ============================================================================= + +describe("traces.ts", () => { + describe("getDetailedTrace", () => { + test("fetches trace with correct params", async () => { + const spans = [ + { + span_id: "span-1", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_001, + }, + ]; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain( + "/organizations/test-org/trace/abc123def456/" + ); + const url = new URL(req.url); + expect(url.searchParams.get("timestamp")).toBe("1700000000"); + expect(url.searchParams.get("limit")).toBe("10000"); + expect(url.searchParams.get("project")).toBe("-1"); + return new Response(JSON.stringify(spans), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getDetailedTrace("test-org", "abc123def456", { + timestamp: 1_700_000_000, + }); + expect(result).toHaveLength(1); + expect(result[0]!.span_id).toBe("span-1"); + }); + }); +}); + +// ============================================================================= +// repositories.ts +// ============================================================================= + +describe("repositories.ts", () => { + describe("listRepositories", () => { + test("lists repositories in organization", async () => { + const repos = [ + { + id: "1", + name: "getsentry/sentry", + url: "https://github.com/getsentry/sentry", + provider: { id: "github", name: "GitHub" }, + status: "active", + }, + ]; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/organizations/test-org/repos/"); + return new Response(JSON.stringify(repos), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listRepositories("test-org"); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("getsentry/sentry"); + }); + }); + + describe("listAllRepositories", () => { + test("walks pagination and concatenates pages", async () => { + const pages: Array> = [ + [ + { id: "1", name: "owner/repo-a" }, + { id: "2", name: "owner/repo-b" }, + ], + [{ id: "3", name: "owner/repo-c" }], + ]; + let callIdx = 0; + + globalThis.fetch = mockFetch(async () => { + const page = pages[callIdx]; + callIdx += 1; + const hasNext = callIdx < pages.length; + const fullPage = page?.map((r) => ({ + ...r, + url: `https://github.com/${r.name}`, + provider: { id: "github", name: "GitHub" }, + status: "active", + })); + return new Response(JSON.stringify(fullPage), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: hasNext + ? '; rel="next"; results="true"; cursor="c1"' + : '; rel="next"; results="false"; cursor=""', + }, + }); + }); + + // Dynamic import to avoid circular issue with already-imported listRepositories + const { listAllRepositories } = await import( + "../../src/lib/api/repositories.js" + ); + const result = await listAllRepositories("test-org"); + expect(result).toHaveLength(3); + expect(result.map((r) => r.name)).toEqual([ + "owner/repo-a", + "owner/repo-b", + "owner/repo-c", + ]); + expect(callIdx).toBe(2); + }); + }); +}); + +// ============================================================================= +// logs.ts +// ============================================================================= + +describe("logs.ts", () => { + describe("listLogs", () => { + test("lists logs for a project slug", async () => { + const logsResponse = { + data: [ + { + "sentry.item_id": "log-1", + timestamp: "2024-01-01T00:00:00Z", + timestamp_precise: 1_704_067_200_000_000_000, + message: "Test log", + severity: "info", + trace: null, + }, + ], + meta: { fields: {} }, + }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/organizations/test-org/events/"); + const url = new URL(req.url); + expect(url.searchParams.get("dataset")).toBe("logs"); + // With a slug, the query should include project:my-project + expect(url.searchParams.get("query")).toContain("project:my-project"); + return new Response(JSON.stringify(logsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listLogs("test-org", "my-project"); + expect(result).toHaveLength(1); + expect(result[0]!["sentry.item_id"]).toBe("log-1"); + }); + + test("uses numeric project ID via project param", async () => { + const logsResponse = { data: [], meta: { fields: {} } }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + // Numeric ID should be passed as project param, not in query + expect(url.searchParams.get("project")).toBe("42"); + // No project: filter in query + const query = url.searchParams.get("query"); + if (query) { + expect(query).not.toContain("project:"); + } + return new Response(JSON.stringify(logsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listLogs("test-org", "42"); + }); + + test("includes afterTimestamp filter in query", async () => { + const logsResponse = { data: [], meta: {} }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? ""; + expect(query).toContain("timestamp_precise:>1700000000"); + return new Response(JSON.stringify(logsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listLogs("test-org", "my-project", { + afterTimestamp: 1_700_000_000, + }); + }); + + test("passes custom query and limit", async () => { + const logsResponse = { data: [], meta: {} }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? ""; + expect(query).toContain("severity:error"); + expect(url.searchParams.get("per_page")).toBe("50"); + return new Response(JSON.stringify(logsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listLogs("test-org", "my-project", { + query: "severity:error", + limit: 50, + }); + }); + }); +}); + +// ============================================================================= +// infrastructure.ts +// ============================================================================= + +describe("infrastructure.ts", () => { + describe("apiRequest", () => { + test("makes GET request to default base URL", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/api/0/organizations/"); + expect(req.method).toBe("GET"); + return new Response(JSON.stringify([{ slug: "org1" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await apiRequest<{ slug: string }[]>("/organizations/"); + expect(result).toHaveLength(1); + }); + }); + + describe("unwrapResult", () => { + test("re-throws ApiError directly", () => { + const apiError = new ApiError("test", 500, "detail"); + expect(() => + unwrapResult( + { data: undefined, error: apiError } as { + data: undefined; + error: unknown; + }, + "context" + ) + ).toThrow(apiError); + }); + + test("re-throws AuthError directly", () => { + const authError = new AuthError("expired"); + expect(() => + unwrapResult( + { data: undefined, error: authError } as { + data: undefined; + error: unknown; + }, + "context" + ) + ).toThrow(authError); + }); + + test("returns data on success", () => { + const result = unwrapResult( + { data: { foo: "bar" }, error: undefined } as { + data: { foo: string }; + error: undefined; + }, + "context" + ); + expect(result).toEqual({ foo: "bar" }); + }); + + test("wraps unknown errors as ApiError via throwApiError", () => { + // Create a mock result with a generic error and a response + const result = { + data: undefined, + error: { detail: "Something went wrong" }, + response: new Response("", { + status: 502, + statusText: "Bad Gateway", + }), + }; + + try { + unwrapResult( + result as { data: undefined; error: unknown }, + "Failed operation" + ); + expect(true).toBe(false); // Should not reach + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(502); + expect(apiErr.message).toContain("Failed operation"); + } + }); + }); + + describe("apiRequestToRegion", () => { + test("parses JSON error detail from non-ok response", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Rate limited" }), { + status: 429, + statusText: "Too Many Requests", + headers: { "Content-Type": "application/json" }, + }) + ); + + try { + await apiRequestToRegion("https://sentry.io", "/test/"); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(429); + expect(apiErr.detail).toBe("Rate limited"); + } + }); + + test("JSON-stringifies error body without detail field", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ code: "ERR", message: "Oops" }), { + status: 400, + statusText: "Bad Request", + headers: { "Content-Type": "application/json" }, + }) + ); + + try { + await apiRequestToRegion("https://sentry.io", "/test/"); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(400); + // Should be JSON.stringify of the whole response + expect(apiErr.detail).toContain("ERR"); + expect(apiErr.detail).toContain("Oops"); + } + }); + + test("uses text as detail when response is not JSON", async () => { + globalThis.fetch = mockFetch( + async () => + new Response("Internal Server Error", { + status: 500, + statusText: "Internal Server Error", + }) + ); + + try { + await apiRequestToRegion("https://sentry.io", "/test/"); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(500); + expect(apiErr.detail).toBe("Internal Server Error"); + } + }); + + test("throws ApiError on schema validation failure", async () => { + const schema = object({ + required_field: string(), + }); + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ wrong_field: "value" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + try { + await apiRequestToRegion("https://sentry.io", "/test/", { schema }); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.message).toContain("Unexpected response format"); + expect(apiErr.status).toBe(200); + } + }); + + test("returns validated data when schema passes", async () => { + const schema = object({ + name: string(), + count: number(), + }); + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ name: "test", count: 42 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const { data } = await apiRequestToRegion("https://sentry.io", "/test/", { + schema, + }); + expect(data).toEqual({ name: "test", count: 42 }); + }); + + test("normalizes endpoint with leading slash", async () => { + globalThis.fetch = mockFetch(async (input) => { + const url = String(input instanceof Request ? input.url : input); + // Should not have double /api/0// + expect(url).not.toContain("/api/0//"); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await apiRequestToRegion("https://sentry.io", "/test/"); + }); + + test("handles endpoint without leading slash", async () => { + globalThis.fetch = mockFetch(async (input) => { + const url = String(input instanceof Request ? input.url : input); + expect(url).toContain("/api/0/test/"); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await apiRequestToRegion("https://sentry.io", "test/"); + }); + + test("includes query params in URL", async () => { + globalThis.fetch = mockFetch(async (input) => { + const url = String(input instanceof Request ? input.url : input); + expect(url).toContain("foo=bar"); + expect(url).toContain("num=42"); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await apiRequestToRegion("https://sentry.io", "/test/", { + params: { foo: "bar", num: 42 }, + }); + }); + + test("sends request body as JSON", async () => { + let capturedBody: unknown; + + globalThis.fetch = mockFetch(async (_input, init) => { + if (init?.body) { + capturedBody = JSON.parse(init.body as string); + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await apiRequestToRegion("https://sentry.io", "/test/", { + method: "POST", + body: { key: "value" }, + }); + expect(capturedBody).toEqual({ key: "value" }); + }); + + test("falls back to statusText when response.text() throws", async () => { + globalThis.fetch = mockFetch(async () => { + // Create a response whose text() method throws + const response = new Response(null, { + status: 503, + statusText: "Service Unavailable", + }); + // Override text() to throw + response.text = () => { + throw new Error("stream error"); + }; + // Mark as not ok + Object.defineProperty(response, "ok", { value: false }); + return response; + }); + + try { + await apiRequestToRegion("https://sentry.io", "/test/"); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(503); + expect(apiErr.detail).toBe("Service Unavailable"); + } + }); + }); +}); + +// ============================================================================= +// teams.ts — listTeamsPaginated +// ============================================================================= + +describe("teams.ts (paginated)", () => { + describe("listTeamsPaginated", () => { + test("returns single page with cursor", async () => { + const teams = [mockTeam({ id: "1" }), mockTeam({ id: "2" })]; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(teams), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("teams-next", true), + }, + }) + ); + + const result = await listTeamsPaginated("test-org"); + expect(result.data).toHaveLength(2); + expect(result.nextCursor).toBe("teams-next"); + }); + + test("passes cursor and perPage options", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("cursor")).toBe("my-cursor"); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listTeamsPaginated("test-org", { + cursor: "my-cursor", + perPage: 50, + }); + }); + }); +}); + +// ============================================================================= +// repositories.ts — listRepositoriesPaginated +// ============================================================================= + +describe("repositories.ts (paginated)", () => { + describe("listRepositoriesPaginated", () => { + test("returns single page with cursor", async () => { + const repos = [ + { + id: "1", + name: "getsentry/sentry", + url: "https://github.com/getsentry/sentry", + provider: { id: "github", name: "GitHub" }, + status: "active", + }, + ]; + + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(repos), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("repos-next", true), + }, + }) + ); + + const result = await listRepositoriesPaginated("test-org"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("repos-next"); + }); + + test("passes cursor and perPage options", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("cursor")).toBe("repo-cursor"); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listRepositoriesPaginated("test-org", { + cursor: "repo-cursor", + perPage: 25, + }); + }); + }); +}); + +// ============================================================================= +// traces.ts — listTransactions +// ============================================================================= + +describe("traces.ts (transactions)", () => { + describe("listTransactions", () => { + test("lists transactions for a project slug", async () => { + const txnResponse = { + data: [ + { + trace: "abc123", + id: "evt-1", + transaction: "GET /api/users", + timestamp: "2024-01-01T00:00:00Z", + "span.duration": 150, + project: "test-project", + }, + ], + meta: { fields: {} }, + }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.pathname).toContain("/organizations/test-org/events/"); + expect(url.searchParams.get("dataset")).toBe("spans"); + expect(url.searchParams.get("query")).toContain("is_transaction:true"); + expect(url.searchParams.get("query")).toContain("project:test-project"); + return new Response(JSON.stringify(txnResponse), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("txn-next", true), + }, + }); + }); + + const result = await listTransactions("test-org", "test-project"); + expect(result.data).toHaveLength(1); + expect(result.data[0]!.transaction).toBe("GET /api/users"); + expect(result.nextCursor).toBe("txn-next"); + }); + + test("uses numeric project ID via project param", async () => { + const txnResponse = { + data: [], + meta: { fields: {} }, + }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("project")).toBe("42"); + const query = url.searchParams.get("query"); + if (query) { + expect(query).not.toContain("project:"); + } + return new Response(JSON.stringify(txnResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listTransactions("test-org", "42"); + }); + + test("passes sort and statsPeriod options", async () => { + const txnResponse = { data: [], meta: {} }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("sort")).toBe("-span.duration"); + expect(url.searchParams.get("statsPeriod")).toBe("24h"); + return new Response(JSON.stringify(txnResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listTransactions("test-org", "test-project", { + sort: "duration", + statsPeriod: "24h", + }); + }); + + test("uses -timestamp sort for date sort option", async () => { + const txnResponse = { data: [], meta: {} }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("sort")).toBe("-timestamp"); + return new Response(JSON.stringify(txnResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listTransactions("test-org", "test-project", { sort: "date" }); + }); + }); +}); + +// ============================================================================= +// logs.ts — getLogs and listTraceLogs +// ============================================================================= + +describe("logs.ts (detailed)", () => { + describe("getLogs", () => { + test("fetches single batch of log entries by ID", async () => { + const logsResponse = { + data: [ + { + "sentry.item_id": "log-1", + timestamp: "2024-01-01T00:00:00Z", + timestamp_precise: 1_704_067_200_000_000_000, + message: "Test log", + severity: "info", + trace: null, + }, + ], + meta: { fields: {} }, + }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? ""; + expect(query).toContain("sentry.item_id:[log-1]"); + expect(query).toContain("project:test-project"); + return new Response(JSON.stringify(logsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await getLogs("test-org", "test-project", ["log-1"]); + expect(result).toHaveLength(1); + expect(result[0]!["sentry.item_id"]).toBe("log-1"); + }); + + test("splits into batches when over API_MAX_PER_PAGE", async () => { + // Create 150 log IDs (more than API_MAX_PER_PAGE=100) + const logIds = Array.from({ length: 150 }, (_, i) => `log-${i}`); + let callCount = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + + // The SDK uses /events/ endpoint for logs + if (url.pathname.includes("/events/")) { + callCount += 1; + // Return a valid but empty batch + return new Response( + JSON.stringify({ + data: [ + { + "sentry.item_id": `batch-${callCount}`, + timestamp: "2024-01-01T00:00:00Z", + timestamp_precise: 1_704_067_200_000_000_000, + message: "test", + }, + ], + meta: { fields: {} }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + + const result = await getLogs("test-org", "test-project", logIds); + // Should have made 2 batch calls (100 + 50) + expect(callCount).toBe(2); + expect(result).toHaveLength(2); + }); + }); + + describe("listTraceLogs", () => { + test("lists logs for a trace", async () => { + const traceLogsResponse = { + data: [ + { + id: "tlog-1", + "project.id": 1, + trace: "abc123def456", + severity: "info", + timestamp: "2024-01-01T00:00:00Z", + message: "Trace log entry", + }, + ], + meta: { fields: {} }, + }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.pathname).toContain("/organizations/test-org/trace-logs/"); + expect(url.searchParams.get("traceId")).toBe("abc123def456"); + expect(url.searchParams.get("statsPeriod")).toBe("14d"); + return new Response(JSON.stringify(traceLogsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listTraceLogs("test-org", "abc123def456"); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("tlog-1"); + }); + + test("passes custom statsPeriod and limit", async () => { + const traceLogsResponse = { data: [], meta: {} }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("statsPeriod")).toBe("7d"); + expect(url.searchParams.get("per_page")).toBe("50"); + return new Response(JSON.stringify(traceLogsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listTraceLogs("test-org", "abc123def456", { + statsPeriod: "7d", + limit: 50, + }); + }); + + test("passes custom query", async () => { + const traceLogsResponse = { data: [], meta: {} }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("query")).toBe("severity:error"); + return new Response(JSON.stringify(traceLogsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listTraceLogs("test-org", "abc123def456", { + query: "severity:error", + }); + }); + }); +}); + +// ============================================================================= +// events.ts — findEventAcrossOrgs +// ============================================================================= + +describe("events.ts (findEventAcrossOrgs)", () => { + test("finds event across multiple orgs", async () => { + // This test needs both listOrganizations and resolveEventInOrg mocked. + // listOrganizations uses /organizations/ (control silo, single call) + // resolveEventInOrg uses /organizations/{org}/eventids/{event_id}/ + const resolved = { + organizationSlug: "test-org", + projectSlug: "test-project", + event: { eventID: "evt-found" }, + }; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + + // listOrganizations → listOrganizationsPage (control silo) + if ( + req.url.includes("/organizations/") && + !req.url.includes("eventids") + ) { + return new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + // resolveOrganizationEventId + if (req.url.includes("/eventids/")) { + return new Response(JSON.stringify(resolved), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }); + + const { findEventAcrossOrgs } = await import("../../src/lib/api-client.js"); + const result = await findEventAcrossOrgs("evt-found"); + expect(result).not.toBeNull(); + expect(result!.org).toBe("test-org"); + }); +}); + +// ============================================================================= +// infrastructure.ts — rawApiRequest +// ============================================================================= + +describe("infrastructure.ts (rawApiRequest)", () => { + describe("rawApiRequest", () => { + test("makes GET request and returns status, headers, body", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("GET"); + expect(req.url).toContain("/api/0/organizations/"); + return new Response(JSON.stringify({ data: "test" }), { + status: 200, + headers: { + "Content-Type": "application/json", + "X-Custom": "value", + }, + }); + }); + + const result = await rawApiRequest("/organizations/"); + expect(result.status).toBe(200); + expect(result.body).toEqual({ data: "test" }); + expect(result.headers.get("X-Custom")).toBe("value"); + }); + + test("returns non-JSON body as text", async () => { + globalThis.fetch = mockFetch( + async () => + new Response("plain text response", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }) + ); + + const result = await rawApiRequest("/test/"); + expect(result.body).toBe("plain text response"); + }); + + test("does not throw on non-2xx responses", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await rawApiRequest("/test/"); + expect(result.status).toBe(404); + expect(result.body).toEqual({ detail: "Not found" }); + }); + + test("sends POST with JSON body and Content-Type", async () => { + let capturedBody: unknown; + let capturedContentType = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedContentType = req.headers.get("Content-Type") ?? ""; + capturedBody = await req.json(); + return new Response(JSON.stringify({}), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + await rawApiRequest("/test/", { + method: "POST", + body: { key: "value" }, + }); + expect(capturedBody).toEqual({ key: "value" }); + expect(capturedContentType).toBe("application/json"); + }); + + test("sends string body without auto Content-Type", async () => { + let capturedBody = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedBody = await req.text(); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await rawApiRequest("/test/", { + method: "POST", + body: "raw-string-body", + }); + expect(capturedBody).toBe("raw-string-body"); + }); + + test("includes custom headers", async () => { + let capturedAccept = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedAccept = req.headers.get("Accept") ?? ""; + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await rawApiRequest("/test/", { + headers: { Accept: "text/csv" }, + }); + expect(capturedAccept).toBe("text/csv"); + }); + + test("merges query params with an existing endpoint query", async () => { + globalThis.fetch = mockFetch(async (input) => { + const url = new URL( + String(input instanceof Request ? input.url : input) + ); + expect(url.searchParams.get("download")).toBe("1"); + expect(url.searchParams.get("per_page")).toBe("10"); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await rawApiRequest("/test/?download=1", { + params: { per_page: 10 }, + }); + }); + }); +}); + +// ============================================================================= +// infrastructure.ts — buildSearchParams and parseLinkHeader +// ============================================================================= + +describe("infrastructure.ts (helpers)", () => { + describe("buildSearchParams", () => { + // Import from infrastructure for direct testing + test("returns undefined for undefined input", async () => { + const { buildSearchParams } = await import( + "../../src/lib/api/infrastructure.js" + ); + expect(buildSearchParams(undefined)).toBeUndefined(); + }); + + test("returns undefined for all-undefined values", async () => { + const { buildSearchParams } = await import( + "../../src/lib/api/infrastructure.js" + ); + expect(buildSearchParams({ a: undefined, b: undefined })).toBeUndefined(); + }); + + test("handles string, number, and boolean values", async () => { + const { buildSearchParams } = await import( + "../../src/lib/api/infrastructure.js" + ); + const result = buildSearchParams({ + str: "hello", + num: 42, + flag: true, + }); + expect(result).toBeDefined(); + expect(result!.get("str")).toBe("hello"); + expect(result!.get("num")).toBe("42"); + expect(result!.get("flag")).toBe("true"); + }); + + test("handles string arrays (repeated keys)", async () => { + const { buildSearchParams } = await import( + "../../src/lib/api/infrastructure.js" + ); + const result = buildSearchParams({ tags: ["a", "b", "c"] }); + expect(result).toBeDefined(); + expect(result!.getAll("tags")).toEqual(["a", "b", "c"]); + }); + + test("skips undefined values", async () => { + const { buildSearchParams } = await import( + "../../src/lib/api/infrastructure.js" + ); + const result = buildSearchParams({ + present: "yes", + missing: undefined, + }); + expect(result).toBeDefined(); + expect(result!.get("present")).toBe("yes"); + expect(result!.has("missing")).toBe(false); + }); + }); + + describe("parseLinkHeader", () => { + test("returns empty for null header", async () => { + const { parseLinkHeader } = await import( + "../../src/lib/api/infrastructure.js" + ); + expect(parseLinkHeader(null)).toEqual({}); + }); + + test("returns empty for empty string", async () => { + const { parseLinkHeader } = await import( + "../../src/lib/api/infrastructure.js" + ); + expect(parseLinkHeader("")).toEqual({}); + }); + + test("extracts next cursor when results=true", async () => { + const { parseLinkHeader } = await import( + "../../src/lib/api/infrastructure.js" + ); + const header = + '; rel="next"; results="true"; cursor="1735689600000:0:0"'; + expect(parseLinkHeader(header)).toEqual({ + nextCursor: "1735689600000:0:0", + }); + }); + + test("returns empty when results=false", async () => { + const { parseLinkHeader } = await import( + "../../src/lib/api/infrastructure.js" + ); + const header = + '; rel="next"; results="false"; cursor="abc"'; + expect(parseLinkHeader(header)).toEqual({}); + }); + + test("handles multiple link entries", async () => { + const { parseLinkHeader } = await import( + "../../src/lib/api/infrastructure.js" + ); + const header = + '; rel="previous"; results="false"; cursor="prev",' + + '; rel="next"; results="true"; cursor="next-val"'; + expect(parseLinkHeader(header)).toEqual({ nextCursor: "next-val" }); + }); + }); +}); diff --git a/packages/cli/test/lib/api-client.multiregion.test.ts b/packages/cli/test/lib/api-client.multiregion.test.ts new file mode 100644 index 000000000..c9fe5785f --- /dev/null +++ b/packages/cli/test/lib/api-client.multiregion.test.ts @@ -0,0 +1,715 @@ +/** + * Multi-Region API Client Tests + * + * Tests for the multi-region support in the Sentry API client. + * Covers region discovery, fan-out, and region-aware routing. + */ + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + findProjectByDsnKey, + getUserRegions, + listOrganizations, + listOrganizationsPage, +} from "../../src/lib/api-client.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + clearOrgRegions, + getAllOrgRegions, + setOrgRegion, +} from "../../src/lib/db/regions.js"; +import { ApiError } from "../../src/lib/errors.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-multiregion-"); +let originalFetch: typeof globalThis.fetch; +let savedAuthToken: string | undefined; +let savedSentryToken: string | undefined; + +beforeEach(async () => { + // Save original fetch + originalFetch = globalThis.fetch; + + // Isolate from env-var auth tokens so isEnvTokenActive() returns false. + // Without this, tests for 403 enrichment would flake when CI sets + // SENTRY_AUTH_TOKEN (the error message changes based on token source). + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + savedSentryToken = process.env.SENTRY_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + + // Set up auth token (manual token, no refresh) + await setAuthToken("test-token"); + + // Clear any existing region cache + clearOrgRegions(); +}); + +afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; + + // Restore env vars + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + if (savedSentryToken !== undefined) { + process.env.SENTRY_TOKEN = savedSentryToken; + } +}); + +/** + * Creates a mock fetch that routes requests based on URL patterns. + */ +function createMultiRegionMockFetch(handlers: { + controlSilo?: (req: Request) => Response | Promise; + usRegion?: (req: Request) => Response | Promise; + euRegion?: (req: Request) => Response | Promise; + default?: (req: Request) => Response | Promise; +}): typeof globalThis.fetch { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = new URL(req.url); + + // Route to appropriate handler based on hostname + if ( + url.hostname === "sentry.io" || + url.hostname === "localhost" || + url.hostname === "127.0.0.1" + ) { + if (handlers.controlSilo) { + return handlers.controlSilo(req); + } + } else if (url.hostname === "us.sentry.io") { + if (handlers.usRegion) { + return handlers.usRegion(req); + } + } else if (url.hostname === "de.sentry.io" && handlers.euRegion) { + return handlers.euRegion(req); + } + + if (handlers.default) { + return handlers.default(req); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +describe("getUserRegions", () => { + test("returns regions from control silo", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + if (req.url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [ + { name: "us", url: "https://us.sentry.io" }, + { name: "de", url: "https://de.sentry.io" }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }, + }); + + const regions = await getUserRegions(); + + expect(regions).toHaveLength(2); + expect(regions[0]).toEqual({ name: "us", url: "https://us.sentry.io" }); + expect(regions[1]).toEqual({ name: "de", url: "https://de.sentry.io" }); + }); + + test("returns empty array when no regions", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + if (req.url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }, + }); + + const regions = await getUserRegions(); + + expect(regions).toHaveLength(0); + }); + + test("returns single region for self-hosted", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + if (req.url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "monolith", url: "https://sentry.io" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }, + }); + + const regions = await getUserRegions(); + + expect(regions).toHaveLength(1); + expect(regions[0]).toEqual({ name: "monolith", url: "https://sentry.io" }); + }); +}); + +describe("listOrganizationsPage", () => { + test("fetches organizations from a specific base URL", async () => { + let capturedUrl: string | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedUrl = req.url; + + return new Response( + JSON.stringify([ + { id: "1", slug: "us-org-1", name: "US Org 1" }, + { id: "2", slug: "us-org-2", name: "US Org 2" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const { data: orgs } = await listOrganizationsPage("https://us.sentry.io"); + + expect(capturedUrl).toContain("us.sentry.io"); + expect(capturedUrl).toContain("/api/0/organizations/"); + expect(orgs).toHaveLength(2); + expect(orgs[0].slug).toBe("us-org-1"); + }); + + test("enriches 403 error with re-auth guidance for OAuth users", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "You do not have permission" }), { + status: 403, + statusText: "Forbidden", + headers: { "Content-Type": "application/json" }, + }); + + try { + await listOrganizationsPage("https://us.sentry.io"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + // Should include the original detail + expect(apiErr.detail).toContain("You do not have permission"); + // OAuth users: suggest re-auth (not token scopes) + expect(apiErr.detail).toContain("sentry auth login"); + expect(apiErr.detail).not.toContain("org:read"); + } + }); + + test("handles base URL with trailing slash", async () => { + let capturedUrl: string | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedUrl = req.url; + + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await listOrganizationsPage("https://de.sentry.io/"); + + // Should not have double slashes + expect(capturedUrl).not.toContain("//api"); + expect(capturedUrl).toContain("de.sentry.io/api/0/organizations/"); + }); +}); + +describe("listOrganizations (control silo)", () => { + test("fetches all orgs from the control silo in a single call", async () => { + const requestedUrls: string[] = []; + + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + requestedUrls.push(req.url); + return new Response( + JSON.stringify([ + { + id: "100", + slug: "us-org", + name: "US Organization", + links: { + organizationUrl: "https://us.sentry.io/organizations/us-org/", + regionUrl: "https://us.sentry.io", + }, + }, + { + id: "200", + slug: "eu-org", + name: "EU Organization", + links: { + organizationUrl: "https://de.sentry.io/organizations/eu-org/", + regionUrl: "https://de.sentry.io", + }, + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }, + }); + + const orgs = await listOrganizations(); + + // No region discovery call, and only one request to the control silo. + expect(requestedUrls.some((u) => u.includes("/users/me/regions/"))).toBe( + false + ); + expect( + requestedUrls.filter((u) => u.includes("/organizations/")) + ).toHaveLength(1); + + // Both orgs (from different regions) come back from that single call. + expect(orgs).toHaveLength(2); + expect(orgs.map((o) => o.slug).sort()).toEqual(["eu-org", "us-org"]); + }); + + test("caches each org's own region URL from links, in one round-trip", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: () => + new Response( + JSON.stringify([ + { + id: "101", + slug: "acme-us", + name: "Acme US", + links: { + organizationUrl: "https://us.sentry.io/organizations/acme-us/", + regionUrl: "https://us.sentry.io", + }, + }, + { + id: "201", + slug: "acme-eu", + name: "Acme EU", + links: { + organizationUrl: "https://de.sentry.io/organizations/acme-eu/", + regionUrl: "https://de.sentry.io", + }, + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ), + }); + + await listOrganizations(); + + // Verify region cache was populated from links in the single response + const cachedRegions = getAllOrgRegions(); + expect(cachedRegions.size).toBe(2); + expect(cachedRegions.get("acme-us")).toBe("https://us.sentry.io"); + expect(cachedRegions.get("acme-eu")).toBe("https://de.sentry.io"); + }); + + test("returns empty array when the user has no orgs", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: () => + new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + }); + + const orgs = await listOrganizations(); + + expect(orgs).toHaveLength(0); + }); + + test("falls back to the control silo URL when an org has no links", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: () => + new Response( + JSON.stringify([ + { + id: "103", + slug: "org-with-links", + name: "Org With Links", + links: { + organizationUrl: + "https://custom.sentry.io/organizations/org-with-links/", + regionUrl: "https://custom.sentry.io", + }, + }, + { + id: "104", + slug: "org-without-links", + name: "Org Without Links", + // No links - should fall back to the control silo URL + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ), + }); + + await listOrganizations(); + + const cachedRegions = getAllOrgRegions(); + // Org with links should use its own regionUrl + expect(cachedRegions.get("org-with-links")).toBe( + "https://custom.sentry.io" + ); + // Org without links falls back to the control silo base URL + expect(cachedRegions.get("org-without-links")).toBe("https://sentry.io"); + }); + + test("propagates a 403 error from the control silo", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: () => + new Response(JSON.stringify({ detail: "You do not have permission" }), { + status: 403, + statusText: "Forbidden", + headers: { "Content-Type": "application/json" }, + }), + }); + + try { + await listOrganizations(); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + // OAuth users: re-auth guidance (no scope hint) + expect(apiErr.detail).toContain("sentry auth login"); + } + }); + + test("auto-paginates through multiple pages via the Link header", async () => { + let callCount = 0; + + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: () => { + callCount += 1; + if (callCount === 1) { + return new Response( + JSON.stringify([{ id: "1", slug: "org-page-1", name: "Page 1" }]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="true"; cursor="page2:0:0"', + }, + } + ); + } + return new Response( + JSON.stringify([{ id: "2", slug: "org-page-2", name: "Page 2" }]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor="end:0:0"', + }, + } + ); + }, + }); + + const orgs = await listOrganizations(); + + expect(callCount).toBe(2); + expect(orgs.map((o) => o.slug).sort()).toEqual([ + "org-page-1", + "org-page-2", + ]); + }); +}); + +describe("findProjectByDsnKey (multi-region)", () => { + test("searches all regions for project with DSN key", async () => { + const requestedUrls: string[] = []; + + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + requestedUrls.push(req.url); + if (req.url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [ + { name: "us", url: "https://us.sentry.io" }, + { name: "de", url: "https://de.sentry.io" }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + }, + usRegion: (req) => { + requestedUrls.push(req.url); + // US region doesn't have the project + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + euRegion: (req) => { + requestedUrls.push(req.url); + // EU region has the project + // Check for URL-encoded query parameter (dsn%3Aabc123) + if (req.url.includes("/projects/") && req.url.includes("abc123")) { + return new Response( + JSON.stringify([ + { + id: "300", + slug: "eu-project", + name: "EU Project", + organization: { id: "200", slug: "eu-org", name: "EU Org" }, + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + }, + }); + + const project = await findProjectByDsnKey("abc123"); + + // Should have searched both regions + expect( + requestedUrls.some( + (u) => u.includes("us.sentry.io") && u.includes("/projects/") + ) + ).toBe(true); + expect( + requestedUrls.some( + (u) => u.includes("de.sentry.io") && u.includes("/projects/") + ) + ).toBe(true); + + // Should find the project from EU region + expect(project).not.toBeNull(); + expect(project?.slug).toBe("eu-project"); + }); + + test("returns null when project not found in any region", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + if (req.url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + }, + usRegion: () => + new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + }); + + const project = await findProjectByDsnKey("nonexistent-key"); + + expect(project).toBeNull(); + }); + + test("falls back to default region for self-hosted (no regions)", async () => { + let capturedUrl: string | undefined; + + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + capturedUrl = req.url; + if (req.url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + if (req.url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { + id: "400", + slug: "self-hosted-project", + name: "Self Hosted Project", + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + }, + }); + + const project = await findProjectByDsnKey("self-hosted-key"); + + // Should query control silo directly when no regions + expect(capturedUrl).toContain("/projects/"); + // URL encodes the colon as %3A + expect(capturedUrl).toContain("self-hosted-key"); + expect(project?.slug).toBe("self-hosted-project"); + }); + + test("continues searching when one region fails", async () => { + globalThis.fetch = createMultiRegionMockFetch({ + controlSilo: (req) => { + if (req.url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [ + { name: "us", url: "https://us.sentry.io" }, + { name: "de", url: "https://de.sentry.io" }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + }, + usRegion: () => { + throw new Error("Network error"); + }, + euRegion: () => + new Response( + JSON.stringify([ + { + id: "500", + slug: "found-project", + name: "Found Project", + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ), + }); + + const captureSpy = vi.spyOn(Sentry, "captureException"); + const withScopeSpy = vi.spyOn(Sentry, "withScope"); + withScopeSpy.mockImplementation((fn: (scope: unknown) => void) => { + fn({ + setTag() { + /* noop */ + }, + setContext() { + /* noop */ + }, + setFingerprint() { + /* noop */ + }, + }); + }); + try { + const project = await findProjectByDsnKey("abc123"); + + // Should find project despite US region failing + expect(project?.slug).toBe("found-project"); + expect(captureSpy).toHaveBeenCalledTimes(1); + expect(captureSpy.mock.calls[0]?.[0]).toMatchObject({ + message: "Network error", + }); + } finally { + captureSpy.mockRestore(); + withScopeSpy.mockRestore(); + } + }); +}); + +describe("org-scoped requests use region cache", () => { + test("routes request to cached region URL", async () => { + // Pre-populate region cache + setOrgRegion("cached-org", "https://de.sentry.io"); + + let capturedUrl: string | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedUrl = req.url; + + return new Response( + JSON.stringify({ + id: "600", + slug: "cached-org", + name: "Cached Organization", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + // Import getOrganization dynamically to use fresh module state + const { getOrganization } = await import("../../src/lib/api-client.js"); + await getOrganization("cached-org"); + + // Should route to EU region based on cache + expect(capturedUrl).toContain("de.sentry.io"); + expect(capturedUrl).toContain("/organizations/cached-org/"); + }); +}); diff --git a/packages/cli/test/lib/api-client.normalize-trace-span.test.ts b/packages/cli/test/lib/api-client.normalize-trace-span.test.ts new file mode 100644 index 000000000..c15120fb6 --- /dev/null +++ b/packages/cli/test/lib/api-client.normalize-trace-span.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { normalizeTraceSpan } from "../../src/lib/api-client.js"; + +describe("normalizeTraceSpan", () => { + test("copies event_id to span_id when span_id is missing", () => { + const span = { event_id: "abc123", start_timestamp: 0 } as Parameters< + typeof normalizeTraceSpan + >[0]; + const result = normalizeTraceSpan(span); + expect(result.span_id).toBe("abc123"); + }); + + test("preserves existing span_id", () => { + const result = normalizeTraceSpan({ + span_id: "existing", + event_id: "other", + start_timestamp: 0, + }); + expect(result.span_id).toBe("existing"); + }); + + test("recurses into children", () => { + const result = normalizeTraceSpan({ + span_id: "parent", + start_timestamp: 0, + children: [{ event_id: "child1", start_timestamp: 1 } as any], + }); + expect(result.children?.[0]?.span_id).toBe("child1"); + }); +}); diff --git a/packages/cli/test/lib/api-client.property.test.ts b/packages/cli/test/lib/api-client.property.test.ts new file mode 100644 index 000000000..abed95f44 --- /dev/null +++ b/packages/cli/test/lib/api-client.property.test.ts @@ -0,0 +1,185 @@ +/** + * Property-Based Tests for API Client Pagination Helpers + * + * Tests parseLinkHeader — the pure function that extracts pagination cursors + * from Sentry's RFC 5988 Link response headers. + */ + +import { + constantFrom, + assert as fcAssert, + nat, + property, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { parseLinkHeader } from "../../src/lib/api-client.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Generate a Sentry-style cursor: `timestamp:offset:is_prev` */ +const cursorArb = tuple( + nat(2_000_000_000_000), + nat(100), + constantFrom(0, 1) +).map(([ts, offset, isPrev]) => `${ts}:${offset}:${isPrev}`); + +/** Generate a valid "next" link part with results="true" and a cursor */ +const nextLinkWithResultsArb = cursorArb.map( + (cursor) => + `; rel="next"; results="true"; cursor="${cursor}"` +); + +/** Generate a "next" link part with results="false" */ +const nextLinkNoResultsArb = cursorArb.map( + (cursor) => + `; rel="next"; results="false"; cursor="${cursor}"` +); + +/** Generate a "previous" link part (should be ignored by parseLinkHeader) */ +const prevLinkArb = cursorArb.map( + (cursor) => + `; rel="previous"; results="true"; cursor="${cursor}"` +); + +/** Generate a rel value that is not "next" */ +const nonNextRelArb = constantFrom("previous", "first", "last", "self"); + +describe("property: parseLinkHeader", () => { + test("null or empty header returns no cursor", () => { + const result1 = parseLinkHeader(null); + expect(result1).toEqual({}); + + const result2 = parseLinkHeader(""); + expect(result2).toEqual({}); + }); + + test("valid next link with results=true returns cursor", () => { + fcAssert( + property(nextLinkWithResultsArb, (header) => { + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeDefined(); + expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("next link with results=false returns no cursor", () => { + fcAssert( + property(nextLinkNoResultsArb, (header) => { + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("previous-only link returns no cursor (ignores non-next)", () => { + fcAssert( + property(prevLinkArb, (header) => { + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("combined prev + next link extracts cursor from next part", () => { + fcAssert( + property(tuple(prevLinkArb, nextLinkWithResultsArb), ([prev, next]) => { + // Sentry sends both prev and next separated by comma + const header = `${prev}, ${next}`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeDefined(); + expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("cursor value is preserved exactly", () => { + fcAssert( + property(cursorArb, (cursor) => { + const header = `; rel="next"; results="true"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBe(cursor); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("missing cursor attribute returns no cursor", () => { + const header = + '; rel="next"; results="true"'; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }); + + test("missing results attribute returns no cursor", () => { + fcAssert( + property(cursorArb, (cursor) => { + const header = `; rel="next"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-next rel with results=true returns no cursor", () => { + fcAssert( + property(tuple(nonNextRelArb, cursorArb), ([rel, cursor]) => { + const header = `; rel="${rel}"; results="true"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("multiple parts: only next with results=true is extracted", () => { + fcAssert( + property(tuple(cursorArb, cursorArb), ([prevCursor, nextCursor]) => { + const header = [ + `; rel="previous"; results="false"; cursor="${prevCursor}"`, + `; rel="next"; results="true"; cursor="${nextCursor}"`, + ].join(", "); + const result = parseLinkHeader(header); + expect(result.nextCursor).toBe(nextCursor); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("random strings without expected attributes return no cursor", () => { + fcAssert( + property(string({ minLength: 0, maxLength: 200 }), (header) => { + // Any random string should not crash and should return a valid result + const result = parseLinkHeader(header); + if (result.nextCursor !== undefined) { + expect(typeof result.nextCursor).toBe("string"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("real Sentry response header with both links", () => { + const header = + '; rel="previous"; results="false"; cursor="1735689600000:0:1", ' + + '; rel="next"; results="true"; cursor="1735689600000:100:0"'; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBe("1735689600000:100:0"); + }); + + test("real Sentry last-page response header", () => { + const header = + '; rel="previous"; results="true"; cursor="1735689600000:0:1", ' + + '; rel="next"; results="false"; cursor="1735689600000:200:0"'; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/api-client.seer-trial.test.ts b/packages/cli/test/lib/api-client.seer-trial.test.ts new file mode 100644 index 000000000..0240f3996 --- /dev/null +++ b/packages/cli/test/lib/api-client.seer-trial.test.ts @@ -0,0 +1,228 @@ +/** + * Product Trial API Client Tests + * + * Tests for getProductTrials and startProductTrial by mocking fetch. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { + getProductTrials, + startProductTrial, +} from "../../src/lib/api-client.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-product-trial-api-"); +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + + await setAuthToken("test-token"); + setOrgRegion("test-org", "https://sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("getProductTrials", () => { + test("returns all trials from the API", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ + productTrials: [ + { + category: "seerUsers", + startDate: null, + endDate: null, + reasonCode: 0, + isStarted: false, + lengthDays: 14, + }, + { + category: "replays", + startDate: "2025-01-01", + endDate: "2025-01-15", + reasonCode: 0, + isStarted: true, + lengthDays: 14, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + const trials = await getProductTrials("test-org"); + + expect(trials).toHaveLength(2); + expect(trials[0]?.category).toBe("seerUsers"); + expect(trials[0]?.isStarted).toBe(false); + expect(trials[1]?.category).toBe("replays"); + expect(trials[1]?.isStarted).toBe(true); + }); + + test("returns empty array when no productTrials field", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const trials = await getProductTrials("test-org"); + + expect(trials).toEqual([]); + }); + + test("returns empty array when productTrials is empty", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ productTrials: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const trials = await getProductTrials("test-org"); + + expect(trials).toEqual([]); + }); + + test("sends GET request to customer endpoint", async () => { + let capturedRequest: Request | undefined; + + globalThis.fetch = mockFetch( + async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + ); + + await getProductTrials("test-org"); + + expect(capturedRequest?.method).toBe("GET"); + expect(capturedRequest?.url).toContain("/customers/test-org/"); + }); + + test("throws ApiError on non-200 response", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(getProductTrials("test-org")).rejects.toThrow(); + }); +}); + +describe("startProductTrial", () => { + test("sends PUT request with correct body for seerUsers", async () => { + let capturedBody: unknown; + + globalThis.fetch = mockFetch( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(init?.body as string); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + ); + + await startProductTrial("test-org", "seerUsers"); + + expect(capturedBody).toEqual({ + referrer: "sentry-cli", + productTrial: { category: "seerUsers", reasonCode: 0 }, + }); + }); + + test("sends PUT request with seerAutofix category", async () => { + let capturedBody: unknown; + + globalThis.fetch = mockFetch( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(init?.body as string); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + ); + + await startProductTrial("test-org", "seerAutofix"); + + expect(capturedBody).toEqual({ + referrer: "sentry-cli", + productTrial: { category: "seerAutofix", reasonCode: 0 }, + }); + }); + + test("sends PUT request with any category", async () => { + let capturedBody: unknown; + + globalThis.fetch = mockFetch( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(init?.body as string); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + ); + + await startProductTrial("test-org", "replays"); + + expect(capturedBody).toEqual({ + referrer: "sentry-cli", + productTrial: { category: "replays", reasonCode: 0 }, + }); + }); + + test("sends PUT to product-trial endpoint", async () => { + let capturedUrl: string | undefined; + let capturedMethod: string | undefined; + + globalThis.fetch = mockFetch( + async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + ); + + await startProductTrial("test-org", "seerUsers"); + + expect(capturedMethod).toBe("PUT"); + expect(capturedUrl).toContain("/customers/test-org/product-trial/"); + }); + + test("throws ApiError on non-200 response", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(startProductTrial("test-org", "seerUsers")).rejects.toThrow(); + }); +}); diff --git a/packages/cli/test/lib/api-client.seer.test.ts b/packages/cli/test/lib/api-client.seer.test.ts new file mode 100644 index 000000000..abf9a263c --- /dev/null +++ b/packages/cli/test/lib/api-client.seer.test.ts @@ -0,0 +1,389 @@ +/** + * Seer API Client Tests + * + * Tests for the seer-related API functions by mocking fetch. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { normalizeAgentStatus } from "../../src/lib/api/seer.js"; +import { + getAutofixState, + triggerRootCauseAnalysis, + triggerSolutionPlanning, +} from "../../src/lib/api-client.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { ApiError } from "../../src/lib/errors.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-seer-api-"); +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + // Save original fetch + originalFetch = globalThis.fetch; + + // Set up auth token (manual token, no refresh) + await setAuthToken("test-token"); + // Pre-populate region cache to avoid region resolution API calls + setOrgRegion("test-org", "https://sentry.io"); +}); + +afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; +}); + +describe("triggerRootCauseAnalysis", () => { + test("sends POST request to autofix endpoint with explorer mode", async () => { + let capturedRequest: Request | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + + return new Response(JSON.stringify({ run_id: 12_345 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await triggerRootCauseAnalysis("test-org", "123456789"); + + expect(capturedRequest?.method).toBe("POST"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); + expect(capturedRequest?.url).toContain("mode=explorer"); + }); + + test("includes step and stopping_point in request body", async () => { + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedBody = await req.json(); + + return new Response(JSON.stringify({ run_id: 12_345 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await triggerRootCauseAnalysis("test-org", "123456789"); + + expect(capturedBody).toEqual({ + step: "root_cause", + referrer: "api.cli", + }); + }); + + test("throws ApiError on 402 response", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "No budget for Seer Autofix" }), { + status: 402, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + triggerRootCauseAnalysis("test-org", "123456789") + ).rejects.toThrow(); + }); + + test("throws ApiError on 403 response", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "AI Autofix is not enabled" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + triggerRootCauseAnalysis("test-org", "123456789") + ).rejects.toThrow(); + }); +}); + +describe("getAutofixState", () => { + test("sends GET request to autofix endpoint with explorer mode", async () => { + let capturedRequest: Request | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + + return new Response( + JSON.stringify({ + autofix: { + sentry_run_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + status: "processing", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const result = await getAutofixState("test-org", "123456789"); + + expect(result?.sentry_run_id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + expect(result?.status).toBe("PROCESSING"); + expect(capturedRequest?.method).toBe("GET"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); + expect(capturedRequest?.url).toContain("mode=explorer"); + }); + + test("still parses legacy run_id when sentry_run_id is absent", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "processing", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("test-org", "123456789"); + + expect(result?.run_id).toBe(12_345); + expect(result?.sentry_run_id).toBeUndefined(); + }); + + test("normalizes agent status values to uppercase", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 1, + status: "awaiting_user_input", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("test-org", "123456789"); + expect(result?.status).toBe("WAITING_FOR_USER_RESPONSE"); + }); + + test("normalizes US spelling 'canceled' to CANCELLED for terminal status match", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 1, + status: "canceled", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("test-org", "123456789"); + expect(result?.status).toBe("CANCELLED"); + }); + + test("normalizes need_more_information to NEED_MORE_INFORMATION", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 1, + status: "need_more_information", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("test-org", "123456789"); + expect(result?.status).toBe("NEED_MORE_INFORMATION"); + }); + + test("returns null when autofix is null", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ autofix: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + const result = await getAutofixState("test-org", "123456789"); + expect(result).toBeNull(); + }); + + test.each([ + { name: "a null response", payload: null }, + { name: "a missing status", payload: { autofix: { run_id: 1 } } }, + { + name: "a non-string status", + payload: { autofix: { run_id: 1, status: 123 } }, + }, + ])("throws a validation ApiError for $name", async ({ payload }) => { + globalThis.fetch = async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + const request = getAutofixState("test-org", "123456789"); + + await expect(request).rejects.toBeInstanceOf(ApiError); + await expect(request).rejects.toMatchObject({ + message: + "Unexpected response format from /organizations/test-org/issues/123456789/autofix/", + status: 200, + detail: expect.any(String), + }); + }); + + test("returns completed state with blocks", async () => { + const blocks = [ + { + id: "block-1", + message: { role: "assistant", content: "Found the root cause" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ + { + key: "root_cause", + data: { + one_line_description: "Test cause", + five_whys: ["Why 1"], + }, + reason: "", + }, + ], + }, + ]; + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 12_345, + status: "completed", + updated_at: "2025-01-01T00:00:00Z", + blocks, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("test-org", "123456789"); + expect(result?.status).toBe("COMPLETED"); + expect(result?.blocks).toEqual(blocks); + }); +}); + +describe("triggerSolutionPlanning", () => { + test("sends POST request to autofix endpoint with explorer mode", async () => { + let capturedRequest: Request | undefined; + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedRequest = new Request(input, init); + capturedBody = await new Request(input, init).json(); + + return new Response(JSON.stringify({ run_id: 12_345 }), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + }; + + await triggerSolutionPlanning("test-org", "123456789", 12_345); + + expect(capturedRequest?.method).toBe("POST"); + expect(capturedRequest?.url).toContain( + "/organizations/test-org/issues/123456789/autofix/" + ); + expect(capturedRequest?.url).toContain("mode=explorer"); + expect(capturedBody).toEqual({ + step: "solution", + run_id: 12_345, + referrer: "api.cli", + }); + }); + + test("sends a UUID run ID under the sentry_run_id body field, not run_id", async () => { + let capturedBody: unknown; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = await new Request(input, init).json(); + + return new Response( + JSON.stringify({ + run_id: 12_345, + sentry_run_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }), + { + status: 202, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + await triggerSolutionPlanning( + "test-org", + "123456789", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ); + + // sentry_run_id is a UUIDField server-side; run_id is an IntegerField. + // Sending the UUID under run_id would fail server-side validation. + expect(capturedBody).toEqual({ + step: "solution", + sentry_run_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + referrer: "api.cli", + }); + }); +}); + +describe("normalizeAgentStatus", () => { + test.each([ + ["processing", "PROCESSING"], + ["completed", "COMPLETED"], + ["error", "ERROR"], + ["canceled", "CANCELLED"], + ["cancelled", "CANCELLED"], + ["awaiting_user_input", "WAITING_FOR_USER_RESPONSE"], + ["need_more_information", "NEED_MORE_INFORMATION"], + ])("maps %s to %s", (input, expected) => { + expect(normalizeAgentStatus(input)).toBe(expected); + }); + + test.each([ + [null], + [undefined], + [""], + ])("defaults %s status to PROCESSING", (input) => { + expect(normalizeAgentStatus(input)).toBe("PROCESSING"); + }); + + test("uppercases unknown statuses", () => { + expect(normalizeAgentStatus("some_new_status")).toBe("SOME_NEW_STATUS"); + }); +}); diff --git a/packages/cli/test/lib/api-client.test.ts b/packages/cli/test/lib/api-client.test.ts new file mode 100644 index 000000000..e8130673b --- /dev/null +++ b/packages/cli/test/lib/api-client.test.ts @@ -0,0 +1,2001 @@ +/** + * API Client Tests + * + * Tests for the Sentry API client 401 retry behavior and utility functions. + * Uses manual fetch mocking to avoid polluting the module cache. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + API_MAX_PER_PAGE, + buildSearchParams, + getLogs, + listIssuesPaginated, + listRepositoriesPaginated, + listTeamsPaginated, + listTraceLogs, + listTransactions, + rawApiRequest, +} from "../../src/lib/api-client.js"; +import { DEFAULT_SENTRY_URL } from "../../src/lib/constants.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setOrgRegion, setOrgRegions } from "../../src/lib/db/regions.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-api-"); + +let originalFetch: typeof globalThis.fetch; + +/** + * Tracks requests made during a test + */ +type RequestLog = { + url: string; + method: string; + authorization: string | null; + isRetry: boolean; +}; + +beforeEach(async () => { + // Set required env var for OAuth refresh + process.env.SENTRY_CLIENT_ID = "test-client-id"; + + // Save original fetch + originalFetch = globalThis.fetch; + + // Set up initial auth token with a refresh token so 401 retry can get a new token + await setAuthToken("initial-token", 3600, "test-refresh-token"); +}); + +afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; +}); + +/** + * Creates a mock fetch that handles API requests. + * Uses rawApiRequest which goes to control silo (no region resolution needed). + * + * The `apiRequestHandler` is called for each API request. + */ +function createMockFetch( + requests: RequestLog[], + apiRequestHandler: ( + req: Request, + requestCount: number + ) => Response | Promise, + options: { + oauthHandler?: (req: Request) => Response | Promise; + } = {} +): typeof globalThis.fetch { + let apiRequestCount = 0; + + return async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push({ + url: req.url, + method: req.method, + authorization: req.headers.get("Authorization"), + isRetry: req.headers.get("x-sentry-cli-retry") === "1", + }); + + // OAuth token refresh endpoint + if (req.url.includes("/oauth/token/")) { + if (options.oauthHandler) { + return options.oauthHandler(req); + } + return new Response( + JSON.stringify({ + access_token: "refreshed-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // API requests - delegate to handler + apiRequestCount += 1; + return apiRequestHandler(req, apiRequestCount); + }; +} + +describe("401 retry behavior", () => { + // Note: These tests use rawApiRequest which goes to control silo (sentry.io) + // and supports 401 retry with token refresh. + + let savedAuthToken: string | undefined; + beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + }); + afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + }); + + test("retries request with new token on 401 response", async () => { + const requests: RequestLog[] = []; + + globalThis.fetch = createMockFetch(requests, (_req, requestCount) => { + // First request: return 401 + if (requestCount === 1) { + return new Response(JSON.stringify({ detail: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + // Retry request: return success + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await rawApiRequest("/test-endpoint/"); + + // Verify successful result from retry + expect(result.status).toBe(200); + expect(result.body).toEqual({ ok: true }); + + // Verify request sequence: + // 1. Initial API request with initial-token -> 401 + // 2. OAuth refresh request + // 3. Retry API request with refreshed-token -> 200 + const apiRequests = requests.filter((r) => + r.url.includes("/test-endpoint") + ); + const oauthRequests = requests.filter((r) => + r.url.includes("/oauth/token/") + ); + + expect(apiRequests).toHaveLength(2); + expect(oauthRequests).toHaveLength(1); + + // First request with initial token + expect(apiRequests[0].authorization).toBe("Bearer initial-token"); + expect(apiRequests[0].isRetry).toBe(false); + + // Retry request with new token + expect(apiRequests[1].authorization).toBe("Bearer refreshed-token"); + expect(apiRequests[1].isRetry).toBe(true); + }); + + test("does not retry on non-401 errors", async () => { + const requests: RequestLog[] = []; + + globalThis.fetch = createMockFetch(requests, () => { + // Return 403 (not 401) - this should not trigger retry + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + }); + + // rawApiRequest doesn't throw on error responses, it returns the status + const result = await rawApiRequest("/test-endpoint/"); + expect(result.status).toBe(403); + + // Should only have initial API request, no retry + const apiRequests = requests.filter((r) => + r.url.includes("/test-endpoint") + ); + expect(apiRequests).toHaveLength(1); + expect(apiRequests[0].isRetry).toBe(false); + + // No OAuth refresh should have been attempted + const oauthRequests = requests.filter((r) => + r.url.includes("/oauth/token/") + ); + expect(oauthRequests).toHaveLength(0); + }); + + test("does not retry infinitely on repeated 401s", async () => { + const requests: RequestLog[] = []; + + globalThis.fetch = createMockFetch(requests, () => { + // Always return 401 for API requests + return new Response(JSON.stringify({ detail: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }); + + // rawApiRequest doesn't throw, returns status + const result = await rawApiRequest("/test-endpoint/"); + expect(result.status).toBe(401); + + // Should have exactly 2 API requests (initial + one retry, no infinite loop) + const apiRequests = requests.filter((r) => + r.url.includes("/test-endpoint") + ); + expect(apiRequests).toHaveLength(2); + expect(apiRequests[0].isRetry).toBe(false); + expect(apiRequests[1].isRetry).toBe(true); + + // OAuth refresh should have been called once (after first 401) + const oauthRequests = requests.filter((r) => + r.url.includes("/oauth/token/") + ); + expect(oauthRequests).toHaveLength(1); + }); + + test("does not retry for manual API tokens (no refresh token)", async () => { + // Manual API tokens have no expiry and no refresh token + await setAuthToken("manual-api-token"); // No expiry, no refresh token + + const requests: RequestLog[] = []; + + globalThis.fetch = createMockFetch(requests, () => { + // Always return 401 + return new Response(JSON.stringify({ detail: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }); + + // rawApiRequest doesn't throw, returns status + const result = await rawApiRequest("/test-endpoint/"); + expect(result.status).toBe(401); + + // Should have exactly 1 API request - no retry since token can't be refreshed + const apiRequests = requests.filter((r) => + r.url.includes("/test-endpoint") + ); + expect(apiRequests).toHaveLength(1); + expect(apiRequests[0].isRetry).toBe(false); + + // No OAuth refresh should have been attempted (no refresh token available) + const oauthRequests = requests.filter((r) => + r.url.includes("/oauth/token/") + ); + expect(oauthRequests).toHaveLength(0); + }); +}); + +describe("buildSearchParams", () => { + test("returns undefined for undefined input", () => { + expect(buildSearchParams(undefined)).toBeUndefined(); + }); + + test("returns undefined for empty object", () => { + expect(buildSearchParams({})).toBeUndefined(); + }); + + test("returns undefined when all values are undefined", () => { + expect(buildSearchParams({ a: undefined, b: undefined })).toBeUndefined(); + }); + + test("builds params from simple key-value pairs", () => { + const result = buildSearchParams({ status: "resolved", limit: 10 }); + expect(result).toBeDefined(); + expect(result?.get("status")).toBe("resolved"); + expect(result?.get("limit")).toBe("10"); + }); + + test("skips undefined values", () => { + const result = buildSearchParams({ + status: "resolved", + query: undefined, + limit: 10, + }); + expect(result).toBeDefined(); + expect(result?.get("status")).toBe("resolved"); + expect(result?.get("limit")).toBe("10"); + expect(result?.has("query")).toBe(false); + }); + + test("handles boolean values", () => { + const result = buildSearchParams({ active: true, archived: false }); + expect(result).toBeDefined(); + expect(result?.get("active")).toBe("true"); + expect(result?.get("archived")).toBe("false"); + }); + + test("handles string arrays as repeated keys", () => { + const result = buildSearchParams({ tags: ["error", "warning", "info"] }); + expect(result).toBeDefined(); + // URLSearchParams.getAll returns all values for repeated keys + expect(result?.getAll("tags")).toEqual(["error", "warning", "info"]); + // toString shows repeated keys + expect(result?.toString()).toBe("tags=error&tags=warning&tags=info"); + }); + + test("handles mixed simple values and arrays", () => { + const result = buildSearchParams({ + status: "unresolved", + tags: ["critical", "backend"], + limit: 25, + }); + expect(result).toBeDefined(); + expect(result?.get("status")).toBe("unresolved"); + expect(result?.getAll("tags")).toEqual(["critical", "backend"]); + expect(result?.get("limit")).toBe("25"); + }); + + test("handles empty array", () => { + const result = buildSearchParams({ tags: [] }); + // Empty array produces no entries, so result should be undefined + expect(result).toBeUndefined(); + }); +}); + +describe("rawApiRequest", () => { + test("sends GET request without body", async () => { + const requests: Request[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + + return new Response(JSON.stringify([{ id: 1 }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await rawApiRequest("organizations/"); + + expect(result.status).toBe(200); + expect(result.body).toEqual([{ id: 1 }]); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + }); + + test("sends POST request with JSON object body", async () => { + const requests: Request[] = []; + let capturedBody: string | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + capturedBody = await req.text(); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await rawApiRequest("issues/123/", { + method: "POST", + body: { status: "resolved" }, + }); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ success: true }); + expect(requests[0].method).toBe("POST"); + expect(capturedBody).toBe('{"status":"resolved"}'); + }); + + test("sends PUT request with string body", async () => { + const requests: Request[] = []; + let capturedBody: string | undefined; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + capturedBody = await req.text(); + + return new Response(JSON.stringify({ updated: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await rawApiRequest("issues/123/", { + method: "PUT", + body: '{"status":"resolved"}', + }); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ updated: true }); + expect(requests[0].method).toBe("PUT"); + // String body should be sent as-is + expect(capturedBody).toBe('{"status":"resolved"}'); + // No explicit Content-Type header set by rawApiRequest for string bodies. + // Node.js Request constructor auto-sets "text/plain;charset=UTF-8" for + // string bodies; Bun leaves it null. Accept either. + const ct = requests[0].headers.get("Content-Type"); + expect(ct === null || ct === "text/plain;charset=UTF-8").toBe(true); + }); + + test("string body with explicit Content-Type header", async () => { + const requests: Request[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await rawApiRequest("issues/123/", { + method: "PUT", + body: "plain text content", + headers: { "Content-Type": "text/plain" }, + }); + + // User-provided Content-Type should be used + expect(requests[0].headers.get("Content-Type")).toBe("text/plain"); + }); + + test("string body with lowercase content-type header (case-insensitive)", async () => { + const requests: Request[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await rawApiRequest("issues/123/", { + method: "PUT", + body: "content", + headers: { "content-type": "text/xml" }, + }); + + // Lowercase content-type should be detected and preserved (case-insensitive check) + expect(requests[0].headers.get("Content-Type")).toBe("text/xml"); + }); + + test("string body with mixed case Content-TYPE header", async () => { + const requests: Request[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await rawApiRequest("issues/123/", { + method: "PUT", + body: "some data", + headers: { "CONTENT-TYPE": "application/octet-stream" }, + }); + + // Mixed case Content-TYPE should be detected and preserved + expect(requests[0].headers.get("Content-Type")).toBe( + "application/octet-stream" + ); + }); + + test("sends request with query params", async () => { + const requests: Request[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await rawApiRequest("issues/", { + params: { status: "resolved", limit: "10" }, + }); + + const url = new URL(requests[0].url); + expect(url.searchParams.get("status")).toBe("resolved"); + expect(url.searchParams.get("limit")).toBe("10"); + }); + + test("sends request with custom headers", async () => { + const requests: Request[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await rawApiRequest("issues/", { + headers: { "X-Custom-Header": "test-value" }, + }); + + expect(requests[0].headers.get("X-Custom-Header")).toBe("test-value"); + }); + + test("custom headers merged with string body (no default Content-Type)", async () => { + const requests: Request[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await rawApiRequest("issues/123/", { + method: "PUT", + body: '{"status":"resolved"}', + headers: { "X-Custom": "value" }, + }); + + // Custom headers should be present, but no explicit Content-Type for string bodies. + // Node.js Request constructor auto-sets "text/plain;charset=UTF-8" for + // string bodies; Bun leaves it null. Accept either. + expect(requests[0].headers.get("X-Custom")).toBe("value"); + const ct = requests[0].headers.get("Content-Type"); + expect(ct === null || ct === "text/plain;charset=UTF-8").toBe(true); + }); + + test("returns non-JSON response body as string", async () => { + globalThis.fetch = async () => + new Response("Plain text response", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + + const result = await rawApiRequest("some-endpoint/"); + + expect(result.status).toBe(200); + expect(result.body).toBe("Plain text response"); + }); + + test("returns error status without throwing", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + + const result = await rawApiRequest("nonexistent/"); + + expect(result.status).toBe(404); + expect(result.body).toEqual({ detail: "Not found" }); + }); + + test("includes response headers", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { + "Content-Type": "application/json", + "X-Request-Id": "abc123", + }, + }); + + const result = await rawApiRequest("test/"); + + expect(result.headers.get("X-Request-Id")).toBe("abc123"); + }); +}); + +describe("findProjectsBySlug", () => { + test("returns matching projects from multiple orgs", async () => { + // Import dynamically inside test to allow mocking + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + const requests: Request[] = []; + + // Mock the regions endpoint first, then org/project requests + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + const url = req.url; + + // Regions endpoint - return single region to simplify test + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Organizations list + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "acme", name: "Acme Corp" }, + { id: "2", slug: "beta", name: "Beta Inc" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // getProject for acme/frontend - found + if (url.includes("/projects/acme/frontend/")) { + return new Response( + JSON.stringify({ id: "101", slug: "frontend", name: "Frontend" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // getProject for beta/frontend - found + if (url.includes("/projects/beta/frontend/")) { + return new Response( + JSON.stringify({ + id: "201", + slug: "frontend", + name: "Beta Frontend", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Default - not found + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const { projects, orgs } = await findProjectsBySlug("frontend"); + + expect(projects).toHaveLength(2); + expect(projects[0].slug).toBe("frontend"); + expect(projects[0].orgSlug).toBe("acme"); + expect(projects[1].slug).toBe("frontend"); + expect(projects[1].orgSlug).toBe("beta"); + expect(orgs).toHaveLength(2); + }); + + test("returns empty array when no projects match", async () => { + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // Regions endpoint + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Organizations list + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "acme", name: "Acme Corp" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // getProject - not found (404) + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const { projects } = await findProjectsBySlug("nonexistent"); + + expect(projects).toHaveLength(0); + }); + + test("skips orgs where user lacks access (403)", async () => { + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // Regions endpoint + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Organizations list + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "acme", name: "Acme Corp" }, + { id: "2", slug: "restricted", name: "Restricted Org" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // getProject for acme/frontend - success + if (url.includes("/projects/acme/frontend/")) { + return new Response( + JSON.stringify({ id: "101", slug: "frontend", name: "Frontend" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // getProject for restricted/frontend - 403 forbidden + if (url.includes("/projects/restricted/frontend/")) { + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + // Should not throw, should just skip the restricted org + const { projects } = await findProjectsBySlug("frontend"); + + expect(projects).toHaveLength(1); + expect(projects[0].orgSlug).toBe("acme"); + }); + + test("resolves numeric project ID when slug differs", async () => { + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // Regions endpoint + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Organizations list + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "acme", name: "Acme Corp" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // getProject for acme/7275560680 - API resolves by numeric ID, + // returns project with a different slug + if (url.includes("/projects/acme/7275560680/")) { + return new Response( + JSON.stringify({ + id: "7275560680", + slug: "frontend", + name: "Frontend", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + // Numeric ID should resolve even though returned slug differs + const { projects } = await findProjectsBySlug("7275560680"); + expect(projects).toHaveLength(1); + expect(projects[0].slug).toBe("frontend"); + expect(projects[0].orgSlug).toBe("acme"); + }); + + test("rejects non-numeric input when returned slug differs", async () => { + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "acme", name: "Acme Corp" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // API returns project with different slug (coincidental ID match) + if (url.includes("/projects/acme/wrong-slug/")) { + return new Response( + JSON.stringify({ id: "999", slug: "actual-slug", name: "Actual" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + // Non-numeric input with slug mismatch should be rejected + const { projects } = await findProjectsBySlug("wrong-slug"); + expect(projects).toHaveLength(0); + }); + + test("uses cached orgs to skip listOrganizations API calls", async () => { + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + + // Seed org_regions cache with full org data (slug, id, name, region) + // so listOrganizations() returns from cache without HTTP calls. + setOrgRegions([ + { + slug: "acme", + regionUrl: DEFAULT_SENTRY_URL, + orgId: "42", + orgName: "Acme Corp", + }, + ]); + + const requests: string[] = []; + + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + requests.push(url); + + // getProject for acme/frontend — success + if (url.includes("/projects/acme/frontend/")) { + return new Response( + JSON.stringify({ id: "101", slug: "frontend", name: "Frontend" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const { projects } = await findProjectsBySlug("frontend"); + + // Found via cached orgs + expect(projects).toHaveLength(1); + expect(projects[0].slug).toBe("frontend"); + expect(projects[0].orgSlug).toBe("acme"); + + // The expensive listOrganizations API calls were skipped + expect(requests.some((r) => r.includes("/users/me/regions/"))).toBe(false); + expect( + requests.some( + (r) => r.includes("/organizations/") && !r.includes("/projects/") + ) + ).toBe(false); + }); +}); + +describe("resolveEventInOrg", () => { + const sampleEvent = { + id: "abc123", + eventID: "abc123def456", + groupID: "12345", + projectID: "67890", + message: "Something went wrong", + title: "Error", + location: null, + user: null, + tags: [], + platform: "node", + dateReceived: "2026-01-01T00:00:00Z", + contexts: null, + size: 100, + entries: [], + dist: null, + sdk: {}, + context: null, + packages: {}, + type: "error", + metadata: null, + errors: [], + occurrence: null, + _meta: {}, + }; + + test("returns resolved event when found in org", async () => { + const { resolveEventInOrg } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + if (url.includes("/eventids/abc123def456/")) { + return new Response( + JSON.stringify({ + organizationSlug: "acme", + projectSlug: "frontend", + groupId: "12345", + eventId: "abc123def456", + event: sampleEvent, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await resolveEventInOrg("acme", "abc123def456"); + expect(result).not.toBeNull(); + expect(result?.org).toBe("acme"); + expect(result?.project).toBe("frontend"); + expect(result?.event.eventID).toBe("abc123def456"); + }); + + test("returns null when event not found in org", async () => { + const { resolveEventInOrg } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await resolveEventInOrg("acme", "notfound000000"); + expect(result).toBeNull(); + }); +}); + +describe("findEventAcrossOrgs", () => { + const sampleEvent = { + id: "abc123", + eventID: "abc123def456", + groupID: "12345", + projectID: "67890", + message: "Something went wrong", + title: "Error", + location: null, + user: null, + tags: [], + platform: "node", + dateReceived: "2026-01-01T00:00:00Z", + contexts: null, + size: 100, + entries: [], + dist: null, + sdk: {}, + context: null, + packages: {}, + type: "error", + metadata: null, + errors: [], + occurrence: null, + _meta: {}, + }; + + test("returns match from the org that has the event", async () => { + const { findEventAcrossOrgs } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + if (url.includes("/organizations/") && !url.includes("/eventids/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "no-event-org", name: "No Event Org" }, + { id: "2", slug: "has-event-org", name: "Has Event Org" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + if (url.includes("/has-event-org/eventids/abc123def456/")) { + return new Response( + JSON.stringify({ + organizationSlug: "has-event-org", + projectSlug: "backend", + groupId: "12345", + eventId: "abc123def456", + event: sampleEvent, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await findEventAcrossOrgs("abc123def456"); + expect(result).not.toBeNull(); + expect(result?.org).toBe("has-event-org"); + expect(result?.project).toBe("backend"); + }); + + test("returns null when event not found in any org", async () => { + const { findEventAcrossOrgs } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + if (url.includes("/organizations/") && !url.includes("/eventids/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "acme", name: "Acme" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await findEventAcrossOrgs("notfound000000"); + expect(result).toBeNull(); + }); +}); + +describe("listTeamsPaginated", () => { + beforeEach(async () => { + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns teams and nextCursor from Link header", async () => { + const teamData = [ + { id: "1", slug: "backend", name: "Backend", memberCount: 5 }, + { id: "2", slug: "frontend", name: "Frontend", memberCount: 3 }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/teams/")) { + return new Response(JSON.stringify(teamData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="true"; cursor="100:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listTeamsPaginated("my-org"); + expect(result.data).toHaveLength(2); + expect(result.nextCursor).toBe("100:1:0"); + }); + + test("returns no nextCursor when Link header has results=false", async () => { + const teamData = [ + { id: "1", slug: "backend", name: "Backend", memberCount: 5 }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/teams/")) { + return new Response(JSON.stringify(teamData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="previous"; results="false"; cursor="100:0:1", ; rel="next"; results="false"; cursor="100:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listTeamsPaginated("my-org"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBeUndefined(); + }); + + test("passes cursor and perPage as query params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/teams/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listTeamsPaginated("my-org", { cursor: "100:2:0", perPage: 10 }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("cursor")).toBe("100:2:0"); + expect(url.searchParams.get("per_page")).toBe("10"); + }); +}); + +describe("listRepositoriesPaginated", () => { + beforeEach(async () => { + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns repos and nextCursor from Link header", async () => { + const repoData = [ + { + id: "1", + name: "getsentry/sentry", + provider: { name: "GitHub" }, + status: "active", + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/repos/")) { + return new Response(JSON.stringify(repoData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="true"; cursor="0:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listRepositoriesPaginated("my-org"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("0:1:0"); + }); + + test("passes cursor and perPage as query params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/repos/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listRepositoriesPaginated("my-org", { cursor: "0:2:0", perPage: 5 }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("cursor")).toBe("0:2:0"); + expect(url.searchParams.get("per_page")).toBe("5"); + }); +}); + +describe("listIssuesPaginated", () => { + beforeEach(async () => { + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns issues and nextCursor from Link header", async () => { + const issueData = [ + { id: "1", shortId: "PROJ-1", title: "Test Error", status: "unresolved" }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + return new Response(JSON.stringify(issueData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="true"; cursor="0:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listIssuesPaginated("my-org", "my-proj"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("0:1:0"); + }); + + test("includes project filter in query param", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj"); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("query")).toContain("project:my-proj"); + }); + + test("combines project filter with custom query", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj", { query: "is:unresolved" }); + + const url = new URL(capturedUrl); + const query = url.searchParams.get("query") ?? ""; + expect(query).toContain("project:my-proj"); + expect(query).toContain("is:unresolved"); + }); + + test("passes cursor, perPage, and sort as query params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj", { + cursor: "0:3:0", + perPage: 20, + sort: "freq", + }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("cursor")).toBe("0:3:0"); + expect(url.searchParams.get("limit")).toBe("20"); + expect(url.searchParams.get("sort")).toBe("freq"); + }); + + test("uses project query param instead of project:slug when projectId is provided", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj", { projectId: 12_345 }); + + const url = new URL(capturedUrl); + // Should use project=12345 query param + expect(url.searchParams.get("project")).toBe("12345"); + // Should NOT include project:my-proj in the search query + const query = url.searchParams.get("query") ?? ""; + expect(query).not.toContain("project:my-proj"); + }); + + test("uses project:slug in query when projectId is not provided", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj"); + + const url = new URL(capturedUrl); + // Should NOT have project query param + expect(url.searchParams.has("project")).toBe(false); + // Should include project:my-proj in the search query + expect(url.searchParams.get("query")).toContain("project:my-proj"); + }); + + test("combines projectId with custom query without project:slug", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj", { + projectId: 12_345, + query: "is:unresolved", + }); + + const url = new URL(capturedUrl); + // Should use project=12345 query param + expect(url.searchParams.get("project")).toBe("12345"); + // Search query should contain custom query but not project:slug + const query = url.searchParams.get("query") ?? ""; + expect(query).toContain("is:unresolved"); + expect(query).not.toContain("project:my-proj"); + }); +}); + +describe("listTransactions", () => { + const transactionData = { + data: [ + { + trace: "aaaa1111bbbb2222cccc3333dddd4444", + id: "evt001", + transaction: "GET /api/users", + timestamp: "2025-01-30T14:32:15+00:00", + "span.duration": 245, + project: "my-project", + }, + ], + meta: { fields: { trace: "string" } }, + }; + + beforeEach(async () => { + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns data and nextCursor from Link header", async () => { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + return new Response(JSON.stringify(transactionData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="true"; cursor="1735689600:0:0"', + }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await listTransactions("my-org", "my-project"); + expect(Array.isArray(result.data)).toBe(true); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("1735689600:0:0"); + }); + + test("returns no nextCursor when link header has results=false", async () => { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + return new Response(JSON.stringify(transactionData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="false"; cursor="1735689600:1:0"', + }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await listTransactions("my-org", "my-project"); + expect(result.nextCursor).toBeUndefined(); + }); + + test("passes cursor, sort, limit, and query as params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + capturedUrl = req.url; + return new Response(JSON.stringify(transactionData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + await listTransactions("my-org", "my-project", { + cursor: "1735689600:0:0", + sort: "duration", + limit: 50, + query: "transaction:GET", + }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("cursor")).toBe("1735689600:0:0"); + expect(url.searchParams.get("sort")).toBe("-span.duration"); + expect(url.searchParams.get("per_page")).toBe("50"); + expect(url.searchParams.get("query")).toContain("transaction:GET"); + expect(url.searchParams.get("query")).toContain("is_transaction:true"); + }); + + test("uses project query param for numeric project IDs", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + capturedUrl = req.url; + return new Response(JSON.stringify(transactionData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + await listTransactions("my-org", "12345"); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("project")).toBe("12345"); + // Query should only carry the transaction filter, not project scoping + const query = url.searchParams.get("query"); + expect(query).toBe("is_transaction:true"); + }); + + test("uses project:slug in query for non-numeric project slugs", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + capturedUrl = req.url; + return new Response(JSON.stringify(transactionData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + await listTransactions("my-org", "my-project"); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("project")).toBeNull(); + expect(url.searchParams.get("query")).toContain("project:my-project"); + }); +}); + +describe("listTraceLogs", () => { + const traceLogsData = { + data: [ + { + id: "log001", + "project.id": 123, + trace: "aaaa1111bbbb2222cccc3333dddd4444", + severity_number: 9, + severity: "info", + timestamp: "2025-01-30T14:32:15+00:00", + timestamp_precise: 1_738_247_535_000_000_000, + message: "Request received", + }, + ], + meta: { fields: { id: "string" } }, + }; + + beforeEach(async () => { + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns trace log entries", async () => { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/trace-logs/")) { + return new Response(JSON.stringify(traceLogsData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await listTraceLogs( + "my-org", + "aaaa1111bbbb2222cccc3333dddd4444" + ); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(1); + expect(result[0].severity).toBe("info"); + }); + + test("passes traceId, statsPeriod, limit, and query as params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/trace-logs/")) { + capturedUrl = req.url; + return new Response(JSON.stringify(traceLogsData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + await listTraceLogs("my-org", "aaaa1111bbbb2222cccc3333dddd4444", { + statsPeriod: "7d", + limit: 100, + query: "severity:error", + }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("traceId")).toBe( + "aaaa1111bbbb2222cccc3333dddd4444" + ); + expect(url.searchParams.get("statsPeriod")).toBe("7d"); + expect(url.searchParams.get("per_page")).toBe("100"); + expect(url.searchParams.get("query")).toBe("severity:error"); + }); + + test("defaults statsPeriod to 14d when not specified", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/trace-logs/")) { + capturedUrl = req.url; + return new Response(JSON.stringify(traceLogsData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + await listTraceLogs("my-org", "aaaa1111bbbb2222cccc3333dddd4444"); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("statsPeriod")).toBe("14d"); + }); + + test("coerces string numeric fields to numbers (API resilience)", async () => { + const stringFieldsData = { + data: [ + { + id: "log001", + "project.id": "123", + trace: "aaaa1111bbbb2222cccc3333dddd4444", + severity_number: "9", + severity: "info", + timestamp: "2025-01-30T14:32:15+00:00", + timestamp_precise: "1738247535000000000", + message: "Request received", + }, + ], + meta: { fields: { id: "string" } }, + }; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/trace-logs/")) { + return new Response(JSON.stringify(stringFieldsData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await listTraceLogs( + "my-org", + "aaaa1111bbbb2222cccc3333dddd4444" + ); + expect(result).toHaveLength(1); + expect(typeof result[0]["project.id"]).toBe("number"); + expect(result[0]["project.id"]).toBe(123); + expect(typeof result[0].severity_number).toBe("number"); + expect(result[0].severity_number).toBe(9); + expect(typeof result[0].timestamp_precise).toBe("number"); + }); + + test("accepts responses with missing optional fields", async () => { + const minimalData = { + data: [ + { + id: "log001", + "project.id": 123, + trace: "aaaa1111bbbb2222cccc3333dddd4444", + severity: "info", + timestamp: "2025-01-30T14:32:15+00:00", + message: "Test", + }, + ], + }; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/trace-logs/")) { + return new Response(JSON.stringify(minimalData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await listTraceLogs( + "my-org", + "aaaa1111bbbb2222cccc3333dddd4444" + ); + expect(result).toHaveLength(1); + expect(result[0].severity_number).toBeUndefined(); + expect(result[0].timestamp_precise).toBeUndefined(); + expect(result[0].severity).toBe("info"); + }); +}); + +describe("getLogs", () => { + const LOG_ID_1 = "a0a1a2a3a4a5a6a7a8a9b0b1b2b3b4b5"; + const LOG_ID_2 = "1111222233334444555566667777aaaa"; + + function makeLogEntry(id: string) { + return { + "sentry.item_id": id, + timestamp: "2025-01-30T14:32:15+00:00", + timestamp_precise: 1_770_060_419_044_800_300, + message: `Log ${id}`, + severity: "info", + trace: "abc123def456abc123def456abc12345", + project: "test-project", + environment: "production", + release: "1.0.0", + "sdk.name": "sentry.javascript.node", + "sdk.version": "8.0.0", + span_id: "span123abc", + "code.function": "handleRequest", + "code.file.path": "src/handlers/api.ts", + "code.line.number": "42", + "sentry.otel.kind": null, + "sentry.otel.status_code": null, + "sentry.otel.instrumentation_scope.name": null, + }; + } + + beforeEach(async () => { + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns matching log entries", async () => { + const responseData = { + data: [makeLogEntry(LOG_ID_1)], + meta: { fields: { "sentry.item_id": "string" } }, + }; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + return new Response(JSON.stringify(responseData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await getLogs("my-org", "my-project", [LOG_ID_1]); + expect(result).toHaveLength(1); + expect(result[0]["sentry.item_id"]).toBe(LOG_ID_1); + }); + + test("passes bracket syntax and per_page in query", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + capturedUrl = req.url; + return new Response( + JSON.stringify({ data: [], meta: { fields: {} } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + await getLogs("my-org", "my-project", [LOG_ID_1, LOG_ID_2]); + const url = new URL(capturedUrl); + const query = url.searchParams.get("query"); + expect(query).toContain(`sentry.item_id:[${LOG_ID_1},${LOG_ID_2}]`); + expect(url.searchParams.get("per_page")).toBe("2"); + }); + + test("returns empty array when no logs found", async () => { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + return new Response( + JSON.stringify({ data: [], meta: { fields: {} } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await getLogs("my-org", "my-project", [ + "deadbeefdeadbeefdeadbeefdeadbeef", + ]); + expect(result).toHaveLength(0); + }); + + test("batches requests when IDs exceed API_MAX_PER_PAGE", async () => { + // Create 150 IDs (should split into 2 batches: 100 + 50) + const ids = Array.from({ length: 150 }, (_, i) => + i.toString(16).padStart(32, "0") + ); + const capturedUrls: string[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/events/")) { + capturedUrls.push(req.url); + // Return one log per batch so we can verify results are merged + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? ""; + const bracketMatch = query.match(/sentry\.item_id:\[([^\]]+)\]/); + const batchIds = bracketMatch ? bracketMatch[1].split(",") : []; + return new Response( + JSON.stringify({ + data: [makeLogEntry(batchIds[0])], + meta: { fields: {} }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const result = await getLogs("my-org", "my-project", ids); + + // Should have made 2 separate requests + expect(capturedUrls).toHaveLength(2); + + // First batch: 100 IDs + const url1 = new URL(capturedUrls[0]); + expect(url1.searchParams.get("per_page")).toBe(String(API_MAX_PER_PAGE)); + + // Second batch: 50 IDs + const url2 = new URL(capturedUrls[1]); + expect(url2.searchParams.get("per_page")).toBe("50"); + + // Results from both batches should be flattened + expect(result).toHaveLength(2); + }); +}); + +describe("getProject", () => { + test("sends ?collapse=organization query parameter", async () => { + const { getProject } = await import("../../src/lib/api-client.js"); + + // Seed region cache so the SDK targets us.sentry.io without /users/me/regions/ + setOrgRegions([ + { + slug: "acme", + regionUrl: DEFAULT_SENTRY_URL, + orgId: "42", + orgName: "Acme Corp", + }, + ]); + + const capturedUrls: string[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedUrls.push(req.url); + + if (req.url.includes("/projects/acme/frontend/")) { + return new Response( + JSON.stringify({ + id: "101", + slug: "frontend", + name: "Frontend", + // Collapsed response: no `name` on organization + organization: { id: "42", slug: "acme" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const project = await getProject("acme", "frontend"); + + expect(project.slug).toBe("frontend"); + expect(project.organization?.slug).toBe("acme"); + + // Verify the outgoing URL carries ?collapse=organization + const projectRequest = capturedUrls.find((u) => + u.includes("/projects/acme/frontend/") + ); + expect(projectRequest).toBeDefined(); + const parsed = new URL(projectRequest as string); + expect(parsed.searchParams.get("collapse")).toBe("organization"); + }); + + test("tolerates collapsed response missing organization.name", async () => { + const { getProject } = await import("../../src/lib/api-client.js"); + + setOrgRegions([ + { + slug: "acme", + regionUrl: DEFAULT_SENTRY_URL, + orgId: "42", + orgName: "Acme Corp", + }, + ]); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + + if (req.url.includes("/projects/acme/frontend/")) { + return new Response( + JSON.stringify({ + id: "101", + slug: "frontend", + name: "Frontend", + organization: { id: "42", slug: "acme" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const project = await getProject("acme", "frontend"); + + // `name` is optional on collapsed responses + expect(project.organization?.name).toBeUndefined(); + expect(project.organization?.slug).toBe("acme"); + }); +}); + +describe("resolveOrgDisplayName", () => { + test("prefers explicit name when provided", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + expect(resolveOrgDisplayName("acme", "Acme Corp")).toBe("Acme Corp"); + }); + + test("falls back to cached org name when explicit name is absent", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + setOrgRegions([ + { + slug: "acme", + regionUrl: DEFAULT_SENTRY_URL, + orgId: "42", + orgName: "Acme Corp", + }, + ]); + + expect(resolveOrgDisplayName("acme")).toBe("Acme Corp"); + }); + + test("falls back to slug when no cache entry exists", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + // Empty cache — no entry for this slug + expect(resolveOrgDisplayName("unknown-org")).toBe("unknown-org"); + }); + + test("falls back to slug when explicit name is empty string", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + // Empty string is falsy, so the cache/slug fallback kicks in + expect(resolveOrgDisplayName("unknown-org", "")).toBe("unknown-org"); + }); +}); diff --git a/packages/cli/test/lib/api-schema.test.ts b/packages/cli/test/lib/api-schema.test.ts new file mode 100644 index 000000000..72cb82f5d --- /dev/null +++ b/packages/cli/test/lib/api-schema.test.ts @@ -0,0 +1,264 @@ +/** + * Unit Tests for API Schema Query Functions + */ + +import { describe, expect, test } from "vitest"; +import { + findEndpointsByIdentifier, + findEndpointsByPath, + getAllEndpoints, + getAllResources, + getEndpoint, + getEndpointsByResource, + getResourceSummaries, + parseEndpointQuery, + searchEndpoints, +} from "../../src/lib/api-schema.js"; + +describe("getAllEndpoints", () => { + test("returns non-empty array", () => { + const endpoints = getAllEndpoints(); + expect(endpoints.length).toBeGreaterThan(100); + }); + + test("every endpoint has required fields", () => { + for (const ep of getAllEndpoints()) { + expect(typeof ep.fn).toBe("string"); + expect(typeof ep.method).toBe("string"); + expect(typeof ep.path).toBe("string"); + expect(typeof ep.resource).toBe("string"); + expect(typeof ep.operationId).toBe("string"); + expect(Array.isArray(ep.queryParams)).toBe(true); + expect(ep.method.length).toBeGreaterThan(0); + expect(ep.path.startsWith("/api/0/")).toBe(true); + } + }); + + test("methods are valid HTTP methods", () => { + const validMethods = new Set(["GET", "POST", "PUT", "DELETE", "PATCH"]); + for (const ep of getAllEndpoints()) { + expect(validMethods.has(ep.method)).toBe(true); + } + }); +}); + +describe("getAllResources", () => { + test("returns sorted unique strings", () => { + const resources = getAllResources(); + expect(resources.length).toBeGreaterThan(10); + + // Verify sorted + const sorted = [...resources].sort(); + expect(resources).toEqual(sorted); + + // Verify unique + expect(new Set(resources).size).toBe(resources.length); + }); + + test("contains known resources", () => { + const resources = getAllResources(); + expect(resources).toContain("issues"); + expect(resources).toContain("projects"); + expect(resources).toContain("organizations"); + expect(resources).toContain("teams"); + }); +}); + +describe("getEndpointsByResource", () => { + test("returns endpoints for known resource", () => { + const endpoints = getEndpointsByResource("issues"); + expect(endpoints.length).toBeGreaterThan(0); + for (const ep of endpoints) { + expect(ep.resource).toBe("issues"); + } + }); + + test("is case-insensitive", () => { + const lower = getEndpointsByResource("issues"); + const upper = getEndpointsByResource("Issues"); + expect(lower).toEqual(upper); + }); + + test("returns empty array for unknown resource", () => { + expect(getEndpointsByResource("nonexistent")).toEqual([]); + }); +}); + +describe("getEndpoint", () => { + test("finds specific endpoint by operationId substring", () => { + const ep = getEndpoint("issues", "getOrganizationIssue"); + expect(ep).toBeDefined(); + expect(ep?.resource).toBe("issues"); + expect(ep?.method).toBe("GET"); + expect(ep?.fn).toBe("getOrganizationIssue"); + }); + + test("is case-insensitive", () => { + const ep1 = getEndpoint("issues", "list"); + const ep2 = getEndpoint("Issues", "List"); + expect(ep1).toEqual(ep2); + }); + + test("returns undefined for unknown endpoint", () => { + expect(getEndpoint("issues", "xyznonexistent123")).toBeUndefined(); + expect(getEndpoint("nonexistent", "list")).toBeUndefined(); + }); +}); + +describe("parseEndpointQuery", () => { + test("parses METHOD + OpenAPI path", () => { + const parsed = parseEndpointQuery( + "GET /api/0/organizations/{organization_id_or_slug}/issues/" + ); + expect(parsed.method).toBe("GET"); + expect(parsed.path).toBe( + "/api/0/organizations/{organization_id_or_slug}/issues/" + ); + }); + + test("parses a bare /api/ path and adds a trailing slash", () => { + const parsed = parseEndpointQuery( + "/api/0/organizations/{organization_id_or_slug}/issues" + ); + expect(parsed.method).toBeUndefined(); + expect(parsed.path).toBe( + "/api/0/organizations/{organization_id_or_slug}/issues/" + ); + }); + + test("parses a full Sentry URL and strips the query string", () => { + const parsed = parseEndpointQuery( + "https://sentry.io/api/0/organizations/acme/issues/?query=is:unresolved" + ); + expect(parsed.path).toBe("/api/0/organizations/acme/issues/"); + }); + + test("leaves keyword searches alone", () => { + expect(parseEndpointQuery("issues")).toEqual({ text: "issues" }); + expect(parseEndpointQuery("GET issues").method).toBe("GET"); + expect(parseEndpointQuery("GET issues").path).toBeUndefined(); + expect(parseEndpointQuery("GET issues").text).toBe("issues"); + }); +}); + +describe("findEndpointsByPath", () => { + test("matches a concrete org slug against {organization_id_or_slug}", () => { + const matches = findEndpointsByPath( + "/api/0/organizations/acme/issues/", + "GET" + ); + expect(matches).toHaveLength(1); + expect(matches[0]?.fn).toBe("listOrganizationIssues"); + }); + + test("does not treat a query {param} as a wildcard for static siblings", () => { + const matches = findEndpointsByPath( + "/api/0/organizations/{organization_id_or_slug}/preprodartifacts/snapshots/{snapshot_id}/", + "GET" + ); + expect(matches.map((e) => e.fn)).toEqual([ + "getOrganizationPreprodArtifactSnapshot", + ]); + }); + + test("prefers a static segment over a neighboring {param}", () => { + const matches = findEndpointsByPath( + "/api/0/organizations/acme/preprodartifacts/snapshots/latest-base/", + "GET" + ); + expect(matches.map((e) => e.fn)).toEqual([ + "getOrganizationPreprodArtifactSnapshotLatestBase", + ]); + }); + + test("does not match getProject when the query is an OpenAPI /issues/ path", () => { + const matches = findEndpointsByPath( + "/api/0/projects/{organization_id_or_slug}/issues/", + "GET" + ); + expect(matches).toEqual([]); + }); +}); + +describe("findEndpointsByIdentifier", () => { + test("finds an endpoint by exact SDK function name", () => { + const matches = findEndpointsByIdentifier("listOrganizationEvents"); + expect(matches).toHaveLength(1); + expect(matches[0]?.fn).toBe("listOrganizationEvents"); + }); + + test("finds an endpoint by exact OpenAPI operation ID", () => { + const endpoint = getAllEndpoints().find( + (candidate) => + candidate.fn && + candidate.operationId && + candidate.fn !== candidate.operationId + ); + expect(endpoint).toBeDefined(); + if (!endpoint) { + return; + } + + expect(findEndpointsByIdentifier(endpoint.operationId)).toContain(endpoint); + }); + + test("is case-insensitive but does not accept partial identifiers", () => { + expect(findEndpointsByIdentifier("LISTORGANIZATIONEVENTS")[0]?.fn).toBe( + "listOrganizationEvents" + ); + expect(findEndpointsByIdentifier("OrganizationEvents")).toEqual([]); + }); +}); + +describe("searchEndpoints", () => { + test("finds endpoints by resource name", () => { + const results = searchEndpoints("issues"); + expect(results.length).toBeGreaterThan(0); + }); + + test("finds endpoints by path fragment", () => { + const results = searchEndpoints("organizations"); + expect(results.length).toBeGreaterThan(0); + }); + + test("finds endpoints by description keyword", () => { + const results = searchEndpoints("replay"); + expect(results.length).toBeGreaterThan(0); + }); + + test("is case-insensitive", () => { + const lower = searchEndpoints("issues"); + const upper = searchEndpoints("ISSUES"); + expect(lower).toEqual(upper); + }); + + test("returns empty for no match", () => { + expect(searchEndpoints("xyznonexistent123")).toEqual([]); + }); +}); + +describe("getResourceSummaries", () => { + test("returns summaries for all resources", () => { + const summaries = getResourceSummaries(); + const resources = getAllResources(); + expect(summaries.length).toBe(resources.length); + }); + + test("each summary has correct shape", () => { + for (const summary of getResourceSummaries()) { + expect(typeof summary.name).toBe("string"); + expect(typeof summary.endpointCount).toBe("number"); + expect(summary.endpointCount).toBeGreaterThan(0); + expect(Array.isArray(summary.methods)).toBe(true); + expect(summary.methods.length).toBeGreaterThan(0); + } + }); + + test("methods are sorted and unique", () => { + for (const summary of getResourceSummaries()) { + const sorted = [...summary.methods].sort(); + expect(summary.methods).toEqual(sorted); + expect(new Set(summary.methods).size).toBe(summary.methods.length); + } + }); +}); diff --git a/packages/cli/test/lib/api-scope.test.ts b/packages/cli/test/lib/api-scope.test.ts new file mode 100644 index 000000000..e983e843e --- /dev/null +++ b/packages/cli/test/lib/api-scope.test.ts @@ -0,0 +1,163 @@ +/** + * Tests for `extractRequiredScopes` — the 403-response scope parser + * that powers the friendly "missing scope: event:read" hint in place + * of the hardcoded "(org:read, project:read)" fallback. + */ + +import { describe, expect, test } from "vitest"; +import { extractRequiredScopes } from "../../src/lib/api-scope.js"; + +describe("extractRequiredScopes", () => { + test("returns [] for undefined or null detail", () => { + expect(extractRequiredScopes(undefined)).toEqual([]); + expect(extractRequiredScopes(null)).toEqual([]); + }); + + test("returns [] when the detail contains no recognizable scopes", () => { + expect( + extractRequiredScopes( + "You do not have permission to perform this action." + ) + ).toEqual([]); + }); + + test("ignores role scopes mentioned in member-project policy guidance", () => { + expect( + extractRequiredScopes( + "Your organization has disabled this feature for members. " + + "This is an org-level policy setting, not an auth issue. " + + "You need org:admin/manager/owner role, or team:admin role on the team." + ) + ).toEqual([]); + }); + + test("extracts a single scope from a detail string", () => { + expect( + extractRequiredScopes( + "You do not have the required scope to perform this action. Required scopes: event:read" + ) + ).toEqual(["event:read"]); + }); + + test("extracts multiple scopes from a detail string preserving order", () => { + expect( + extractRequiredScopes( + "Required scopes: event:read, project:write. Got: none." + ) + ).toEqual(["event:read", "project:write"]); + }); + + test("deduplicates repeated scopes", () => { + expect( + extractRequiredScopes( + "event:read is required. Try obtaining event:read scope." + ) + ).toEqual(["event:read"]); + }); + + test("ignores random foo:bar substrings that aren't real scopes", () => { + // The regex is anchored to the known resource namespace so that + // response text mentioning things like `http:localhost` or + // `timestamp:now` doesn't accidentally match. + expect( + extractRequiredScopes("http:localhost timestamp:now category:billing") + ).toEqual([]); + }); + + test("lowercases the matched scope identifier", () => { + expect(extractRequiredScopes("Required: EVENT:READ")).toEqual([ + "event:read", + ]); + }); + + test("pulls scopes from a structured `required` field", () => { + expect( + extractRequiredScopes({ + detail: "You do not have permission to perform this action.", + required: ["event:read"], + }) + ).toEqual(["event:read"]); + }); + + test("pulls scopes from a structured `requiredScopes` field", () => { + expect( + extractRequiredScopes({ + detail: "Missing scopes.", + requiredScopes: ["project:admin", "team:write"], + }) + ).toEqual(["project:admin", "team:write"]); + }); + + test("pulls scopes from a `scopes` field of {scope} objects", () => { + expect( + extractRequiredScopes({ + detail: "Missing scopes.", + scopes: [{ scope: "org:read" }, { scope: "org:write" }], + }) + ).toEqual(["org:read", "org:write"]); + }); + + test("falls back to text scanning when the structured fields are absent", () => { + // An object without the known field names gets serialized and + // scanned — catches responses that carry scope info under a + // non-standard key. + expect( + extractRequiredScopes({ + message: "Your token lacks project:read.", + }) + ).toEqual(["project:read"]); + }); + + test("ignores non-string entries in the required array", () => { + expect( + extractRequiredScopes({ + required: [42, null, "event:read", { unrelated: true }], + }) + ).toEqual(["event:read"]); + }); + + test("matches every scope in the canonical Sentry SENTRY_SCOPES list", () => { + // Canonical list mirrored from getsentry/sentry + // `src/sentry/conf/server.py` SENTRY_SCOPES. Keeping this test here + // makes it easy to spot when the backend adds or removes a scope. + const allScopes = [ + "org:read", + "org:write", + "org:admin", + "org:integrations", + "org:ci", + "member:invite", + "member:read", + "member:write", + "member:admin", + "team:read", + "team:write", + "team:admin", + "project:read", + "project:write", + "project:admin", + "project:releases", + "project:distribution", + "event:read", + "event:write", + "event:admin", + "alerts:read", + "alerts:write", + ]; + expect( + extractRequiredScopes(`Required scopes: ${allScopes.join(", ")}`) + ).toEqual(allScopes); + }); + + test("rejects scope-shaped strings that are not real Sentry scopes", () => { + // Catches regressions where the regex accidentally widens to + // `:` products that Sentry doesn't actually define — + // e.g. `release:read` (no `release` namespace; the real scope is + // `project:releases`) or `alerts:admin` (no admin tier). + expect( + extractRequiredScopes( + "Not real: release:read release:write alerts:admin team:superuser" + ) + ).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/api/alerts.test.ts b/packages/cli/test/lib/api/alerts.test.ts new file mode 100644 index 000000000..5e42d82ca --- /dev/null +++ b/packages/cli/test/lib/api/alerts.test.ts @@ -0,0 +1,517 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + createIssueAlertRule, + createMetricAlertRule, + deleteIssueAlertRule, + deleteMetricAlertRule, + getIssueAlertRule, + getIssueAlertWorkflowDocument, + getMetricAlertRule, + getMetricAlertRuleDocument, + listIssueAlertsPaginated, + listMetricAlertsPaginated, + putMetricAlertRule, + resolveErrorDetectorId, + updateIssueAlertRule, +} from "../../../src/lib/api/alerts.js"; +import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { resetAuthenticatedFetch } from "../../../src/lib/sentry-client.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("api-alerts-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + resetAuthenticatedFetch(); + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); +}); + +describe("deleteIssueAlertRule", () => { + test("treats empty-body 204 as success", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("DELETE"); + expect(req.url).toBe( + `${DEFAULT_SENTRY_URL}/api/0/organizations/test-org/workflows/42/` + ); + expect(req.headers.get("Authorization")).toBe("Bearer test-token"); + return new Response(null, { status: 204 }); + }); + + await expect( + deleteIssueAlertRule("test-org", "42") + ).resolves.toBeUndefined(); + }); +}); + +/** Minimal workflow/rule payload from the org-scoped `/workflows/` endpoint. */ +function workflowRule(overrides: Record) { + return { + id: "1", + name: "Rule", + status: "active", + actionMatch: "any", + conditions: [], + actions: [], + frequency: 30, + environment: null, + owner: null, + projects: [], + detectorIds: [7], + dateCreated: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("listIssueAlertsPaginated", () => { + test("reads from org-scoped /workflows/ and drops unattached workflows", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/workflows/"); + expect(url.searchParams.get("projectSlug")).toBe("test-project"); + return Response.json([ + workflowRule({ id: "1", name: "Attached", detectorIds: [7] }), + workflowRule({ id: "2", name: "Unattached", detectorIds: [] }), + ]); + }); + + const { data } = await listIssueAlertsPaginated("test-org", "test-project"); + expect(data).toHaveLength(1); + expect(data[0]?.id).toBe("1"); + }); +}); + +describe("getIssueAlertRule", () => { + test("reads from /workflows/ filtered by project and id", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/workflows/"); + expect(url.searchParams.get("projectSlug")).toBe("test-project"); + expect(url.searchParams.get("id")).toBe("42"); + return Response.json([workflowRule({ id: "42", name: "My Rule" })]); + }); + + const rule = await getIssueAlertRule("test-org", "test-project", "42"); + expect(rule.id).toBe("42"); + expect(rule.name).toBe("My Rule"); + }); + + test("throws 404 ApiError when no attached rule matches", async () => { + globalThis.fetch = mockFetch(async () => + // Only an unattached workflow comes back → filtered out → not found. + Response.json([workflowRule({ id: "42", detectorIds: [] })]) + ); + + await expect( + getIssueAlertRule("test-org", "test-project", "42") + ).rejects.toMatchObject({ name: "ApiError", status: 404 }); + }); +}); + +/** Minimal metric-issue detector payload from the org-scoped `/detectors/` endpoint. */ +function metricDetector(overrides: Record) { + return { + id: "9", + name: "P95 latency", + type: "metric_issue", + enabled: true, + projectSlug: "backend", + owner: null, + dateCreated: "2026-01-01T00:00:00Z", + dataSources: [ + { + aggregate: "p95(span.duration)", + dataset: "spans", + query: "environment:prod", + // Detectors expose the window in seconds; 300s == 5m. + timeWindow: 300, + environment: "prod", + }, + ], + conditionGroup: null, + config: { detectionType: "static" }, + ...overrides, + }; +} + +describe("listMetricAlertsPaginated", () => { + test("reads from org-scoped /detectors/ filtered to metric_issue and flattens the payload", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/detectors/"); + expect(url.searchParams.get("query")).toBe("type:metric_issue"); + return Response.json([metricDetector({ id: "9", name: "P95 latency" })]); + }); + + const { data } = await listMetricAlertsPaginated("test-org"); + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ + id: "9", + name: "P95 latency", + status: 0, + aggregate: "p95(span.duration)", + dataset: "spans", + query: "environment:prod", + // 300s from the detector payload is normalized to 5 minutes. + timeWindow: 5, + environment: "prod", + projects: ["backend"], + }); + }); +}); + +describe("getMetricAlertRule", () => { + test("reads from /detectors/{id}/ and maps disabled detectors to status 1", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/detectors/9/"); + return Response.json( + metricDetector({ id: "9", name: "Disabled rule", enabled: false }) + ); + }); + + const rule = await getMetricAlertRule("test-org", "9"); + expect(rule.id).toBe("9"); + expect(rule.name).toBe("Disabled rule"); + expect(rule.status).toBe(1); + }); + + test("reads threshold fields nested under snubaQuery", async () => { + globalThis.fetch = mockFetch(async () => + Response.json( + metricDetector({ + dataSources: [ + { + snubaQuery: { + aggregate: "count()", + dataset: "errors", + query: "event.type:error", + // 900s == 15m after normalization. + timeWindow: 900, + }, + }, + ], + }) + ) + ); + + const rule = await getMetricAlertRule("test-org", "9"); + expect(rule.aggregate).toBe("count()"); + expect(rule.dataset).toBe("errors"); + expect(rule.query).toBe("event.type:error"); + expect(rule.timeWindow).toBe(15); + }); + + test("reads threshold fields nested under queryObj.snubaQuery", async () => { + globalThis.fetch = mockFetch(async () => + Response.json( + metricDetector({ + dataSources: [ + { + queryObj: { + snubaQuery: { + aggregate: "p75(measurements.lcp)", + dataset: "spans", + query: "span.op:pageload", + timeWindow: 600, + }, + }, + }, + ], + }) + ) + ); + + const rule = await getMetricAlertRule("test-org", "9"); + expect(rule.aggregate).toBe("p75(measurements.lcp)"); + expect(rule.dataset).toBe("spans"); + expect(rule.query).toBe("span.op:pageload"); + expect(rule.timeWindow).toBe(10); + }); + + test("prefers projectSlug and falls back to a projects array for projects", async () => { + globalThis.fetch = mockFetch(async () => + Response.json( + metricDetector({ projectSlug: undefined, projects: ["frontend"] }) + ) + ); + + const rule = await getMetricAlertRule("test-org", "9"); + expect(rule.projects).toEqual(["frontend"]); + }); + + test("coerces a non-numeric timeWindow to 0 instead of NaN", async () => { + globalThis.fetch = mockFetch(async () => + Response.json( + metricDetector({ + dataSources: [{ snubaQuery: { timeWindow: "not-a-number" } }], + }) + ) + ); + + const rule = await getMetricAlertRule("test-org", "9"); + expect(rule.timeWindow).toBe(0); + }); + + test("rejects a non-metric detector with a 404 instead of mapping it", async () => { + globalThis.fetch = mockFetch(async () => + Response.json(metricDetector({ type: "uptime_domain_failure" })) + ); + + await expect(getMetricAlertRule("test-org", "9")).rejects.toMatchObject({ + name: "ApiError", + status: 404, + }); + }); + + test("reconstructs the legacy actor identifier from an object owner", async () => { + globalThis.fetch = mockFetch(async () => + Response.json( + metricDetector({ owner: { type: "team", id: "42", name: "backend" } }) + ) + ); + + const rule = await getMetricAlertRule("test-org", "9"); + expect(rule.owner).toBe("team:42"); + }); +}); + +describe("createIssueAlertRule", () => { + test("POSTs the body to the org-scoped /workflows/ endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("POST"); + expect(new URL(req.url).pathname).toBe( + "/api/0/organizations/test-org/workflows/" + ); + expect(await req.json()).toEqual({ name: "New", detectorIds: [7] }); + return Response.json(workflowRule({ id: "5", name: "New" })); + }); + + const created = await createIssueAlertRule("test-org", { + name: "New", + detectorIds: [7], + }); + expect(created.id).toBe("5"); + }); +}); + +describe("updateIssueAlertRule", () => { + test("PUTs the body to the /workflows/{id}/ endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + expect(new URL(req.url).pathname).toBe( + "/api/0/organizations/test-org/workflows/42/" + ); + expect(await req.json()).toEqual({ name: "Renamed" }); + return Response.json(workflowRule({ id: "42", name: "Renamed" })); + }); + + const updated = await updateIssueAlertRule("test-org", "42", { + name: "Renamed", + }); + expect(updated.name).toBe("Renamed"); + }); +}); + +describe("getIssueAlertWorkflowDocument", () => { + test("reads the single workflow detail endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/workflows/42/"); + return Response.json(workflowRule({ id: "42" })); + }); + + const doc = await getIssueAlertWorkflowDocument("test-org", "42"); + expect(doc.id).toBe("42"); + }); +}); + +describe("resolveErrorDetectorId", () => { + test("returns the id of the project's error detector", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/detectors/"); + expect(url.searchParams.get("project")).toBe("test-project"); + expect(url.searchParams.get("query")).toBe("type:error"); + return Response.json([{ id: 7, type: "error" }]); + }); + + await expect( + resolveErrorDetectorId("test-org", "test-project") + ).resolves.toBe(7); + }); + + test("throws 404 ApiError when the project has no error detector", async () => { + globalThis.fetch = mockFetch(async () => Response.json([])); + + await expect( + resolveErrorDetectorId("test-org", "test-project") + ).rejects.toMatchObject({ name: "ApiError", status: 404 }); + }); +}); + +describe("deleteMetricAlertRule", () => { + test("deletes via the org-scoped /detectors/{id}/ endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("DELETE"); + expect(new URL(req.url).pathname).toBe( + "/api/0/organizations/test-org/detectors/9/" + ); + expect(req.headers.get("Authorization")).toBe("Bearer test-token"); + return new Response(null, { status: 204 }); + }); + + await expect( + deleteMetricAlertRule("test-org", "9") + ).resolves.toBeUndefined(); + }); +}); + +describe("createMetricAlertRule", () => { + test("POSTs a nested detector body to the project-scoped /detectors/ endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("POST"); + expect(new URL(req.url).pathname).toBe( + "/api/0/organizations/test-org/projects/backend/detectors/" + ); + expect(await req.json()).toEqual({ + name: "P95 latency", + type: "metric_issue", + dataSources: [ + { + aggregate: "p95(span.duration)", + dataset: "spans", + query: "environment:prod", + queryType: 1, + eventTypes: ["trace_item_span"], + // 5m from the flat body is sent to the detector API as 300s. + timeWindow: 300, + environment: "prod", + }, + ], + config: { detectionType: "static" }, + conditionGroup: { + logicType: "any", + conditions: [{ alertThreshold: 500, actions: [{ id: "notify" }] }], + }, + }); + return Response.json( + { ...metricDetector({ id: "77", name: "P95 latency" }) }, + { status: 201 } + ); + }); + + const created = await createMetricAlertRule("test-org", { + name: "P95 latency", + query: "environment:prod", + aggregate: "p95(span.duration)", + dataset: "spans", + timeWindow: 5, + environment: "prod", + triggers: [{ alertThreshold: 500, actions: [{ id: "notify" }] }], + projects: ["backend"], + }); + expect(created.id).toBe("77"); + }); + + test("maps error-like datasets to queryType 0 and error eventTypes", async () => { + let sentBody: Record = {}; + globalThis.fetch = mockFetch(async (input, init) => { + sentBody = (await new Request(input!, init).json()) as Record< + string, + unknown + >; + return Response.json(metricDetector({ id: "1" }), { status: 201 }); + }); + + await createMetricAlertRule("test-org", { + name: "Error volume", + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 15, + triggers: [{ alertThreshold: 100, actions: [{ id: "notify" }] }], + projects: ["backend"], + }); + + const source = (sentBody.dataSources as Record[])[0]; + expect(source?.queryType).toBe(0); + expect(source?.eventTypes).toEqual(["error", "default"]); + expect(source?.timeWindow).toBe(900); + }); + + test("throws ValidationError when no project is provided", async () => { + await expect( + createMetricAlertRule("test-org", { + name: "No project", + query: "", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + triggers: [{ alertThreshold: 1, actions: [{ id: "notify" }] }], + }) + ).rejects.toMatchObject({ name: "ValidationError" }); + }); +}); + +describe("getMetricAlertRuleDocument", () => { + test("returns the flat rule baseline from the detector GET", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/detectors/9/"); + return Response.json(metricDetector({ id: "9", name: "Baseline" })); + }); + + const doc = await getMetricAlertRuleDocument("test-org", "9"); + expect(doc).toMatchObject({ + id: "9", + name: "Baseline", + aggregate: "p95(span.duration)", + dataset: "spans", + timeWindow: 5, + }); + }); +}); + +describe("putMetricAlertRule", () => { + test("PUTs a nested detector body to the /detectors/{id}/ endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + expect(new URL(req.url).pathname).toBe( + "/api/0/organizations/test-org/detectors/9/" + ); + const body = (await req.json()) as Record; + expect(body.name).toBe("Renamed"); + expect(body.type).toBe("metric_issue"); + // status 1 (disabled) maps onto the detector's enabled=false flag. + expect(body.enabled).toBe(false); + return Response.json(metricDetector({ id: "9", name: "Renamed" })); + }); + + const updated = await putMetricAlertRule("test-org", "9", { + name: "Renamed", + query: "environment:prod", + aggregate: "p95(span.duration)", + dataset: "spans", + timeWindow: 5, + status: 1, + triggers: [{ alertThreshold: 500, actions: [{ id: "notify" }] }], + }); + expect(updated.id).toBe("9"); + }); +}); diff --git a/packages/cli/test/lib/api/auth.test.ts b/packages/cli/test/lib/api/auth.test.ts new file mode 100644 index 000000000..2a134e9ea --- /dev/null +++ b/packages/cli/test/lib/api/auth.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../src/lib/api/infrastructure.js", () => ({ + apiRequestToRegion: vi.fn(), +})); + +import { getCurrentAuthScopes } from "../../../src/lib/api/auth.js"; +import { apiRequestToRegion } from "../../../src/lib/api/infrastructure.js"; + +describe("getCurrentAuthScopes", () => { + beforeEach(() => vi.mocked(apiRequestToRegion).mockReset()); + + test("reads effective token scopes from the API index", async () => { + vi.mocked(apiRequestToRegion).mockResolvedValue({ + data: { auth: { scopes: ["org:read", "team:admin"] } }, + headers: new Headers(), + }); + + await expect(getCurrentAuthScopes()).resolves.toEqual([ + "org:read", + "team:admin", + ]); + expect(apiRequestToRegion).toHaveBeenCalledWith( + expect.any(String), + "", + expect.objectContaining({ schema: expect.any(Object) }) + ); + }); + + test("returns null when the API index reports no authenticated token", async () => { + vi.mocked(apiRequestToRegion).mockResolvedValue({ + data: { auth: null }, + headers: new Headers(), + }); + + await expect(getCurrentAuthScopes()).resolves.toBeNull(); + }); + + test("preserves a 401 from an invalid bearer", async () => { + const error = new Error("401 Unauthorized"); + vi.mocked(apiRequestToRegion).mockImplementationOnce(async () => { + throw error; + }); + + let thrown: unknown; + try { + await getCurrentAuthScopes(); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBe(error); + }); +}); diff --git a/packages/cli/test/lib/api/chunk-upload.test.ts b/packages/cli/test/lib/api/chunk-upload.test.ts new file mode 100644 index 000000000..744873b46 --- /dev/null +++ b/packages/cli/test/lib/api/chunk-upload.test.ts @@ -0,0 +1,100 @@ +/** + * Tests for the chunk-upload server-options schema. + * + * Focuses on the optional `maxFileSize` / `maxWait` fields added for + * `debug-files upload`: a server response that omits them must still parse + * (backward compatibility), and present values must be carried through. + */ + +import { safeParse } from "valibot"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + ChunkServerOptionsSchema, + pollAssembly, +} from "../../../src/lib/api/chunk-upload.js"; +import { apiRequestToRegion } from "../../../src/lib/api/infrastructure.js"; + +vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/api/infrastructure.js") + >(); + return { ...actual, apiRequestToRegion: vi.fn() }; +}); +const apiMock = vi.mocked(apiRequestToRegion); + +const BASE = { + url: "https://us.sentry.io/api/0/chunk-upload/", + chunkSize: 8192, + chunksPerRequest: 64, + maxRequestSize: 1_048_576, + hashAlgorithm: "sha1", + concurrency: 8, + compression: ["gzip"], +}; + +describe("ChunkServerOptionsSchema", () => { + test("parses a response that omits maxFileSize and maxWait", () => { + const result = safeParse(ChunkServerOptionsSchema, BASE); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.maxFileSize).toBeUndefined(); + expect(result.output.maxWait).toBeUndefined(); + } + }); + + test("carries through present maxFileSize and maxWait", () => { + const result = safeParse(ChunkServerOptionsSchema, { + ...BASE, + maxFileSize: 2_147_483_648, + maxWait: 300, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.maxFileSize).toBe(2_147_483_648); + expect(result.output.maxWait).toBe(300); + } + }); +}); + +describe("pollAssembly: waitForOk", () => { + const params = { + regionUrl: "https://us.sentry.io", + endpoint: "org/artifactbundle/assemble/", + body: {}, + entityName: "Artifact bundle", + }; + + beforeEach(() => { + vi.useFakeTimers(); + apiMock.mockReset(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test("default (waitForOk=false) returns as soon as state is 'created'", async () => { + apiMock.mockResolvedValue({ + data: { state: "created" }, + } as unknown as Awaited>); + const promise = pollAssembly(params); + await vi.advanceTimersByTimeAsync(1000); + await expect(promise).resolves.toBeUndefined(); + expect(apiMock).toHaveBeenCalledTimes(1); + }); + + test("waitForOk=true keeps polling through 'created' until 'ok'", async () => { + apiMock + .mockResolvedValueOnce({ + data: { state: "created" }, + } as unknown as Awaited>) + .mockResolvedValueOnce({ + data: { state: "ok" }, + } as unknown as Awaited>); + const promise = pollAssembly({ ...params, waitForOk: true }); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + await expect(promise).resolves.toBeUndefined(); + expect(apiMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/test/lib/api/conversations.test.ts b/packages/cli/test/lib/api/conversations.test.ts new file mode 100644 index 000000000..790ebfb73 --- /dev/null +++ b/packages/cli/test/lib/api/conversations.test.ts @@ -0,0 +1,441 @@ +/** + * Conversations API Tests + * + * Tests for `listConversations` and `getConversationSpans` in + * src/lib/api/conversations.ts, covering: + * - listConversations sends correct params + * - listConversations parses link header for pagination + * - getConversationSpans parses the details envelope and extracts spans + * - getConversationSpans paginates through multiple pages + * - getConversationSpans returns truncated=true when pagination limit reached + * - getConversationSpans returns truncated=false when all pages fetched + * + * Mocks fetch at the global level like other API test files (traces, replays). + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + getConversationSpans, + listConversations, +} from "../../../src/lib/api/conversations.js"; +import { MAX_PAGINATION_PAGES } from "../../../src/lib/api/infrastructure.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +// ============================================================================ +// Helpers +// ============================================================================ + +const ORG = "test-org"; + +/** Helper to mock fetch with a single OK response */ +function mockOk( + body: unknown, + headers: Record = {} +): { getCapturedUrl: () => string; getCapturedMethod: () => string } { + let capturedUrl = ""; + let capturedMethod = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + + return { + getCapturedUrl: () => capturedUrl, + getCapturedMethod: () => capturedMethod, + }; +} + +/** + * Helper to mock sequential fetch responses for multi-page tests. + * Each call to fetch returns the next response in the queue. + */ +function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> +): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; +} + +/** Build a Link header with next cursor */ +function linkHeader(cursor: string, results = "true"): Record { + return { + Link: `; rel="next"; results="${results}"; cursor="${cursor}"`, + }; +} + +// ============================================================================ +// Setup +// ============================================================================ + +useTestConfigDir("conversations-api-test-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ============================================================================ +// listConversations +// ============================================================================ + +describe("listConversations", () => { + test("hits /organizations/{org}/agents/conversations/ with GET", async () => { + const { getCapturedUrl, getCapturedMethod } = mockOk([]); + + await listConversations(ORG); + + expect(getCapturedMethod()).toBe("GET"); + expect(getCapturedUrl()).toContain( + `/api/0/organizations/${ORG}/agents/conversations/` + ); + }); + + test("sends per_page=10 by default", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG); + + expect(getCapturedUrl()).toContain("per_page=10"); + }); + + test("passes custom limit as per_page", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { limit: 50 }); + + expect(getCapturedUrl()).toContain("per_page=50"); + }); + + test("passes query param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { query: "has:errors" }); + + expect(decodeURIComponent(getCapturedUrl())).toContain("query=has:errors"); + }); + + test("passes statsPeriod param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { statsPeriod: "24h" }); + + expect(getCapturedUrl()).toContain("statsPeriod=24h"); + }); + + test("passes start and end params", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { + start: "2024-01-01T00:00:00", + end: "2024-01-31T23:59:59", + }); + + const url = getCapturedUrl(); + expect(url).toContain("start="); + expect(url).toContain("end="); + }); + + test("passes cursor param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { cursor: "1735689600000:0:0" }); + + expect(getCapturedUrl()).toContain( + `cursor=${encodeURIComponent("1735689600000:0:0")}` + ); + }); + + test("passes project param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { project: "my-project" }); + + expect(getCapturedUrl()).toContain("project=my-project"); + }); + + test("does not include undefined optional params", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { limit: 10 }); + + const url = getCapturedUrl(); + expect(url).not.toContain("query="); + expect(url).not.toContain("statsPeriod="); + expect(url).not.toContain("start="); + expect(url).not.toContain("end="); + expect(url).not.toContain("cursor="); + expect(url).not.toContain("project="); + }); + + test("parses link header for next cursor", async () => { + mockOk([], linkHeader("1735689600000:0:0")); + + const result = await listConversations(ORG); + + expect(result.nextCursor).toBe("1735689600000:0:0"); + }); + + test("returns undefined nextCursor when no more pages", async () => { + mockOk([]); + + const result = await listConversations(ORG); + + expect(result.nextCursor).toBeUndefined(); + }); + + test("returns data from response", async () => { + const conversations = [ + { + conversationId: "conv-abc", + webUrl: + "https://sentry.io/organizations/test-org/explore/agents/conversations/conv-abc/", + flow: [], + errors: 0, + llmCalls: 1, + toolCalls: 0, + totalTokens: 100, + totalCost: 0, + startTimestamp: 1_716_500_000, + endTimestamp: 1_716_500_060, + traceCount: 1, + traceIds: [], + firstInput: "hello", + lastOutput: "hi", + toolNames: [], + toolErrors: 0, + }, + ]; + mockOk(conversations); + + const result = await listConversations(ORG); + + expect(result.data).toHaveLength(1); + expect(result.data[0]).toMatchObject({ + conversationId: "conv-abc", + webUrl: + "https://sentry.io/organizations/test-org/explore/agents/conversations/conv-abc/", + }); + }); + + test("caps per_page at API_MAX_PER_PAGE when limit exceeds it", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { limit: 200 }); + + expect(getCapturedUrl()).toContain("per_page=100"); + expect(getCapturedUrl()).not.toContain("per_page=200"); + }); + + test("accumulates results across pages when limit > API_MAX_PER_PAGE", async () => { + const item = (id: string) => ({ + conversationId: id, + flow: [], + errors: 0, + llmCalls: 0, + toolCalls: 0, + totalTokens: 0, + totalCost: 0, + startTimestamp: 1_716_500_000, + endTimestamp: 1_716_500_060, + traceCount: 0, + traceIds: [], + firstInput: "", + lastOutput: "", + toolNames: [], + toolErrors: 0, + }); + + const { getCapturedUrls } = mockSequential([ + { body: [item("c1")], headers: linkHeader("cursor1") }, + { body: [item("c2")], headers: linkHeader("cursor2", "false") }, + ]); + + const result = await listConversations(ORG, { limit: 200 }); + + expect(result.data).toHaveLength(2); + expect(result.data.map((c) => c.conversationId)).toEqual(["c1", "c2"]); + expect(getCapturedUrls()[0]).toContain("per_page=100"); + }); +}); + +// ============================================================================ +// getConversationSpans +// ============================================================================ + +describe("getConversationSpans", () => { + const CONV_ID = "conv-abc-123"; + + /** Minimal valid span for schema validation */ + function makeSpan(id: string) { + return { + "gen_ai.conversation.id": CONV_ID, + span_id: id, + trace: "00112233445566778899aabbccddeeff", + project: "my-project", + "project.id": 42, + "span.name": "gen_ai.invoke_agent", + "span.status": "ok", + "precise.start_ts": 1_716_500_000, + "precise.finish_ts": 1_716_500_010, + "gen_ai.operation.type": "ai_client", + }; + } + + /** Details envelope returned by the conversation details endpoint. */ + function makeEnvelope( + spans: ReturnType[], + title: string | null = "Test conversation" + ) { + return { + conversationId: CONV_ID, + title, + spans, + }; + } + + test("hits /organizations/{org}/agents/conversations/{conversationId}/ with GET", async () => { + const { getCapturedUrl, getCapturedMethod } = mockOk(makeEnvelope([])); + + await getConversationSpans(ORG, CONV_ID); + + expect(getCapturedMethod()).toBe("GET"); + expect(getCapturedUrl()).toContain( + `/api/0/organizations/${ORG}/agents/conversations/${CONV_ID}/` + ); + }); + + test("sends default per_page=1000 and statsPeriod=30d", async () => { + const { getCapturedUrl } = mockOk(makeEnvelope([])); + + await getConversationSpans(ORG, CONV_ID); + + const url = getCapturedUrl(); + expect(url).toContain("per_page=1000"); + expect(url).toContain("statsPeriod=30d"); + }); + + test("returns spans from a single page envelope", async () => { + const spans = [makeSpan("span-1-aabb1122")]; + mockOk(makeEnvelope(spans, "Refund flow")); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.spans).toHaveLength(1); + expect(result.spans[0].span_id).toBe("span-1-aabb1122"); + expect(result.title).toBe("Refund flow"); + expect(result.truncated).toBe(false); + }); + + test("rejects a bare span array (pre-envelope response shape)", async () => { + mockOk([makeSpan("span-1-aabb1122")]); + + await expect(getConversationSpans(ORG, CONV_ID)).rejects.toThrow( + /Unexpected response format/ + ); + }); + + test("paginates through multiple pages", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: makeEnvelope([makeSpan("span-page-1-aa11")], "Multi-page"), + headers: linkHeader("page-2-cursor"), + }, + { + body: makeEnvelope([makeSpan("span-page-2-bb22")], "Multi-page"), + // No Link header → last page + }, + ]); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.spans).toHaveLength(2); + expect(result.spans[0].span_id).toBe("span-page-1-aa11"); + expect(result.spans[1].span_id).toBe("span-page-2-bb22"); + // Title is taken from the first page + expect(result.title).toBe("Multi-page"); + expect(result.truncated).toBe(false); + expect(getCapturedUrls()).toHaveLength(2); + // Second request should include cursor + expect(getCapturedUrls()[1]).toContain("cursor=page-2-cursor"); + }); + + test("returns truncated=true when pagination limit reached", async () => { + // Create responses that always have more pages + const responses = Array.from({ length: MAX_PAGINATION_PAGES }, (_, i) => ({ + body: makeEnvelope([makeSpan(`span-${i}-aabb1122`)]), + headers: linkHeader("always-more-cursor"), + })); + + mockSequential(responses); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.truncated).toBe(true); + expect(result.spans).toHaveLength(MAX_PAGINATION_PAGES); + }); + + test("returns truncated=false when all pages fetched before limit", async () => { + mockOk(makeEnvelope([makeSpan("only-page-span1")], null)); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.truncated).toBe(false); + expect(result.title).toBeNull(); + }); + + test("uses custom options when provided", async () => { + const { getCapturedUrl } = mockOk(makeEnvelope([])); + + await getConversationSpans(ORG, CONV_ID, { + statsPeriod: "7d", + project: "my-project", + perPage: 500, + }); + + const url = getCapturedUrl(); + expect(url).toContain("per_page=500"); + expect(url).toContain("statsPeriod=7d"); + expect(url).toContain("project=my-project"); + }); + + test("encodes conversationId in URL", async () => { + const specialId = "conv/with special"; + const { getCapturedUrl } = mockOk({ + conversationId: specialId, + title: null, + spans: [], + }); + + await getConversationSpans(ORG, specialId); + + expect(getCapturedUrl()).toContain( + `/agents/conversations/${encodeURIComponent(specialId)}/` + ); + }); +}); diff --git a/packages/cli/test/lib/api/dashboards.test.ts b/packages/cli/test/lib/api/dashboards.test.ts new file mode 100644 index 000000000..a191f4b70 --- /dev/null +++ b/packages/cli/test/lib/api/dashboards.test.ts @@ -0,0 +1,260 @@ +/** + * Dashboard API helper tests + * + * Tests for periodToSeconds, computeOptimalInterval, and queryAllWidgets + * from src/lib/api/dashboards.ts. + */ + +import { describe, expect, test, vi } from "vitest"; +import { + computeOptimalInterval, + periodToSeconds, + queryAllWidgets, + restoreDashboardRevision, +} from "../../../src/lib/api/dashboards.js"; +import type { + DashboardDetail, + DashboardWidget, +} from "../../../src/types/dashboard.js"; + +const { apiRequestToRegionMock, resolveOrgRegionMock } = vi.hoisted(() => ({ + apiRequestToRegionMock: vi.fn(), + resolveOrgRegionMock: vi.fn(), +})); + +vi.mock("../../../src/lib/region.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, resolveOrgRegion: resolveOrgRegionMock }; +}); + +vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/api/infrastructure.js") + >(); + return { ...actual, apiRequestToRegion: apiRequestToRegionMock }; +}); + +// --------------------------------------------------------------------------- +// restoreDashboardRevision +// --------------------------------------------------------------------------- + +describe("restoreDashboardRevision", () => { + test("posts to the revision restore endpoint", async () => { + const dashboard = { + id: "123", + title: "Restored Dashboard", + widgets: [], + } as DashboardDetail; + resolveOrgRegionMock.mockResolvedValue("https://us.sentry.io"); + apiRequestToRegionMock.mockResolvedValue({ data: dashboard }); + + await expect( + restoreDashboardRevision("test-org", "123", "42") + ).resolves.toBe(dashboard); + + expect(apiRequestToRegionMock).toHaveBeenCalledWith( + "https://us.sentry.io", + "/organizations/test-org/dashboards/123/revisions/42/restore/", + expect.objectContaining({ method: "POST" }) + ); + }); +}); + +// --------------------------------------------------------------------------- +// periodToSeconds +// --------------------------------------------------------------------------- + +describe("periodToSeconds", () => { + test("parses seconds", () => { + expect(periodToSeconds("30s")).toBe(30); + expect(periodToSeconds("1s")).toBe(1); + }); + + test("parses minutes", () => { + expect(periodToSeconds("1m")).toBe(60); + expect(periodToSeconds("5m")).toBe(300); + expect(periodToSeconds("30m")).toBe(1800); + }); + + test("parses hours", () => { + expect(periodToSeconds("1h")).toBe(3600); + expect(periodToSeconds("24h")).toBe(86_400); + expect(periodToSeconds("4h")).toBe(14_400); + }); + + test("parses days", () => { + expect(periodToSeconds("1d")).toBe(86_400); + expect(periodToSeconds("7d")).toBe(604_800); + expect(periodToSeconds("14d")).toBe(1_209_600); + expect(periodToSeconds("90d")).toBe(7_776_000); + }); + + test("parses weeks", () => { + expect(periodToSeconds("1w")).toBe(604_800); + expect(periodToSeconds("2w")).toBe(1_209_600); + }); + + test("returns undefined for invalid input", () => { + expect(periodToSeconds("")).toBeUndefined(); + expect(periodToSeconds("abc")).toBeUndefined(); + expect(periodToSeconds("24")).toBeUndefined(); + expect(periodToSeconds("h")).toBeUndefined(); + expect(periodToSeconds("24x")).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// computeOptimalInterval +// --------------------------------------------------------------------------- + +/** Build a minimal widget with optional layout width. */ +function makeWidget(layoutW?: number, interval?: string): DashboardWidget { + return { + title: "Test Widget", + displayType: "line", + layout: + layoutW !== undefined ? { x: 0, y: 0, w: layoutW, h: 2 } : undefined, + interval, + } as DashboardWidget; +} + +describe("computeOptimalInterval", () => { + test("returns a valid Sentry interval string", () => { + const validIntervals = new Set([ + "1m", + "5m", + "15m", + "30m", + "1h", + "4h", + "12h", + "1d", + ]); + const result = computeOptimalInterval("24h", makeWidget(2)); + expect(result).toBeDefined(); + expect(validIntervals.has(result!)).toBe(true); + }); + + test("shorter periods produce finer intervals", () => { + const interval24h = computeOptimalInterval("24h", makeWidget(2)); + const interval7d = computeOptimalInterval("7d", makeWidget(2)); + const interval90d = computeOptimalInterval("90d", makeWidget(2)); + + // Convert back to seconds for comparison + const sec24h = periodToSeconds(interval24h!) ?? 0; + const sec7d = periodToSeconds(interval7d!) ?? 0; + const sec90d = periodToSeconds(interval90d!) ?? 0; + + expect(sec24h).toBeLessThanOrEqual(sec7d); + expect(sec7d).toBeLessThanOrEqual(sec90d); + }); + + test("wider widgets produce finer intervals", () => { + const narrow = computeOptimalInterval("24h", makeWidget(1)); + const wide = computeOptimalInterval("24h", makeWidget(6)); + + const secNarrow = periodToSeconds(narrow!) ?? 0; + const secWide = periodToSeconds(wide!) ?? 0; + + // Wider widget has more columns → finer interval + expect(secWide).toBeLessThanOrEqual(secNarrow); + }); + + test("falls back to widget interval for invalid period", () => { + const widget = makeWidget(2, "5m"); + expect(computeOptimalInterval("invalid", widget)).toBe("5m"); + }); + + test("falls back to widget interval when no layout", () => { + const widget = makeWidget(undefined, "15m"); + // Without layout, uses default GRID_COLS (full width) — still computes + const result = computeOptimalInterval("24h", widget); + expect(result).toBeDefined(); + }); + + test("never returns undefined for valid periods", () => { + const periods = ["1h", "24h", "7d", "14d", "90d"]; + for (const period of periods) { + const result = computeOptimalInterval(period, makeWidget(2)); + expect(result).toBeDefined(); + } + }); + + test("returns 1m as floor for very short periods", () => { + // 1h / ~40 cols ≈ 90s → should pick "1m" (finest available) + const result = computeOptimalInterval("1h", makeWidget(2)); + expect(result).toBe("1m"); + }); +}); + +// --------------------------------------------------------------------------- +// queryAllWidgets — text widget handling +// --------------------------------------------------------------------------- + +/** + * Build a minimal DashboardDetail with the given widgets. + * + * Text widgets carry markdown in `description` (a passthrough field not in + * the typed schema), so widgets are cast via `as any`. + */ +function makeDashboard(widgets: Record[]): DashboardDetail { + return { id: "1", title: "Test Dashboard", widgets } as DashboardDetail; +} + +describe("queryAllWidgets", () => { + // Text widgets return immediately — no API call, no mocking needed. + // regionUrl and orgSlug are unused for text widgets. + const UNUSED_URL = "https://unused.example.com"; + const UNUSED_ORG = "unused-org"; + + test("returns TextResult for text widget with description", async () => { + const dashboard = makeDashboard([ + { title: "Notes", displayType: "text", description: "# Hello\n**bold**" }, + ]); + + const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard); + + expect(results.size).toBe(1); + expect(results.get(0)).toEqual({ + type: "text", + content: "# Hello\n**bold**", + }); + }); + + test("returns TextResult with empty content when description is missing", async () => { + const dashboard = makeDashboard([ + { title: "Empty Notes", displayType: "text" }, + ]); + + const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard); + + expect(results.get(0)).toEqual({ type: "text", content: "" }); + }); + + test("returns TextResult with empty content for non-string description", async () => { + const dashboard = makeDashboard([ + { title: "Bad Description", displayType: "text", description: 42 }, + ]); + + const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard); + + expect(results.get(0)).toEqual({ type: "text", content: "" }); + }); + + test("handles multiple text widgets in a single dashboard", async () => { + const dashboard = makeDashboard([ + { title: "Notes 1", displayType: "text", description: "First" }, + { title: "Notes 2", displayType: "text", description: "Second" }, + { title: "Notes 3", displayType: "text", description: "Third" }, + ]); + + const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard); + + expect(results.size).toBe(3); + expect(results.get(0)).toEqual({ type: "text", content: "First" }); + expect(results.get(1)).toEqual({ type: "text", content: "Second" }); + expect(results.get(2)).toEqual({ type: "text", content: "Third" }); + }); +}); diff --git a/packages/cli/test/lib/api/debug-files.test.ts b/packages/cli/test/lib/api/debug-files.test.ts new file mode 100644 index 000000000..c11509f34 --- /dev/null +++ b/packages/cli/test/lib/api/debug-files.test.ts @@ -0,0 +1,473 @@ +/** + * Tests for the debug information file upload API. + * + * Mocks the chunk-upload primitives and the region request layer so the + * assemble body shape, missing-chunk upload, and the no-wait/wait completion + * modes can be exercised without a network. `hashBuffer` and `pickUploadEncoding` + * remain real (they are pure). + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { getChunkUploadOptions } from "../../../src/lib/api/chunk-upload.js"; +import { + type DebugFileUpload, + uploadDebugFiles, +} from "../../../src/lib/api/debug-files.js"; +import { ApiError } from "../../../src/lib/errors.js"; + +const { apiRequestToRegionMock, uploadMissingBufferChunksMock } = vi.hoisted( + () => ({ + apiRequestToRegionMock: vi.fn(), + uploadMissingBufferChunksMock: vi.fn(() => Promise.resolve()), + }) +); + +vi.mock("../../../src/lib/region.js", () => ({ + resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), +})); + +vi.mock("../../../src/lib/api/infrastructure.js", () => ({ + apiRequestToRegion: apiRequestToRegionMock, +})); + +vi.mock("../../../src/lib/api/chunk-upload.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/api/chunk-upload.js") + >(); + return { + ...actual, + getChunkUploadOptions: vi.fn(async () => ({ + url: "https://us.sentry.io/api/0/chunk-upload/", + chunkSize: 8192, + chunksPerRequest: 64, + maxRequestSize: 1_048_576, + hashAlgorithm: "sha1", + concurrency: 8, + compression: ["gzip"], + })), + uploadMissingBufferChunks: uploadMissingBufferChunksMock, + }; +}); + +/** Build a single debug-file upload payload. */ +function makeDif( + name: string, + content: string, + debugId?: string +): DebugFileUpload { + return { name, debugId, content: Buffer.from(content) }; +} + +/** The single checksum key from the most recent assemble body. */ +function lastAssembleBody(): Record< + string, + { name: string; debug_id?: string; chunks: string[] } +> { + const calls = apiRequestToRegionMock.mock.calls; + const lastCall = calls.at(-1); + return lastCall?.[2].body; +} + +const SOLE = { + org: "acme", + project: "app", +}; + +/** Base server options the mock returns unless a test overrides it. */ +const BASE_SERVER_OPTIONS = { + url: "https://us.sentry.io/api/0/chunk-upload/", + chunkSize: 8192, + chunksPerRequest: 64, + maxRequestSize: 1_048_576, + hashAlgorithm: "sha1", + concurrency: 8, + compression: ["gzip"], +}; + +/** Override the chunk-upload server options for the current test. */ +function setServerOptions(overrides: Record): void { + vi.mocked(getChunkUploadOptions).mockResolvedValue({ + ...BASE_SERVER_OPTIONS, + ...overrides, + } as Awaited>); +} + +beforeEach(() => { + apiRequestToRegionMock.mockReset(); + uploadMissingBufferChunksMock.mockClear(); + vi.mocked(getChunkUploadOptions).mockResolvedValue( + BASE_SERVER_OPTIONS as Awaited> + ); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("uploadDebugFiles", () => { + test("empty input returns no results and makes no requests", async () => { + const results = await uploadDebugFiles({ + ...SOLE, + difs: [], + wait: false, + maxWaitMs: 1000, + }); + expect(results).toEqual([]); + expect(apiRequestToRegionMock).not.toHaveBeenCalled(); + }); + + test("assemble body keys by checksum and includes name + debug_id + chunks", async () => { + const dif = makeDif("libfoo.so", "ELF debug bytes", "abc123-def"); + // The checksum is content-derived, so we capture the request body and + // echo its keys back as "ok" — server already holds the file, so no-wait + // completes on the first assemble. + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const body = init.body as Record; + const data: Record = {}; + for (const key of Object.keys(body)) { + data[key] = { state: "ok" }; + } + return { data }; + } + ); + + const results = await uploadDebugFiles({ + ...SOLE, + difs: [dif], + wait: false, + maxWaitMs: 1000, + }); + + const body = lastAssembleBody(); + const keys = Object.keys(body); + expect(keys).toHaveLength(1); + expect(keys[0]).toMatch(/^[0-9a-f]{40}$/); + const entry = body[keys[0] as string]; + expect(entry?.name).toBe("libfoo.so"); + expect(entry?.debug_id).toBe("abc123-def"); + expect(Array.isArray(entry?.chunks)).toBe(true); + expect(entry?.chunks.every((c) => /^[0-9a-f]{40}$/.test(c))).toBe(true); + + expect(results).toHaveLength(1); + expect(results[0]?.state).toBe("ok"); + expect(results[0]?.name).toBe("libfoo.so"); + }); + + test("omits debug_id from the body when not provided", async () => { + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const body = init.body as Record; + const data: Record = {}; + for (const key of Object.keys(body)) { + data[key] = { state: "ok" }; + } + return { data }; + } + ); + + await uploadDebugFiles({ + ...SOLE, + difs: [makeDif("anon.so", "bytes")], + wait: false, + maxWaitMs: 1000, + }); + + const entry = Object.values(lastAssembleBody())[0]; + expect(entry).toBeDefined(); + expect("debug_id" in (entry as object)).toBe(false); + }); + + test("uploads missing chunks then completes (no-wait)", async () => { + vi.useFakeTimers(); + const dif = makeDif("libbar.so", "more debug bytes", "id-1"); + + // First assemble: server is missing the chunk. Second: held (created). + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const body = init.body as Record; + const key = Object.keys(body)[0] as string; + return { + data: { + [key]: { state: "not_found", missingChunks: body[key]?.chunks }, + }, + }; + } + ); + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "created" } } }; + } + ); + + const promise = uploadDebugFiles({ + ...SOLE, + difs: [dif], + wait: false, + maxWaitMs: 60_000, + }); + await vi.runAllTimersAsync(); + const results = await promise; + + expect(uploadMissingBufferChunksMock).toHaveBeenCalled(); + expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); + expect(results[0]?.state).toBe("created"); + }); + + test("wait mode polls past 'assembling' until terminal 'ok'", async () => { + vi.useFakeTimers(); + const dif = makeDif("libbaz.so", "debug bytes", "id-2"); + + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "assembling" } } }; + } + ); + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "ok" } } }; + } + ); + + const promise = uploadDebugFiles({ + ...SOLE, + difs: [dif], + wait: true, + maxWaitMs: 60_000, + }); + await vi.runAllTimersAsync(); + const results = await promise; + + expect(apiRequestToRegionMock).toHaveBeenCalledTimes(2); + expect(results[0]?.state).toBe("ok"); + }); + + test("wait mode collects an 'error' state without throwing", async () => { + const dif = makeDif("broken.so", "debug bytes", "id-3"); + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { + data: { [key]: { state: "error", detail: "corrupt object" } }, + }; + } + ); + + const results = await uploadDebugFiles({ + ...SOLE, + difs: [dif], + wait: true, + maxWaitMs: 60_000, + }); + + expect(results[0]?.state).toBe("error"); + expect(results[0]?.detail).toBe("corrupt object"); + }); + + test("wait mode throws ApiError when assembly does not finish in time", async () => { + const dif = makeDif("slow.so", "debug bytes", "id-4"); + // Always "assembling" → never terminal. maxWaitMs=0 trips the deadline. + apiRequestToRegionMock.mockImplementation( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "assembling" } } }; + } + ); + + await expect( + uploadDebugFiles({ + ...SOLE, + difs: [dif], + wait: true, + maxWaitMs: 0, + }) + ).rejects.toBeInstanceOf(ApiError); + }); + + test("batches multiple files into one assemble request", async () => { + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const body = init.body as Record; + const data: Record = {}; + for (const key of Object.keys(body)) { + data[key] = { state: "ok" }; + } + return { data }; + } + ); + + const results = await uploadDebugFiles({ + ...SOLE, + difs: [makeDif("a.so", "alpha", "id-a"), makeDif("b.so", "beta", "id-b")], + wait: false, + maxWaitMs: 1000, + }); + + expect(apiRequestToRegionMock).toHaveBeenCalledTimes(1); + expect(Object.keys(lastAssembleBody())).toHaveLength(2); + expect(results).toHaveLength(2); + }); + + test("re-sends every chunk when server response omits a file's entry", async () => { + // First call: server has no record of either file (entries missing). + // Second call: server holds both files. No-wait completes on the second + // call after the chunks are re-uploaded. + apiRequestToRegionMock.mockImplementationOnce(async () => ({ data: {} })); + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const body = init.body as Record; + const data: Record = {}; + for (const key of Object.keys(body)) { + data[key] = { state: "ok" }; + } + return { data }; + } + ); + + await uploadDebugFiles({ + ...SOLE, + difs: [makeDif("missing.so", "alpha", "id-a")], + wait: false, + maxWaitMs: 60_000, + }); + + // First call has the file's entry missing from response, so all its + // chunks must have been queued for re-upload. + expect(uploadMissingBufferChunksMock).toHaveBeenCalled(); + const firstCall = uploadMissingBufferChunksMock.mock.calls[0]?.[0]; + const passedMissing = firstCall?.missingChecksums as Set; + expect(passedMissing.size).toBeGreaterThan(0); + }); + + test("re-sends every chunk when entry state is not_found without missingChunks", async () => { + // Server returns `not_found` but omits the `missingChunks` field — the + // client must still upload all chunks for that file, otherwise the loop + // would poll forever without sending anything. + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "not_found" } } }; + } + ); + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "ok" } } }; + } + ); + + await uploadDebugFiles({ + ...SOLE, + difs: [makeDif("silent.so", "alpha", "id-x")], + wait: false, + maxWaitMs: 60_000, + }); + + const firstCall = uploadMissingBufferChunksMock.mock.calls[0]?.[0]; + const passedMissing = firstCall?.missingChecksums as Set; + // Must contain the file's chunk checksums, not be empty. + expect(passedMissing.size).toBeGreaterThan(0); + }); + + test("does not assemble oversized files but reports them as failures", async () => { + setServerOptions({ maxFileSize: 5 }); + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const body = init.body as Record; + const data: Record = {}; + for (const key of Object.keys(body)) { + data[key] = { state: "ok" }; + } + return { data }; + } + ); + + const results = await uploadDebugFiles({ + ...SOLE, + // "small" is 5 bytes (<= cap), "this-one-is-too-long" exceeds it. + difs: [ + makeDif("small.so", "small", "id-ok"), + makeDif("big.so", "this-one-is-too-long", "id-big"), + ], + wait: false, + maxWaitMs: 1000, + }); + + // Only the in-cap file is actually assembled. + const body = lastAssembleBody(); + const names = Object.values(body).map((e) => e.name); + expect(names).toEqual(["small.so"]); + + // But the oversized file is surfaced as an `error` result so a partial + // drop yields a non-zero exit instead of a silent success. + expect(results).toHaveLength(2); + const small = results.find((r) => r.name === "small.so"); + const big = results.find((r) => r.name === "big.so"); + expect(small?.state).toBe("ok"); + expect(big?.state).toBe("error"); + expect(big?.detail).toMatch(/maximum file size/); + }); + + test("throws when every file exceeds the server maxFileSize", async () => { + setServerOptions({ maxFileSize: 1 }); + + await expect( + uploadDebugFiles({ + ...SOLE, + difs: [makeDif("big.so", "way too large", "id-big")], + wait: false, + maxWaitMs: 1000, + }) + ).rejects.toThrow(/exceed the maximum file size/); + expect(apiRequestToRegionMock).not.toHaveBeenCalled(); + }); + + test("clamps the wait to the server maxWait (reflected in the timeout)", async () => { + setServerOptions({ maxWait: 1 }); + vi.useFakeTimers(); + // Always "assembling" → never terminal. The clamped 1s deadline trips + // well before the requested 60s. + apiRequestToRegionMock.mockImplementation( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "assembling" } } }; + } + ); + + const promise = uploadDebugFiles({ + ...SOLE, + difs: [makeDif("slow.so", "debug bytes", "id-slow")], + wait: true, + maxWaitMs: 60_000, + }).catch((caught: unknown) => caught); + await vi.runAllTimersAsync(); + const err = await promise; + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).detail).toContain("within 1s"); + }); + + test("does not clamp when the server advertises no maxWait (0)", async () => { + setServerOptions({ maxWait: 0 }); + const dif = makeDif("ok.so", "debug bytes", "id-ok"); + apiRequestToRegionMock.mockImplementationOnce( + async (_url: string, _endpoint: string, init: { body: object }) => { + const key = Object.keys(init.body as object)[0] as string; + return { data: { [key]: { state: "ok" } } }; + } + ); + + const results = await uploadDebugFiles({ + ...SOLE, + difs: [dif], + wait: true, + maxWaitMs: 60_000, + }); + + expect(results[0]?.state).toBe("ok"); + }); +}); diff --git a/packages/cli/test/lib/api/events-overshoot.test.ts b/packages/cli/test/lib/api/events-overshoot.test.ts new file mode 100644 index 000000000..7256d6128 --- /dev/null +++ b/packages/cli/test/lib/api/events-overshoot.test.ts @@ -0,0 +1,130 @@ +/** + * Regression tests for listIssueEvents pagination. + * + * Two bugs framed this behavior: + * 1. The original skip bug: trimming an overshooting page while returning the + * server's next-page cursor skipped the trimmed tail on `-c next`. + * 2. The overcorrection: dropping the cursor on overshoot stranded all events + * past the first `limit`, so `sentry issue events` could never page forward. + * + * The fix caps page size with `per_page = min(limit, API_MAX_PER_PAGE)` so the + * server cursor is page-aligned, and trims defensively while PRESERVING the + * cursor (the events cursor is offset-based, so resuming re-includes any trimmed + * tail). These tests pin both the `per_page` request and the cursor preservation. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { listIssueEvents } from "../../../src/lib/api/events.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-events-overshoot-"); +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", "https://sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** Build a Sentry pagination Link header advertising a next page. */ +function nextLinkHeader(cursor: string): string { + return `; rel="next"; results="true"; cursor="${cursor}"`; +} + +function makeEvents(count: number): Array<{ id: string }> { + return Array.from({ length: count }, (_unused, i) => ({ id: `evt-${i}` })); +} + +describe("listIssueEvents pagination", () => { + test("sends per_page capped at the requested limit", async () => { + let capturedUrl = ""; + globalThis.fetch = mockFetch(async (input, init) => { + capturedUrl = new Request(input!, init).url; + return new Response(JSON.stringify(makeEvents(2)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listIssueEvents("test-org", "123", { limit: 2 }); + + expect(new URL(capturedUrl).searchParams.get("per_page")).toBe("2"); + }); + + test("caps per_page at API_MAX_PER_PAGE for very large limits", async () => { + let capturedUrl = ""; + globalThis.fetch = mockFetch(async (input, init) => { + capturedUrl = new Request(input!, init).url; + return new Response(JSON.stringify(makeEvents(10)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listIssueEvents("test-org", "123", { limit: 5000 }); + + expect(new URL(capturedUrl).searchParams.get("per_page")).toBe("100"); + }); + + test("trims to limit but PRESERVES nextCursor when a page overshoots", async () => { + // Server ignores per_page and returns 5 with a next cursor; caller wants 2. + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(makeEvents(5)), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: nextLinkHeader("page2"), + }, + }) + ); + + const result = await listIssueEvents("test-org", "123", { limit: 2 }); + + expect(result.data).toHaveLength(2); + expect(result.data[0]?.id).toBe("evt-0"); + expect(result.data[1]?.id).toBe("evt-1"); + // Cursor preserved so `-c next` can advance; the offset-based events cursor + // re-includes the trimmed tail rather than skipping it. + expect(result.nextCursor).toBe("page2"); + }); + + test("preserves nextCursor when the page exactly fills the limit", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(makeEvents(2)), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: nextLinkHeader("page2"), + }, + }) + ); + + const result = await listIssueEvents("test-org", "123", { limit: 2 }); + + expect(result.data).toHaveLength(2); + expect(result.nextCursor).toBe("page2"); + }); + + test("returns all events with no cursor when under the limit", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(makeEvents(3)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await listIssueEvents("test-org", "123", { limit: 25 }); + + expect(result.data).toHaveLength(3); + expect(result.nextCursor).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/api/explore.test.ts b/packages/cli/test/lib/api/explore.test.ts new file mode 100644 index 000000000..566dec77d --- /dev/null +++ b/packages/cli/test/lib/api/explore.test.ts @@ -0,0 +1,371 @@ +/** + * Tests for the explore API helper. + * + * Verifies URL construction, query parameter encoding (especially repeated + * `field` params), schema validation, and pagination cursor extraction. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { queryEvents } from "../../../src/lib/api/explore.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +describe("queryEvents", () => { + useTestConfigDir("explore-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedMethod = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedMethod = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + } + + test("hits /organizations/{org}/events/ with GET", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { fields: ["title", "count()"] }); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toContain("/api/0/organizations/my-org/events/"); + }); + + test("encodes fields as repeated query params (field=a&field=b)", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { + fields: ["title", "count()", "count_unique(user)"], + }); + + // Each field appears as its own query param (not joined with comma). + // URLSearchParams encodes parens as %28/%29 — assert against decoded URL. + const decoded = decodeURIComponent(capturedUrl); + expect(decoded).toContain("field=title"); + expect(decoded).toContain("field=count()"); + expect(decoded).toContain("field=count_unique(user)"); + // Verify we got 3 separate field= params, not 1 with commas + expect(capturedUrl.match(/field=/g)?.length).toBe(3); + }); + + test("defaults dataset to errors when not provided", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { fields: ["title"] }); + + expect(capturedUrl).toContain("dataset=errors"); + }); + + test("passes explicit dataset", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { + fields: ["span.op"], + dataset: "spans", + }); + + expect(capturedUrl).toContain("dataset=spans"); + }); + + test("omits empty query (does not send query=&)", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { fields: ["title"], query: "" }); + + expect(capturedUrl).not.toContain("query="); + }); + + test("passes non-empty query", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { + fields: ["title"], + query: "is:unresolved", + }); + + expect(capturedUrl).toContain( + `query=${encodeURIComponent("is:unresolved")}` + ); + }); + + test("omits sort when not provided", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { fields: ["title"] }); + + expect(capturedUrl).not.toContain("sort="); + }); + + test("passes sort when provided", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { + fields: ["span.op"], + dataset: "spans", + sort: "-count()", + }); + + // URLSearchParams encodes ( and ) but leaves - and most chars alone + expect(decodeURIComponent(capturedUrl)).toContain("sort=-count()"); + }); + + test("uses statsPeriod when no absolute range provided", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { + fields: ["title"], + statsPeriod: "1h", + }); + + expect(capturedUrl).toContain("statsPeriod=1h"); + expect(capturedUrl).not.toContain("start="); + expect(capturedUrl).not.toContain("end="); + }); + + test("uses absolute start/end and skips statsPeriod fallback", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { + fields: ["title"], + start: "2024-01-15T00:00:00Z", + end: "2024-01-16T00:00:00Z", + // statsPeriod also passed but should be ignored + statsPeriod: "7d", + }); + + expect(capturedUrl).toContain( + `start=${encodeURIComponent("2024-01-15T00:00:00Z")}` + ); + expect(capturedUrl).toContain( + `end=${encodeURIComponent("2024-01-16T00:00:00Z")}` + ); + expect(capturedUrl).not.toContain("statsPeriod="); + }); + + test("passes per_page from limit", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { fields: ["title"], limit: 50 }); + + expect(capturedUrl).toContain("per_page=50"); + }); + + test("passes cursor when provided", async () => { + mockOk({ data: [], meta: { fields: {} } }); + + await queryEvents("my-org", { + fields: ["title"], + cursor: "0:50:0", + }); + + expect(capturedUrl).toContain(`cursor=${encodeURIComponent("0:50:0")}`); + }); + + test("returns parsed data and extracts nextCursor from Link header", async () => { + const cursor = "0:50:0"; + mockOk( + { + data: [{ title: "Error A", "count()": 100 }], + meta: { + fields: { title: "string", "count()": "integer" }, + units: {}, + }, + }, + { + Link: `; rel="next"; results="true"; cursor="${cursor}"`, + } + ); + + const result = await queryEvents("my-org", { + fields: ["title", "count()"], + }); + + expect(result.data.data).toHaveLength(1); + expect(result.data.meta?.fields).toEqual({ + title: "string", + "count()": "integer", + }); + expect(result.nextCursor).toBe(cursor); + }); + + test("returns undefined nextCursor when results=false in Link header", async () => { + mockOk( + { + data: [], + meta: { fields: {} }, + }, + { + Link: `; rel="next"; results="false"; cursor=""`, + } + ); + + const result = await queryEvents("my-org", { fields: ["title"] }); + + expect(result.nextCursor).toBeUndefined(); + }); + + // --------------------------------------------------------------------------- + // Auto-pagination tests (limit > API_MAX_PER_PAGE) + // --------------------------------------------------------------------------- + + /** + * Helper to mock sequential fetch responses for multi-page tests. + * Each call to fetch returns the next response in the queue. + */ + function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> + ): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + /** Generate N rows of fake event data */ + function makeRows(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + title: `Error ${i}`, + "count()": 100 - i, + })); + } + + const PAGE_META = { + fields: { title: "string", "count()": "integer" }, + units: {}, + }; + + test("auto-paginates when limit exceeds API_MAX_PER_PAGE", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeRows(100), meta: PAGE_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeRows(50), meta: PAGE_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await queryEvents("my-org", { + fields: ["title", "count()"], + limit: 150, + }); + + expect(result.data.data).toHaveLength(150); + expect(result.data.meta?.fields).toEqual(PAGE_META.fields); + expect(result.nextCursor).toBeUndefined(); + expect(getCapturedUrls()).toHaveLength(2); + }); + + test("trims results and drops nextCursor when overshoot", async () => { + mockSequential([ + { + body: { data: makeRows(100), meta: PAGE_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeRows(100), meta: PAGE_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:200:0"`, + }, + }, + ]); + + const result = await queryEvents("my-org", { + fields: ["title", "count()"], + limit: 120, + }); + + expect(result.data.data).toHaveLength(120); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single-page fast path when limit <= API_MAX_PER_PAGE", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeRows(50), meta: PAGE_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await queryEvents("my-org", { + fields: ["title"], + limit: 50, + }); + + expect(result.data.data).toHaveLength(50); + expect(getCapturedUrls()).toHaveLength(1); + expect(getCapturedUrls()[0]).toContain("per_page=50"); + }); + + test("preserves meta from first page across multi-page fetch", async () => { + const firstPageMeta = { + fields: { title: "string", "count()": "integer" }, + units: { "count()": null }, + }; + const secondPageMeta = { + fields: { title: "string", "count()": "integer" }, + units: { "count()": null }, + }; + + mockSequential([ + { + body: { data: makeRows(100), meta: firstPageMeta }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeRows(50), meta: secondPageMeta }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await queryEvents("my-org", { + fields: ["title", "count()"], + limit: 150, + }); + + // Meta should come from the first page + expect(result.data.meta).toEqual(firstPageMeta); + }); +}); diff --git a/packages/cli/test/lib/api/feedback.property.test.ts b/packages/cli/test/lib/api/feedback.property.test.ts new file mode 100644 index 000000000..fbb393fb8 --- /dev/null +++ b/packages/cli/test/lib/api/feedback.property.test.ts @@ -0,0 +1,47 @@ +/** + * Property tests for the User Feedback API query boundary. + */ + +import fc from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + buildFeedbackQuery, + type FeedbackStatus, +} from "../../../src/lib/api/feedback.js"; + +const CATEGORY_FILTER = "issue.category:feedback"; + +describe("buildFeedbackQuery", () => { + test("always preserves the mandatory feedback category", () => { + fc.assert( + fc.property( + fc.constantFrom( + "unresolved", + "resolved", + "spam", + "all" + ), + fc.string(), + (status, query) => { + expect(buildFeedbackQuery(status, query)).toStartWith( + CATEGORY_FILTER + ); + } + ) + ); + }); + + test.each([ + ["unresolved", "status:unresolved"], + ["resolved", "status:resolved"], + ["spam", "status:ignored"], + ] as const)("maps %s to %s", (status, expected) => { + expect(buildFeedbackQuery(status)).toBe(`${CATEGORY_FILTER} ${expected}`); + }); + + test("omits the generated status filter for all", () => { + expect(buildFeedbackQuery("all", "browser:Chrome")).toBe( + `${CATEGORY_FILTER} browser:Chrome` + ); + }); +}); diff --git a/packages/cli/test/lib/api/infrastructure.test.ts b/packages/cli/test/lib/api/infrastructure.test.ts new file mode 100644 index 000000000..acda59ccf --- /dev/null +++ b/packages/cli/test/lib/api/infrastructure.test.ts @@ -0,0 +1,778 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + API_MAX_PER_PAGE, + isTextualContentType, + paginate, + rawApiRequest, + throwApiError, +} from "../../../src/lib/api/infrastructure.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { ApiError, HostScopeError } from "../../../src/lib/errors.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +describe("throwApiError", () => { + test("network failure with Error produces readable message", () => { + expect(() => + throwApiError( + new TypeError("fetch failed"), + undefined, + "Failed to resolve short ID" + ) + ).toThrow( + expect.objectContaining({ + message: "Failed to resolve short ID: Network error", + status: 0, + }) + ); + + try { + throwApiError( + new TypeError("fetch failed"), + undefined, + "Failed to resolve short ID" + ); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.detail).toContain("fetch failed"); + expect(apiError.detail).toContain("Unable to reach Sentry API"); + expect(apiError.detail).toContain( + "Check your internet connection and try again" + ); + } + }); + + test("network failure with non-Error produces readable message", () => { + try { + throwApiError("connection refused", undefined, "Failed to list issues"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toBe("Failed to list issues: Network error"); + expect(apiError.status).toBe(0); + expect(apiError.detail).toContain("connection refused"); + } + }); + + test("HTTP error with response preserves status and detail", () => { + const mockResponse = new Response("", { + status: 400, + statusText: "Bad Request", + }); + + try { + throwApiError( + { detail: "Invalid query syntax" }, + mockResponse, + "Failed to list issues" + ); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toBe("Failed to list issues: 400 Bad Request"); + expect(apiError.status).toBe(400); + expect(apiError.detail).toBe("Invalid query syntax"); + } + }); + + test("HTTP error without detail uses stringified error", () => { + const mockResponse = new Response("", { + status: 500, + statusText: "Internal Server Error", + }); + + try { + throwApiError("something went wrong", mockResponse, "Failed to fetch"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toBe( + "Failed to fetch: 500 Internal Server Error" + ); + expect(apiError.status).toBe(500); + expect(apiError.detail).toBe("something went wrong"); + } + }); + + test("network failure with ECONNREFUSED-style error", () => { + const err = new Error("connect ECONNREFUSED 127.0.0.1:443"); + + try { + throwApiError(err, undefined, "Failed to get event"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toBe("Failed to get event: Network error"); + expect(apiError.detail).toContain("ECONNREFUSED"); + } + }); + + test("non-403 errors do not get enriched", () => { + const mockResponse = new Response("", { + status: 404, + statusText: "Not Found", + }); + + try { + throwApiError( + { detail: "Resource not found" }, + mockResponse, + "Failed to get org" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(false); + expect(apiError.detail).toBe("Resource not found"); + } + }); + + describe("403 enrichment", () => { + // Test preload sets SENTRY_AUTH_TOKEN, so isEnvTokenActive() returns true + // by default in these tests. + + test("does not suggest token scopes for org-policy disabled-feature 403s", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { + detail: "Your organization has disabled this feature for members.", + }, + mockResponse, + "Failed to create project" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain("disabled this feature"); + expect(apiError.detail).toContain("org-level policy"); + expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN"); + } + }); + + test("enriches 403 with env-var token hints", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { detail: "You do not have permission to perform this action." }, + mockResponse, + "Failed to get organization" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.status).toBe(403); + expect(apiError.detail).toContain( + "You do not have permission to perform this action." + ); + expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN"); + expect(apiError.detail).toContain( + "https://sentry.io/settings/account/api/auth-tokens/" + ); + } + }); + + test("extracts specific scope names when present in detail", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { + detail: + "You do not have permission. Required scope: org:read, project:read", + }, + mockResponse, + "Failed to list issues" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain( + "missing the required scope(s) 'org:read', 'project:read'" + ); + } + }); + + test("uses generic scope hint when no scope names in detail", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { detail: "You do not have permission to perform this action." }, + mockResponse, + "Failed to get organization" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.detail).toContain( + "may lack the required scope for this operation" + ); + } + }); + + test("handles undefined detail without producing noise", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { detail: undefined }, + mockResponse, + "Failed to get organization" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + // Should contain enrichment hints + expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN"); + // Should NOT contain noisy fallback strings + expect(apiError.detail).not.toMatch(/^undefined/); + expect(apiError.detail).not.toContain("{}"); + } + }); + + test("handles null detail without producing noise", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { detail: null }, + mockResponse, + "Failed to get organization" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN"); + // Should NOT contain noisy fallback strings + expect(apiError.detail).not.toMatch(/^null/); + expect(apiError.detail).not.toContain('{"detail":null}'); + } + }); + + describe("with OAuth token (no env var)", () => { + let savedAuthToken: string | undefined; + let savedToken: string | undefined; + + beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + savedToken = process.env.SENTRY_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + }); + + afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } else { + delete process.env.SENTRY_AUTH_TOKEN; + } + if (savedToken !== undefined) { + process.env.SENTRY_TOKEN = savedToken; + } else { + delete process.env.SENTRY_TOKEN; + } + }); + + test("does not suggest re-authentication for org-policy disabled-feature 403s", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { + detail: + "Your organization has disabled this feature for members.", + }, + mockResponse, + "Failed to create project" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain("disabled this feature"); + expect(apiError.detail).toContain("org-level policy"); + expect(apiError.detail).not.toContain("Re-authenticate"); + expect(apiError.detail).not.toContain("sentry auth login"); + } + }); + + test("suggests re-authentication for OAuth tokens", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { + detail: "You do not have permission to perform this action.", + }, + mockResponse, + "Failed to get organization" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain( + "You may not have access to this resource." + ); + expect(apiError.detail).toContain("sentry auth login"); + // Should NOT mention SENTRY_AUTH_TOKEN + expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN"); + } + }); + + test("suggests --scope when specific scopes are detected in 403 detail", () => { + const mockResponse = new Response("", { + status: 403, + statusText: "Forbidden", + }); + + try { + throwApiError( + { + detail: + "You do not have permission. Required scope: event:read, project:read", + }, + mockResponse, + "Failed to list issues" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.enriched403).toBe(true); + expect(apiError.detail).toContain( + "missing the required scope(s) 'event:read', 'project:read'" + ); + expect(apiError.detail).toContain( + "sentry auth refresh --scope event:read --scope project:read" + ); + // Should NOT mention env var or web UI + expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN"); + expect(apiError.detail).not.toContain("auth-tokens/"); + } + }); + }); + }); + + describe("401 enrichment", () => { + // Test preload sets SENTRY_AUTH_TOKEN, so isEnvTokenActive() returns true + // by default in these tests. + + test("uses 'not recognized or has been revoked' for invalid token", () => { + const mockResponse = new Response("", { + status: 401, + statusText: "Unauthorized", + }); + + try { + throwApiError( + { detail: "Invalid token" }, + mockResponse, + "Failed to list organizations" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.status).toBe(401); + expect(apiError.message).toBe( + "Failed to list organizations: 401 Unauthorized" + ); + expect(apiError.detail).toContain("Invalid token"); + expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN"); + expect(apiError.detail).toContain("not recognized or has been revoked"); + expect(apiError.detail).toContain( + "https://sentry.io/settings/account/api/auth-tokens/" + ); + } + }); + + test("uses 'has expired' for Token expired detail", () => { + const mockResponse = new Response("", { + status: 401, + statusText: "Unauthorized", + }); + + try { + throwApiError( + { detail: "Token expired" }, + mockResponse, + "Failed to list organizations" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.status).toBe(401); + expect(apiError.detail).toContain("Token expired"); + expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN"); + expect(apiError.detail).toContain("has expired"); + expect(apiError.detail).not.toContain("not recognized"); + expect(apiError.detail).toContain( + "https://sentry.io/settings/account/api/auth-tokens/" + ); + } + }); + + test("falls back to 'not recognized' when detail is absent", () => { + const mockResponse = new Response("", { + status: 401, + statusText: "Unauthorized", + }); + + try { + throwApiError( + { detail: undefined }, + mockResponse, + "Failed to list organizations" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.status).toBe(401); + expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN"); + expect(apiError.detail).toContain("not recognized or has been revoked"); + expect(apiError.detail).not.toMatch(/^undefined/); + expect(apiError.detail).not.toContain("{}"); + } + }); + + test("treats member-disabled-over-limit as a seat-limit issue, not auth", () => { + const mockResponse = new Response("", { + status: 401, + statusText: "Unauthorized", + }); + + let captured: ApiError | undefined; + try { + throwApiError( + { + detail: { + code: "member-disabled-over-limit", + message: "Organization over member limit", + extra: { next: "/organizations/chisme/disabled-member/" }, + }, + }, + mockResponse, + "Failed to list teams" + ); + } catch (error) { + captured = error as ApiError; + } + + expect(captured).toBeDefined(); + expect(captured?.status).toBe(401); + expect(captured?.detail).toContain("over its member limit"); + expect(captured?.detail).toContain("billing/seat-limit"); + // The fix must NOT give the misleading re-auth advice for this case. + expect(captured?.detail).not.toContain("sentry auth login"); + expect(captured?.detail).not.toContain("session has expired"); + expect(captured?.detail).not.toContain("SENTRY_AUTH_TOKEN"); + }); + + describe("with OAuth token (no env var)", () => { + let savedAuthToken: string | undefined; + let savedToken: string | undefined; + + beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + savedToken = process.env.SENTRY_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + }); + + afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } else { + delete process.env.SENTRY_AUTH_TOKEN; + } + if (savedToken !== undefined) { + process.env.SENTRY_TOKEN = savedToken; + } else { + delete process.env.SENTRY_TOKEN; + } + }); + + test("suggests re-authentication for OAuth tokens", () => { + const mockResponse = new Response("", { + status: 401, + statusText: "Unauthorized", + }); + + try { + throwApiError( + { detail: "Authentication credentials were not provided." }, + mockResponse, + "Failed to list organizations" + ); + } catch (error) { + const apiError = error as ApiError; + expect(apiError.status).toBe(401); + expect(apiError.detail).toContain("session has expired"); + expect(apiError.detail).toContain("sentry auth login"); + // Should NOT mention SENTRY_AUTH_TOKEN + expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN"); + } + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// isTextualContentType + rawApiRequest binary handling (getsentry/cli#1303) +// --------------------------------------------------------------------------- + +describe("isTextualContentType", () => { + test.each([ + [null, true], + [undefined as unknown as null, true], + ["", true], + [" ", true], + ["application/json", true], + ["application/json; charset=utf-8", true], + ["APPLICATION/JSON", true], + ["text/plain", true], + ["text/html; charset=utf-8", true], + ["application/problem+json", true], + ["application/vnd.api+json", true], + ["application/xml", true], + ["application/atom+xml", true], + ["application/yaml", true], + ["application/x-yaml", true], + ["application/javascript", true], + // Binary / unknown → false (allowlist default) + ["image/png", false], + ["application/png", false], + ["application/octet-stream", false], + ["application/zip", false], + ["application/pdf", false], + ["application/x-dmp", false], + ["image/jpeg", false], + ["audio/mpeg", false], + ["video/mp4", false], + ["multipart/form-data", false], + ] as const)("%s → %s", (input, expected) => { + // Treat undefined like missing header for the null case already covered. + expect(isTextualContentType(input ?? null)).toBe(expected); + }); +}); + +describe("rawApiRequest binary handling", () => { + useTestConfigDir("raw-api-binary-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("rejects an absolute base URL outside the token trust scope", async () => { + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + const fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + await expect( + rawApiRequest("organizations/", { + baseUrl: "https://example.invalid", + }) + ).rejects.toBeInstanceOf(HostScopeError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("returns Uint8Array for image/png without UTF-8 corruption", async () => { + // Real PNG signature: 89 50 4e 47 0d 0a 1a 0a — the leading 0x89 is not + // valid UTF-8 and would become EF BF BD if response.text() were used. + const pngBytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, + ]); + + globalThis.fetch = mockFetch( + async () => + new Response(pngBytes, { + status: 200, + headers: { "content-type": "image/png" }, + }) + ); + + const result = await rawApiRequest( + "projects/org/proj/events/abc/attachments/1/?download=1" + ); + + expect(result.status).toBe(200); + expect(result.body).toBeInstanceOf(Uint8Array); + const body = result.body as Uint8Array; + expect(Array.from(body.slice(0, 8))).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + // Must NOT contain the UTF-8 replacement sequence EF BF BD + expect(Array.from(body.slice(0, 3))).not.toEqual([0xef, 0xbf, 0xbd]); + expect(body).toEqual(pngBytes); + }); + + test("returns Uint8Array for application/octet-stream", async () => { + const bytes = new Uint8Array([0xff, 0xfe, 0x00, 0x01, 0x80]); + globalThis.fetch = mockFetch( + async () => + new Response(bytes, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }) + ); + + const result = await rawApiRequest("debug-files/1/download/"); + expect(result.body).toBeInstanceOf(Uint8Array); + expect(result.body).toEqual(bytes); + }); + + test("still parses JSON for application/json", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + const result = await rawApiRequest("organizations/"); + expect(result.body).toEqual({ ok: true }); + }); + + test("still parses JSON when Content-Type is missing", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ slug: "acme" }), { + status: 200, + }) + ); + + const result = await rawApiRequest("organizations/acme/"); + expect(result.body).toEqual({ slug: "acme" }); + }); + + test("returns plain text string for text/plain non-JSON", async () => { + globalThis.fetch = mockFetch( + async () => + new Response("not json", { + status: 200, + headers: { "content-type": "text/plain" }, + }) + ); + + const result = await rawApiRequest("some/text/"); + expect(result.body).toBe("not json"); + }); + + test("returns the HTTP status text with the response", async () => { + globalThis.fetch = mockFetch( + async () => + new Response("", { + status: 404, + statusText: "Not Found", + }) + ); + + const result = await rawApiRequest("missing/"); + expect(result.status).toBe(404); + expect(result.statusText).toBe("Not Found"); + expect(result.body).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// paginate helper (getsentry/cli#1473) +// --------------------------------------------------------------------------- + +describe("paginate", () => { + test("caps perPage at API_MAX_PER_PAGE", async () => { + const spy = vi.fn().mockResolvedValue({ data: [] }); + await paginate({ limit: 200 }, spy); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(API_MAX_PER_PAGE, undefined); + }); + + test("uses defaultLimit when limit is undefined", async () => { + const spy = vi.fn().mockResolvedValue({ data: [] }); + await paginate({}, spy, 25); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(25, undefined); + }); + + test("forwards initial cursor", async () => { + const spy = vi.fn().mockResolvedValue({ data: [] }); + await paginate({ cursor: "abc", limit: 5 }, spy); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(5, "abc"); + }); + + test("passes literal limit as perPage below cap", async () => { + const spy = vi.fn().mockResolvedValue({ data: [] }); + await paginate({ limit: 5 }, spy); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(5, undefined); + }); + + test("accumulates across pages when limit exceeds cap", async () => { + const spy = vi.fn((perPage: number, cursor: string | undefined) => { + const offset = cursor ? Number(cursor) : 0; + const data = Array.from({ length: perPage }, (_, i) => offset + i); + return Promise.resolve({ + data, + nextCursor: String(offset + perPage), + }); + }); + + const result = await paginate({ limit: 150 }, spy); + expect(result.data).toEqual(Array.from({ length: 150 }, (_, i) => i)); + expect(spy.mock.calls.map(([perPage]) => perPage)).toEqual([ + API_MAX_PER_PAGE, + 50, + ]); + expect(result.nextCursor).toBe("150"); + }); + + test("drops nextCursor when a page exceeds the remaining budget", async () => { + const spy = vi + .fn() + .mockResolvedValueOnce({ + data: Array.from({ length: 100 }, (_, i) => i), + nextCursor: "c1", + }) + .mockResolvedValueOnce({ + data: Array.from({ length: 100 }, (_, i) => 100 + i), + nextCursor: "c2", + }); + + const result = await paginate({ limit: 150 }, spy); + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(spy.mock.calls.map(([perPage]) => perPage)).toEqual([ + API_MAX_PER_PAGE, + 50, + ]); + }); +}); diff --git a/packages/cli/test/lib/api/issues.test.ts b/packages/cli/test/lib/api/issues.test.ts new file mode 100644 index 000000000..5bf192de8 --- /dev/null +++ b/packages/cli/test/lib/api/issues.test.ts @@ -0,0 +1,296 @@ +/** + * Tests for issue API helpers: parseResolveSpec + mergeIssues. + * + * Other issue API functions (list/get/update) are covered by + * test/lib/api-client.coverage.test.ts. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + mergeIssues, + parseResolveSpec, + RESOLVE_COMMIT_EXPLICIT_PREFIX, + RESOLVE_COMMIT_SENTINEL, + RESOLVE_NEXT_RELEASE_SENTINEL, +} from "../../../src/lib/api-client.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { + getCachedResponse, + storeCachedResponse, +} from "../../../src/lib/response-cache.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +describe("parseResolveSpec", () => { + test("returns null for undefined", () => { + expect(parseResolveSpec(undefined)).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(parseResolveSpec("")).toBeNull(); + }); + + test("returns null for whitespace-only string", () => { + expect(parseResolveSpec(" ")).toBeNull(); + }); + + test("parses @next sentinel as static inNextRelease", () => { + expect(parseResolveSpec(RESOLVE_NEXT_RELEASE_SENTINEL)).toEqual({ + kind: "static", + details: { inNextRelease: true }, + }); + }); + + test("parses bare @commit as commit/auto", () => { + expect(parseResolveSpec(RESOLVE_COMMIT_SENTINEL)).toEqual({ + kind: "commit", + spec: { kind: "auto" }, + }); + }); + + test("parses explicit @commit:@ as commit/explicit", () => { + expect( + parseResolveSpec(`${RESOLVE_COMMIT_EXPLICIT_PREFIX}getsentry/cli@abc123`) + ).toEqual({ + kind: "commit", + spec: { kind: "explicit", repository: "getsentry/cli", commit: "abc123" }, + }); + }); + + test("explicit @commit splits on the LAST '@' (scoped repo names like @acme/web)", () => { + expect( + parseResolveSpec(`${RESOLVE_COMMIT_EXPLICIT_PREFIX}@acme/web@abc123`) + ).toEqual({ + kind: "commit", + spec: { kind: "explicit", repository: "@acme/web", commit: "abc123" }, + }); + }); + + test("@commit:@ rejects missing SHA", () => { + expect(() => + parseResolveSpec(`${RESOLVE_COMMIT_EXPLICIT_PREFIX}getsentry/cli@`) + ).toThrow(ValidationError); + }); + + test("@commit:@ rejects missing repo", () => { + expect(() => + parseResolveSpec(`${RESOLVE_COMMIT_EXPLICIT_PREFIX}@abc123`) + ).toThrow(ValidationError); + }); + + test("@commit:@ rejects payload with no '@' separator", () => { + expect(() => + parseResolveSpec(`${RESOLVE_COMMIT_EXPLICIT_PREFIX}getsentry/cli`) + ).toThrow(ValidationError); + }); + + test("treats any other value as static inRelease (including monorepo 'pkg@1.2.3')", () => { + expect(parseResolveSpec("0.26.1")).toEqual({ + kind: "static", + details: { inRelease: "0.26.1" }, + }); + expect(parseResolveSpec("v2.3.0")).toEqual({ + kind: "static", + details: { inRelease: "v2.3.0" }, + }); + // Monorepo-style release — must NOT be mistaken for a commit spec + // because it lacks the `@commit:` anchor. + expect(parseResolveSpec("spotlight@1.2.3")).toEqual({ + kind: "static", + details: { inRelease: "spotlight@1.2.3" }, + }); + expect(parseResolveSpec("my-release-tag")).toEqual({ + kind: "static", + details: { inRelease: "my-release-tag" }, + }); + }); + + test("trims surrounding whitespace from the version", () => { + expect(parseResolveSpec(" 0.26.1 ")).toEqual({ + kind: "static", + details: { inRelease: "0.26.1" }, + }); + }); + + test("sentinel matching is case-insensitive (@Next, @NEXT, @Commit, ...)", () => { + // Users sometimes copy sentinels from docs with different casing, or + // a stack frame name may be auto-capitalized. All variants must + // normalize to the canonical sentinel, never silently fall through to + // inRelease. + expect(parseResolveSpec("@Next")).toEqual({ + kind: "static", + details: { inNextRelease: true }, + }); + expect(parseResolveSpec("@NEXT")).toEqual({ + kind: "static", + details: { inNextRelease: true }, + }); + expect(parseResolveSpec("@Commit")).toEqual({ + kind: "commit", + spec: { kind: "auto" }, + }); + expect(parseResolveSpec("@COMMIT")).toEqual({ + kind: "commit", + spec: { kind: "auto" }, + }); + }); + + test("explicit @commit prefix is case-insensitive but preserves payload case", () => { + expect(parseResolveSpec("@Commit:GetSentry/CLI@ABCDEF123")).toEqual({ + kind: "commit", + spec: { + kind: "explicit", + repository: "GetSentry/CLI", + commit: "ABCDEF123", + }, + }); + }); + + test("rejects unknown @-prefixed tokens instead of silent inRelease fallback", () => { + // Typo guard: @netx, @commmit, @release, etc. must error instead of + // quietly creating a release named "@netx". Releases cannot legally + // start with @ in any supported format, so this is always a typo. + expect(() => parseResolveSpec("@netx")).toThrow(ValidationError); + expect(() => parseResolveSpec("@commmit")).toThrow(ValidationError); + expect(() => parseResolveSpec("@release")).toThrow(ValidationError); + }); +}); + +describe("mergeIssues", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("rejects fewer than 2 group IDs", async () => { + await expect(mergeIssues("test-org", ["1"])).rejects.toThrow( + ValidationError + ); + await expect(mergeIssues("test-org", [])).rejects.toThrow(ValidationError); + }); + + test("sends PUT to org bulk-mutate endpoint with id= params and merge body", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody: unknown; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + capturedBody = await req.json(); + return new Response( + JSON.stringify({ + merge: { parent: "100", children: ["200", "300"] }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }); + + const result = await mergeIssues("test-org", ["100", "200", "300"]); + + expect(capturedMethod).toBe("PUT"); + expect(capturedUrl).toContain("/api/0/organizations/test-org/issues/"); + // All three IDs present as repeated query params + expect(capturedUrl).toContain("id=100"); + expect(capturedUrl).toContain("id=200"); + expect(capturedUrl).toContain("id=300"); + expect(capturedBody).toEqual({ merge: 1 }); + expect(result).toEqual({ parent: "100", children: ["200", "300"] }); + }); + + test("propagates API errors (e.g. 'Only error issues can be merged')", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(["Only error issues can be merged."]), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect( + mergeIssues("test-org", ["100", "200"]) + ).rejects.toBeInstanceOf(ApiError); + }); + + test("handles 204 No Content (no matching issues) gracefully", async () => { + // Sentry's bulk mutate returns 204 when IDs are out of scope or the + // matched set is empty — without a body. Previously this crashed with + // SyntaxError in response.json(). + globalThis.fetch = mockFetch( + async () => + new Response(null, { + status: 204, + }) + ); + + await expect( + mergeIssues("test-org", ["100", "200"]) + ).rejects.toBeInstanceOf(ApiError); + await expect(mergeIssues("test-org", ["100", "200"])).rejects.toThrow( + /no matching issues|out of scope/i + ); + }); +}); + +describe("mergeIssues: cross-origin legacy cache", () => { + useTestConfigDir("merge-legacy-cache-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + setAuthToken("test-token", 3600, "test-refresh"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("clears legacy /issues/{id}/ cache for every affected ID", async () => { + // Regression: the HTTP-layer invalidation hook only sees + // `organizations/{org}/issues/?id=...`, so it can't clear the + // per-ID legacy caches under the control-silo origin + // (`sentry.io/api/0/issues/{id}/`) that `getIssue()` reads. + // mergeIssues must do it manually after the merge response + // identifies the affected IDs. + const legacyUrl = (id: string) => `https://sentry.io/api/0/issues/${id}/`; + + for (const id of ["100", "200", "300"]) { + await storeCachedResponse( + "GET", + legacyUrl(id), + {}, + new Response(JSON.stringify({ id }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + expect(await getCachedResponse("GET", legacyUrl(id), {})).toBeDefined(); + } + + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ + merge: { parent: "100", children: ["200", "300"] }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + await mergeIssues("test-org", ["100", "200", "300"]); + + for (const id of ["100", "200", "300"]) { + expect(await getCachedResponse("GET", legacyUrl(id), {})).toBeUndefined(); + } + }); +}); diff --git a/packages/cli/test/lib/api/logs.test.ts b/packages/cli/test/lib/api/logs.test.ts new file mode 100644 index 000000000..c09351a3b --- /dev/null +++ b/packages/cli/test/lib/api/logs.test.ts @@ -0,0 +1,306 @@ +/** + * Tests for listLogs and getLogs — guards against non-object SDK responses. + * + * CLI-20C: self-hosted instances can return non-object data (plain text, HTML) + * from the /events/?dataset=logs endpoint when the logs dataset is unsupported + * or a reverse proxy intercepts the request. Previously this crashed with an + * unhandled schema validation error; now it throws a descriptive ApiError. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getLogs, listLogs } from "../../../src/lib/api/logs.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("logs-api-test-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("fake-token-for-test", 3600); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** + * Mock fetch to return a fixed JSON body for all requests. + * The SDK parses the response via response.json(), so wrapping in + * JSON.stringify ensures the SDK receives the raw value as `data`. + */ +function mockOk(body: unknown) { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); +} + +/** + * Mock fetch that captures the request URL of the last call and returns the + * given body. Lets tests assert how a project was scoped (query vs. param). + */ +function captureRequest(body: unknown): { url: () => string } { + let lastUrl = ""; + globalThis.fetch = mockFetch(async (input: RequestInfo | URL) => { + if (typeof input === "string") { + lastUrl = input; + } else if (input instanceof URL) { + lastUrl = input.toString(); + } else { + lastUrl = input.url; + } + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + return { url: () => lastUrl }; +} + +const EMPTY_LOGS = { data: [], meta: { fields: {} } }; + +describe("listLogs", () => { + test("returns logs when API returns a valid response", async () => { + mockOk({ + data: [ + { + "sentry.item_id": "log-001", + timestamp: "2025-01-30T14:32:15+00:00", + timestamp_precise: 1_770_060_419_044_800_300, + message: "Test log message", + severity: "info", + trace: "abc123def456abc123def456abc12345", + }, + ], + meta: { fields: {} }, + }); + + const logs = await listLogs("test-org", "test-project"); + expect(logs).toHaveLength(1); + expect(logs[0]["sentry.item_id"]).toBe("log-001"); + }); + + test("throws ApiError when API returns a string instead of object", async () => { + mockOk("Proxy error: upstream not found"); + + await expect(listLogs("test-org", "test-project")).rejects.toThrow( + ApiError + ); + + try { + await listLogs("test-org", "test-project"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain("unexpected response format"); + expect(apiError.detail).toContain("received string"); + } + }); + + test("throws ApiError when API returns null", async () => { + mockOk(null); + + await expect(listLogs("test-org", "test-project")).rejects.toThrow( + ApiError + ); + + try { + await listLogs("test-org", "test-project"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain("unexpected response format"); + expect(apiError.detail).toContain("received null"); + } + }); + + test("throws ApiError when response has wrong shape", async () => { + mockOk({ wrong: "shape" }); + + await expect(listLogs("test-org", "test-project")).rejects.toThrow( + ApiError + ); + + try { + await listLogs("test-org", "test-project"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).message).toContain( + "unexpected response format" + ); + } + }); + + test("scopes via the project param when projectId is provided (#1317)", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await listLogs("test-org", "my-project", { projectId: 4242 }); + + const url = captured.url(); + // Numeric ID goes to the `project` query param, not the search query. + expect(url).toContain("project=4242"); + expect(url).not.toContain("project%3Amy-project"); + }); + + test("falls back to project: query when no projectId is available", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await listLogs("test-org", "my-project"); + + const url = captured.url(); + // Without an ID, scope via search syntax (`project:my-project`). + expect(url).toContain("project%3Amy-project"); + expect(url).not.toMatch(/[?&]project=/); + }); + + test("treats an all-digits slug as a numeric project ID", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await listLogs("test-org", "12345"); + + const url = captured.url(); + expect(url).toContain("project=12345"); + expect(url).not.toContain("project%3A12345"); + }); + + test("caps per_page at API_MAX_PER_PAGE when limit exceeds the API max", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await listLogs("test-org", "my-project", { limit: 200 }); + + expect(captured.url()).toContain("per_page=100"); + }); + + test("sends the requested limit as per_page when below the API max", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await listLogs("test-org", "my-project", { limit: 50 }); + + expect(captured.url()).toContain("per_page=50"); + }); + + test("defaults per_page to API_MAX_PER_PAGE when no limit is given", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await listLogs("test-org", "my-project"); + + expect(captured.url()).toContain("per_page=100"); + }); + + test("auto-paginates to fill a limit above API_MAX_PER_PAGE", async () => { + const makeRows = (n: number, offset: number) => + Array.from({ length: n }, (_, i) => ({ + "sentry.item_id": `log-${offset + i}`, + timestamp: "2025-01-30T14:32:15+00:00", + timestamp_precise: 1_770_060_419_044_800_300, + message: `msg ${offset + i}`, + severity: "info", + trace: "abc123def456abc123def456abc12345", + })); + + const responses = [ + { + body: { data: makeRows(100, 0), meta: { fields: {} } }, + link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + { + body: { data: makeRows(50, 100), meta: { fields: {} } }, + link: `; rel="next"; results="false"; cursor=""`, + }, + ]; + const urls: string[] = []; + let call = 0; + globalThis.fetch = mockFetch(async (input: RequestInfo | URL) => { + urls.push(typeof input === "string" ? input : (input as Request).url); + const resp = responses[call]!; + call += 1; + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", Link: resp.link }, + }); + }); + + const logs = await listLogs("test-org", "my-project", { limit: 150 }); + + expect(logs).toHaveLength(150); + expect(urls).toHaveLength(2); + }); +}); + +describe("getLogs", () => { + test("returns logs when API returns a valid detailed response", async () => { + mockOk({ + data: [ + { + "sentry.item_id": "log-001", + timestamp: "2025-01-30T14:32:15+00:00", + timestamp_precise: 1_770_060_419_044_800_300, + message: "Test log message", + severity: "info", + trace: "abc123def456abc123def456abc12345", + project: "test-project", + environment: "production", + release: "1.0.0", + "sdk.name": "sentry.javascript.node", + "sdk.version": "8.0.0", + span_id: "abc123def456abc1", + "code.function": "main", + "code.file.path": "/app/index.ts", + "code.line.number": "42", + "sentry.otel.kind": "INTERNAL", + "sentry.otel.status_code": "OK", + "sentry.otel.instrumentation_scope.name": "my-app", + }, + ], + meta: { fields: {} }, + }); + + const logs = await getLogs("test-org", "test-project", ["log-001"]); + expect(logs).toHaveLength(1); + expect(logs[0]["sentry.item_id"]).toBe("log-001"); + }); + + test("throws ApiError when API returns a string instead of object", async () => { + mockOk("502 Bad Gateway"); + + await expect( + getLogs("test-org", "test-project", ["log-001"]) + ).rejects.toThrow(ApiError); + + try { + await getLogs("test-org", "test-project", ["log-001"]); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain("unexpected response format"); + expect(apiError.detail).toContain("received string"); + expect(apiError.detail).toContain("self-hosted"); + } + }); + + test("scopes via the project param when projectId is provided (#1317)", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await getLogs("test-org", "my-project", ["log-001"], { projectId: 4242 }); + + const url = captured.url(); + expect(url).toContain("project=4242"); + expect(url).not.toContain("project%3Amy-project"); + }); + + test("falls back to project: query when no projectId is available", async () => { + const captured = captureRequest(EMPTY_LOGS); + + await getLogs("test-org", "my-project", ["log-001"]); + + const url = captured.url(); + expect(url).toContain("project%3Amy-project"); + expect(url).not.toMatch(/[?&]project=/); + }); +}); diff --git a/packages/cli/test/lib/api/monitors.test.ts b/packages/cli/test/lib/api/monitors.test.ts new file mode 100644 index 000000000..97a3098fb --- /dev/null +++ b/packages/cli/test/lib/api/monitors.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for cron monitor types. + * + * Validates that `SentryMonitorSchema` accepts a representative + * `/organizations/{org}/monitors/` API response, including crontab and + * interval schedule shapes and nullable threshold fields. + */ + +import { safeParse } from "valibot"; +import { describe, expect, test } from "vitest"; +import { SentryMonitorSchema } from "../../../src/types/sentry.js"; + +const crontabMonitor = { + id: "12345", + slug: "nightly-job", + name: "Nightly Job", + status: "active", + isMuted: false, + isUpserting: false, + config: { + schedule_type: "crontab", + schedule: "0 0 * * *", + checkin_margin: null, + max_runtime: 30, + timezone: "UTC", + failure_issue_threshold: null, + recovery_threshold: null, + alert_rule_id: null, + }, + dateCreated: "2024-01-01T00:00:00Z", + project: { + id: "1", + slug: "my-project", + name: "My Project", + platform: "python", + }, +}; + +const intervalMonitor = { + id: "67890", + slug: "hourly-job", + name: "Hourly Job", + status: "disabled", + config: { + schedule_type: "interval", + schedule: [1, "hour"], + checkin_margin: 5, + max_runtime: null, + timezone: null, + }, +}; + +describe("SentryMonitorSchema", () => { + test("accepts a crontab monitor response", () => { + const result = safeParse(SentryMonitorSchema, crontabMonitor); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.slug).toBe("nightly-job"); + expect(result.output.config?.schedule).toBe("0 0 * * *"); + // unknown fields are preserved via passthrough + expect((result.output as Record).isUpserting).toBe( + false + ); + } + }); + + test("accepts an interval monitor with tuple schedule", () => { + const result = safeParse(SentryMonitorSchema, intervalMonitor); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.config?.schedule).toEqual([1, "hour"]); + } + }); + + test("rejects a monitor missing required core identifiers", () => { + const result = safeParse(SentryMonitorSchema, { + slug: "no-id", + name: "No ID", + status: "active", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/api/organizations.test.ts b/packages/cli/test/lib/api/organizations.test.ts new file mode 100644 index 000000000..98d5b5a2b --- /dev/null +++ b/packages/cli/test/lib/api/organizations.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for listOrganizationsPage — guards against non-array SDK responses. + * + * CLI-1CQ: self-hosted instances can return non-array data from + * GET /api/0/organizations/ when a reverse proxy or WAF interferes. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { listOrganizationsPage } from "../../../src/lib/api/organizations.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("org-api-test-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("fake-token-for-test", 3600, undefined, { + host: "https://sentry.example.com", + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("listOrganizationsPage", () => { + test("returns organizations when API returns a valid array", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + const { data: orgs } = await listOrganizationsPage( + "https://sentry.example.com" + ); + expect(orgs).toHaveLength(1); + expect(orgs[0].slug).toBe("test-org"); + }); + + test("throws ApiError when API returns an empty object instead of array", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect( + listOrganizationsPage("https://sentry.example.com") + ).rejects.toThrow(ApiError); + + try { + await listOrganizationsPage("https://sentry.example.com"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain("unexpected response format"); + expect(apiError.detail).toContain("sentry.example.com"); + } + }); + + test("throws ApiError when API returns empty body", async () => { + globalThis.fetch = mockFetch( + async () => + new Response("", { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect( + listOrganizationsPage("https://sentry.example.com") + ).rejects.toThrow(ApiError); + }); +}); diff --git a/packages/cli/test/lib/api/preprod-artifacts.test.ts b/packages/cli/test/lib/api/preprod-artifacts.test.ts new file mode 100644 index 000000000..87ff1bd58 --- /dev/null +++ b/packages/cli/test/lib/api/preprod-artifacts.test.ts @@ -0,0 +1,534 @@ +/** + * Tests for the preprod-artifacts (mobile build) API. + * + * Pure URL helpers are tested directly. `getBuildInstallDetails` mocks the + * region request layer; `downloadBuildArtifact` mocks `customFetch` and the + * auth-token getter so the streaming write and — critically — the + * same-origin-only auth attachment can be verified without a network. + */ + +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse, safeParse } from "valibot"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ApiError, EXIT, ValidationError } from "../../../src/lib/errors.js"; + +const { + customFetchMock, + apiRequestToRegionMock, + apiRequestToRegionNoContentMock, + getAuthTokenMock, + uploadMissingChunksMock, +} = vi.hoisted(() => ({ + customFetchMock: vi.fn(), + apiRequestToRegionMock: vi.fn(), + apiRequestToRegionNoContentMock: vi.fn(() => Promise.resolve()), + getAuthTokenMock: vi.fn<() => string | undefined>(() => "secret-token"), + uploadMissingChunksMock: vi.fn(() => Promise.resolve()), +})); + +vi.mock("../../../src/lib/region.js", () => ({ + resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), +})); +vi.mock("../../../src/lib/api/infrastructure.js", () => ({ + apiRequestToRegion: apiRequestToRegionMock, + apiRequestToRegionNoContent: apiRequestToRegionNoContentMock, +})); +vi.mock("../../../src/lib/custom-ca.js", () => ({ + customFetch: customFetchMock, +})); +vi.mock("../../../src/lib/db/auth.js", () => ({ + getAuthToken: getAuthTokenMock, +})); +vi.mock("../../../src/lib/api/chunk-upload.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/api/chunk-upload.js") + >(); + return { + ...actual, + getChunkUploadOptions: vi.fn(async () => ({ + url: "https://us.sentry.io/api/0/chunk-upload/", + chunkSize: 8192, + chunksPerRequest: 64, + maxRequestSize: 1_048_576, + hashAlgorithm: "sha1", + concurrency: 8, + compression: ["gzip"], + })), + uploadMissingChunks: uploadMissingChunksMock, + }; +}); + +import { + buildFormatFromUrl, + createPreprodSnapshot, + downloadBuildArtifact, + fetchSnapshotsUploadOptions, + getBuildInstallDetails, + getLatestBaseSnapshot, + LatestBaseSnapshotSchema, + openSnapshotArchive, + toBinaryDownloadUrl, + uploadBuild, + waitForSnapshotArchive, +} from "../../../src/lib/api/preprod-artifacts.js"; + +describe("toBinaryDownloadUrl", () => { + test("rewrites a plist manifest URL to fetch the ipa binary", () => { + expect( + toBinaryDownloadUrl("https://us.sentry.io/dl/?response_format=plist") + ).toBe("https://us.sentry.io/dl/?response_format=ipa"); + }); + + test("leaves non-plist URLs unchanged", () => { + expect( + toBinaryDownloadUrl("https://us.sentry.io/dl/?response_format=apk") + ).toBe("https://us.sentry.io/dl/?response_format=apk"); + }); +}); + +describe("buildFormatFromUrl", () => { + test("detects ipa", () => { + expect(buildFormatFromUrl("https://x/?response_format=ipa")).toBe("ipa"); + }); + test("detects apk", () => { + expect(buildFormatFromUrl("https://x/?response_format=apk")).toBe("apk"); + }); + test("throws on an unrecognized format", () => { + expect(() => buildFormatFromUrl("https://x/?response_format=zip")).toThrow( + ValidationError + ); + }); +}); + +describe("getBuildInstallDetails", () => { + beforeEach(() => { + apiRequestToRegionMock.mockReset(); + }); + + test("hits the install-details endpoint and returns parsed data", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { isInstallable: true, installUrl: "https://u/" }, + headers: new Headers(), + }); + + const result = await getBuildInstallDetails("my-org", "build 1"); + + expect(result).toEqual({ isInstallable: true, installUrl: "https://u/" }); + const lastCall = apiRequestToRegionMock.mock.calls.at(-1); + expect(lastCall?.[0]).toBe("https://us.sentry.io"); + // Build id is URL-encoded into the path. + expect(lastCall?.[1]).toBe( + "organizations/my-org/preprodartifacts/build%201/install-details/" + ); + expect(lastCall?.[2]?.schema).toBeDefined(); + }); +}); + +describe("downloadBuildArtifact", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "preprod-dl-")); + customFetchMock.mockReset(); + getAuthTokenMock.mockReturnValue("secret-token"); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + test("streams the body to disk and attaches auth for a same-origin URL", async () => { + customFetchMock.mockResolvedValue( + new Response("FAKE-BINARY", { status: 200 }) + ); + const dest = join(tmpDir, "out.ipa"); + + await downloadBuildArtifact( + "https://us.sentry.io", + "https://us.sentry.io/dl/?response_format=ipa", + dest + ); + + expect(await readFile(dest, "utf8")).toBe("FAKE-BINARY"); + const init = customFetchMock.mock.calls.at(-1)?.[1] as { + headers: Record; + }; + expect(init.headers.Authorization).toBe("Bearer secret-token"); + }); + + test("rejects a malformed bearer before downloading a build or snapshot", async () => { + getAuthTokenMock.mockReturnValue("synthetic-prefix\nsynthetic-secret-tail"); + for (const download of [ + () => + downloadBuildArtifact( + "https://us.sentry.io", + "https://us.sentry.io/dl/?response_format=ipa", + join(tmpDir, "out.ipa") + ), + () => openSnapshotArchive("my-org", "snap-1"), + ]) { + const error = await download().catch((caught: unknown) => caught); + expect(error).toMatchObject({ + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(String(error)).not.toContain("synthetic-secret-tail"); + } + expect(customFetchMock).not.toHaveBeenCalled(); + }); + + test("does NOT attach the auth token to a cross-origin (signed) URL", async () => { + customFetchMock.mockResolvedValue(new Response("X", { status: 200 })); + + await downloadBuildArtifact( + "https://us.sentry.io", + "https://cdn.example.com/blob?response_format=ipa", + join(tmpDir, "o2.ipa") + ); + + const init = customFetchMock.mock.calls.at(-1)?.[1] as { + headers: Record; + }; + expect(init.headers.Authorization).toBeUndefined(); + }); + + test("omits auth when no token is available", async () => { + getAuthTokenMock.mockReturnValue(undefined); + customFetchMock.mockResolvedValue(new Response("X", { status: 200 })); + + await downloadBuildArtifact( + "https://us.sentry.io", + "https://us.sentry.io/dl/?response_format=ipa", + join(tmpDir, "o3.ipa") + ); + + const init = customFetchMock.mock.calls.at(-1)?.[1] as { + headers: Record; + }; + expect(init.headers.Authorization).toBeUndefined(); + }); + + test("throws ApiError on a non-2xx response", async () => { + customFetchMock.mockResolvedValue( + new Response("nope", { status: 404, statusText: "Not Found" }) + ); + + await expect( + downloadBuildArtifact( + "https://us.sentry.io", + "https://us.sentry.io/dl/?response_format=ipa", + join(tmpDir, "o4.ipa") + ) + ).rejects.toThrow(ApiError); + }); +}); + +describe("uploadBuild", () => { + let contentPath: string; + let buildTmpDir: string; + + beforeEach(async () => { + apiRequestToRegionMock.mockReset(); + uploadMissingChunksMock.mockClear(); + buildTmpDir = await mkdtemp(join(tmpdir(), "preprod-build-")); + contentPath = join(buildTmpDir, "normalized.zip"); + await writeFile(contentPath, Buffer.from("normalized-build-zip-bytes")); + }); + + afterEach(async () => { + await rm(buildTmpDir, { recursive: true, force: true }); + }); + + test("returns the artifactUrl and folds metadata into the assemble body", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { state: "ok", artifactUrl: "https://sentry.io/artifact/1" }, + headers: new Headers(), + }); + + const url = await uploadBuild({ + org: "my-org", + project: "my-project", + contentPath, + metadata: { + buildConfiguration: "Release", + releaseNotes: "notes", + installGroups: ["qa", "beta"], + }, + }); + + expect(url).toBe("https://sentry.io/artifact/1"); + const call = apiRequestToRegionMock.mock.calls.at(-1); + expect(call?.[1]).toBe( + "projects/my-org/my-project/files/preprodartifacts/assemble/" + ); + const body = call?.[2]?.body as Record; + expect(body.checksum).toEqual(expect.any(String)); + expect(Array.isArray(body.chunks)).toBe(true); + expect(body.build_configuration).toBe("Release"); + expect(body.release_notes).toBe("notes"); + expect(body.install_groups).toEqual(["qa", "beta"]); + // No missing chunks on the first (and only) response. + expect(uploadMissingChunksMock).not.toHaveBeenCalled(); + }); + + test("uploads missing chunks then returns the artifactUrl", async () => { + // Real timers: uploadBuild now hashes the wrapper via async file I/O before + // the first assemble POST, which does not interleave cleanly with fake + // timers. The single real inter-poll sleep (~1s) is tolerable. + apiRequestToRegionMock + .mockResolvedValueOnce({ + data: { state: "created", missingChunks: ["deadbeef"] }, + headers: new Headers(), + }) + .mockResolvedValueOnce({ + data: { state: "ok", artifactUrl: "https://sentry.io/artifact/2" }, + headers: new Headers(), + }); + + await expect( + uploadBuild({ + org: "my-org", + project: "my-project", + contentPath, + metadata: {}, + }) + ).resolves.toBe("https://sentry.io/artifact/2"); + expect(uploadMissingChunksMock).toHaveBeenCalledTimes(1); + }); + + test("flattens vcs fields into the top level of the assemble body", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { state: "ok", artifactUrl: "https://sentry.io/artifact/9" }, + headers: new Headers(), + }); + + await uploadBuild({ + org: "my-org", + project: "my-project", + contentPath, + metadata: { vcs: { head_sha: "abc", provider: "github", pr_number: 3 } }, + }); + + const body = apiRequestToRegionMock.mock.calls.at(-1)?.[2]?.body as Record< + string, + unknown + >; + // Flattened alongside checksum/chunks — not nested under a "vcs" key. + expect(body.head_sha).toBe("abc"); + expect(body.provider).toBe("github"); + expect(body.pr_number).toBe(3); + expect(body).not.toHaveProperty("vcs"); + }); + + test("omits optional metadata fields when unset", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { state: "ok", artifactUrl: "https://sentry.io/artifact/3" }, + headers: new Headers(), + }); + + await uploadBuild({ + org: "my-org", + project: "my-project", + contentPath, + metadata: {}, + }); + + const body = apiRequestToRegionMock.mock.calls.at(-1)?.[2]?.body as Record< + string, + unknown + >; + expect(Object.keys(body).sort()).toEqual(["checksum", "chunks"]); + }); + + test("throws ApiError when assembly reports an error", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { state: "error", detail: "bad build" }, + headers: new Headers(), + }); + + await expect( + uploadBuild({ + org: "my-org", + project: "my-project", + contentPath, + metadata: {}, + }) + ).rejects.toThrow(ApiError); + }); + + test("fails fast when finished (ok) without an artifact URL", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { state: "ok" }, + headers: new Headers(), + }); + + await expect( + uploadBuild({ + org: "my-org", + project: "my-project", + contentPath, + metadata: {}, + }) + ).rejects.toThrow(/no artifact URL/i); + }); +}); + +describe("snapshots", () => { + beforeEach(() => { + apiRequestToRegionMock.mockReset(); + apiRequestToRegionNoContentMock.mockClear(); + customFetchMock.mockReset(); + }); + + test("LatestBaseSnapshotSchema expects snake_case (server contract)", () => { + // The server returns snake_case; the schema must accept it and reject + // camelCase, guarding against the C1 regression. + expect( + safeParse(LatestBaseSnapshotSchema, { + head_artifact_id: "art-1", + image_count: 5, + }).success + ).toBe(true); + expect( + safeParse(LatestBaseSnapshotSchema, { + headArtifactId: "art-1", + imageCount: 5, + }).success + ).toBe(false); + }); + + test.each([ + { usecase: "preprod_snapshots", expectedUsecase: "preprod_snapshots" }, + { usecase: "preprod", expectedUsecase: "preprod" }, + { usecase: undefined, expectedUsecase: "preprod" }, + ])("fetchSnapshotsUploadOptions negotiates auto and parses $usecase as $expectedUsecase", async ({ + usecase, + expectedUsecase, + }) => { + apiRequestToRegionMock.mockImplementation( + async (_region, _path, { schema }) => ({ + data: parse(schema, { + objectstore: { + url: "https://os.example.com", + usecase, + scopes: [["org", "1"]], + authToken: "tok", + expirationPolicy: "ttl:30d", + }, + }), + }) + ); + const opts = await fetchSnapshotsUploadOptions("my-org", "my-project"); + expect(opts.objectstore.url).toBe("https://os.example.com"); + expect(opts.objectstore.usecase).toBe(expectedUsecase); + const [region, endpoint, options] = + apiRequestToRegionMock.mock.calls.at(-1) ?? []; + expect(region).toBe("https://us.sentry.io"); + expect(endpoint).toBe( + "projects/my-org/my-project/preprodartifacts/snapshots/upload-options/" + ); + expect(options.params).toEqual({ usecase: "auto" }); + }); + + test("createPreprodSnapshot POSTs the manifest and parses the response", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { artifactId: "snap-1", imageCount: 3, snapshotUrl: "https://s" }, + }); + const manifest = { app_id: "app", images: {} }; + const res = await createPreprodSnapshot("my-org", "my-project", manifest); + expect(res.artifactId).toBe("snap-1"); + expect(res.imageCount).toBe(3); + const [, endpoint, options] = + apiRequestToRegionMock.mock.calls.at(-1) ?? []; + expect(endpoint).toBe( + "projects/my-org/my-project/preprodartifacts/snapshots/" + ); + expect(options.method).toBe("POST"); + expect(options.body).toBe(manifest); + }); + + test("getLatestBaseSnapshot maps the response and forwards params", async () => { + // apiRequestToRegion returns schema-validated snake_case data. + apiRequestToRegionMock.mockResolvedValue({ + data: { head_artifact_id: "art-1", image_count: 5 }, + headers: new Headers(), + }); + + await expect( + getLatestBaseSnapshot("my-org", "my-app", { + branch: "main", + project: "proj-1", + }) + ).resolves.toEqual({ headArtifactId: "art-1", imageCount: 5 }); + // app_id + optional branch/project go through as query params. + const params = apiRequestToRegionMock.mock.calls.at(-1)?.[2]?.params; + expect(params).toEqual({ + app_id: "my-app", + branch: "main", + project: "proj-1", + }); + }); + + test("getLatestBaseSnapshot returns null on 404", async () => { + apiRequestToRegionMock.mockRejectedValue( + new ApiError("not found", 404, "", "endpoint") + ); + await expect( + getLatestBaseSnapshot("my-org", "missing") + ).resolves.toBeNull(); + }); + + test("waitForSnapshotArchive triggers a build then resolves when ready", async () => { + apiRequestToRegionMock + .mockResolvedValueOnce({ data: { ready: false }, headers: new Headers() }) + .mockResolvedValueOnce({ data: { ready: true }, headers: new Headers() }); + const onBuild = vi.fn(); + + await waitForSnapshotArchive("my-org", "snap-1", onBuild); + + expect(apiRequestToRegionNoContentMock).toHaveBeenCalledTimes(1); + expect(onBuild).toHaveBeenCalledTimes(1); + }); + + test("waitForSnapshotArchive skips the build when already ready", async () => { + apiRequestToRegionMock.mockResolvedValue({ + data: { ready: true }, + headers: new Headers(), + }); + const onBuild = vi.fn(); + + await waitForSnapshotArchive("my-org", "snap-1", onBuild); + + expect(apiRequestToRegionNoContentMock).not.toHaveBeenCalled(); + expect(onBuild).not.toHaveBeenCalled(); + }); + + test("openSnapshotArchive returns the response for streaming", async () => { + const response = { ok: true, status: 200, body: {} }; + customFetchMock.mockResolvedValue(response); + + await expect(openSnapshotArchive("my-org", "snap-1")).resolves.toBe( + response + ); + // Auth token attached; URL targets the region archive endpoint. + const [url, init] = customFetchMock.mock.calls.at(-1) ?? []; + expect(url).toContain( + "/preprodartifacts/snapshots/snap-1/archive/?download" + ); + expect(init?.headers?.Authorization).toBe("Bearer secret-token"); + }); + + test("openSnapshotArchive throws on a non-2xx response", async () => { + customFetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Server Error", + }); + await expect(openSnapshotArchive("my-org", "snap-1")).rejects.toThrow( + ApiError + ); + }); +}); diff --git a/packages/cli/test/lib/api/proguard.test.ts b/packages/cli/test/lib/api/proguard.test.ts new file mode 100644 index 000000000..a945428f8 --- /dev/null +++ b/packages/cli/test/lib/api/proguard.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for ProGuard upload infrastructure. + * + * Tests the raw-byte chunking used by the DIF upload protocol. + * ProGuard mappings are chunked as raw bytes (no ZIP wrapping). + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { hashBuffer } from "../../../src/lib/api/chunk-upload.js"; +import { apiRequestToRegion } from "../../../src/lib/api/infrastructure.js"; +import { uploadProguardMappings } from "../../../src/lib/api/proguard.js"; + +vi.mock("../../../src/lib/api/infrastructure.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/api/infrastructure.js") + >(); + return { ...actual, apiRequestToRegion: vi.fn() }; +}); +vi.mock("../../../src/lib/region.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveOrgRegion: vi.fn(async () => "https://us.sentry.io"), + }; +}); + +const apiMock = vi.mocked(apiRequestToRegion); + +describe("hashBuffer", () => { + test("single chunk for small content", () => { + const content = Buffer.from("void\n"); + const { chunks, overallChecksum } = hashBuffer(content, 8192); + + expect(chunks).toHaveLength(1); + expect(chunks[0]?.offset).toBe(0); + expect(chunks[0]?.size).toBe(5); + expect(chunks[0]?.sha1).toMatch(/^[0-9a-f]{40}$/); + expect(overallChecksum).toMatch(/^[0-9a-f]{40}$/); + // Single chunk: chunk sha1 === overall checksum + expect(chunks[0]?.sha1).toBe(overallChecksum); + }); + + test("splits into multiple chunks when content exceeds chunkSize", () => { + const content = Buffer.alloc(100, "a"); + const { chunks, overallChecksum } = hashBuffer(content, 30); + + // 100 bytes / 30 per chunk = 4 chunks (30 + 30 + 30 + 10) + expect(chunks).toHaveLength(4); + expect(chunks[0]?.offset).toBe(0); + expect(chunks[0]?.size).toBe(30); + expect(chunks[1]?.offset).toBe(30); + expect(chunks[1]?.size).toBe(30); + expect(chunks[2]?.offset).toBe(60); + expect(chunks[2]?.size).toBe(30); + expect(chunks[3]?.offset).toBe(90); + expect(chunks[3]?.size).toBe(10); + expect(overallChecksum).toMatch(/^[0-9a-f]{40}$/); + }); + + test("overall checksum is deterministic", () => { + const content = Buffer.from("com.example.MyClass -> a:\n"); + const result1 = hashBuffer(content, 8192); + const result2 = hashBuffer(content, 8192); + + expect(result1.overallChecksum).toBe(result2.overallChecksum); + expect(result1.chunks).toHaveLength(result2.chunks.length); + }); + + test("different content yields different checksums", () => { + const content1 = Buffer.from("mapping one\n"); + const content2 = Buffer.from("mapping two\n"); + const result1 = hashBuffer(content1, 8192); + const result2 = hashBuffer(content2, 8192); + + expect(result1.overallChecksum).not.toBe(result2.overallChecksum); + }); + + test("chunk SHA-1 checksums are valid hex strings", () => { + const content = Buffer.alloc(200, "x"); + const { chunks } = hashBuffer(content, 50); + + for (const chunk of chunks) { + expect(chunk.sha1).toMatch(/^[0-9a-f]{40}$/); + } + }); + + test("empty buffer yields no chunks", () => { + const content = Buffer.alloc(0); + const { chunks, overallChecksum } = hashBuffer(content, 8192); + + expect(chunks).toHaveLength(0); + expect(overallChecksum).toMatch(/^[0-9a-f]{40}$/); + }); +}); + +describe("uploadProguardMappings", () => { + const CHUNK_UPLOAD_OPTIONS = { + url: "https://us.sentry.io/api/0/chunk-upload/", + chunkSize: 8192, + chunksPerRequest: 64, + maxRequestSize: 1_048_576, + hashAlgorithm: "sha1", + concurrency: 8, + compression: ["gzip"], + }; + + beforeEach(() => { + apiMock.mockReset(); + }); + + test("assemble request names each mapping with a leading-slash /proguard/ path", async () => { + let assembleBody: Record = {}; + + apiMock.mockImplementation( + async (_regionUrl, endpoint, options?: { body?: unknown }) => { + if (endpoint.includes("chunk-upload/")) { + return { data: CHUNK_UPLOAD_OPTIONS } as never; + } + // DIF assemble endpoint: report every checksum as already "ok" so the + // upload short-circuits without needing to mock chunk uploads too. + assembleBody = options?.body as typeof assembleBody; + const response: Record = {}; + for (const checksum of Object.keys(assembleBody)) { + response[checksum] = { state: "ok" }; + } + return { data: response } as never; + } + ); + + await uploadProguardMappings({ + org: "test-org", + project: "test-project", + mappings: [ + { + path: "mapping.txt", + uuid: "5db7294d-87fc-5726-a5c0-4a90679657a5", + content: Buffer.from("void\n"), + }, + ], + }); + + const [entry] = Object.values(assembleBody); + // Leading slash required for the server to recognize the DIF as proguard. + expect(entry?.name).toBe( + "/proguard/5db7294d-87fc-5726-a5c0-4a90679657a5.txt" + ); + }); +}); diff --git a/packages/cli/test/lib/api/projects.test.ts b/packages/cli/test/lib/api/projects.test.ts new file mode 100644 index 000000000..9b7f12ffa --- /dev/null +++ b/packages/cli/test/lib/api/projects.test.ts @@ -0,0 +1,350 @@ +/** + * Tests for createProjectWithDsn — verifies project and DSN caches are + * seeded after project creation. + * + * Issue #745: After `sentry project create` or `sentry init`, resolved + * project info wasn't saved to the local DB, forcing the next command to + * re-scan files and hit the API. + */ + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + createProjectWithDsn, + listProjects, +} from "../../../src/lib/api/projects.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { + getCachedProjectByDsnKey, + getCachedProjectBySlug, +} from "../../../src/lib/db/project-cache.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import type { SentryProject } from "../../../src/types/index.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +const projectCacheMocks = vi.hoisted(() => ({ + cacheProjectsForOrg: vi.fn(), + setCachedProjectByDsnKey: vi.fn(), + restoreActual: () => { + /* set in vi.mock factory */ + }, +})); + +vi.mock("../../../src/lib/db/project-cache.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/db/project-cache.js") + >(); + projectCacheMocks.cacheProjectsForOrg.mockImplementation( + actual.cacheProjectsForOrg + ); + projectCacheMocks.setCachedProjectByDsnKey.mockImplementation( + actual.setCachedProjectByDsnKey + ); + projectCacheMocks.restoreActual = () => { + projectCacheMocks.cacheProjectsForOrg.mockImplementation( + actual.cacheProjectsForOrg + ); + projectCacheMocks.setCachedProjectByDsnKey.mockImplementation( + actual.setCachedProjectByDsnKey + ); + }; + return { + ...actual, + cacheProjectsForOrg: ( + ...args: Parameters + ) => projectCacheMocks.cacheProjectsForOrg(...args), + setCachedProjectByDsnKey: ( + ...args: Parameters + ) => projectCacheMocks.setCachedProjectByDsnKey(...args), + }; +}); + +useTestConfigDir("api-projects-test-"); + +const SAMPLE_PROJECT: SentryProject = { + id: "42", + slug: "my-new-project", + name: "My New Project", + organization: { id: "1", slug: "test-org", name: "Test Org" }, +}; + +const SAMPLE_DSN = "https://abc123publickey@o1.ingest.us.sentry.io/42"; + +const SAMPLE_KEY = { + id: "key-1", + isActive: true, + dsn: { + public: SAMPLE_DSN, + secret: "https://abc123publickey:secret@o1.ingest.us.sentry.io/42", + csp: "https://o1.ingest.us.sentry.io/api/42/csp-report/?sentry_key=abc123publickey", + security: "", + minidump: "", + unreal: "", + cdn: "", + }, + label: "Default", + name: "Default", + rateLimit: null, + secret: "secret", + public: "abc123publickey", + projectId: 42, + dateCreated: "2025-01-01T00:00:00Z", +}; + +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", "https://us.sentry.io"); + projectCacheMocks.restoreActual(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + projectCacheMocks.restoreActual(); +}); + +function spyCaptureException(): { + captureSpy: ReturnType; + restore: () => void; +} { + const captureSpy = vi.spyOn(Sentry, "captureException"); + const withScopeSpy = vi.spyOn(Sentry, "withScope"); + withScopeSpy.mockImplementation((fn: (scope: unknown) => void) => { + fn({ + setTag() { + /* noop */ + }, + setContext() { + /* noop */ + }, + setFingerprint() { + /* noop */ + }, + }); + }); + return { + captureSpy, + restore() { + captureSpy.mockRestore(); + withScopeSpy.mockRestore(); + }, + }; +} + +/** + * Build a mock fetch that responds to the project-create POST and then + * the client-keys GET that `tryGetPrimaryDsn` fires. + */ +function mockCreateAndKeysFlow(options?: { + /** Return empty keys (no DSN available) */ + emptyKeys?: boolean; +}): typeof fetch { + let callIndex = 0; + return mockFetch(async (input, init) => { + const req = new Request(input!, init); + callIndex += 1; + + // First call: POST to create the project + if (callIndex === 1) { + return new Response(JSON.stringify(SAMPLE_PROJECT), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + + // Second call: GET to list project client keys + if (callIndex === 2) { + const keys = options?.emptyKeys ? [] : [SAMPLE_KEY]; + return new Response(JSON.stringify(keys), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + throw new Error( + `Unexpected fetch call #${callIndex}: ${req.method} ${req.url}` + ); + }); +} + +describe("createProjectWithDsn", () => { + test("seeds project cache after creation", async () => { + globalThis.fetch = mockCreateAndKeysFlow(); + + const result = await createProjectWithDsn("test-org", "test-team", { + name: "My New Project", + }); + + expect(result.project.slug).toBe("my-new-project"); + expect(result.dsn).toBe(SAMPLE_DSN); + + // Verify project cache was populated + const cached = getCachedProjectBySlug("test-org", "my-new-project"); + expect(cached).toBeDefined(); + expect(cached!.orgSlug).toBe("test-org"); + expect(cached!.projectSlug).toBe("my-new-project"); + expect(cached!.projectName).toBe("My New Project"); + expect(cached!.projectId).toBe("42"); + }); + + test("seeds DSN-based project cache when DSN is available", async () => { + globalThis.fetch = mockCreateAndKeysFlow(); + + await createProjectWithDsn("test-org", "test-team", { + name: "My New Project", + }); + + // The public key from the DSN should be cached + const cached = getCachedProjectByDsnKey("abc123publickey"); + expect(cached).toBeDefined(); + expect(cached!.orgSlug).toBe("test-org"); + expect(cached!.projectSlug).toBe("my-new-project"); + expect(cached!.projectName).toBe("My New Project"); + expect(cached!.projectId).toBe("42"); + }); + + test("seeds project cache but not DSN cache when no DSN returned", async () => { + globalThis.fetch = mockCreateAndKeysFlow({ emptyKeys: true }); + + const result = await createProjectWithDsn("test-org", "test-team", { + name: "My New Project", + }); + + expect(result.dsn).toBeNull(); + + // Project cache should still be populated + const cached = getCachedProjectBySlug("test-org", "my-new-project"); + expect(cached).toBeDefined(); + expect(cached!.projectSlug).toBe("my-new-project"); + + // DSN cache should NOT be populated (no DSN to extract key from) + const dsnCached = getCachedProjectByDsnKey("abc123publickey"); + expect(dsnCached).toBeUndefined(); + }); + + test("uses organization name from response when available", async () => { + globalThis.fetch = mockCreateAndKeysFlow(); + + await createProjectWithDsn("test-org", "test-team", { + name: "My New Project", + }); + + const cached = getCachedProjectBySlug("test-org", "my-new-project"); + expect(cached).toBeDefined(); + // The org name comes from SAMPLE_PROJECT.organization.name + expect(cached!.orgName).toBe("Test Org"); + }); + + test("falls back to slug for org name when organization.name is missing", async () => { + const projectWithoutOrgName: SentryProject = { + ...SAMPLE_PROJECT, + organization: { id: "1", slug: "test-org" }, + }; + + let callIndex = 0; + globalThis.fetch = mockFetch(async (input, init) => { + callIndex += 1; + + if (callIndex === 1) { + return new Response(JSON.stringify(projectWithoutOrgName), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + + const req = new Request(input!, init); + if (callIndex === 2) { + return new Response(JSON.stringify([SAMPLE_KEY]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + throw new Error( + `Unexpected fetch call #${callIndex}: ${req.method} ${req.url}` + ); + }); + + await createProjectWithDsn("test-org", "test-team", { + name: "My New Project", + }); + + const cached = getCachedProjectBySlug("test-org", "my-new-project"); + expect(cached).toBeDefined(); + // Should fall back to orgSlug when name is missing + expect(cached!.orgName).toBe("test-org"); + }); + + test("reports project-cache write failures to Sentry without failing creation", async () => { + globalThis.fetch = mockCreateAndKeysFlow(); + projectCacheMocks.cacheProjectsForOrg.mockImplementation(() => { + throw new Error("disk full"); + }); + const { captureSpy, restore } = spyCaptureException(); + try { + const result = await createProjectWithDsn("test-org", "test-team", { + name: "My New Project", + }); + expect(result.project.id).toBe("42"); + expect(result.dsn).toBe(SAMPLE_DSN); + expect(captureSpy).toHaveBeenCalledTimes(1); + expect(captureSpy.mock.calls[0]?.[0]).toMatchObject({ + message: "disk full", + }); + } finally { + restore(); + } + }); + + test("reports DSN-key cache write failures to Sentry without failing creation", async () => { + globalThis.fetch = mockCreateAndKeysFlow(); + projectCacheMocks.setCachedProjectByDsnKey.mockImplementation(() => { + throw new Error("dsn cache locked"); + }); + const { captureSpy, restore } = spyCaptureException(); + try { + const result = await createProjectWithDsn("test-org", "test-team", { + name: "My New Project", + }); + expect(result.project.slug).toBe("my-new-project"); + expect(result.dsn).toBe(SAMPLE_DSN); + expect(captureSpy).toHaveBeenCalledTimes(1); + expect(captureSpy.mock.calls[0]?.[0]).toMatchObject({ + message: "dsn cache locked", + }); + } finally { + restore(); + } + }); +}); + +describe("listProjects cache seeding", () => { + test("reports cache write failures to Sentry without failing the list", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify([SAMPLE_PROJECT]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + projectCacheMocks.cacheProjectsForOrg.mockImplementation(() => { + throw new Error("disk full"); + }); + const { captureSpy, restore } = spyCaptureException(); + try { + const result = await listProjects("test-org"); + expect(result).toHaveLength(1); + expect(result[0]?.slug).toBe("my-new-project"); + expect(captureSpy).toHaveBeenCalledTimes(1); + expect(captureSpy.mock.calls[0]?.[0]).toMatchObject({ + message: "disk full", + }); + } finally { + restore(); + } + }); +}); diff --git a/packages/cli/test/lib/api/releases.test.ts b/packages/cli/test/lib/api/releases.test.ts new file mode 100644 index 000000000..f857aba7a --- /dev/null +++ b/packages/cli/test/lib/api/releases.test.ts @@ -0,0 +1,533 @@ +/** + * Release API Function Tests + * + * Tests the real API function bodies by mocking globalThis.fetch. + * This ensures the functions correctly call the SDK, pass parameters, + * and transform responses. + * + * The `setCommitsAuto` tests additionally use `vi.mock()` to stub the + * git helpers (`getRepositoryName`, etc.) because `setCommitsAuto` reads + * them at runtime. `getRepositoryName` is a controllable `vi.fn()` so + * individual tests can change its return value (e.g. null for the + * "no git remote" path). + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Controllable git-helper mocks. `setCommitsAuto` calls these at runtime to +// build the `refs` array sent to Sentry. +const { mockGetRepositoryName } = vi.hoisted(() => ({ + mockGetRepositoryName: vi.fn((): string | null => "getsentry/cli"), +})); + +vi.mock("../../../src/lib/git.js", () => ({ + getRepositoryName: mockGetRepositoryName, + getHeadCommit: () => "abc123def456789012345678901234567890abcd", + isInsideGitWorkTree: () => true, + isShallowRepository: () => false, + getCommitLog: () => [], + getUncommittedFiles: () => [], + parseRemoteUrl: (url: string) => url, +})); + +// Dynamic import: must run AFTER vi.mock() so setCommitsAuto picks up +// the mocked git helpers. +const { + createRelease, + createReleaseDeploy, + deleteRelease, + getRelease, + listReleaseDeploys, + listReleasesPaginated, + setCommitsAuto, + setCommitsLocal, + updateRelease, +} = await import("../../../src/lib/api/releases.js"); + +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import type { SentryDeploy, SentryRelease } from "../../../src/types/index.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("api-releases-"); + +const SAMPLE_RELEASE: SentryRelease = { + id: 1, + version: "1.0.0", + shortVersion: "1.0.0", + status: "open", + dateCreated: "2025-01-01T00:00:00Z", + dateReleased: null, + firstEvent: null, + lastEvent: null, + ref: null, + url: null, + commitCount: 0, + deployCount: 0, + newGroups: 0, + authors: [], + projects: [ + { + id: 1, + slug: "test-project", + name: "Test Project", + platform: "javascript", + platforms: ["javascript"], + hasHealthData: false, + newGroups: 0, + }, + ], + data: {}, + versionInfo: null, +}; + +const SAMPLE_DEPLOY: SentryDeploy = { + id: "42", + environment: "production", + dateStarted: null, + dateFinished: "2025-01-01T12:00:00Z", + name: null, + url: null, +}; + +let originalFetch: typeof globalThis.fetch; + +/** Create a Link header for pagination */ +function linkHeader(cursor: string, hasResults: boolean): string { + return `; rel="next"; results="${hasResults}"; cursor="${cursor}"`; +} + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", "https://us.sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ============================================================================= +// getRelease +// ============================================================================= + +describe("getRelease", () => { + test("fetches a release by version", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/releases/1.0.0/"); + return new Response(JSON.stringify(SAMPLE_RELEASE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await getRelease("test-org", "1.0.0"); + + expect(release.version).toBe("1.0.0"); + expect(release.shortVersion).toBe("1.0.0"); + expect(release.id).toBe(1); + }); +}); + +// ============================================================================= +// createRelease +// ============================================================================= + +describe("createRelease", () => { + test("creates a release with version and projects", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("POST"); + const body = (await req.json()) as { + version: string; + projects: string[]; + }; + expect(body.version).toBe("1.0.0"); + expect(body.projects).toEqual(["test-project"]); + return new Response(JSON.stringify(SAMPLE_RELEASE), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await createRelease("test-org", { + version: "1.0.0", + projects: ["test-project"], + }); + + expect(release.version).toBe("1.0.0"); + expect(release.projects).toHaveLength(1); + }); +}); + +// ============================================================================= +// updateRelease +// ============================================================================= + +describe("updateRelease", () => { + test("updates a release with dateReleased", async () => { + const updated = { ...SAMPLE_RELEASE, dateReleased: "2025-06-15T00:00:00Z" }; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + expect(req.url).toContain("/releases/1.0.0/"); + const body = (await req.json()) as { dateReleased: string }; + expect(body.dateReleased).toBe("2025-06-15T00:00:00Z"); + return new Response(JSON.stringify(updated), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await updateRelease("test-org", "1.0.0", { + dateReleased: "2025-06-15T00:00:00Z", + }); + + expect(release.dateReleased).toBe("2025-06-15T00:00:00Z"); + }); +}); + +// ============================================================================= +// deleteRelease +// ============================================================================= + +describe("deleteRelease", () => { + test("deletes a release", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("DELETE"); + expect(req.url).toContain("/releases/1.0.0/"); + return new Response(null, { status: 204 }); + }); + + // Should not throw + await deleteRelease("test-org", "1.0.0"); + }); +}); + +// ============================================================================= +// listReleaseDeploys +// ============================================================================= + +describe("listReleaseDeploys", () => { + test("returns deploys for a release", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/releases/1.0.0/deploys/"); + return new Response(JSON.stringify([SAMPLE_DEPLOY]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const deploys = await listReleaseDeploys("test-org", "1.0.0"); + + expect(deploys).toHaveLength(1); + expect(deploys[0].environment).toBe("production"); + expect(deploys[0].id).toBe("42"); + }); +}); + +// ============================================================================= +// createReleaseDeploy +// ============================================================================= + +describe("createReleaseDeploy", () => { + test("creates a deploy for a release", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("POST"); + expect(req.url).toContain("/releases/1.0.0/deploys/"); + const body = (await req.json()) as { environment: string }; + expect(body.environment).toBe("production"); + return new Response(JSON.stringify(SAMPLE_DEPLOY), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + const deploy = await createReleaseDeploy("test-org", "1.0.0", { + environment: "production", + }); + + expect(deploy.environment).toBe("production"); + expect(deploy.id).toBe("42"); + }); +}); + +// ============================================================================= +// setCommitsAuto +// ============================================================================= + +describe("setCommitsAuto", () => { + const SAMPLE_REPO = { + id: "1", + name: "getsentry/cli", + url: "https://github.com/getsentry/cli", + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }; + + beforeEach(() => { + // Reset the git-helper mock to the default (cli repo). Individual tests + // can override via mockGetRepositoryName.mockReturnValueOnce(null) or + // mockReturnValue("..."). + mockGetRepositoryName.mockReturnValue("getsentry/cli"); + }); + + test("lists repos, discovers HEAD, fetches previous commit, and sends refs", async () => { + const withCommits = { ...SAMPLE_RELEASE, commitCount: 5 }; + const requests: { method: string; url: string }[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + requests.push({ method: req.method, url: req.url }); + + // List org repositories (SDK uses /repos/ endpoint) + if (req.url.includes("/repos/")) { + expect(req.method).toBe("GET"); + return new Response(JSON.stringify([SAMPLE_REPO]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Previous release commit lookup + if (req.url.includes("/previous-with-commits/")) { + expect(req.method).toBe("GET"); + return new Response( + JSON.stringify({ + lastCommit: { id: "prev000000000000000000000000000000000000" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // PUT refs on the release + expect(req.method).toBe("PUT"); + expect(req.url).toContain("/releases/1.0.0/"); + const body = (await req.json()) as { + refs: Array<{ + repository: string; + commit: string; + previousCommit?: string; + }>; + }; + expect(body.refs).toEqual([ + { + repository: "getsentry/cli", + commit: "abc123def456789012345678901234567890abcd", + previousCommit: "prev000000000000000000000000000000000000", + }, + ]); + return new Response(JSON.stringify(withCommits), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await setCommitsAuto("test-org", "1.0.0", "/tmp"); + + expect(release.commitCount).toBe(5); + }); + + test("throws ApiError when org has no repositories", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(setCommitsAuto("test-org", "1.0.0", "/tmp")).rejects.toThrow( + /No repository integrations/ + ); + }); + + test("throws ValidationError when no repo matches local remote", async () => { + const otherRepo = { ...SAMPLE_REPO, name: "getsentry/sentry" }; + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify([otherRepo]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(setCommitsAuto("test-org", "1.0.0", "/tmp")).rejects.toThrow( + /No Sentry repository matching/ + ); + }); + + test("paginates through multiple pages to find matching repo", async () => { + const withCommits = { ...SAMPLE_RELEASE, commitCount: 3 }; + const otherRepo = { ...SAMPLE_REPO, id: "2", name: "getsentry/sentry" }; + let repoRequestCount = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + + if (req.url.includes("/repos/")) { + repoRequestCount += 1; + if (repoRequestCount === 1) { + // First page: different repo, with a next cursor + return new Response(JSON.stringify([otherRepo]), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="true"; cursor="page2:0:0"', + }, + }); + } + // Second page: the matching repo, no next cursor + return new Response(JSON.stringify([SAMPLE_REPO]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Previous release commit lookup (no previous release) + if (req.url.includes("/previous-with-commits/")) { + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // PUT refs on the release + return new Response(JSON.stringify(withCommits), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await setCommitsAuto("test-org", "1.0.0", "/tmp"); + + expect(release.commitCount).toBe(3); + expect(repoRequestCount).toBe(2); + }); + + test("throws an actionable ValidationError when no git remote is configured", async () => { + mockGetRepositoryName.mockReturnValue(null); + + // A missing 'origin' remote (e.g. a fresh `git init`) is an expected + // precondition, not a crash. It must surface as a CliError (exit 21, + // rendered via format() — no stack trace) with actionable guidance, and + // keep field "repository" so default mode can fall back to local git. + await expect( + setCommitsAuto("test-org", "1.0.0", "/tmp") + ).rejects.toMatchObject({ + name: "ValidationError", + field: "repository", + exitCode: 21, + }); + + await expect(setCommitsAuto("test-org", "1.0.0", "/tmp")).rejects.toThrow( + /Could not determine the repository from the local git remote/ + ); + await expect(setCommitsAuto("test-org", "1.0.0", "/tmp")).rejects.toThrow( + /--local/ + ); + }); +}); + +// ============================================================================= +// setCommitsLocal +// ============================================================================= + +describe("setCommitsLocal", () => { + test("sends explicit commits to the API", async () => { + const withCommits = { ...SAMPLE_RELEASE, commitCount: 2 }; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + const body = (await req.json()) as { + commits: Array<{ id: string; message: string }>; + }; + expect(body.commits).toHaveLength(1); + expect(body.commits[0].id).toBe("abc123"); + return new Response(JSON.stringify(withCommits), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const release = await setCommitsLocal("test-org", "1.0.0", [ + { + id: "abc123", + message: "fix: something", + author_name: "Test", + author_email: "test@example.com", + timestamp: "2025-01-01T00:00:00Z", + }, + ]); + + expect(release.commitCount).toBe(2); + }); +}); + +// ============================================================================= +// listReleasesPaginated +// ============================================================================= + +describe("listReleasesPaginated", () => { + test("returns a page of releases with cursor", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.url).toContain("/releases/"); + return new Response(JSON.stringify([SAMPLE_RELEASE]), { + status: 200, + headers: { + "Content-Type": "application/json", + link: linkHeader("abc:0:0", true), + }, + }); + }); + + const result = await listReleasesPaginated("test-org", { perPage: 25 }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].version).toBe("1.0.0"); + expect(result.nextCursor).toBe("abc:0:0"); + }); + + test("returns no cursor when no more pages", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify([SAMPLE_RELEASE]), { + status: 200, + headers: { + "Content-Type": "application/json", + link: linkHeader("abc:0:0", false), + }, + }) + ); + + const result = await listReleasesPaginated("test-org"); + + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBeUndefined(); + }); + + test("passes query and sort options", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.searchParams.get("query")).toBe("1.0"); + expect(url.searchParams.get("sort")).toBe("date"); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await listReleasesPaginated("test-org", { + query: "1.0", + sort: "date", + }); + + expect(result.data).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/lib/api/replays.test.ts b/packages/cli/test/lib/api/replays.test.ts new file mode 100644 index 000000000..fe329352b --- /dev/null +++ b/packages/cli/test/lib/api/replays.test.ts @@ -0,0 +1,536 @@ +/** + * Tests for the replay API helpers. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { MAX_PAGINATION_PAGES } from "../../../src/lib/api/infrastructure.js"; +import { + getReplay, + getReplayRecordingSegments, + listReplayIdsForIssue, + listReplays, + resolveReplay, +} from "../../../src/lib/api/replays.js"; +import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("replays-api-test-"); + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; + +function replayRow(id = REPLAY_ID) { + return { + id, + count_errors: 2, + count_segments: 4, + duration: 95, + started_at: "2025-01-30T14:32:15+00:00", + user: { display_name: "Test User" }, + }; +} + +function recordingSegmentsResponse( + body: unknown, + nextCursor?: string +): Response { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (nextCursor) { + headers.Link = `; rel="next"; results="true"; cursor="${nextCursor}"`; + } + + return new Response(JSON.stringify(body), { + status: 200, + headers, + }); +} + +describe("listReplays", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the organization replay index with repeated field params", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify({ data: [replayRow()] }), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: `; rel="next"; results="true"; cursor="0:25:0"`, + }, + }); + }); + + const result = await listReplays("test-org", { + limit: 25, + projectSlugs: ["cli"], + query: "count_errors:>0", + sort: "-count_errors", + statsPeriod: "24h", + }); + + const url = new URL(capturedUrl); + expect(url.pathname).toContain("/api/0/organizations/test-org/replays/"); + expect(url.searchParams.get("projectSlug")).toBe("cli"); + expect(url.searchParams.get("query")).toBe("count_errors:>0"); + expect(url.searchParams.get("sort")).toBe("-count_errors"); + expect(url.searchParams.get("statsPeriod")).toBe("24h"); + expect(url.searchParams.get("per_page")).toBe("25"); + expect(url.searchParams.getAll("field")).toContain("id"); + expect(url.searchParams.getAll("field")).toContain("user"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("0:25:0"); + }); + + test("passes replay environment filters and custom field selection", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify({ data: [replayRow()] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listReplays("test-org", { + environment: ["production", "canary"], + fields: ["id", "user", "urls"], + limit: 10, + sort: "-count_rage_clicks", + }); + + const url = new URL(capturedUrl); + expect(url.searchParams.getAll("environment")).toEqual([ + "production", + "canary", + ]); + expect(url.searchParams.getAll("field")).toEqual(["id", "user", "urls"]); + expect(url.searchParams.get("sort")).toBe("-count_rage_clicks"); + }); + + test("auto-paginates when limit exceeds the API cap", async () => { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const body = + callIndex === 0 + ? { + data: Array.from({ length: 100 }, (_, index) => + replayRow(index.toString(16).padStart(32, "a").slice(-32)) + ), + } + : { + data: Array.from({ length: 50 }, (_, index) => + replayRow(index.toString(16).padStart(32, "b").slice(-32)) + ), + }; + const headers = + callIndex === 0 + ? { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + } + : { + Link: `; rel="next"; results="false"; cursor=""`, + }; + callIndex += 1; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + + const result = await listReplays("test-org", { limit: 150 }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(capturedUrls).toHaveLength(2); + expect(capturedUrls[0]).toContain("per_page=100"); + expect(capturedUrls[1]).toContain("per_page=50"); + }); +}); + +describe("getReplay", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the replay detail endpoint", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify({ data: replayRow() }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const replay = await getReplay("test-org", REPLAY_ID); + + expect(capturedUrl).toContain( + `/api/0/organizations/test-org/replays/${REPLAY_ID}/` + ); + expect(replay.id).toBe(REPLAY_ID); + expect(replay.count_errors).toBe(2); + }); + + test("normalizes archived replay payload oddities", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ + data: { + ...replayRow(), + error_ids: undefined, + info_ids: undefined, + project_id: 42, + releases: null, + tags: [], + trace_ids: undefined, + urls: null, + warning_ids: undefined, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + const replay = await getReplay("test-org", REPLAY_ID); + + expect(replay.project_id).toBe("42"); + expect(replay.tags).toEqual({}); + expect(replay.releases).toEqual([]); + expect(replay.urls).toEqual([]); + expect(replay.error_ids).toEqual([]); + expect(replay.info_ids).toEqual([]); + expect(replay.trace_ids).toEqual([]); + expect(replay.warning_ids).toEqual([]); + }); +}); + +describe("resolveReplay", () => { + let originalFetch: typeof globalThis.fetch; + const TRACE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + + test("returns the replay when the detail endpoint hits", async () => { + const capturedUrls: string[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + return jsonResponse({ data: replayRow() }); + }); + + const replay = await resolveReplay("test-org", REPLAY_ID); + + expect(replay.id).toBe(REPLAY_ID); + expect(capturedUrls).toHaveLength(1); + expect(capturedUrls[0]).toContain( + `/api/0/organizations/test-org/replays/${REPLAY_ID}/` + ); + }); + + test("falls back to a linked trace search after a 404", async () => { + const capturedUrls: string[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + const url = new URL(req.url); + + if (url.pathname.endsWith(`/replays/${TRACE_ID}/`)) { + return jsonResponse({ detail: "Not Found" }, 404); + } + if (url.pathname.endsWith(`/replays/${REPLAY_ID}/`)) { + return jsonResponse({ data: replayRow() }); + } + if (url.pathname.endsWith("/replays/")) { + return jsonResponse({ data: [replayRow()] }); + } + throw new Error(`unexpected request ${req.url}`); + }); + + const replay = await resolveReplay("test-org", TRACE_ID, { + projectSlugs: ["javascript"], + }); + + expect(replay.id).toBe(REPLAY_ID); + expect(capturedUrls).toHaveLength(3); + + const listUrl = new URL(capturedUrls[1]!); + expect(listUrl.pathname).toContain( + "/api/0/organizations/test-org/replays/" + ); + expect(listUrl.searchParams.get("query")).toBe(`trace:${TRACE_ID}`); + expect(listUrl.searchParams.get("statsPeriod")).toBe("90d"); + expect(listUrl.searchParams.get("per_page")).toBe("1"); + expect(listUrl.searchParams.get("sort")).toBe("-started_at"); + expect(listUrl.searchParams.get("projectSlug")).toBe("javascript"); + expect(listUrl.searchParams.get("project")).toBeNull(); + }); + + test("rethrows the original 404 when no replay is linked to the id", async () => { + const capturedUrls: string[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + const url = new URL(req.url); + + if (url.pathname.endsWith(`/replays/${TRACE_ID}/`)) { + return jsonResponse({ detail: "Replay not found" }, 404); + } + if (url.pathname.endsWith("/replays/")) { + return jsonResponse({ data: [] }); + } + throw new Error(`unexpected request ${req.url}`); + }); + + await expect(resolveReplay("test-org", TRACE_ID)).rejects.toMatchObject({ + name: "ApiError", + status: 404, + detail: "Replay not found", + }); + + const listUrl = new URL(capturedUrls[1]!); + expect(listUrl.searchParams.get("query")).toBe(`trace:${TRACE_ID}`); + expect(listUrl.searchParams.get("project")).toBe("-1"); + expect(listUrl.searchParams.get("projectSlug")).toBeNull(); + }); + + test("does not search by trace when the detail endpoint fails with a non-404", async () => { + const capturedUrls: string[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + return jsonResponse({ detail: "Bad Request" }, 400); + }); + + await expect(resolveReplay("test-org", TRACE_ID)).rejects.toMatchObject({ + name: "ApiError", + status: 400, + }); + expect(capturedUrls).toHaveLength(1); + expect(capturedUrls[0]).toContain(`/replays/${TRACE_ID}/`); + }); +}); + +describe("getReplayRecordingSegments", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the project replay recording-segments endpoint", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return recordingSegmentsResponse([[{ timestamp: 1 }]]); + }); + + const segments = await getReplayRecordingSegments( + "test-org", + "42", + REPLAY_ID + ); + + const url = new URL(capturedUrl); + expect(url.pathname).toContain( + `/api/0/projects/test-org/42/replays/${REPLAY_ID}/recording-segments/` + ); + expect(url.searchParams.get("download")).toBeNull(); + expect(url.searchParams.get("per_page")).toBe("100"); + expect(segments).toEqual([[{ timestamp: 1 }]]); + }); + + test("rejects non-object recording events at the API boundary", async () => { + globalThis.fetch = mockFetch(async () => + recordingSegmentsResponse([[null]]) + ); + + await expect( + getReplayRecordingSegments("test-org", "42", REPLAY_ID) + ).rejects.toBeInstanceOf(ApiError); + }); + + test("auto-paginates recording segments using the link cursor", async () => { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const body = + callIndex === 0 + ? Array.from({ length: 100 }, (_, index) => [{ segment: index }]) + : [[{ segment: 100 }]]; + const nextCursor = callIndex === 0 ? "0:100:0" : undefined; + callIndex += 1; + + return recordingSegmentsResponse(body, nextCursor); + }); + + const segments = await getReplayRecordingSegments( + "test-org", + "42", + REPLAY_ID, + { expectedSegments: 101 } + ); + + expect(segments).toHaveLength(101); + expect(capturedUrls).toHaveLength(2); + + const firstUrl = new URL(capturedUrls[0]!); + expect(firstUrl.searchParams.get("per_page")).toBe("100"); + expect(firstUrl.searchParams.get("cursor")).toBeNull(); + + const secondUrl = new URL(capturedUrls[1]!); + expect(secondUrl.searchParams.get("cursor")).toBe("0:100:0"); + expect(secondUrl.searchParams.get("per_page")).toBe("100"); + }); + + test("stops recording segment pagination at the safety cap", async () => { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const nextCursor = `0:${(callIndex + 1) * 100}:0`; + const body = [[{ segment: callIndex }]]; + callIndex += 1; + + return recordingSegmentsResponse(body, nextCursor); + }); + + const segments = await getReplayRecordingSegments( + "test-org", + "42", + REPLAY_ID + ); + + expect(segments).toHaveLength(MAX_PAGINATION_PAGES); + expect(capturedUrls).toHaveLength(MAX_PAGINATION_PAGES); + + const finalUrl = new URL(capturedUrls.at(-1)!); + expect(finalUrl.searchParams.get("cursor")).toBe( + `0:${(MAX_PAGINATION_PAGES - 1) * 100}:0` + ); + }); +}); + +describe("listReplayIdsForIssue", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the replay-count endpoint with the issue query", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response( + JSON.stringify({ + "12345": [ + "346789a703f6454384f1de473b8b9fcc", + "aaaaaaaa03f6454384f1de473b8b9fcc", + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }); + + const replayIds = await listReplayIdsForIssue("test-org", "12345"); + const url = new URL(capturedUrl); + + expect(url.pathname).toContain( + "/api/0/organizations/test-org/replay-count/" + ); + expect(url.searchParams.get("returnIds")).toBe("true"); + expect(url.searchParams.get("query")).toBe("issue.id:[12345]"); + expect(url.searchParams.get("data_source")).toBe("events"); + expect(url.searchParams.get("statsPeriod")).toBe("90d"); + expect(url.searchParams.getAll("project")).toEqual(["-1"]); + expect(replayIds).toEqual([ + "346789a703f6454384f1de473b8b9fcc", + "aaaaaaaa03f6454384f1de473b8b9fcc", + ]); + }); +}); diff --git a/packages/cli/test/lib/api/repositories.test.ts b/packages/cli/test/lib/api/repositories.test.ts new file mode 100644 index 000000000..46471389c --- /dev/null +++ b/packages/cli/test/lib/api/repositories.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for the cached repository helper. + * + * Covers the cache-hit / cache-miss paths and the resilience guard that + * keeps a broken SQLite write from crashing a command whose primary API + * fetch already succeeded (established project pattern — see AGENTS.md + * lore on cache-write resilience). + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { listRepositoriesCached } from "../../../src/lib/api/repositories.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as repoCache from "../../../src/lib/db/repo-cache.js"; +import type { SentryRepository } from "../../../src/types/sentry.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +function repoApiResponse(repos: { name: string }[]): Response { + const body = repos.map((r) => ({ + id: r.name, + name: r.name, + url: `https://github.com/${r.name}`, + provider: { id: "github", name: "GitHub" }, + status: "active", + })); + return new Response(JSON.stringify(body), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor=""', + }, + }); +} + +describe("listRepositoriesCached", () => { + useTestConfigDir("repo-cache-"); + let originalFetch: typeof globalThis.fetch; + let getSpy: ReturnType; + let setSpy: ReturnType; + + beforeEach(() => { + setAuthToken("test-token", 3600, "test-refresh"); + originalFetch = globalThis.fetch; + getSpy = vi.spyOn(repoCache, "getCachedRepos"); + setSpy = vi.spyOn(repoCache, "setCachedRepos"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + getSpy.mockRestore(); + setSpy.mockRestore(); + }); + + test("returns cached list without hitting the API on cache hit", async () => { + const cached: SentryRepository[] = [ + { id: "1", name: "owner/cached" } as unknown as SentryRepository, + ]; + getSpy.mockReturnValue(cached); + let fetchCalled = false; + globalThis.fetch = mockFetch(async () => { + fetchCalled = true; + return new Response("[]"); + }); + + const result = await listRepositoriesCached("my-org"); + expect(result).toBe(cached); + expect(fetchCalled).toBe(false); + expect(setSpy).not.toHaveBeenCalled(); + }); + + test("refetches and writes cache on cache miss", async () => { + getSpy.mockReturnValue(null); + setSpy.mockImplementation(() => { + /* succeed silently */ + }); + globalThis.fetch = mockFetch(async () => + repoApiResponse([{ name: "owner/fresh" }]) + ); + + const result = await listRepositoriesCached("my-org"); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe("owner/fresh"); + expect(setSpy).toHaveBeenCalledWith( + "my-org", + expect.arrayContaining([expect.objectContaining({ name: "owner/fresh" })]) + ); + }); + + test("does NOT crash when cache write throws (resilience)", async () => { + getSpy.mockReturnValue(null); + setSpy.mockImplementation(() => { + // Simulate a read-only DB / corrupted SQLite write + throw new Error("attempt to write a readonly database"); + }); + globalThis.fetch = mockFetch(async () => + repoApiResponse([{ name: "owner/primary-succeeded" }]) + ); + + // The API fetch succeeded, so the command should still receive the + // data even though the cache write failed. No exception should escape. + const result = await listRepositoriesCached("my-org"); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe("owner/primary-succeeded"); + }); +}); diff --git a/packages/cli/test/lib/api/shared-issues.test.ts b/packages/cli/test/lib/api/shared-issues.test.ts new file mode 100644 index 000000000..f11c8abe7 --- /dev/null +++ b/packages/cli/test/lib/api/shared-issues.test.ts @@ -0,0 +1,111 @@ +/** + * Public shared-issue response and request-boundary regressions. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getSharedIssue } from "../../../src/lib/api/issues.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { mockFetch, useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +describe("getSharedIssue", () => { + useTestConfigDir("shared-issues-"); + useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CUSTOM_HEADERS", + ]); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("reads the backend id from the public org-scoped endpoint without bearer auth", async () => { + setAuthToken("test-token"); + let request: Request | undefined; + globalThis.fetch = mockFetch(async (input, init) => { + request = new Request(input, init); + return Response.json({ + id: "12345", + title: "Example shared issue", + project: { slug: "example-project" }, + }); + }); + + const result = await getSharedIssue( + "https://sentry.io", + "example org", + "share/id" + ); + + expect(result.id).toBe("12345"); + expect(request?.url).toBe( + "https://sentry.io/api/0/organizations/example%20org/shared/issues/share%2Fid/" + ); + expect(request?.method).toBe("GET"); + expect(request?.headers.has("Authorization")).toBe(false); + }); + + test.each([ + ["null body", null], + ["missing id", {}], + ["obsolete groupID field", { groupID: "12345" }], + ["null id", { id: null }], + ["numeric id", { id: 12_345 }], + ["empty id", { id: "" }], + ])("rejects a response with %s", async (_description, body) => { + globalThis.fetch = mockFetch(async () => Response.json(body)); + + await expect( + getSharedIssue("https://sentry.io", "example-org", "share-id") + ).rejects.toBeInstanceOf(ApiError); + }); + + test("reports malformed JSON as an API response error", async () => { + globalThis.fetch = mockFetch(async () => new Response("not JSON")); + + await expect( + getSharedIssue("https://sentry.io", "example-org", "share-id") + ).rejects.toMatchObject({ + name: "ApiError", + message: expect.stringContaining("invalid JSON"), + }); + }); + + test("preserves body read errors", async () => { + const error = new TypeError("Response stream interrupted"); + globalThis.fetch = mockFetch( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(error); + }, + }) + ) + ); + + await expect( + getSharedIssue("https://sentry.io", "example-org", "share-id") + ).rejects.toBe(error); + }); + + test.each([ + [404, "Share link not found or expired"], + [503, "Failed to resolve share link"], + ])("preserves the HTTP %s error", async (status, message) => { + globalThis.fetch = mockFetch(async () => new Response("", { status })); + + await expect( + getSharedIssue("https://sentry.io", "example-org", "share-id") + ).rejects.toMatchObject({ name: "ApiError", status, message }); + }); +}); diff --git a/packages/cli/test/lib/api/sourcemaps.test.ts b/packages/cli/test/lib/api/sourcemaps.test.ts new file mode 100644 index 000000000..3281e9c9b --- /dev/null +++ b/packages/cli/test/lib/api/sourcemaps.test.ts @@ -0,0 +1,246 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gunzipSync, zstdDecompressSync } from "node:zlib"; +import { safeParse } from "valibot"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + buildArtifactBundle, + ChunkServerOptionsSchema, + DEFAULT_UPLOAD_MAX_WAIT_MS, + encodeChunk, + pickUploadEncoding, + resolveUploadWait, +} from "../../../src/lib/api/sourcemaps.js"; + +describe("resolveUploadWait", () => { + test("no flags → do not wait, default cap", () => { + expect(resolveUploadWait({})).toEqual({ + wait: false, + maxWaitMs: DEFAULT_UPLOAD_MAX_WAIT_MS, + }); + }); + + test("--wait → wait with default cap", () => { + expect(resolveUploadWait({ wait: true })).toEqual({ + wait: true, + maxWaitMs: DEFAULT_UPLOAD_MAX_WAIT_MS, + }); + }); + + test("--wait-for → wait with a custom cap (enables waiting)", () => { + expect(resolveUploadWait({ "wait-for": 45 })).toEqual({ + wait: true, + maxWaitMs: 45_000, + }); + }); + + test.each([ + Number.NaN, + 0, + -5, + ])("rejects a non-positive --wait-for (%s)", (value) => { + expect(() => resolveUploadWait({ "wait-for": value })).toThrow( + /positive number of seconds/ + ); + }); +}); + +describe("pickUploadEncoding", () => { + test("prefers zstd when both zstd and gzip are advertised", () => { + expect(pickUploadEncoding(["gzip", "zstd"])).toBe("zstd"); + expect(pickUploadEncoding(["zstd", "gzip"])).toBe("zstd"); + }); + + test("falls back to gzip when zstd is absent", () => { + expect(pickUploadEncoding(["gzip"])).toBe("gzip"); + }); + + test("returns undefined when the server opts out of compression", () => { + expect(pickUploadEncoding([])).toBeUndefined(); + }); + + test("ignores unknown codecs the CLI does not implement", () => { + expect(pickUploadEncoding(["br", "deflate"])).toBeUndefined(); + expect(pickUploadEncoding(["br", "gzip"])).toBe("gzip"); + }); +}); + +describe("encodeChunk", () => { + const payload = Buffer.from("hello chunk-upload world".repeat(128)); + + test("gzip encoding emits gzip magic bytes and round-trips", async () => { + const encoded = await encodeChunk(payload, "gzip"); + expect(encoded.byteLength).toBeLessThan(payload.byteLength); + // gzip magic: 1f 8b + expect(encoded[0]).toBe(0x1f); + expect(encoded[1]).toBe(0x8b); + expect(Buffer.from(gunzipSync(encoded)).equals(payload)).toBe(true); + }); + + test("zstd encoding emits zstd magic bytes and round-trips", async () => { + const encoded = await encodeChunk(payload, "zstd"); + expect(encoded.byteLength).toBeLessThan(payload.byteLength); + // zstd magic: 28 b5 2f fd (little-endian 0xFD2FB528) + expect(encoded[0]).toBe(0x28); + expect(encoded[1]).toBe(0xb5); + expect(encoded[2]).toBe(0x2f); + expect(encoded[3]).toBe(0xfd); + const decoded = zstdDecompressSync(encoded); + expect(Buffer.from(decoded).equals(payload)).toBe(true); + }); + + test("returns the input unchanged when no encoding is selected", async () => { + const encoded = await encodeChunk(payload, undefined); + // Plain path returns the same buffer, not a copy. + expect(encoded).toBe(payload); + }); +}); + +describe("buildArtifactBundle", () => { + // ZIP local file header format: at offset 8 (LE u16) is the + // compression method (0 = STORED, 8 = DEFLATE). The CLI prefixes + // every artifact bundle with an 8-byte SYSB SourceBundle header. + const SYSB_HEADER_BYTES = 8; + const LOCAL_HEADER_METHOD_OFFSET = SYSB_HEADER_BYTES + 8; + + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "bundle-test-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function makePair(): Promise< + { + path: string; + debugId: string; + type: "minified_source" | "source_map"; + url: string; + sourcemapFilename?: string; + }[] + > { + const jsPath = join(tmpDir, "app.js"); + const mapPath = join(tmpDir, "app.js.map"); + // Highly redundant content so DEFLATE has work to do — the size + // assertions below depend on this. + await writeFile(jsPath, "console.log('hello');\n".repeat(500)); + await writeFile(mapPath, JSON.stringify({ version: 3, sources: [] })); + return [ + { + path: jsPath, + debugId: "00000000-0000-0000-0000-000000000001", + type: "minified_source" as const, + url: "~/app.js", + sourcemapFilename: "app.js.map", + }, + { + path: mapPath, + debugId: "00000000-0000-0000-0000-000000000001", + type: "source_map" as const, + url: "~/app.js.map", + }, + ]; + } + + test("default compression is DEFLATE", async () => { + const files = await makePair(); + const out = join(tmpDir, "bundle-default.zip"); + await buildArtifactBundle(out, files, { org: "o", project: "p" }); + + const bytes = await readFile(out); + expect(bytes.readUInt16LE(LOCAL_HEADER_METHOD_OFFSET)).toBe(8); + }); + + test("compression: 'stored' writes entries uncompressed", async () => { + const files = await makePair(); + const out = join(tmpDir, "bundle-stored.zip"); + await buildArtifactBundle(out, files, { + org: "o", + project: "p", + compression: "stored", + }); + + const bytes = await readFile(out); + expect(bytes.readUInt16LE(LOCAL_HEADER_METHOD_OFFSET)).toBe(0); + }); + + test("uses in-memory content and never reads disk for inline maps", async () => { + const mapBytes = Buffer.from( + JSON.stringify({ version: 3, sources: [], mappings: "AAAA" }) + ); + const out = join(tmpDir, "bundle-inline.zip"); + // `path` points at a nonexistent file — must not be read because + // `content` is provided. + await buildArtifactBundle( + out, + [ + { + path: join(tmpDir, "does-not-exist.map"), + content: mapBytes, + debugId: "00000000-0000-0000-0000-000000000002", + type: "source_map" as const, + url: "~/app.js.map", + }, + ], + { org: "o", project: "p", compression: "stored" } + ); + + // Build succeeded (no ENOENT) and the STORED archive contains the bytes. + const bytes = await readFile(out); + expect(bytes.includes(mapBytes)).toBe(true); + }); + + test("STORED archive is larger than DEFLATE for the same redundant input", async () => { + const files = await makePair(); + const deflateOut = join(tmpDir, "bundle-deflate.zip"); + const storedOut = join(tmpDir, "bundle-stored-size.zip"); + await buildArtifactBundle(deflateOut, files, { org: "o", project: "p" }); + await buildArtifactBundle(storedOut, files, { + org: "o", + project: "p", + compression: "stored", + }); + + const deflateSize = (await readFile(deflateOut)).length; + const storedSize = (await readFile(storedOut)).length; + // Sanity: redundant input should DEFLATE meaningfully smaller than + // STORED. If this ever fails, either the ZIP layout changed or the + // payload stopped being compressible. + expect(storedSize).toBeGreaterThan(deflateSize * 2); + }); +}); + +describe("ChunkServerOptionsSchema", () => { + test("accepts compression: [] (server opt-out)", () => { + const result = safeParse(ChunkServerOptionsSchema, { + url: "https://example.com/api/0/organizations/o/chunk-upload/", + chunkSize: 8_388_608, + chunksPerRequest: 64, + maxRequestSize: 33_554_432, + hashAlgorithm: "sha1", + concurrency: 8, + compression: [], + }); + expect(result.success).toBe(true); + }); + + test("accepts compression with zstd + gzip advertised", () => { + const result = safeParse(ChunkServerOptionsSchema, { + url: "https://example.com/api/0/organizations/o/chunk-upload/", + chunkSize: 8_388_608, + chunksPerRequest: 64, + maxRequestSize: 33_554_432, + hashAlgorithm: "sha1", + concurrency: 8, + compression: ["gzip", "zstd"], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(pickUploadEncoding(result.output.compression)).toBe("zstd"); + } + }); +}); diff --git a/packages/cli/test/lib/api/status-page.test.ts b/packages/cli/test/lib/api/status-page.test.ts new file mode 100644 index 000000000..36c2d5c41 --- /dev/null +++ b/packages/cli/test/lib/api/status-page.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { fetchSentryStatus } from "../../../src/lib/api/status-page.js"; + +const { customFetchMock } = vi.hoisted(() => ({ customFetchMock: vi.fn() })); +vi.mock("../../../src/lib/custom-ca.js", () => ({ + customFetch: customFetchMock, +})); + +beforeEach(() => { + customFetchMock.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** A minimal, valid Statuspage summary payload. */ +function summaryResponse( + indicator: string, + description: string, + pageUrl: string +): Response { + return Response.json({ + page: { url: pageUrl }, + status: { indicator, description }, + components: [], + incidents: [], + }); +} + +test("probes summary.json first and uses it when the target is Statuspage", async () => { + customFetchMock.mockResolvedValue( + summaryResponse( + "none", + "All Systems Operational", + "https://status.sentry.io" + ) + ); + + const status = await fetchSentryStatus(); + + expect(status.description).toBe("All Systems Operational"); + // Exactly one request: the summary probe succeeded, no health fallback. + expect(customFetchMock).toHaveBeenCalledTimes(1); + const [calledUrl] = customFetchMock.mock.calls[0] ?? []; + expect(calledUrl).toBe("https://status.sentry.io/api/v2/summary.json"); +}); + +test("uses summary.json for an arbitrary host that responds like Statuspage", async () => { + customFetchMock.mockResolvedValue( + summaryResponse( + "minor", + "Minor Service Outage", + "https://sentry.example.com" + ) + ); + + const status = await fetchSentryStatus("https://sentry.example.com"); + + expect(status.indicator).toBe("minor"); + expect(customFetchMock).toHaveBeenCalledTimes(1); + const [calledUrl] = customFetchMock.mock.calls[0] ?? []; + expect(calledUrl).toBe("https://sentry.example.com/api/v2/summary.json"); +}); + +test("falls back to /_health/ when summary.json is not found (404)", async () => { + customFetchMock + .mockResolvedValueOnce(new Response("Not Found", { status: 404 })) + .mockResolvedValueOnce(new Response("", { status: 200, statusText: "OK" })); + + const status = await fetchSentryStatus("https://self.sentry.local"); + + expect(status.indicator).toBe("none"); + expect(status.url).toBe("https://self.sentry.local"); + expect(customFetchMock).toHaveBeenCalledTimes(2); + const [summaryUrl] = customFetchMock.mock.calls[0] ?? []; + const [healthUrl, healthInit] = customFetchMock.mock.calls[1] ?? []; + expect(summaryUrl).toBe("https://self.sentry.local/api/v2/summary.json"); + expect(healthUrl).toBe("https://self.sentry.local/_health/?full=1"); + expect(healthInit).toHaveProperty("signal"); +}); + +test("falls back to /_health/ when summary.json returns non-Statuspage JSON", async () => { + customFetchMock + .mockResolvedValueOnce(Response.json({ hello: "world" })) + .mockResolvedValueOnce(new Response("", { status: 200, statusText: "OK" })); + + const status = await fetchSentryStatus("https://self.sentry.local"); + + expect(status.indicator).toBe("none"); + expect(customFetchMock).toHaveBeenCalledTimes(2); + const [healthUrl] = customFetchMock.mock.calls[1] ?? []; + expect(healthUrl).toBe("https://self.sentry.local/_health/?full=1"); +}); + +test("falls back to /_health/ when summary.json is not JSON", async () => { + customFetchMock + .mockResolvedValueOnce( + new Response("not json", { status: 200 }) + ) + .mockResolvedValueOnce(new Response("", { status: 200, statusText: "OK" })); + + const status = await fetchSentryStatus("https://self.sentry.local"); + + expect(status.indicator).toBe("none"); + expect(customFetchMock).toHaveBeenCalledTimes(2); +}); + +test("reports major when the health fallback returns non-2xx", async () => { + customFetchMock + .mockResolvedValueOnce(new Response("Not Found", { status: 404 })) + .mockResolvedValueOnce( + new Response("", { status: 503, statusText: "Service Unavailable" }) + ); + + const status = await fetchSentryStatus("https://self.sentry.local"); + + expect(status.indicator).toBe("major"); + expect(status.description).toContain("Service Unavailable"); +}); + +test("reports major when both the summary probe and health probe throw", async () => { + customFetchMock.mockRejectedValue(new Error("network down")); + + const status = await fetchSentryStatus("https://self.sentry.local"); + + expect(status.indicator).toBe("major"); + expect(status.description).toContain("network down"); +}); diff --git a/packages/cli/test/lib/api/traces.test.ts b/packages/cli/test/lib/api/traces.test.ts new file mode 100644 index 000000000..6bde9ed08 --- /dev/null +++ b/packages/cli/test/lib/api/traces.test.ts @@ -0,0 +1,946 @@ +/** + * Tests for the traces API helpers (getDetailedTrace, listTransactions, listSpans). + * + * Verifies URL construction, query parameter encoding, schema validation, + * pagination cursor extraction, auto-pagination, and the 14d→90d empty-trace retry. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + fetchMultiSpanDetails, + getDetailedTrace, + getSpanDetails, + listSpans, + listTransactions, +} from "../../../src/lib/api/traces.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +// --------------------------------------------------------------------------- +// listTransactions +// --------------------------------------------------------------------------- + +describe("listTransactions", () => { + useTestConfigDir("traces-txn-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedMethod = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedMethod = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + } + + /** + * Helper to mock sequential fetch responses for multi-page tests. + * Each call to fetch returns the next response in the queue. + */ + function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> + ): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + const TX_META = { + fields: { + trace: "string", + id: "string", + transaction: "string", + timestamp: "date", + "span.duration": "duration", + project: "string", + }, + }; + + /** Generate N rows of fake transaction data */ + function makeTxnRows(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + trace: `trace-${i}`, + id: `id-${i}`, + transaction: `/api/endpoint-${i}`, + timestamp: "2024-01-15T00:00:00Z", + "span.duration": 100 + i, + project: "my-project", + })); + } + + test("hits /organizations/{org}/events/ with GET", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toContain("/api/0/organizations/my-org/events/"); + }); + + test("sends dataset=spans with is_transaction:true", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain("dataset=spans"); + expect(decodeURIComponent(capturedUrl)).toContain("is_transaction:true"); + }); + + test("uses the remaining item budget for the final page", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + await listTransactions("my-org", "my-project", { limit: 150 }); + + expect(getCapturedUrls()[0]).toContain("per_page=100"); + expect(getCapturedUrls()[1]).toContain("per_page=50"); + }); + + test("sends sort=-timestamp by default", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain(`sort=${encodeURIComponent("-timestamp")}`); + }); + + test('sends sort=-span.duration for sort="duration"', async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { sort: "duration" }); + + expect(decodeURIComponent(capturedUrl)).toContain("sort=-span.duration"); + }); + + test("passes cursor when provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { cursor: "0:50:0" }); + + expect(capturedUrl).toContain(`cursor=${encodeURIComponent("0:50:0")}`); + }); + + test("uses statsPeriod when no absolute range provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { statsPeriod: "1h" }); + + expect(capturedUrl).toContain("statsPeriod=1h"); + expect(capturedUrl).not.toContain("start="); + expect(capturedUrl).not.toContain("end="); + }); + + test("defaults statsPeriod to 7d when not provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain("statsPeriod=7d"); + }); + + test("suppresses statsPeriod when start/end are present", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { + start: "2024-01-15T00:00:00Z", + end: "2024-01-16T00:00:00Z", + statsPeriod: "7d", + }); + + expect(capturedUrl).toContain( + `start=${encodeURIComponent("2024-01-15T00:00:00Z")}` + ); + expect(capturedUrl).toContain( + `end=${encodeURIComponent("2024-01-16T00:00:00Z")}` + ); + expect(capturedUrl).not.toContain("statsPeriod="); + }); + + test("auto-paginates when limit > 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 150, + }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(getCapturedUrls()).toHaveLength(2); + }); + + test("trims results and drops nextCursor when overshoot", async () => { + mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:200:0"`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 120, + }); + + expect(result.data).toHaveLength(120); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single-page fast path for limit <= 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 50, + }); + + expect(result.data).toHaveLength(50); + expect(getCapturedUrls()).toHaveLength(1); + expect(getCapturedUrls()[0]).toContain("per_page=50"); + }); + + test("non-numeric project slug goes in query as project:slug", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(decodeURIComponent(capturedUrl).replaceAll("+", " ")).toContain( + "query=is_transaction:true project:my-project" + ); + // Should NOT appear as a separate project= param + expect(capturedUrl).not.toMatch(/[?&]project=my-project/); + }); + + test("scopes via the project param when projectId is provided (#1317)", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { projectId: 4242 }); + + expect(capturedUrl).toContain("project=4242"); + expect(decodeURIComponent(capturedUrl)).not.toContain("project:my-project"); + }); + + test("numeric project ID goes as project param", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "12345"); + + expect(capturedUrl).toContain("project=12345"); + // Should NOT appear as project:12345 in the query string + expect(decodeURIComponent(capturedUrl)).not.toContain("project:12345"); + }); + + test("returns nextCursor from Link header", async () => { + const cursor = "0:10:0"; + mockOk( + { data: makeTxnRows(10), meta: TX_META }, + { + Link: `; rel="next"; results="true"; cursor="${cursor}"`, + } + ); + + const result = await listTransactions("my-org", "my-project", { + limit: 10, + }); + + expect(result.nextCursor).toBe(cursor); + }); + + test("returns undefined nextCursor when results=false", async () => { + mockOk( + { data: [], meta: TX_META }, + { + Link: `; rel="next"; results="false"; cursor=""`, + } + ); + + const result = await listTransactions("my-org", "my-project"); + + expect(result.nextCursor).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// listSpans +// --------------------------------------------------------------------------- + +describe("listSpans", () => { + useTestConfigDir("traces-span-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedMethod = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedMethod = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + } + + function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> + ): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + const SPAN_META = { + fields: { + id: "string", + parent_span: "string", + "span.op": "string", + description: "string", + "span.duration": "duration", + timestamp: "date", + project: "string", + transaction: "string", + trace: "string", + }, + }; + + /** Generate N rows of fake span data */ + function makeSpanRows(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + id: `span-${i}`, + parent_span: `parent-${i}`, + "span.op": "http.client", + description: `GET /api/endpoint-${i}`, + "span.duration": 50 + i, + timestamp: "2024-01-15T00:00:00Z", + project: "my-project", + transaction: "/api/foo", + trace: `trace-${i}`, + })); + } + + test("hits /organizations/{org}/events/ with GET", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toContain("/api/0/organizations/my-org/events/"); + }); + + test("sends dataset=spans", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain("dataset=spans"); + }); + + test("uses the remaining item budget for the final page", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(50), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + await listSpans("my-org", "my-project", { limit: 150 }); + + expect(getCapturedUrls()[0]).toContain("per_page=100"); + expect(getCapturedUrls()[1]).toContain("per_page=50"); + }); + + test("sends sort=-timestamp by default", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain(`sort=${encodeURIComponent("-timestamp")}`); + }); + + test('sends sort=-span.duration for sort="duration"', async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { sort: "duration" }); + + expect(decodeURIComponent(capturedUrl)).toContain("sort=-span.duration"); + }); + + test("allProjects sends project=-1", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { allProjects: true }); + + expect(capturedUrl).toContain("project=-1"); + // Should NOT have project:my-project in query + expect(decodeURIComponent(capturedUrl)).not.toContain( + "project%3Amy-project" + ); + }); + + test("scopes via the project param when projectId is provided (#1317)", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { projectId: 4242 }); + + expect(capturedUrl).toContain("project=4242"); + expect(decodeURIComponent(capturedUrl)).not.toContain("project:my-project"); + }); + + test("auto-paginates when limit > 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(50), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 150 }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(getCapturedUrls()).toHaveLength(2); + }); + + test("trims results when overshoot", async () => { + mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:200:0"`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 120 }); + + expect(result.data).toHaveLength(120); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single-page fast path for limit <= 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(30), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 30 }); + + expect(result.data).toHaveLength(30); + expect(getCapturedUrls()).toHaveLength(1); + expect(getCapturedUrls()[0]).toContain("per_page=30"); + }); + + test("passes extraFields when provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { + extraFields: ["span.self_time", "span.category"], + }); + + const decoded = decodeURIComponent(capturedUrl); + expect(decoded).toContain("field=span.self_time"); + expect(decoded).toContain("field=span.category"); + // Standard fields should still be present + expect(decoded).toContain("field=id"); + expect(decoded).toContain("field=span.op"); + }); + + test("non-numeric project slug goes in query as project:slug", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain( + `query=${encodeURIComponent("project:my-project")}` + ); + // Should NOT appear as a separate project= param with the slug value + expect(capturedUrl).not.toMatch(/[?&]project=my-project/); + }); + + test("numeric project ID goes as project param", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "12345"); + + expect(capturedUrl).toContain("project=12345"); + expect(decodeURIComponent(capturedUrl)).not.toContain("project:12345"); + }); + + test("uses statsPeriod when no absolute range provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { statsPeriod: "1h" }); + + expect(capturedUrl).toContain("statsPeriod=1h"); + expect(capturedUrl).not.toContain("start="); + expect(capturedUrl).not.toContain("end="); + }); + + test("defaults statsPeriod to 7d when not provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain("statsPeriod=7d"); + }); + + test("suppresses statsPeriod when start/end are present", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { + start: "2024-01-15T00:00:00Z", + end: "2024-01-16T00:00:00Z", + statsPeriod: "7d", + }); + + expect(capturedUrl).toContain( + `start=${encodeURIComponent("2024-01-15T00:00:00Z")}` + ); + expect(capturedUrl).toContain( + `end=${encodeURIComponent("2024-01-16T00:00:00Z")}` + ); + expect(capturedUrl).not.toContain("statsPeriod="); + }); + + test("passes cursor when provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { cursor: "0:50:0" }); + + expect(capturedUrl).toContain(`cursor=${encodeURIComponent("0:50:0")}`); + }); + + test("returns nextCursor from Link header", async () => { + const cursor = "0:10:0"; + mockOk( + { data: makeSpanRows(10), meta: SPAN_META }, + { + Link: `; rel="next"; results="true"; cursor="${cursor}"`, + } + ); + + const result = await listSpans("my-org", "my-project", { limit: 10 }); + + expect(result.nextCursor).toBe(cursor); + }); + + test("returns undefined nextCursor when results=false", async () => { + mockOk( + { data: [], meta: SPAN_META }, + { + Link: `; rel="next"; results="false"; cursor=""`, + } + ); + + const result = await listSpans("my-org", "my-project"); + + expect(result.nextCursor).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// getSpanDetails +// --------------------------------------------------------------------------- + +describe("getSpanDetails", () => { + useTestConfigDir("traces-span-details-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedParams: Record = {}; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedParams = {}; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + const url = new URL(capturedUrl); + url.searchParams.forEach((v, k) => { + capturedParams[k] = v; + }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + } + + const DETAIL_RESPONSE = { + itemId: "abc123", + timestamp: "2026-01-01T00:00:00Z", + attributes: [ + { name: "span.op", type: "str", value: "db.query" }, + { name: "user.id", type: "str", value: "u_42" }, + ], + }; + + test("calls trace-items endpoint with item_type=spans", async () => { + mockOk(DETAIL_RESPONSE); + + await getSpanDetails("my-org", "my-project", "span-id-abc", "trace-id-xyz"); + + expect(capturedUrl).toContain( + "/projects/my-org/my-project/trace-items/span-id-abc/" + ); + expect(capturedParams.item_type).toBe("spans"); + expect(capturedParams.trace_id).toBe("trace-id-xyz"); + }); + + test("returns parsed attributes", async () => { + mockOk(DETAIL_RESPONSE); + + const result = await getSpanDetails( + "my-org", + "my-project", + "span-id-abc", + "trace-id-xyz" + ); + + expect(result.itemId).toBe("abc123"); + expect(result.attributes).toHaveLength(2); + expect(result.attributes[0]).toEqual({ + name: "span.op", + type: "str", + value: "db.query", + }); + }); +}); + +// --------------------------------------------------------------------------- +// fetchMultiSpanDetails +// --------------------------------------------------------------------------- + +describe("fetchMultiSpanDetails", () => { + useTestConfigDir("traces-multi-span-details-test-"); + + let originalFetch: typeof globalThis.fetch; + let requestedUrls: string[] = []; + + beforeEach(() => { + originalFetch = globalThis.fetch; + requestedUrls = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk() { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + requestedUrls.push(req.url); + return new Response( + JSON.stringify({ + itemId: "x", + timestamp: "2026-01-01T00:00:00Z", + attributes: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }); + } + + test("skips spans with no project slug instead of issuing a malformed request", async () => { + mockOk(); + + // fallbackProject is empty (org-scoped target) and the span has no + // project_slug — a request here would produce /projects/my-org//trace-items/… + const details = await fetchMultiSpanDetails( + [{ span_id: "span-no-project" }], + { org: "my-org", fallbackProject: "", traceId: "trace-xyz" } + ); + + expect(details.size).toBe(0); + expect(requestedUrls).toHaveLength(0); + }); + + test("uses fallback project when a span has no project_slug", async () => { + mockOk(); + + await fetchMultiSpanDetails([{ span_id: "span-a" }], { + org: "my-org", + fallbackProject: "fallback-proj", + traceId: "trace-xyz", + }); + + expect(requestedUrls).toHaveLength(1); + expect(requestedUrls[0]).toContain( + "/projects/my-org/fallback-proj/trace-items/span-a/" + ); + }); + + test("does not skip other spans when one lacks a project slug", async () => { + mockOk(); + + const details = await fetchMultiSpanDetails( + [ + { span_id: "span-no-project" }, + { span_id: "span-b", project_slug: "proj-b" }, + ], + { org: "my-org", fallbackProject: "", traceId: "trace-xyz" } + ); + + expect(requestedUrls).toHaveLength(1); + expect(requestedUrls[0]).toContain( + "/projects/my-org/proj-b/trace-items/span-b/" + ); + expect(details.has("span-b")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// getDetailedTrace +// --------------------------------------------------------------------------- + +describe("getDetailedTrace", () => { + useTestConfigDir("traces-detail-test-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const SPAN = { + span_id: "span-1", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_001, + }; + + function mockSequential(bodies: unknown[]): { + getCapturedUrls: () => string[]; + } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + const body = bodies[callIndex] ?? []; + callIndex += 1; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + test("sends timestamp and skips statsPeriod when event time is known", async () => { + const { getCapturedUrls } = mockSequential([[SPAN]]); + + const result = await getDetailedTrace("my-org", "abc123def456", { + timestamp: 1_700_000_000, + }); + + expect(result).toHaveLength(1); + expect(result[0]!.span_id).toBe("span-1"); + expect(getCapturedUrls()).toHaveLength(1); + const url = new URL(getCapturedUrls()[0]!); + expect(url.searchParams.get("timestamp")).toBe("1700000000"); + expect(url.searchParams.get("statsPeriod")).toBeNull(); + expect(url.searchParams.get("limit")).toBe("10000"); + expect(url.searchParams.get("project")).toBe("-1"); + }); + + test("queries 14d and does not retry when spans are returned", async () => { + const { getCapturedUrls } = mockSequential([[SPAN]]); + + const result = await getDetailedTrace("my-org", "abc123def456"); + + expect(result).toHaveLength(1); + expect(getCapturedUrls()).toHaveLength(1); + const url = new URL(getCapturedUrls()[0]!); + expect(url.searchParams.get("statsPeriod")).toBe("14d"); + expect(url.searchParams.get("timestamp")).toBeNull(); + }); + + test("retries 90d when the 14d lookup is empty", async () => { + const { getCapturedUrls } = mockSequential([[], [SPAN]]); + + const result = await getDetailedTrace("my-org", "abc123def456"); + + expect(result).toHaveLength(1); + expect(result[0]!.span_id).toBe("span-1"); + expect(getCapturedUrls()).toHaveLength(2); + expect(new URL(getCapturedUrls()[0]!).searchParams.get("statsPeriod")).toBe( + "14d" + ); + expect(new URL(getCapturedUrls()[1]!).searchParams.get("statsPeriod")).toBe( + "90d" + ); + expect( + new URL(getCapturedUrls()[0]!).searchParams.get("timestamp") + ).toBeNull(); + expect( + new URL(getCapturedUrls()[1]!).searchParams.get("timestamp") + ).toBeNull(); + }); + + test("does not widen when a known timestamp returns empty", async () => { + const { getCapturedUrls } = mockSequential([[]]); + + const result = await getDetailedTrace("my-org", "abc123def456", { + timestamp: 1_700_000_000, + }); + + expect(result).toHaveLength(0); + expect(getCapturedUrls()).toHaveLength(1); + expect(new URL(getCapturedUrls()[0]!).searchParams.get("timestamp")).toBe( + "1700000000" + ); + expect( + new URL(getCapturedUrls()[0]!).searchParams.get("statsPeriod") + ).toBeNull(); + }); + + test("forwards project and additional attributes on both lookups", async () => { + const { getCapturedUrls } = mockSequential([[], [SPAN]]); + + await getDetailedTrace("my-org", "abc123def456", { + projectId: 42, + additionalAttributes: ["gen_ai.request.model"], + }); + + expect(getCapturedUrls()).toHaveLength(2); + for (const raw of getCapturedUrls()) { + const url = new URL(raw); + expect(url.searchParams.get("project")).toBe("42"); + expect(url.searchParams.getAll("additional_attributes")).toEqual([ + "gen_ai.request.model", + ]); + } + }); +}); diff --git a/packages/cli/test/lib/app-scope-recovery.test.ts b/packages/cli/test/lib/app-scope-recovery.test.ts new file mode 100644 index 000000000..73bada980 --- /dev/null +++ b/packages/cli/test/lib/app-scope-recovery.test.ts @@ -0,0 +1,46 @@ +import { homedir } from "node:os"; +import { run } from "@stricli/core"; +import { afterEach, describe, expect, test } from "vitest"; +import { app } from "../../src/app.js"; +import type { SentryContext } from "../../src/context.js"; +import { getConfigDir } from "../../src/lib/db/index.js"; +import { ApiError } from "../../src/lib/errors.js"; + +const originalFetch = globalThis.fetch; + +function context(): SentryContext { + return { + process, + env: process.env, + cwd: process.cwd(), + homeDir: homedir(), + configDir: getConfigDir(), + stdout: { write: () => true }, + stderr: { write: () => true }, + stdin: process.stdin, + }; +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("scope-recovery error boundary", () => { + test.each([401, 403])("lets API %i escape Stricli", async (status) => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ detail: "Denied" }), { + status, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + let thrown: unknown; + try { + await run(app, ["org", "list", "--fresh"], context()); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(ApiError); + expect((thrown as ApiError).status).toBe(status); + }); +}); diff --git a/packages/cli/test/lib/arg-parsing.property.test.ts b/packages/cli/test/lib/arg-parsing.property.test.ts new file mode 100644 index 000000000..a3c56e43e --- /dev/null +++ b/packages/cli/test/lib/arg-parsing.property.test.ts @@ -0,0 +1,646 @@ +/** + * Property-Based Tests for Argument Parsing + * + * Uses fast-check to verify invariants of parseIssueArg() and parseOrgProjectArg() + * that are difficult to exhaustively test with example-based tests. + */ + +import { + constantFrom, + assert as fcAssert, + oneof, + property, + stringMatching, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + detectSwappedViewArgs, + looksLikeIssueShortId, + normalizeSlug, + parseIssueArg, + parseOrgProjectArg, + parseSelector, +} from "../../src/lib/arg-parsing.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries for generating valid inputs + +/** Generates valid org slugs (lowercase, alphanumeric with hyphens) */ +const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/); + +/** Generates valid project slugs (lowercase, alphanumeric with hyphens) */ +const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/); + +/** Generates valid issue suffixes (alphanumeric, 1-10 chars) */ +const suffixArb = stringMatching(/^[a-zA-Z0-9]{1,10}$/); + +/** Generates numeric-only strings (valid issue IDs) */ +const numericIdArb = stringMatching(/^[1-9][0-9]{0,15}$/); + +describe("parseIssueArg properties", () => { + test("numeric-only inputs always return type 'numeric'", async () => { + await fcAssert( + property(numericIdArb, (input) => { + const result = parseIssueArg(input); + expect(result.type).toBe("numeric"); + if (result.type === "numeric") { + expect(result.id).toBe(input); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("suffix is always uppercase in result", async () => { + await fcAssert( + property(suffixArb, (suffix) => { + const result = parseIssueArg(suffix); + // suffix-only type (no dash, no slash, not numeric) + if (result.type === "suffix-only") { + expect(result.suffix).toBe(suffix.toUpperCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/project-suffix returns type 'explicit' with uppercase suffix", async () => { + await fcAssert( + property( + tuple(orgSlugArb, projectSlugArb, suffixArb), + ([org, project, suffix]) => { + const input = `${org}/${project}-${suffix}`; + const result = parseIssueArg(input); + + expect(result.type).toBe("explicit"); + if (result.type === "explicit") { + expect(result.org).toBe(org); + expect(result.project).toBe(project); + expect(result.suffix).toBe(suffix.toUpperCase()); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/numericId returns type 'explicit-org-numeric'", async () => { + await fcAssert( + property(tuple(orgSlugArb, numericIdArb), ([org, numericId]) => { + const input = `${org}/${numericId}`; + const result = parseIssueArg(input); + + expect(result.type).toBe("explicit-org-numeric"); + if (result.type === "explicit-org-numeric") { + expect(result.org).toBe(org); + expect(result.numericId).toBe(numericId); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/suffix (no dash) returns type 'explicit-org-suffix' with uppercase suffix", async () => { + await fcAssert( + property(tuple(orgSlugArb, suffixArb), ([org, suffix]) => { + // Skip if suffix looks numeric (would be explicit-org-numeric) + if (/^\d+$/.test(suffix)) return; + + const input = `${org}/${suffix}`; + const result = parseIssueArg(input); + + expect(result.type).toBe("explicit-org-suffix"); + if (result.type === "explicit-org-suffix") { + expect(result.org).toBe(org); + expect(result.suffix).toBe(suffix.toUpperCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("project-suffix returns type 'project-search' with uppercase suffix", async () => { + await fcAssert( + property(tuple(projectSlugArb, suffixArb), ([project, suffix]) => { + const input = `${project}-${suffix}`; + const result = parseIssueArg(input); + + expect(result.type).toBe("project-search"); + if (result.type === "project-search") { + expect(result.projectSlug).toBe(project); + expect(result.suffix).toBe(suffix.toUpperCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("alphanumeric input without dash/slash that isn't numeric returns 'suffix-only'", async () => { + // Generate alphanumeric strings that contain at least one letter (not pure numeric) + const alphanumericWithLetterArb = stringMatching( + /^[a-zA-Z][a-zA-Z0-9]{0,9}$/ + ); + + await fcAssert( + property(alphanumericWithLetterArb, (input) => { + const result = parseIssueArg(input); + + expect(result.type).toBe("suffix-only"); + if (result.type === "suffix-only") { + expect(result.suffix).toBe(input.toUpperCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result type is always one of the 7 valid types", async () => { + const validTypes = [ + "numeric", + "explicit", + "explicit-org-suffix", + "explicit-org-numeric", + "project-search", + "suffix-only", + "selector", + ]; + + // Generate various valid inputs + const validInputArb = oneof( + numericIdArb, + tuple(orgSlugArb, projectSlugArb, suffixArb).map( + ([o, p, s]) => `${o}/${p}-${s}` + ), + tuple(orgSlugArb, numericIdArb).map(([o, n]) => `${o}/${n}`), + tuple(orgSlugArb, suffixArb).map(([o, s]) => `${o}/${s}`), + tuple(projectSlugArb, suffixArb).map(([p, s]) => `${p}-${s}`), + suffixArb + ); + + await fcAssert( + property(validInputArb, (input) => { + try { + const result = parseIssueArg(input); + expect(validTypes).toContain(result.type); + } catch { + // Some generated inputs may throw - that's expected for invalid formats + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("parsing never mutates the input", async () => { + const inputArb = oneof( + numericIdArb, + tuple(orgSlugArb, projectSlugArb, suffixArb).map( + ([o, p, s]) => `${o}/${p}-${s}` + ), + suffixArb + ); + + await fcAssert( + property(inputArb, (input) => { + const originalInput = input; + try { + parseIssueArg(input); + } catch { + // Ignore errors + } + // String is immutable in JS, but this verifies no weird side effects + expect(input).toBe(originalInput); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("parseIssueArg: GitHub-style # separator properties (CLI-1G1)", () => { + /** Forbidden characters (besides the structural #) that must always be rejected. */ + const forbiddenCharArb = constantFrom("?", "%", " ", "\t"); + + test("org/project#FULL-SHORTID is equivalent to org/project/FULL-SHORTID", async () => { + await fcAssert( + property( + tuple(orgSlugArb, projectSlugArb, suffixArb), + ([org, project, suffix]) => { + const shortId = `${project}-${suffix}`; + const hashForm = parseIssueArg(`${org}/${project}#${shortId}`); + const slashForm = parseIssueArg(`${org}/${project}/${shortId}`); + expect(hashForm).toEqual(slashForm); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("project#FULL-SHORTID always yields project-search with extracted suffix", async () => { + await fcAssert( + property(tuple(projectSlugArb, suffixArb), ([project, suffix]) => { + const result = parseIssueArg(`${project}#${project}-${suffix}`); + expect(result).toEqual({ + type: "project-search", + projectSlug: project, + suffix: suffix.toUpperCase(), + }); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("forbidden character in fragment always throws", async () => { + await fcAssert( + property( + tuple(projectSlugArb, suffixArb, forbiddenCharArb), + ([project, suffix, bad]) => { + expect(() => parseIssueArg(`${project}#${suffix}${bad}x`)).toThrow(); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("parseOrgProjectArg properties", () => { + test("undefined or empty string returns type 'auto-detect'", async () => { + const emptyInputArb = constantFrom(undefined, "", " ", "\t", "\n"); + + await fcAssert( + property(emptyInputArb, (input) => { + const result = parseOrgProjectArg(input); + expect(result.type).toBe("auto-detect"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/project returns type 'explicit'", async () => { + await fcAssert( + property(tuple(orgSlugArb, projectSlugArb), ([org, project]) => { + const input = `${org}/${project}`; + const result = parseOrgProjectArg(input); + + expect(result.type).toBe("explicit"); + if (result.type === "explicit") { + expect(result.org).toBe(org); + expect(result.project).toBe(project); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/ (trailing slash) returns type 'org-all'", async () => { + await fcAssert( + property(orgSlugArb, (org) => { + const input = `${org}/`; + const result = parseOrgProjectArg(input); + + expect(result.type).toBe("org-all"); + if (result.type === "org-all") { + expect(result.org).toBe(org); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("project without slash returns type 'project-search'", async () => { + await fcAssert( + property(projectSlugArb, (project) => { + const result = parseOrgProjectArg(project); + + expect(result.type).toBe("project-search"); + if (result.type === "project-search") { + expect(result.projectSlug).toBe(project); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("/project (leading slash) returns type 'project-search'", async () => { + await fcAssert( + property(projectSlugArb, (project) => { + const input = `/${project}`; + const result = parseOrgProjectArg(input); + + expect(result.type).toBe("project-search"); + if (result.type === "project-search") { + expect(result.projectSlug).toBe(project); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result type is always one of the 4 valid types", async () => { + const validTypes = ["explicit", "org-all", "project-search", "auto-detect"]; + + const validInputArb = oneof( + constantFrom(undefined, ""), + tuple(orgSlugArb, projectSlugArb).map(([o, p]) => `${o}/${p}`), + orgSlugArb.map((o) => `${o}/`), + projectSlugArb, + projectSlugArb.map((p) => `/${p}`) + ); + + await fcAssert( + property(validInputArb, (input) => { + try { + const result = parseOrgProjectArg(input); + expect(validTypes).toContain(result.type); + } catch { + // Some inputs may throw - that's expected + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("whitespace is trimmed from input", async () => { + await fcAssert( + property( + tuple(orgSlugArb, projectSlugArb, constantFrom("", " ", " ")), + ([org, project, ws]) => { + const input = `${ws}${org}/${project}${ws}`; + const result = parseOrgProjectArg(input); + + expect(result.type).toBe("explicit"); + if (result.type === "explicit") { + expect(result.org).toBe(org); + expect(result.project).toBe(project); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("parseIssueArg and parseOrgProjectArg consistency", () => { + test("parseIssueArg uses parseOrgProjectArg for dash-separated inputs", async () => { + // When parseIssueArg gets "org/project-suffix", it should parse "org/project" + // the same way parseOrgProjectArg would + await fcAssert( + property( + tuple(orgSlugArb, projectSlugArb, suffixArb), + ([org, project, suffix]) => { + const orgProject = `${org}/${project}`; + const issueArg = `${orgProject}-${suffix}`; + + const orgProjectResult = parseOrgProjectArg(orgProject); + const issueResult = parseIssueArg(issueArg); + + // parseOrgProjectArg returns "explicit" for "org/project" + expect(orgProjectResult.type).toBe("explicit"); + + // parseIssueArg should return "explicit" with matching org/project + expect(issueResult.type).toBe("explicit"); + if ( + orgProjectResult.type === "explicit" && + issueResult.type === "explicit" + ) { + expect(issueResult.org).toBe(orgProjectResult.org); + expect(issueResult.project).toBe(orgProjectResult.project); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +/** Generates all-lowercase slug-like strings with at least one dash */ +const lowercaseSlugWithDashArb = stringMatching( + /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)+$/ +); + +/** Generates alphanumeric strings without dashes */ +const noDashAlphanumArb = stringMatching(/^[a-zA-Z0-9]{1,20}$/); + +/** Generates strings that contain at least one slash */ +const withSlashArb = stringMatching(/^[a-zA-Z0-9]+\/[a-zA-Z0-9]+$/); + +/** Generates strings without slashes */ +const noSlashArb = stringMatching(/^[a-zA-Z0-9-]{1,20}$/); + +/** Generates display-name-like strings with spaces (e.g., "My Project") */ +const displayNameLikeArb = stringMatching(/^[A-Za-z][A-Za-z0-9 _-]{0,20}$/); + +describe("normalizeSlug properties (no-op)", () => { + test("always returns the input unchanged", async () => { + await fcAssert( + property(displayNameLikeArb, (input) => { + const result = normalizeSlug(input); + expect(result.slug).toBe(input); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("normalized is always false", async () => { + await fcAssert( + property(displayNameLikeArb, (input) => { + const result = normalizeSlug(input); + expect(result.normalized).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent: normalizing twice yields same result as normalizing once", async () => { + await fcAssert( + property(displayNameLikeArb, (input) => { + const first = normalizeSlug(input); + const second = normalizeSlug(first.slug); + expect(second.slug).toBe(first.slug); + expect(second.normalized).toBe(first.normalized); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("looksLikeIssueShortId properties", () => { + test("all-lowercase slugs with dashes never match", async () => { + await fcAssert( + property(lowercaseSlugWithDashArb, (input) => { + expect(looksLikeIssueShortId(input)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("strings without dashes never match", async () => { + await fcAssert( + property(noDashAlphanumArb, (input) => { + expect(looksLikeIssueShortId(input)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("strings with slashes never match", async () => { + await fcAssert( + property(withSlashArb, (input) => { + expect(looksLikeIssueShortId(input)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("two-part lowercase slugs do not match with ignoreCase", async () => { + await fcAssert( + property(lowercaseSlugWithDashArb, (input) => { + if (input.split("-").length === 2) { + expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe( + false + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("three-plus-part lowercase slugs without alphanumeric final segment do not match with ignoreCase", async () => { + await fcAssert( + property(lowercaseSlugWithDashArb, (input) => { + const parts = input.split("-"); + const lastPart = parts.at(-1) ?? ""; + const hasDigit = /\d/.test(lastPart); + const hasLetter = /[a-z]/.test(lastPart); + if (parts.length >= 3 && !(hasDigit && hasLetter)) { + expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe( + false + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("three-plus-part lowercase slugs with alphanumeric final segment match with ignoreCase", async () => { + await fcAssert( + property(lowercaseSlugWithDashArb, (input) => { + const parts = input.split("-"); + const lastPart = parts.at(-1) ?? ""; + if ( + parts.length >= 3 && + /\d/.test(lastPart) && + /[a-z]/.test(lastPart) + ) { + expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("parseSelector properties", () => { + /** All recognized selector spellings (case-insensitive) */ + const selectorVariantArb = constantFrom( + "@latest", + "@Latest", + "@LATEST", + "@most_frequent", + "@Most_Frequent", + "@MOST_FREQUENT", + "@mostfrequent", + "@most-frequent" + ); + + test("recognized selectors always return a canonical value", async () => { + await fcAssert( + property(selectorVariantArb, (input) => { + const result = parseSelector(input); + expect(result).toBeDefined(); + expect(["@latest", "@most_frequent"]).toContain(result!); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-@ strings never match", async () => { + await fcAssert( + property(orgSlugArb, (input) => { + expect(parseSelector(input)).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("bare @selector parses to type 'selector'", async () => { + await fcAssert( + property(selectorVariantArb, (input) => { + const result = parseIssueArg(input); + expect(result.type).toBe("selector"); + if (result.type === "selector") { + expect(["@latest", "@most_frequent"]).toContain(result.selector); + expect(result.org).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/@selector parses to type 'selector' with org", async () => { + await fcAssert( + property(tuple(orgSlugArb, selectorVariantArb), ([org, sel]) => { + const input = `${org}/${sel}`; + const result = parseIssueArg(input); + expect(result.type).toBe("selector"); + if (result.type === "selector") { + expect(["@latest", "@most_frequent"]).toContain(result.selector); + expect(result.org).toBe(org); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("canonical form is stable regardless of casing", async () => { + await fcAssert( + property(selectorVariantArb, (input) => { + const direct = parseSelector(input); + const lower = parseSelector(input.toLowerCase()); + expect(direct).toBe(lower); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("detectSwappedViewArgs properties", () => { + test("symmetric inverse: if swap(a,b) is non-null then swap(b,a) is null when exactly one has slash", async () => { + await fcAssert( + property(tuple(noSlashArb, withSlashArb), ([noSlash, withSlash]) => { + // noSlash first, withSlash second → swapped → non-null + expect(detectSwappedViewArgs(noSlash, withSlash)).not.toBeNull(); + // withSlash first, noSlash second → correct → null + expect(detectSwappedViewArgs(withSlash, noSlash)).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("both without slashes always returns null", async () => { + await fcAssert( + property(tuple(noSlashArb, noSlashArb), ([a, b]) => { + expect(detectSwappedViewArgs(a, b)).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("both with slashes always returns null", async () => { + await fcAssert( + property(tuple(withSlashArb, withSlashArb), ([a, b]) => { + expect(detectSwappedViewArgs(a, b)).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/arg-parsing.test.ts b/packages/cli/test/lib/arg-parsing.test.ts new file mode 100644 index 000000000..c23f6680d --- /dev/null +++ b/packages/cli/test/lib/arg-parsing.test.ts @@ -0,0 +1,1758 @@ +/** + * Argument Parsing Tests + * + * Note: Core invariants (return type determination, suffix normalization) are tested + * via property-based tests in arg-parsing.property.test.ts. These tests focus on + * error messages and edge cases. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + detectSwappedTrialArgs, + detectSwappedViewArgs, + looksLikeIssueShortId, + normalizeSlug, + parseIssueArg, + parseOrgProjectArg, + parseSlashSeparatedArg, + rejectIssueCommandTokenListTarget, + splitNewlineArg, +} from "../../src/lib/arg-parsing.js"; +import { stripDsnOrgPrefix } from "../../src/lib/dsn/index.js"; +import { ValidationError } from "../../src/lib/errors.js"; + +describe("stripDsnOrgPrefix", () => { + test("strips 'o' prefix from DSN-style org IDs", () => { + expect(stripDsnOrgPrefix("o1081365")).toBe("1081365"); + expect(stripDsnOrgPrefix("o123")).toBe("123"); + expect(stripDsnOrgPrefix("o0")).toBe("0"); + expect(stripDsnOrgPrefix("o9999999999")).toBe("9999999999"); + }); + + test("preserves normal org slugs", () => { + expect(stripDsnOrgPrefix("sentry")).toBe("sentry"); + expect(stripDsnOrgPrefix("my-org")).toBe("my-org"); + expect(stripDsnOrgPrefix("acme-corp")).toBe("acme-corp"); + }); + + test("preserves slugs starting with 'o' that have non-digit chars", () => { + expect(stripDsnOrgPrefix("organic")).toBe("organic"); + expect(stripDsnOrgPrefix("org-name")).toBe("org-name"); + expect(stripDsnOrgPrefix("o1abc")).toBe("o1abc"); + expect(stripDsnOrgPrefix("open123")).toBe("open123"); + }); + + test("preserves pure numeric strings (no 'o' prefix)", () => { + expect(stripDsnOrgPrefix("1081365")).toBe("1081365"); + expect(stripDsnOrgPrefix("123")).toBe("123"); + }); + + test("preserves empty string and 'o' alone", () => { + expect(stripDsnOrgPrefix("")).toBe(""); + expect(stripDsnOrgPrefix("o")).toBe("o"); + }); +}); + +describe("parseOrgProjectArg", () => { + // Representative examples for documentation (invariants covered by property tests) + test("org/project returns explicit", () => { + expect(parseOrgProjectArg("sentry/cli")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + }); + }); + + test("handles multi-part project slugs", () => { + expect(parseOrgProjectArg("sentry/spotlight-electron")).toEqual({ + type: "explicit", + org: "sentry", + project: "spotlight-electron", + }); + }); + + // Error case - verify specific message + test("just slash throws error", () => { + expect(() => parseOrgProjectArg("/")).toThrow( + 'Invalid format: "/" requires a project slug' + ); + }); + + // Parser preserves DSN-style org identifiers (normalization moved to resolution layer) + describe("DSN-style org identifiers are preserved", () => { + test("preserves 'o' prefix in org-all mode", () => { + expect(parseOrgProjectArg("o1081365/")).toEqual({ + type: "org-all", + org: "o1081365", + }); + }); + + test("preserves 'o' prefix in explicit mode", () => { + expect(parseOrgProjectArg("o1081365/myproject")).toEqual({ + type: "explicit", + org: "o1081365", + project: "myproject", + }); + }); + + test("preserves normal org slugs", () => { + expect(parseOrgProjectArg("organic/cli")).toEqual({ + type: "explicit", + org: "organic", + project: "cli", + }); + }); + + test("preserves slugs with mixed chars after 'o'", () => { + expect(parseOrgProjectArg("o1abc/cli")).toEqual({ + type: "explicit", + org: "o1abc", + project: "cli", + }); + }); + }); + + // URL integration tests — applySentryUrlContext may set SENTRY_HOST/SENTRY_URL as a side effect. + // Host-scoping: non-SaaS URLs now require the token to be scoped to the + // same host. Tests that pass self-hosted URLs must set SENTRY_HOST before + // running so the env-token-host snapshot matches. + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + let savedSentryHost: string | undefined; + + beforeEach(async () => { + savedSentryUrl = process.env.SENTRY_URL; + savedSentryHost = process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + delete process.env.SENTRY_HOST; + const { resetEnvTokenHostForTesting } = await import( + "../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + }); + + afterEach(async () => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + if (savedSentryHost !== undefined) { + process.env.SENTRY_HOST = savedSentryHost; + } else { + delete process.env.SENTRY_HOST; + } + const { resetEnvTokenHostForTesting } = await import( + "../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + }); + + test("issue URL returns org-all", () => { + expect( + parseOrgProjectArg( + "https://sentry.io/organizations/my-org/issues/12345/" + ) + ).toEqual({ + type: "org-all", + org: "my-org", + }); + }); + + test("project settings URL returns explicit", () => { + expect( + parseOrgProjectArg( + "https://sentry.io/settings/my-org/projects/backend/" + ) + ).toEqual({ + type: "explicit", + org: "my-org", + project: "backend", + }); + }); + + test("org-only URL returns org-all", () => { + expect( + parseOrgProjectArg("https://sentry.io/organizations/my-org/") + ).toEqual({ + type: "org-all", + org: "my-org", + }); + }); + + test("self-hosted URL extracts org when token is scoped to that host", () => { + // Pin env-token to sentry.example.com so the URL-arg's host matches. + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect( + parseOrgProjectArg( + "https://sentry.example.com/organizations/acme-corp/issues/99/" + ) + ).toEqual({ + type: "org-all", + org: "acme-corp", + }); + }); + + test("self-hosted URL throws when token is scoped to a different host", () => { + // No SENTRY_HOST set → env-token defaults to SaaS → mismatch on self-hosted URL. + expect(() => + parseOrgProjectArg( + "https://sentry.example.com/organizations/acme-corp/issues/99/" + ) + ).toThrow(/does not match|sentry auth login --url/); + }); + }); + + describe("space handling (no normalization)", () => { + test("bare project with spaces produces project-search with originalSlug", () => { + const result = parseOrgProjectArg("My Project"); + expect(result).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + }); + }); + + test("org with spaces in explicit mode throws ValidationError", () => { + expect(() => parseOrgProjectArg("My Org/cli")).toThrow(ValidationError); + }); + + test("project with spaces in explicit mode produces project-search with org", () => { + expect(parseOrgProjectArg("sentry/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "sentry", + }); + }); + + test("org with spaces in org-all mode throws ValidationError", () => { + expect(() => parseOrgProjectArg("My Org/")).toThrow(ValidationError); + }); + + test("org with underscores and project with spaces produces project-search with org", () => { + expect(parseOrgProjectArg("my_org/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "my_org", + }); + }); + + test("does not throw for auto-detect", () => { + expect(parseOrgProjectArg(undefined)).toEqual({ type: "auto-detect" }); + }); + + test("does not throw when no spaces present", () => { + expect(parseOrgProjectArg("sentry/cli")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + }); + }); + + test("does not throw for underscored slug", () => { + // Underscores are valid in Sentry slugs — no normalization, no error. + expect(parseOrgProjectArg("selfbase_admin_backend")).toEqual({ + type: "project-search", + projectSlug: "selfbase_admin_backend", + }); + }); + }); + + describe("@-selector rejection", () => { + test("@latest throws with redirect to issue view", () => { + expect(() => parseOrgProjectArg("@latest")).toThrow( + "is an issue selector, not a project slug" + ); + expect(() => parseOrgProjectArg("@latest")).toThrow( + "sentry issue view @latest" + ); + }); + + test("@most_frequent throws with redirect to issue view", () => { + expect(() => parseOrgProjectArg("@most_frequent")).toThrow( + "is an issue selector, not a project slug" + ); + expect(() => parseOrgProjectArg("@most_frequent")).toThrow( + "sentry issue view @most_frequent" + ); + }); + + test("case-insensitive selector variants are rejected", () => { + expect(() => parseOrgProjectArg("@Latest")).toThrow( + "is an issue selector" + ); + expect(() => parseOrgProjectArg("@LATEST")).toThrow( + "is an issue selector" + ); + expect(() => parseOrgProjectArg("@mostFrequent")).toThrow( + "is an issue selector" + ); + }); + + test("unknown @-prefixed value throws as invalid slug", () => { + expect(() => parseOrgProjectArg("@unknown")).toThrow("starts with '@'"); + }); + + test("/@latest (leading slash) throws with redirect", () => { + expect(() => parseOrgProjectArg("/@latest")).toThrow( + "is an issue selector" + ); + }); + + test("sentry/@latest (org/selector) throws with redirect", () => { + expect(() => parseOrgProjectArg("sentry/@latest")).toThrow( + "is an issue selector" + ); + }); + + test("@latest/project (selector as org) throws", () => { + expect(() => parseOrgProjectArg("@latest/cli")).toThrow( + "is an issue selector, not an organization slug" + ); + }); + + test("@unknown/project (unknown @ as org) throws", () => { + expect(() => parseOrgProjectArg("@unknown/cli")).toThrow( + "starts with '@'" + ); + }); + }); +}); + +describe("parseIssueArg", () => { + // Representative examples for documentation (invariants covered by property tests) + describe("representative examples", () => { + test("org/project-suffix returns explicit", () => { + expect(parseIssueArg("sentry/cli-G")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "G", + }); + }); + + test("handles multi-part project slugs", () => { + expect(parseIssueArg("sentry/spotlight-electron-4Y")).toEqual({ + type: "explicit", + org: "sentry", + project: "spotlight-electron", + suffix: "4Y", + }); + }); + }); + + // Multi-line input (CLI-1G1): agents and shells pass identifiers with + // internal newlines (command substitution capturing extra output, an + // appended note, or several newline-separated IDs). A bare trim() leaves the + // internal newline, which previously threw a cryptic "contains a newline" + // ValidationError. We keep the first non-blank line instead. + describe("multi-line and whitespace input (CLI-1G1)", () => { + const expected = { + type: "explicit", + org: "sentry", + project: "cli", + suffix: "G", + }; + + test("strips a trailing newline (CLI-16M regression)", () => { + expect(parseIssueArg("sentry/cli-G\n")).toEqual(expected); + }); + + test("skips leading blank lines", () => { + expect(parseIssueArg("\n\n sentry/cli-G")).toEqual(expected); + }); + + test("keeps the first line when a note is appended after a newline", () => { + expect(parseIssueArg("sentry/cli-G\nthe auth-token error")).toEqual( + expected + ); + }); + + test("keeps the first identifier when several are newline-separated", () => { + expect(parseIssueArg("sentry/cli-G\nsentry/cli-H")).toEqual(expected); + }); + + test("handles CRLF line endings", () => { + expect(parseIssueArg("sentry/cli-G\r\ntrailing line")).toEqual(expected); + }); + + test("throws a clear error when input is only blank lines", () => { + expect(() => parseIssueArg("\n \n\t\n")).toThrow(ValidationError); + expect(() => parseIssueArg("\n \n\t\n")).toThrow( + /empty after trimming/ + ); + }); + }); + + // Error cases - verify specific error messages + describe("error cases", () => { + test("org/-suffix throws error", () => { + expect(() => parseIssueArg("sentry/-G")).toThrow( + "Cannot use trailing slash before suffix" + ); + }); + + test("-suffix (empty left) throws error", () => { + expect(() => parseIssueArg("-G")).toThrow( + "Missing project before suffix" + ); + }); + + test("trailing dash (empty suffix) throws error", () => { + expect(() => parseIssueArg("cli-")).toThrow("Missing suffix after dash"); + }); + + test("org/project with trailing dash (empty suffix) throws error", () => { + expect(() => parseIssueArg("sentry/cli-")).toThrow( + "Missing suffix after dash" + ); + }); + + test("org with trailing slash (empty issue ID) throws error", () => { + expect(() => parseIssueArg("sentry/")).toThrow( + "Missing issue ID after slash" + ); + }); + + test("issue-1 throws ValidationError for issue command token prefix", () => { + expect(() => parseIssueArg("issue-1")).toThrow(ValidationError); + expect(() => parseIssueArg("issue-1")).toThrow( + "looks like a command token plus a suffix" + ); + }); + + test("my-org/issue-1 throws ValidationError for org-qualified issue command token", () => { + expect(() => parseIssueArg("my-org/issue-1")).toThrow(ValidationError); + }); + + test("ISSUE-1 parses as project-search (uppercase short ID, not command token)", () => { + expect(parseIssueArg("ISSUE-1")).toEqual({ + type: "project-search", + projectSlug: "issue", + suffix: "1", + }); + }); + + test("my-org/api-G parses as explicit org/project-suffix", () => { + expect(parseIssueArg("my-org/api-G")).toEqual({ + type: "explicit", + org: "my-org", + project: "api", + suffix: "G", + }); + }); + + test("api-G parses as project-search", () => { + expect(parseIssueArg("api-G")).toEqual({ + type: "project-search", + projectSlug: "api", + suffix: "G", + }); + }); + + test("api-1 parses as project-search for projects named api", () => { + expect(parseIssueArg("api-1")).toEqual({ + type: "project-search", + projectSlug: "api", + suffix: "1", + }); + }); + + test("release-123 parses as project-search for projects named release", () => { + expect(parseIssueArg("release-123")).toEqual({ + type: "project-search", + projectSlug: "release", + suffix: "123", + }); + }); + + test("my-org/api-1 parses as explicit for org-qualified api project", () => { + expect(parseIssueArg("my-org/api-1")).toEqual({ + type: "explicit", + org: "my-org", + project: "api", + suffix: "1", + }); + }); + + test("cli-g still parses as project-search", () => { + expect(parseIssueArg("cli-g")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + + test("just slash throws error", () => { + expect(() => parseIssueArg("/")).toThrow("Missing issue ID after slash"); + }); + + test("invalid issue format throws ValidationError (not raw Error)", () => { + // Ensures the fingerprint rule's `cli_error.class:"ValidationError"` tag + // fires for these user-input errors, so they group under the canonical + // ValidationError parent instead of as generic `Error` issues in Sentry. + // Regression: CLI-1C9 was created because `parseIssueArg` used raw + // `throw new Error(...)` instead of `ValidationError` for format errors. + expect(() => parseIssueArg("XQUIK-")).toThrow(ValidationError); + expect(() => parseIssueArg("cli-")).toThrow(ValidationError); + expect(() => parseIssueArg("sentry/")).toThrow(ValidationError); + expect(() => parseIssueArg("sentry/-G")).toThrow(ValidationError); + expect(() => parseIssueArg("-G")).toThrow(ValidationError); + expect(() => parseIssueArg("/")).toThrow(ValidationError); + }); + }); + + // URL integration tests — applySentryUrlContext may set SENTRY_HOST/SENTRY_URL as a side effect + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + let savedSentryHost: string | undefined; + + beforeEach(() => { + savedSentryUrl = process.env.SENTRY_URL; + savedSentryHost = process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + delete process.env.SENTRY_HOST; + }); + + afterEach(() => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + if (savedSentryHost !== undefined) { + process.env.SENTRY_HOST = savedSentryHost; + } else { + delete process.env.SENTRY_HOST; + } + }); + + test("issue URL with numeric ID returns explicit-org-numeric", () => { + expect( + parseIssueArg("https://sentry.io/organizations/my-org/issues/32886/") + ).toEqual({ + type: "explicit-org-numeric", + org: "my-org", + numericId: "32886", + }); + }); + + test("issue URL with short ID returns explicit with lowercase project", () => { + expect( + parseIssueArg("https://sentry.io/organizations/my-org/issues/CLI-G/") + ).toEqual({ + type: "explicit", + org: "my-org", + project: "cli", + suffix: "G", + }); + }); + + test("issue URL with multi-part short ID returns explicit with lowercase project", () => { + expect( + parseIssueArg( + "https://sentry.io/organizations/my-org/issues/SPOTLIGHT-ELECTRON-4Y/" + ) + ).toEqual({ + type: "explicit", + org: "my-org", + project: "spotlight-electron", + suffix: "4Y", + }); + }); + + test("self-hosted issue URL with query params (requires matching token host)", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect( + parseIssueArg( + "https://sentry.example.com/organizations/acme/issues/32886/?project=2" + ) + ).toEqual({ + type: "explicit-org-numeric", + org: "acme", + numericId: "32886", + }); + }); + + test("event URL extracts issue ID (ignores event part)", () => { + const result = parseIssueArg( + "https://sentry.io/organizations/my-org/issues/32886/events/abc123/" + ); + expect(result).toEqual({ + type: "explicit-org-numeric", + org: "my-org", + numericId: "32886", + }); + }); + + test("trace URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg( + "https://sentry.io/organizations/my-org/traces/a4d1aae7216b47ff/" + ) + ).toThrow(ValidationError); + }); + + test("org-only URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg("https://sentry.io/organizations/my-org/") + ).toThrow(ValidationError); + }); + + test("project settings URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg("https://sentry.io/settings/my-org/projects/backend/") + ).toThrow(ValidationError); + }); + + test("non-issue URL error mentions issue URL format", () => { + try { + parseIssueArg("https://sentry.io/organizations/my-org/traces/abc/"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain( + "does not contain an issue ID" + ); + } + }); + + test("SaaS subdomain share URL returns share type with org", () => { + expect( + parseIssueArg( + "https://gibush-kq.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ) + ).toEqual({ + type: "share", + shareId: "f1abd515c51346778384ff25dfb341e5", + org: "gibush-kq", + baseUrl: "https://gibush-kq.sentry.io", + }); + }); + + test("bare sentry.io share URL returns share type without org", () => { + expect( + parseIssueArg( + "https://sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ) + ).toEqual({ + type: "share", + shareId: "f1abd515c51346778384ff25dfb341e5", + org: undefined, + baseUrl: "https://sentry.io", + }); + }); + + test("self-hosted share URL returns share type (requires matching token host)", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect( + parseIssueArg( + "https://sentry.example.com/share/issue/aabbccdd11223344aabbccdd11223344/" + ) + ).toEqual({ + type: "share", + shareId: "aabbccdd11223344aabbccdd11223344", + org: undefined, + baseUrl: "https://sentry.example.com", + }); + }); + }); + + // Parser preserves DSN-style org identifiers (normalization moved to resolution layer) + describe("DSN-style org identifiers are preserved", () => { + test("preserves 'o' prefix in explicit", () => { + expect(parseIssueArg("o1081365/CLI-G")).toEqual({ + type: "explicit", + org: "o1081365", + project: "cli", + suffix: "G", + }); + }); + + test("preserves 'o' prefix in explicit-org-numeric", () => { + expect(parseIssueArg("o999/123456789")).toEqual({ + type: "explicit-org-numeric", + org: "o999", + numericId: "123456789", + }); + }); + + test("preserves 'o' prefix in explicit-org-suffix", () => { + expect(parseIssueArg("o1081365/G")).toEqual({ + type: "explicit-org-suffix", + org: "o1081365", + suffix: "G", + }); + }); + + test("preserves normal org slugs in issue args", () => { + expect(parseIssueArg("organic/cli-G")).toEqual({ + type: "explicit", + org: "organic", + project: "cli", + suffix: "G", + }); + }); + }); + + // Multi-slash issue args (org/project/suffix) + describe("multi-slash issue args", () => { + test("org/project/numeric returns explicit-org-numeric", () => { + expect(parseIssueArg("org/project/101149101")).toEqual({ + type: "explicit-org-numeric", + org: "org", + numericId: "101149101", + }); + }); + + test("org/project/short-numeric returns explicit-org-numeric", () => { + expect(parseIssueArg("org/project/123456")).toEqual({ + type: "explicit-org-numeric", + org: "org", + numericId: "123456", + }); + }); + + test("org/project/PROJ-G where PROJ ≠ project returns explicit with combined suffix", () => { + expect(parseIssueArg("org/project/PROJ-G")).toEqual({ + type: "explicit", + org: "org", + project: "project", + suffix: "PROJ-G", + }); + }); + + test("org/project/PROJECT-G where prefix matches project strips prefix (CLI-KC)", () => { + expect(parseIssueArg("sentry/cli/CLI-A1")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "A1", + }); + }); + + test("org/project/PROJECT-suffix is case-insensitive on prefix match", () => { + expect(parseIssueArg("sentry/cli/cli-b6")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "B6", + }); + }); + + test("compound project slug with matching full short ID (CLI-KC)", () => { + expect( + parseIssueArg("org/spotlight-electron/SPOTLIGHT-ELECTRON-4Y") + ).toEqual({ + type: "explicit", + org: "org", + project: "spotlight-electron", + suffix: "4Y", + }); + }); + + test("org/project/numeric-id returns explicit-org-numeric (CLI-B6)", () => { + expect(parseIssueArg("fever/cashless/6918259357")).toEqual({ + type: "explicit-org-numeric", + org: "fever", + numericId: "6918259357", + }); + }); + + test("org/project/G returns explicit with suffix", () => { + expect(parseIssueArg("org/project/G")).toEqual({ + type: "explicit", + org: "org", + project: "project", + suffix: "G", + }); + }); + + test("org/project/ (trailing slash, empty suffix) throws error", () => { + expect(() => parseIssueArg("org/project/")).toThrow( + "Missing project or issue ID segment" + ); + }); + + test("org//suffix (empty project) throws error", () => { + expect(() => parseIssueArg("org//suffix")).toThrow( + "Missing project or issue ID segment" + ); + }); + }); + + // Edge cases - document tricky behaviors + describe("edge cases", () => { + test("/suffix returns suffix-only", () => { + // Leading slash with no org - treat as suffix + expect(parseIssueArg("/G")).toEqual({ + type: "suffix-only", + suffix: "G", + }); + }); + + test("/project-suffix returns project-search", () => { + // Leading slash with project and suffix + expect(parseIssueArg("/cli-G")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + + test("/multi-part-project-suffix returns project-search", () => { + // Leading slash with multi-part project slug + expect(parseIssueArg("/spotlight-electron-4Y")).toEqual({ + type: "project-search", + projectSlug: "spotlight-electron", + suffix: "4Y", + }); + }); + }); + + // Colon-separated issue args — users type PROJECT:SHORTID or PROJECT:NUMERICID + describe("colon-separated issue args (CLI-PH)", () => { + test("PROJECT:SUFFIX returns project-search", () => { + expect(parseIssueArg("CHATEX:W9")).toEqual({ + type: "project-search", + projectSlug: "chatex", + suffix: "W9", + }); + }); + + test("PROJECT:PROJECT-SUFFIX extracts suffix from last dash", () => { + expect(parseIssueArg("CHATEX:CHATEX-W9")).toEqual({ + type: "project-search", + projectSlug: "chatex", + suffix: "W9", + }); + }); + + test("PROJECT:PROJECT-SUFFIX with multi-hyphen project", () => { + expect(parseIssueArg("CHATEX:CHATEX-12A")).toEqual({ + type: "project-search", + projectSlug: "chatex", + suffix: "12A", + }); + }); + + test("MULTI-PROJECT:NUMERICID returns numeric", () => { + expect(parseIssueArg("MYAH-FRONTEND:115562020")).toEqual({ + type: "numeric", + id: "115562020", + }); + }); + + test("PROJECT:NUMERICID returns numeric", () => { + expect(parseIssueArg("CLI:123456789")).toEqual({ + type: "numeric", + id: "123456789", + }); + }); + + test("colon with empty project falls through to normal parsing", () => { + // ":W9" has empty project part — parseWithColon returns null, + // falls through to normal parsing (no slash, no dash → suffix-only) + expect(parseIssueArg(":W9")).toEqual({ + type: "suffix-only", + suffix: ":W9", + }); + }); + + test("colon with empty suffix falls through to normal parsing", () => { + // "CLI:" has empty id part — parseWithColon returns null, + // falls through to normal parsing (no slash, no dash → suffix-only) + expect(parseIssueArg("CLI:")).toEqual({ + type: "suffix-only", + suffix: "CLI:", + }); + }); + + test("multi-hyphen project with colon-separated short ID", () => { + expect(parseIssueArg("ARES-BACKEND:4P")).toEqual({ + type: "project-search", + projectSlug: "ares-backend", + suffix: "4P", + }); + }); + + test("org/project:suffix falls through to slash parsing", () => { + // When input has both slash and colon, slash parsing takes precedence + // because parseWithColon returns null for slash-containing project parts. + // "CLI:W9" has no dash, so parseAfterSlash returns explicit-org-suffix. + expect(parseIssueArg("sentry/CLI:W9")).toEqual({ + type: "explicit-org-suffix", + org: "sentry", + suffix: "CLI:W9", + }); + }); + }); + + describe("magic @ selectors", () => { + test("@latest returns selector type", () => { + expect(parseIssueArg("@latest")).toEqual({ + type: "selector", + selector: "@latest", + }); + }); + + test("@most_frequent returns selector type", () => { + expect(parseIssueArg("@most_frequent")).toEqual({ + type: "selector", + selector: "@most_frequent", + }); + }); + + test("case-insensitive: @LATEST and @Latest both work", () => { + expect(parseIssueArg("@LATEST")).toEqual({ + type: "selector", + selector: "@latest", + }); + expect(parseIssueArg("@Latest")).toEqual({ + type: "selector", + selector: "@latest", + }); + }); + + test("alternative spellings: @mostfrequent, @most-frequent", () => { + expect(parseIssueArg("@mostfrequent")).toEqual({ + type: "selector", + selector: "@most_frequent", + }); + expect(parseIssueArg("@most-frequent")).toEqual({ + type: "selector", + selector: "@most_frequent", + }); + }); + + test("org/@latest returns selector with org", () => { + expect(parseIssueArg("sentry/@latest")).toEqual({ + type: "selector", + selector: "@latest", + org: "sentry", + }); + }); + + test("org/@most_frequent returns selector with org", () => { + expect(parseIssueArg("my-org/@most_frequent")).toEqual({ + type: "selector", + selector: "@most_frequent", + org: "my-org", + }); + }); + + test("unrecognized @selector falls through to suffix-only", () => { + // Unrecognized @ values are treated as suffix-only since @ is not + // in the forbidden character set for resource IDs. They will fail + // at the API level rather than at parse time. + expect(parseIssueArg("@unknown")).toEqual({ + type: "suffix-only", + suffix: "@UNKNOWN", + }); + }); + }); +}); + +describe("normalizeSlug", () => { + test("preserves underscores (valid in Sentry slugs — #770)", () => { + // Sentry accepts underscores in project slugs, so the CLI must not + // rewrite them. Previously normalized to "selfbase-admin-backend" + // which then failed API lookups. + expect(normalizeSlug("selfbase_admin_backend")).toEqual({ + slug: "selfbase_admin_backend", + normalized: false, + }); + }); + + test("preserves normal slugs (no spaces)", () => { + expect(normalizeSlug("my-project")).toEqual({ + slug: "my-project", + normalized: false, + }); + }); + + test("preserves multiple underscores", () => { + expect(normalizeSlug("a_b_c_d")).toEqual({ + slug: "a_b_c_d", + normalized: false, + }); + }); + + test("preserves leading underscore", () => { + expect(normalizeSlug("_leading")).toEqual({ + slug: "_leading", + normalized: false, + }); + }); + + test("preserves trailing underscore", () => { + expect(normalizeSlug("trailing_")).toEqual({ + slug: "trailing_", + normalized: false, + }); + }); + + test("preserves mixed case when no spaces (no lowercasing trigger)", () => { + expect(normalizeSlug("My_Project")).toEqual({ + slug: "My_Project", + normalized: false, + }); + }); + + test("handles empty string", () => { + expect(normalizeSlug("")).toEqual({ + slug: "", + normalized: false, + }); + }); + + test("passes through input with spaces unchanged (no-op)", () => { + expect(normalizeSlug("My Project")).toEqual({ + slug: "My Project", + normalized: false, + }); + }); + + test("passes through input with consecutive spaces unchanged (no-op)", () => { + expect(normalizeSlug("My Project")).toEqual({ + slug: "My Project", + normalized: false, + }); + }); + + test("passes through input with leading/trailing spaces unchanged (no-op)", () => { + expect(normalizeSlug(" My Project ")).toEqual({ + slug: " My Project ", + normalized: false, + }); + }); + + test("passes through input with underscores and spaces unchanged (no-op)", () => { + // normalizeSlug is a no-op — spaces and underscores both pass through. + expect(normalizeSlug("My_Project Name")).toEqual({ + slug: "My_Project Name", + normalized: false, + }); + }); +}); + +describe("looksLikeIssueShortId", () => { + describe("matches valid issue short IDs", () => { + test("CAM-82X", () => { + expect(looksLikeIssueShortId("CAM-82X")).toBe(true); + }); + + test("CLI-G", () => { + expect(looksLikeIssueShortId("CLI-G")).toBe(true); + }); + + test("SPOTLIGHT-ELECTRON-4Y", () => { + expect(looksLikeIssueShortId("SPOTLIGHT-ELECTRON-4Y")).toBe(true); + }); + + test("A-1", () => { + expect(looksLikeIssueShortId("A-1")).toBe(true); + }); + + test("CLI-123", () => { + expect(looksLikeIssueShortId("CLI-123")).toBe(true); + }); + }); + + describe("rejects non-issue strings", () => { + test("my-project (lowercase)", () => { + expect(looksLikeIssueShortId("my-project")).toBe(false); + }); + + test("a9b4ad2c (no dash)", () => { + expect(looksLikeIssueShortId("a9b4ad2c")).toBe(false); + }); + + test("org/project (has slash)", () => { + expect(looksLikeIssueShortId("org/project")).toBe(false); + }); + + test("CAM- (trailing dash, empty suffix)", () => { + expect(looksLikeIssueShortId("CAM-")).toBe(false); + }); + + test("-82X (leading dash)", () => { + expect(looksLikeIssueShortId("-82X")).toBe(false); + }); + + test("G (single char, no dash)", () => { + expect(looksLikeIssueShortId("G")).toBe(false); + }); + + test("cam-82x (all lowercase)", () => { + expect(looksLikeIssueShortId("cam-82x")).toBe(false); + }); + + test("123 (pure numeric)", () => { + expect(looksLikeIssueShortId("123")).toBe(false); + }); + }); + + describe("with ignoreCase option", () => { + test("cam-82x with ignoreCase is false (two-part lowercase slug)", () => { + expect(looksLikeIssueShortId("cam-82x", { ignoreCase: true })).toBe( + false + ); + }); + + test("CaM-82x with ignoreCase (mixed case short ID)", () => { + expect(looksLikeIssueShortId("CaM-82x", { ignoreCase: true })).toBe(true); + }); + + test("javascript-react-mr-1b with ignoreCase", () => { + expect( + looksLikeIssueShortId("javascript-react-mr-1b", { ignoreCase: true }) + ).toBe(true); + }); + + test("my-frontend-app with ignoreCase is false (3-part project slug)", () => { + expect( + looksLikeIssueShortId("my-frontend-app", { ignoreCase: true }) + ).toBe(false); + }); + + test("my-app-2 with ignoreCase is false (versioned project slug)", () => { + expect(looksLikeIssueShortId("my-app-2", { ignoreCase: true })).toBe( + false + ); + }); + + test("my-app-2b with ignoreCase is true (lowercase multi-segment, alphanumeric final)", () => { + expect(looksLikeIssueShortId("my-app-2b", { ignoreCase: true })).toBe( + true + ); + }); + + test("my-apps-2b with ignoreCase is true (alphanumeric final, no length special-casing)", () => { + expect(looksLikeIssueShortId("my-apps-2b", { ignoreCase: true })).toBe( + true + ); + }); + + test("api-gateway-1 with ignoreCase is false (versioned project slug)", () => { + expect(looksLikeIssueShortId("api-gateway-1", { ignoreCase: true })).toBe( + false + ); + }); + + test("My-App-2 with ignoreCase is false (title-case project slug)", () => { + expect(looksLikeIssueShortId("My-App-2", { ignoreCase: true })).toBe( + false + ); + }); + + test("My-Frontend-App with ignoreCase is false (title-case project slug)", () => { + expect( + looksLikeIssueShortId("My-Frontend-App", { ignoreCase: true }) + ).toBe(false); + }); + + test("My-2 with ignoreCase is false (two-part title-case slug)", () => { + expect(looksLikeIssueShortId("My-2", { ignoreCase: true })).toBe(false); + }); + + test("My-Project with ignoreCase is false (two-part title-case slug)", () => { + expect(looksLikeIssueShortId("My-Project", { ignoreCase: true })).toBe( + false + ); + }); + + test("My-2b with ignoreCase is false (title-case slug, alphanumeric suffix)", () => { + expect(looksLikeIssueShortId("My-2b", { ignoreCase: true })).toBe(false); + }); + + test("Foo-3 with ignoreCase is false (two-part title-case slug)", () => { + expect(looksLikeIssueShortId("Foo-3", { ignoreCase: true })).toBe(false); + }); + + test("CLI-5 with ignoreCase is true (all-uppercase prefix, numeric suffix)", () => { + expect(looksLikeIssueShortId("CLI-5", { ignoreCase: true })).toBe(true); + }); + + test("P-1 with ignoreCase is true (single-letter short ID prefix)", () => { + expect(looksLikeIssueShortId("P-1", { ignoreCase: true })).toBe(true); + }); + + test("A-1 with ignoreCase is true (single-letter short ID prefix)", () => { + expect(looksLikeIssueShortId("A-1", { ignoreCase: true })).toBe(true); + }); + + test("spotlight-electron-4y with ignoreCase is true (lowercase multi-segment short ID)", () => { + expect( + looksLikeIssueShortId("spotlight-electron-4y", { ignoreCase: true }) + ).toBe(true); + }); + + test("SPOTLIGHT-ELECTRON-5 with ignoreCase is true (uppercase multi-segment numeric suffix)", () => { + expect( + looksLikeIssueShortId("SPOTLIGHT-ELECTRON-5", { ignoreCase: true }) + ).toBe(true); + }); + + test("JAVASCRIPT-NUXT-52 with ignoreCase is true (uppercase multi-segment numeric suffix)", () => { + expect( + looksLikeIssueShortId("JAVASCRIPT-NUXT-52", { ignoreCase: true }) + ).toBe(true); + }); + + test("my-project with ignoreCase is false (project slug)", () => { + expect(looksLikeIssueShortId("my-project", { ignoreCase: true })).toBe( + false + ); + }); + + test("acme-corp with ignoreCase is false (org/project slug)", () => { + expect(looksLikeIssueShortId("acme-corp", { ignoreCase: true })).toBe( + false + ); + }); + }); +}); + +describe("rejectIssueCommandTokenListTarget", () => { + test("issue-1 throws ValidationError", () => { + expect(() => rejectIssueCommandTokenListTarget("issue-1")).toThrow( + ValidationError + ); + expect(() => rejectIssueCommandTokenListTarget("issue-1")).toThrow( + "looks like a command token plus a suffix" + ); + }); + + test("my-org/issue-1 throws ValidationError", () => { + expect(() => rejectIssueCommandTokenListTarget("my-org/issue-1")).toThrow( + ValidationError + ); + }); + + test("whitespace-padded issue-1 throws ValidationError", () => { + expect(() => rejectIssueCommandTokenListTarget(" issue-1 ")).toThrow( + ValidationError + ); + expect(() => + rejectIssueCommandTokenListTarget(" my-org/issue-1\n") + ).toThrow(ValidationError); + }); + + test("api-1 does not throw", () => { + expect(() => rejectIssueCommandTokenListTarget("api-1")).not.toThrow(); + }); + + test("my-org/cli does not throw", () => { + expect(() => rejectIssueCommandTokenListTarget("my-org/cli")).not.toThrow(); + }); + + test("ISSUE-1 does not throw (uppercase short ID, not command token)", () => { + expect(() => rejectIssueCommandTokenListTarget("ISSUE-1")).not.toThrow(); + }); +}); + +describe("detectSwappedViewArgs", () => { + test("returns warning when second has slash but first does not (swapped)", () => { + const result = detectSwappedViewArgs("a9b4ad2c", "mv-software/mvsoftware"); + expect(result).not.toBeNull(); + expect(result).toContain("mv-software/mvsoftware"); + expect(result).toContain("a9b4ad2c"); + }); + + test("returns null when first has slash (correct order)", () => { + expect( + detectSwappedViewArgs("mv-software/mvsoftware", "a9b4ad2c") + ).toBeNull(); + }); + + test("returns null when neither has slash", () => { + expect(detectSwappedViewArgs("a9b4ad2c", "deadbeef")).toBeNull(); + }); + + test("returns null when both have slashes", () => { + expect(detectSwappedViewArgs("org/project", "other/thing")).toBeNull(); + }); +}); + +describe("detectSwappedTrialArgs", () => { + const isKnown = (v: string) => ["seer", "replays", "performance"].includes(v); + + test("returns null when first arg is a known name (correct order)", () => { + expect(detectSwappedTrialArgs("seer", "my-org", isKnown)).toBeNull(); + }); + + test("returns swap result when second is known but first is not", () => { + const result = detectSwappedTrialArgs("my-org", "seer", isKnown); + expect(result).not.toBeNull(); + expect(result!.name).toBe("seer"); + expect(result!.org).toBe("my-org"); + expect(result!.warning).toContain("reversed"); + }); + + test("returns null when neither is a known name", () => { + expect(detectSwappedTrialArgs("my-org", "other-org", isKnown)).toBeNull(); + }); + + test("returns null when both are known names", () => { + // If both are trial names, first is treated as the name (correct order) + expect(detectSwappedTrialArgs("seer", "replays", isKnown)).toBeNull(); + }); +}); + +describe("parseOrgProjectArg: underscores pass through", () => { + // Sentry allows underscores in project slugs (the UI and API both accept + // them at creation time), so the CLI must not rewrite them. Previously + // these inputs were silently converted to dashes, causing "Project not + // found" errors for customers with underscored slugs (see #770). + + test("preserves org slug underscores in explicit mode", () => { + const result = parseOrgProjectArg("org_name/project"); + expect(result).toEqual({ + type: "explicit", + org: "org_name", + project: "project", + }); + expect(result).not.toHaveProperty("normalized"); + }); + + test("preserves project slug underscores in explicit mode", () => { + const result = parseOrgProjectArg("org/project_name"); + expect(result).toEqual({ + type: "explicit", + org: "org", + project: "project_name", + }); + expect(result).not.toHaveProperty("normalized"); + }); + + test("preserves both org and project underscores", () => { + const result = parseOrgProjectArg("org_name/project_name"); + expect(result).toEqual({ + type: "explicit", + org: "org_name", + project: "project_name", + }); + expect(result).not.toHaveProperty("normalized"); + }); + + test("preserves project-search underscores", () => { + const result = parseOrgProjectArg("selfbase_admin_backend"); + expect(result).toEqual({ + type: "project-search", + projectSlug: "selfbase_admin_backend", + }); + expect(result).not.toHaveProperty("normalized"); + }); + + test("normalized is absent for normal slugs (explicit)", () => { + const result = parseOrgProjectArg("sentry/cli"); + expect(result.type).toBe("explicit"); + expect(result).not.toHaveProperty("normalized"); + }); + + test("normalized is absent for normal slugs (project-search)", () => { + const result = parseOrgProjectArg("my-project"); + expect(result.type).toBe("project-search"); + expect(result).not.toHaveProperty("normalized"); + }); + + test("preserves org slug underscores in org-all mode", () => { + const result = parseOrgProjectArg("org_name/"); + expect(result).toEqual({ + type: "org-all", + org: "org_name", + }); + expect(result).not.toHaveProperty("normalized"); + }); +}); + +describe("parseOrgProjectArg space handling (no normalization)", () => { + test("bare project with spaces produces project-search with raw input", () => { + expect(parseOrgProjectArg("My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + }); + }); + + test("org/project with spaces throws ValidationError (spaces in org)", () => { + expect(() => parseOrgProjectArg("My Org/My Project")).toThrow( + ValidationError + ); + }); + + test("consecutive spaces in bare project produces project-search with raw input", () => { + expect(parseOrgProjectArg("My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + }); + }); + + test("org-all with spaces throws ValidationError (spaces in org)", () => { + expect(() => parseOrgProjectArg("My Org/")).toThrow(ValidationError); + }); + + test("leading-slash with spaces produces project-search with raw input", () => { + expect(parseOrgProjectArg("/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + }); + }); + + test("underscores with spaces in explicit mode produces project-search with org", () => { + expect(parseOrgProjectArg("my_org/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "my_org", + }); + }); +}); + +// --------------------------------------------------------------------------- +// Input hardening against agent hallucinations (#350) +// --------------------------------------------------------------------------- + +describe("parseOrgProjectArg: injection hardening", () => { + test("rejects query injection in org slug", () => { + expect(() => parseOrgProjectArg("my-org?query=foo/cli")).toThrow( + ValidationError + ); + }); + + test("rejects query injection in project slug", () => { + expect(() => parseOrgProjectArg("sentry/cli?extra=1")).toThrow( + ValidationError + ); + }); + + test("rejects fragment injection in org slug", () => { + expect(() => parseOrgProjectArg("my-org#anchor/cli")).toThrow( + ValidationError + ); + }); + + test("rejects fragment injection in project slug", () => { + expect(() => parseOrgProjectArg("sentry/my-project#anchor")).toThrow( + ValidationError + ); + }); + + test("rejects pre-encoded space in project slug", () => { + expect(() => parseOrgProjectArg("sentry/my%20project")).toThrow( + ValidationError + ); + }); + + test("bare project slug with space produces project-search with raw input", () => { + // Spaces in bare slugs are treated as display names — no normalization, + // the resolution layer does a fuzzy name-based search. + expect(parseOrgProjectArg("my project")).toEqual({ + type: "project-search", + projectSlug: "my project", + originalSlug: "my project", + }); + }); + + test("rejects tab character in org slug", () => { + expect(() => parseOrgProjectArg("my-org\t/cli")).toThrow(ValidationError); + }); + + test("rejects null byte in project slug", () => { + expect(() => parseOrgProjectArg("sentry/cli\x00extra")).toThrow( + ValidationError + ); + }); +}); + +describe("parseIssueArg: injection hardening", () => { + test("rejects query injection in issue arg", () => { + expect(() => parseIssueArg("CLI-G?query=foo")).toThrow(ValidationError); + }); + + test("rejects forbidden characters inside a # fragment", () => { + // A bare "#" is now a valid GitHub-style separator (CLI-1G1), but the + // fragment after it is still validated against injection characters. + expect(() => parseIssueArg("cli#an chor")).toThrow(ValidationError); + expect(() => parseIssueArg("cli#an%20chor")).toThrow(ValidationError); + expect(() => parseIssueArg("cli#G?extra")).toThrow(ValidationError); + }); + + test("rejects pre-encoded space in issue arg", () => { + expect(() => parseIssueArg("CLI-G%20extra")).toThrow(ValidationError); + }); + + test("rejects control characters in issue arg", () => { + expect(() => parseIssueArg("CLI-G\x00")).toThrow(ValidationError); + // Tab in the middle is still rejected (trailing tab is trimmed like newlines) + expect(() => parseIssueArg("CLI\tG")).toThrow(ValidationError); + }); + + test("rejects space in numeric ID", () => { + expect(() => parseIssueArg("12345 6789")).toThrow(ValidationError); + }); + + test("rejects query string in org/issue format", () => { + expect(() => parseIssueArg("sentry/CLI-G?extra")).toThrow(ValidationError); + }); +}); + +// --------------------------------------------------------------------------- +// GitHub-style "#" separator: org/project#SHORTID (CLI-1G1) +// --------------------------------------------------------------------------- + +describe("parseIssueArg: GitHub-style # separator (CLI-1G1)", () => { + describe("org/project#SHORTID", () => { + test("full short ID whose prefix matches the project → explicit suffix", () => { + expect(parseIssueArg("sentry/cli#CLI-G")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "G", + }); + }); + + test("numeric fragment → explicit-org-numeric", () => { + expect(parseIssueArg("sentry/cli#123")).toEqual({ + type: "explicit-org-numeric", + org: "sentry", + numericId: "123", + }); + }); + + test("non-matching prefix → entire fragment becomes the suffix", () => { + expect(parseIssueArg("sentry/cli#SUBPROJ-G")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "SUBPROJ-G", + }); + }); + + test("bare suffix fragment → explicit suffix", () => { + expect(parseIssueArg("my-org/my-project#G")).toEqual({ + type: "explicit", + org: "my-org", + project: "my-project", + suffix: "G", + }); + }); + }); + + describe("project#SHORTID", () => { + test("full short ID fragment → project-search with extracted suffix", () => { + expect(parseIssueArg("cli#CLI-G")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + + test("bare suffix fragment → project-search", () => { + expect(parseIssueArg("cli#G")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + + test("numeric fragment → numeric (project context redundant)", () => { + expect(parseIssueArg("cli#123")).toEqual({ + type: "numeric", + id: "123", + }); + }); + + test("lowercase fragment is uppercased into the suffix", () => { + expect(parseIssueArg("cli#g")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + + test("multi-hyphen project slug is preserved and lowercased", () => { + expect(parseIssueArg("CLI-G#anchor")).toEqual({ + type: "project-search", + projectSlug: "cli-g", + suffix: "ANCHOR", + }); + }); + }); + + describe("bare #SHORTID", () => { + test("full short ID → project-search", () => { + expect(parseIssueArg("#CLI-G")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + + test("numeric → numeric", () => { + expect(parseIssueArg("#123")).toEqual({ + type: "numeric", + id: "123", + }); + }); + + test("bare suffix → suffix-only", () => { + expect(parseIssueArg("#G")).toEqual({ + type: "suffix-only", + suffix: "G", + }); + }); + }); + + describe("error cases", () => { + test("multiple # separators throw", () => { + expect(() => parseIssueArg("a#b#c")).toThrow(ValidationError); + }); + + test("empty fragment throws", () => { + expect(() => parseIssueArg("org/project#")).toThrow(ValidationError); + expect(() => parseIssueArg("cli#")).toThrow(ValidationError); + expect(() => parseIssueArg("#")).toThrow(ValidationError); + }); + + test("forbidden characters in fragment throw", () => { + expect(() => parseIssueArg("cli#?bad")).toThrow(ValidationError); + expect(() => parseIssueArg("cli#an chor")).toThrow(ValidationError); + expect(() => parseIssueArg("cli#an%20chor")).toThrow(ValidationError); + }); + + test("colon mixed with # is rejected with a clear message", () => { + expect(() => parseIssueArg("cli#frag:more")).toThrow(/'#' and ':'/); + }); + + test("forbidden characters in project prefix throw", () => { + expect(() => parseIssueArg("bad%proj#G")).toThrow(ValidationError); + }); + + test("forbidden characters in org/project prefix throw", () => { + expect(() => parseIssueArg("bad%org/project#G")).toThrow(ValidationError); + expect(() => parseIssueArg("org/bad%proj#G")).toThrow(ValidationError); + }); + + test("multiple # error message suggests the correct format", () => { + expect(() => parseIssueArg("a#b#c")).toThrow(/org\/project#PROJ-123/); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Whitespace trimming for agent-injected newlines (CLI-16M) +// --------------------------------------------------------------------------- + +describe("parseIssueArg: whitespace trimming", () => { + test("trims trailing newline from issue short ID", () => { + expect(parseIssueArg("CLI-G5\n")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G5", + }); + }); + + test("trims trailing space from issue short ID", () => { + expect(parseIssueArg("CLI-G5 ")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G5", + }); + }); + + test("trims leading/trailing whitespace from numeric ID", () => { + expect(parseIssueArg(" 123456789 ")).toEqual({ + type: "numeric", + id: "123456789", + }); + }); + + test("trims carriage return + newline", () => { + expect(parseIssueArg("CLI-G5\r\n")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G5", + }); + }); + + test("trims trailing newline from org/issue format", () => { + expect(parseIssueArg("sentry/CLI-G5\n")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "G5", + }); + }); + + test("empty string after trimming throws", () => { + expect(() => parseIssueArg(" \n ")).toThrow(ValidationError); + }); +}); + +describe("parseSlashSeparatedArg: whitespace trimming", () => { + test("trims trailing newline from plain ID", () => { + const result = parseSlashSeparatedArg( + "a9b4ad2c\n", + "Event ID", + "sentry event view " + ); + expect(result).toEqual({ id: "a9b4ad2c", targetArg: undefined }); + }); + + test("trims trailing newline from structured arg", () => { + const result = parseSlashSeparatedArg( + "sentry/cli/a9b4ad2c\n", + "Event ID", + "sentry event view " + ); + expect(result).toEqual({ id: "a9b4ad2c", targetArg: "sentry/cli" }); + }); + + test("trims leading/trailing whitespace from plain ID", () => { + const result = parseSlashSeparatedArg( + " a9b4ad2c ", + "Event ID", + "sentry event view " + ); + expect(result).toEqual({ id: "a9b4ad2c", targetArg: undefined }); + }); + + test("preserves newlines in no-slash path (log view splits downstream)", () => { + const result = parseSlashSeparatedArg( + "abc123\ndef456", + "Log ID", + "sentry log view " + ); + // No-slash path must NOT strip newlines — log view splits them downstream + expect(result.id).toBe("abc123\ndef456"); + expect(result.targetArg).toBeUndefined(); + }); +}); + +describe("splitNewlineArg", () => { + test("splits on newlines and trims each part", () => { + expect(splitNewlineArg("abc\n def \nghi")).toEqual(["abc", "def", "ghi"]); + }); + + test("filters out empty lines", () => { + expect(splitNewlineArg("abc\n\n\ndef")).toEqual(["abc", "def"]); + }); + + test("handles CRLF", () => { + expect(splitNewlineArg("abc\r\ndef")).toEqual(["abc", "def"]); + }); + + test("returns single element for no newlines", () => { + expect(splitNewlineArg("abc123")).toEqual(["abc123"]); + }); + + test("returns empty array for whitespace-only input", () => { + expect(splitNewlineArg(" \n \n ")).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/async-channel.test.ts b/packages/cli/test/lib/async-channel.test.ts new file mode 100644 index 000000000..b42a464f3 --- /dev/null +++ b/packages/cli/test/lib/async-channel.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "vitest"; +import { createAsyncChannel } from "../../src/lib/async-channel.js"; + +describe("createAsyncChannel", () => { + test("push-then-pull: values are received in FIFO order", async () => { + const ch = createAsyncChannel(); + ch.push(1); + ch.push(2); + ch.push(3); + ch.close(); + + const results: number[] = []; + for await (const v of ch) { + results.push(v); + } + expect(results).toEqual([1, 2, 3]); + }); + + test("pull-then-push: next() resolves when value is pushed later", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + // Start waiting before any value is pushed + const promise = iter.next(); + ch.push("hello"); + + const result = await promise; + expect(result).toEqual({ value: "hello", done: false }); + }); + + test("close: next() returns done after close", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + ch.close(); + + const result = await iter.next(); + expect(result.done).toBe(true); + }); + + test("close: pending next() resolves as done when close is called", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + const promise = iter.next(); + ch.close(); + + const result = await promise; + expect(result.done).toBe(true); + }); + + test("error: next() rejects after error", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + const err = new Error("boom"); + ch.error(err); + + await expect(iter.next()).rejects.toThrow("boom"); + }); + + test("error: pending next() rejects when error is called", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + const promise = iter.next(); + ch.error(new Error("fail")); + + await expect(promise).rejects.toThrow("fail"); + }); + + test("for-await-of: iterates buffered values then stops on close", async () => { + const ch = createAsyncChannel(); + ch.push("a"); + ch.push("b"); + ch.close(); + + const results: string[] = []; + for await (const v of ch) { + results.push(v); + } + expect(results).toEqual(["a", "b"]); + }); + + test("for-await-of: break triggers onReturn callback", async () => { + let returnCalled = false; + const ch = createAsyncChannel({ + onReturn: () => { + returnCalled = true; + }, + }); + + ch.push(1); + ch.push(2); + ch.push(3); + + for await (const _v of ch) { + break; + } + + expect(returnCalled).toBe(true); + }); + + test("push after close is a silent no-op", async () => { + const ch = createAsyncChannel(); + ch.push(1); + ch.close(); + + // Should not throw + ch.push(2); + ch.push(3); + + const results: number[] = []; + for await (const v of ch) { + results.push(v); + } + // Only the value pushed before close should appear + expect(results).toEqual([1]); + }); + + test("push after error is a silent no-op", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + ch.error(new Error("oops")); + + // Should not throw + ch.push(42); + + await expect(iter.next()).rejects.toThrow("oops"); + }); + + test("multiple close calls are idempotent", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + ch.close(); + ch.close(); + ch.close(); + + const result = await iter.next(); + expect(result.done).toBe(true); + }); + + test("interleaved push/pull: FIFO order preserved", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + ch.push(1); + const r1 = await iter.next(); + expect(r1).toEqual({ value: 1, done: false }); + + ch.push(2); + const r2 = await iter.next(); + expect(r2).toEqual({ value: 2, done: false }); + + ch.push(3); + ch.push(4); + const r3 = await iter.next(); + const r4 = await iter.next(); + expect(r3).toEqual({ value: 3, done: false }); + expect(r4).toEqual({ value: 4, done: false }); + }); + + test("return() clears buffer and marks as done", async () => { + const ch = createAsyncChannel(); + const iter = ch[Symbol.asyncIterator](); + + ch.push(1); + ch.push(2); + + const result = await iter.return!(); + expect(result.done).toBe(true); + + // Subsequent next() also returns done + const after = await iter.next(); + expect(after.done).toBe(true); + }); + + test("for-await-of with error throws inside loop", async () => { + const ch = createAsyncChannel(); + ch.push(1); + + const results: number[] = []; + await expect(async () => { + for await (const v of ch) { + results.push(v); + if (v === 1) { + // Simulate producer error after first value is consumed + ch.error(new Error("stream failed")); + } + } + }).rejects.toThrow("stream failed"); + + expect(results).toEqual([1]); + }); +}); diff --git a/packages/cli/test/lib/auth-header.property.test.ts b/packages/cli/test/lib/auth-header.property.test.ts new file mode 100644 index 000000000..3b3fa3f10 --- /dev/null +++ b/packages/cli/test/lib/auth-header.property.test.ts @@ -0,0 +1,90 @@ +/** Invariants for opaque bearer credentials and removable edge padding. */ + +import { + array, + constantFrom, + assert as fcAssert, + integer, + property, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + formatAuthHeader, + normalizeAuthToken, + trimAuthToken, +} from "../../src/lib/auth-header.js"; +import { MalformedAuthTokenError } from "../../src/lib/errors.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const printable = array(integer({ min: 0x21, max: 0x7e }), { + minLength: 1, + maxLength: 80, +}).map((codes) => String.fromCharCode(...codes)); +const paddingCharacter = constantFrom( + ...Array.from({ length: 33 }, (_, code) => String.fromCharCode(code)), + "\x7f", + "\u00a0", + "\ufeff" +); +const padding = array(paddingCharacter, { maxLength: 20 }).map((chars) => + chars.join("") +); + +describe("auth token normalization", () => { + test("preserves every printable credential regardless of surrounding padding", () => { + fcAssert( + property(printable, padding, padding, (token, before, after) => { + const input = before + token + after; + expect(trimAuthToken(input)).toBe(token); + expect(normalizeAuthToken(input)).toBe(token); + expect(normalizeAuthToken(normalizeAuthToken(input))).toBe(token); + expect(formatAuthHeader(input)).toBe(`Bearer ${token}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("rejects padding inside a credential without removing it", () => { + fcAssert( + property( + printable, + paddingCharacter, + printable, + (before, char, after) => { + const input = before + char + after; + expect(trimAuthToken(input)).toBe(input); + expect(() => normalizeAuthToken(input)).toThrow( + MalformedAuthTokenError + ); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("rejects empty or padding-only credentials", () => { + fcAssert( + property(padding, (input) => { + expect(() => normalizeAuthToken(input)).toThrow( + MalformedAuthTokenError + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test.each([ + "\x80", + "\x85", + "\u200b", + "é", + "💥", + ])("does not silently remove other Unicode characters %#", (char) => { + expect(() => normalizeAuthToken(`${char}token`)).toThrow( + MalformedAuthTokenError + ); + expect(() => normalizeAuthToken(`token${char}`)).toThrow( + MalformedAuthTokenError + ); + }); +}); diff --git a/packages/cli/test/lib/auth-hint.test.ts b/packages/cli/test/lib/auth-hint.test.ts new file mode 100644 index 000000000..b2a5a7b03 --- /dev/null +++ b/packages/cli/test/lib/auth-hint.test.ts @@ -0,0 +1,246 @@ +/** + * Tests for the one-shot env-token-ignored hint + * (`maybeWarnEnvTokenIgnored`). + * + * Covers: + * - Hint fires when env token + stored OAuth coexist. + * - Hint does NOT fire without an env token, without a stored login, + * or when SENTRY_FORCE_ENV_TOKEN is set. + * - Repeat calls in the same process are silent. + * - User label preference order (username > email > name > fallback). + * - Names the `.sentryclirc` source when the token came from the rc shim. + */ + +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + maybeWarnEnvTokenIgnored, + resetAuthHintState, +} from "../../src/lib/auth-hint.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setUserInfo } from "../../src/lib/db/user.js"; +import { + applySentryCliRcEnvShim, + CONFIG_FILENAME, + clearSentryCliRcCache, +} from "../../src/lib/sentryclirc.js"; +import { useTestConfigDir } from "../helpers.js"; + +const getConfigDir = useTestConfigDir("auth-hint-"); + +let savedAuthToken: string | undefined; +let savedSentryToken: string | undefined; +let savedForceEnv: string | undefined; + +beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + savedSentryToken = process.env.SENTRY_TOKEN; + savedForceEnv = process.env.SENTRY_FORCE_ENV_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + delete process.env.SENTRY_FORCE_ENV_TOKEN; + clearSentryCliRcCache(); + resetAuthHintState(); +}); + +afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } else { + delete process.env.SENTRY_AUTH_TOKEN; + } + if (savedSentryToken !== undefined) { + process.env.SENTRY_TOKEN = savedSentryToken; + } else { + delete process.env.SENTRY_TOKEN; + } + if (savedForceEnv !== undefined) { + process.env.SENTRY_FORCE_ENV_TOKEN = savedForceEnv; + } else { + delete process.env.SENTRY_FORCE_ENV_TOKEN; + } + clearSentryCliRcCache(); +}); + +/** + * Capture stderr output from consola's default reporter. + * + * We can't reliably spy on `logger.withTag("auth").info` from a test + * because each `withTag()` call returns a fresh consola instance — the + * spy target and the instance used inside `auth-hint.ts` are + * different objects. Hooking `process.stderr.write` captures the final + * rendered output regardless of which instance emitted it. + */ +function captureStderr() { + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + return { + /** Number of calls whose first argument contained the env-hint body. */ + hintCalls: () => + stderrSpy.mock.calls.filter((call) => + String(call[0] ?? "").includes("SENTRY_FORCE_ENV_TOKEN=1") + ).length, + /** Flattened first-arg text across all calls (for substring assertions). */ + text: () => + stderrSpy.mock.calls.map((call) => String(call[0] ?? "")).join(""), + restore: () => stderrSpy.mockRestore(), + }; +} + +describe("maybeWarnEnvTokenIgnored", () => { + test("does nothing when no env token is set", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.hintCalls()).toBe(0); + } finally { + cap.restore(); + } + }); + + test("does nothing when no stored OAuth login exists", () => { + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.hintCalls()).toBe(0); + } finally { + cap.restore(); + } + }); + + test("does nothing when SENTRY_FORCE_ENV_TOKEN is set", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.hintCalls()).toBe(0); + } finally { + cap.restore(); + } + }); + + test("fires once when env token collides with stored OAuth", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + setUserInfo({ + userId: "u-1", + username: "alice", + email: "alice@example.com", + }); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.hintCalls()).toBe(1); + const text = cap.text(); + expect(text).toContain("SENTRY_AUTH_TOKEN env var"); + expect(text).toContain("stored login for alice"); + expect(text).toContain("SENTRY_FORCE_ENV_TOKEN=1"); + } finally { + cap.restore(); + } + }); + + test("names the .sentryclirc source when the token came from the rc shim", async () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + setUserInfo({ userId: "u-1", username: "alice" }); + + // The shim reads $SENTRY_CONFIG_DIR/.sentryclirc as a global fallback + // and copies its token into SENTRY_AUTH_TOKEN, recording the source. + const rcPath = join(getConfigDir(), CONFIG_FILENAME); + writeFileSync(rcPath, "[auth]\ntoken = rc-token\n", "utf-8"); + await applySentryCliRcEnvShim(getConfigDir()); + + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.hintCalls()).toBe(1); + const text = cap.text(); + expect(text).toContain(`Detected a token in .sentryclirc (${rcPath})`); + expect(text).not.toContain("env var"); + expect(text).toContain("stored login for alice"); + } finally { + cap.restore(); + } + }); + + test("is silent on repeat calls within the same process", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + maybeWarnEnvTokenIgnored(); + maybeWarnEnvTokenIgnored(); + expect(cap.hintCalls()).toBe(1); + } finally { + cap.restore(); + } + }); + + test("uses SENTRY_TOKEN when only that legacy var is set", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + process.env.SENTRY_TOKEN = "legacy_token"; + + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.hintCalls()).toBe(1); + expect(cap.text()).toContain("SENTRY_TOKEN env var"); + } finally { + cap.restore(); + } + }); + + test("falls back to email when username is unavailable", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + setUserInfo({ + userId: "u-1", + email: "alice@example.com", + }); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.text()).toContain("stored login for alice@example.com"); + } finally { + cap.restore(); + } + }); + + test("falls back to name when username and email are unavailable", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + setUserInfo({ userId: "u-1", name: "Alice Wonderland" }); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.text()).toContain("stored login for Alice Wonderland"); + } finally { + cap.restore(); + } + }); + + test("uses a neutral label when no user info is cached", () => { + setAuthToken("stored_oauth", 3600, "refresh_a"); + // No setUserInfo — user_info table is empty. + process.env.SENTRY_AUTH_TOKEN = "env_token"; + + const cap = captureStderr(); + try { + maybeWarnEnvTokenIgnored(); + expect(cap.text()).toContain("stored login for stored OAuth user"); + } finally { + cap.restore(); + } + }); +}); diff --git a/packages/cli/test/lib/auto-auth.test.ts b/packages/cli/test/lib/auto-auth.test.ts new file mode 100644 index 000000000..249373922 --- /dev/null +++ b/packages/cli/test/lib/auto-auth.test.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for the auto-authentication recovery flow (`recoverWithAutoLogin` + * and `shouldAutoAuth`), extracted from the CLI middleware so the host-trust + * gate and login/retry behavior are testable without driving the whole CLI. + * + * The host-trust gate is the security-relevant part: auto-login must refuse an + * unconfirmed self-hosted host (getsentry/cli#1121), exactly like `auth login`. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + assertAutoLoginHostTrusted, + recoverWithAutoLogin, + shouldAutoAuth, +} from "../../src/lib/auto-auth.js"; +import { setDefaultUrl } from "../../src/lib/db/defaults.js"; +import { AuthError, HostScopeError } from "../../src/lib/errors.js"; +import { registerLoginTrustAnchor } from "../../src/lib/token-host.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +const noop = () => { + // status-line sink for tests that don't assert on output +}; + +const ENV_KEYS = ["SENTRY_HOST", "SENTRY_URL"] as const; +const alwaysInteractive = () => true; + +describe("shouldAutoAuth", () => { + test("true for not_authenticated in an interactive TTY", () => { + expect(shouldAutoAuth(new AuthError("not_authenticated"), () => true)).toBe( + true + ); + }); + + test("true for expired in an interactive TTY", () => { + expect(shouldAutoAuth(new AuthError("expired"), () => true)).toBe(true); + }); + + test("false when not interactive", () => { + expect(shouldAutoAuth(new AuthError("expired"), () => false)).toBe(false); + }); + + test("false for non-recoverable auth reasons (invalid)", () => { + expect(shouldAutoAuth(new AuthError("invalid"), () => true)).toBe(false); + }); + + test("false when the error opts out via skipAutoAuth", () => { + const err = new AuthError("not_authenticated", undefined, { + skipAutoAuth: true, + }); + expect(shouldAutoAuth(err, () => true)).toBe(false); + }); + + test("false for non-AuthError values", () => { + expect(shouldAutoAuth(new Error("boom"), () => true)).toBe(false); + expect(shouldAutoAuth("nope", () => true)).toBe(false); + }); +}); + +describe("assertAutoLoginHostTrusted", () => { + useTestConfigDir("auto-auth-assert-"); + useEnvSandbox(ENV_KEYS); + + beforeEach(async () => { + await resetHostScopingState(); + }); + + afterEach(async () => { + await resetHostScopingState(); + }); + + test("passes for SaaS (no env host)", () => { + expect(() => assertAutoLoginHostTrusted()).not.toThrow(); + }); + + test("throws for an unconfirmed self-hosted host", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect(() => assertAutoLoginHostTrusted()).toThrow(HostScopeError); + }); + + test("passes for a self-hosted host confirmed via default URL", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + setDefaultUrl("https://sentry.example.com"); + expect(() => assertAutoLoginHostTrusted()).not.toThrow(); + }); + + test("passes for a self-hosted host with a process trust anchor", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + registerLoginTrustAnchor("https://sentry.example.com"); + expect(() => assertAutoLoginHostTrusted()).not.toThrow(); + }); +}); + +describe("recoverWithAutoLogin", () => { + useTestConfigDir("auto-auth-"); + useEnvSandbox(ENV_KEYS); + + beforeEach(async () => { + await resetHostScopingState(); + }); + + afterEach(async () => { + await resetHostScopingState(); + }); + + test("re-throws a non-auth error without attempting login", async () => { + const runInteractiveLogin = vi.fn(); + const retry = vi.fn(); + const boom = new Error("boom"); + + await expect( + recoverWithAutoLogin(boom, retry, { + runInteractiveLogin, + isInteractive: alwaysInteractive, + }) + ).rejects.toBe(boom); + expect(runInteractiveLogin).not.toHaveBeenCalled(); + expect(retry).not.toHaveBeenCalled(); + }); + + test("re-throws when not in an interactive TTY (default probe)", async () => { + // No isInteractive injected → falls back to isatty(0), which is false in + // the test runner, so the error is re-thrown untouched. + const runInteractiveLogin = vi.fn(); + const err = new AuthError("not_authenticated"); + + await expect( + recoverWithAutoLogin(err, vi.fn(), { runInteractiveLogin }) + ).rejects.toBe(err); + expect(runInteractiveLogin).not.toHaveBeenCalled(); + }); + + test("refuses an unconfirmed self-hosted host (the #1121 gate)", async () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + const runInteractiveLogin = vi.fn(); + const writes: string[] = []; + + await expect( + recoverWithAutoLogin(new AuthError("not_authenticated"), vi.fn(), { + runInteractiveLogin, + isInteractive: alwaysInteractive, + write: (m) => writes.push(m), + }) + ).rejects.toBeInstanceOf(HostScopeError); + + // No login attempt, no browser, no status output. + expect(runInteractiveLogin).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + }); + + test("SaaS host: logs in, retries, returns undefined", async () => { + const runInteractiveLogin = vi.fn().mockResolvedValue({ ok: true }); + const retry = vi.fn().mockResolvedValue(undefined); + const writes: string[] = []; + + const exitCode = await recoverWithAutoLogin( + new AuthError("not_authenticated"), + retry, + { + runInteractiveLogin, + isInteractive: alwaysInteractive, + write: (m) => writes.push(m), + } + ); + + expect(exitCode).toBeUndefined(); + expect(runInteractiveLogin).toHaveBeenCalledTimes(1); + expect(retry).toHaveBeenCalledTimes(1); + expect(writes[0]).toContain("Authentication required"); + expect(writes.join("")).toContain("Retrying command"); + }); + + test("expired reason uses the 'expired' status message", async () => { + const writes: string[] = []; + + await recoverWithAutoLogin(new AuthError("expired"), vi.fn(), { + runInteractiveLogin: vi.fn().mockResolvedValue(true), + isInteractive: alwaysInteractive, + write: (m) => writes.push(m), + }); + + expect(writes[0]).toContain("Authentication expired"); + }); + + test("returns exit code 1 and skips retry when login fails", async () => { + const retry = vi.fn(); + const writes: string[] = []; + + const exitCode = await recoverWithAutoLogin( + new AuthError("expired"), + retry, + { + runInteractiveLogin: vi.fn().mockResolvedValue(null), + isInteractive: alwaysInteractive, + write: (m) => writes.push(m), + } + ); + + expect(exitCode).toBe(1); + expect(retry).not.toHaveBeenCalled(); + expect(writes.join("")).not.toContain("Retrying command"); + }); + + test("self-hosted host confirmed via persisted default URL proceeds", async () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + setDefaultUrl("https://sentry.example.com"); + const runInteractiveLogin = vi.fn().mockResolvedValue(true); + const retry = vi.fn().mockResolvedValue(undefined); + + const exitCode = await recoverWithAutoLogin( + new AuthError("expired"), + retry, + { + runInteractiveLogin, + isInteractive: alwaysInteractive, + write: noop, + } + ); + + expect(exitCode).toBeUndefined(); + expect(runInteractiveLogin).toHaveBeenCalledTimes(1); + expect(retry).toHaveBeenCalledTimes(1); + }); + + test("default write target is process.stderr", async () => { + // Don't inject `write` → exercises the process.stderr fallback branch. + const spy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + try { + await recoverWithAutoLogin(new AuthError("not_authenticated"), vi.fn(), { + runInteractiveLogin: vi.fn().mockResolvedValue(true), + isInteractive: alwaysInteractive, + }); + const written = spy.mock.calls.map((c) => String(c[0])).join(""); + expect(written).toContain("Authentication required"); + expect(written).toContain("Retrying command"); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/cli/test/lib/auto-paginate.property.test.ts b/packages/cli/test/lib/auto-paginate.property.test.ts new file mode 100644 index 000000000..64e54194d --- /dev/null +++ b/packages/cli/test/lib/auto-paginate.property.test.ts @@ -0,0 +1,148 @@ +/** + * Property-based tests for autoPaginate(). + * + * autoPaginate has two paths with DIFFERENT trimming contracts: + * + * - Fast path (`limit <= API_MAX_PER_PAGE`): returns a single page verbatim, + * WITHOUT trimming — so a page that overshoots `limit` is returned as-is with + * its nextCursor. Callers whose endpoint can overshoot a single page (e.g. + * `listIssueEvents`, which has no per-page param) must trim-and-drop-cursor + * themselves; that behavior is covered by `events-overshoot.test.ts`. + * - Multi-page path (`limit > API_MAX_PER_PAGE`): accumulates across pages up to + * `limit`. On true overshoot (a page pushes the total strictly past `limit`), + * it trims to `limit` AND drops nextCursor, so cursor navigation never skips + * the trimmed rows. But when `limit` lands exactly on a page boundary, nothing + * is trimmed and the last page's cursor points precisely at the first + * unreturned row — so it is PRESERVED (dropping it would strand the tail, the + * regression pinned by events-overshoot.test.ts). + * + * These tests pin the multi-page contract, the source of the original bug class. + */ + +import { asyncProperty, assert as fcAssert, integer, nat } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + API_MAX_PER_PAGE, + autoPaginate, + type PaginatedResponse, +} from "../../src/lib/api/infrastructure.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** + * Build a paginated fetcher over a fixed pool of sequential numbers, served in + * pages of `pageSize` (capped at API_MAX_PER_PAGE to mimic a real endpoint). + */ +function makePagedFetcher(total: number, pageSize: number) { + const cappedPageSize = Math.min(pageSize, API_MAX_PER_PAGE); + return (cursor: string | undefined): Promise> => { + const offset = cursor ? Number(cursor) : 0; + const data = Array.from( + { length: Math.min(cappedPageSize, Math.max(0, total - offset)) }, + (_unused, i) => offset + i + ); + const nextOffset = offset + data.length; + const nextCursor = nextOffset < total ? String(nextOffset) : undefined; + return Promise.resolve({ data, nextCursor }); + }; +} + +// Multi-page path requires limit > API_MAX_PER_PAGE. Real callers cap perPage at +// min(limit, API_MAX_PER_PAGE) and the multi-page cap is MAX_PAGINATION_PAGES, so +// the achievable ceiling is API_MAX_PER_PAGE * MAX_PAGINATION_PAGES. We keep the +// limit comfortably under that ceiling and use realistic (large) page sizes so +// the page-cap safety path is not the thing under test here. +const multiPageLimit = integer({ + min: API_MAX_PER_PAGE + 1, + max: API_MAX_PER_PAGE * 4, +}); +const pageSizeArb = integer({ + min: Math.floor(API_MAX_PER_PAGE / 2), + max: API_MAX_PER_PAGE, +}); + +describe("property: autoPaginate multi-page contract", () => { + test("never returns more than limit items", () => { + fcAssert( + asyncProperty( + nat(API_MAX_PER_PAGE * 5), + multiPageLimit, + pageSizeArb, + async (total, limit, pageSize) => { + const result = await autoPaginate( + makePagedFetcher(total, pageSize), + limit + ); + expect(result.data.length).toBeLessThanOrEqual(limit); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("drops nextCursor on overshoot, but keeps a page-aligned boundary cursor", () => { + fcAssert( + asyncProperty( + nat(API_MAX_PER_PAGE * 5), + multiPageLimit, + pageSizeArb, + async (total, limit, pageSize) => { + const result = await autoPaginate( + makePagedFetcher(total, pageSize), + limit + ); + + // Only meaningful when more rows exist than the limit. + if (total <= limit) { + return; + } + + // The result is always capped at `limit`. + expect(result.data.length).toBe(limit); + + // Two distinct cases share `total > limit`, and they have OPPOSITE + // cursor contracts. The discriminator is whether `limit` falls exactly + // on a page boundary of the (capped) page size: + const effectivePageSize = Math.min(pageSize, API_MAX_PER_PAGE); + const limitOnPageBoundary = limit % effectivePageSize === 0; + + if (limitOnPageBoundary) { + // Exact boundary: accumulation lands precisely on `limit`, so NO row + // was trimmed. The last page's cursor points at exactly index + // `limit` — the first unreturned row — so it MUST be preserved and + // resume contiguously. Dropping it would strand the tail (the + // regression documented in events-overshoot.test.ts). + expect(result.nextCursor).toBe(String(limit)); + } else { + // True overshoot: the page that crossed `limit` was trimmed, so the + // server cursor points PAST the trimmed rows. It MUST be dropped — + // a preserved cursor would skip the rows between the trim point and + // that cursor. This is the original skip-bug guard. + expect(result.nextCursor).toBeUndefined(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("accumulated rows are contiguous from offset 0 (no skips/dupes)", () => { + fcAssert( + asyncProperty( + nat(API_MAX_PER_PAGE * 5), + multiPageLimit, + pageSizeArb, + async (total, limit, pageSize) => { + const result = await autoPaginate( + makePagedFetcher(total, pageSize), + limit + ); + const expectedLen = Math.min(total, limit); + expect(result.data).toEqual( + Array.from({ length: expectedLen }, (_unused, i) => i) + ); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/bash-hook/traceback.test.ts b/packages/cli/test/lib/bash-hook/traceback.test.ts new file mode 100644 index 000000000..1a59bf573 --- /dev/null +++ b/packages/cli/test/lib/bash-hook/traceback.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for the bash-hook traceback parser and event builder. + * + * Tests parseTracebackContent (pure function) and buildBashErrorEvent + * (async, reads files from disk). + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + buildBashErrorEvent, + parseTracebackContent, +} from "../../../src/lib/bash-hook/traceback.js"; + +describe("parseTracebackContent", () => { + test("parses frames with function, file, and line", () => { + const content = + "main:/home/user/script.sh:10\nhelper:/home/user/lib.sh:5\n"; + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(2); + expect(result.frames[0]).toMatchObject({ + function: "main", + filename: "/home/user/script.sh", + lineno: 10, + }); + expect(result.frames[1]).toMatchObject({ + function: "helper", + filename: "/home/user/lib.sh", + lineno: 5, + }); + }); + + test("extracts command metadata", () => { + const content = "@command:curl https://example.com\n@exit_code:127\n"; + const result = parseTracebackContent(content); + + expect(result.command).toBe("curl https://example.com"); + expect(result.exitCode).toBe(127); + }); + + test("defaults to unknown command and exit code 1", () => { + const content = "main:/script.sh:1\n"; + const result = parseTracebackContent(content); + + expect(result.command).toBe("unknown"); + expect(result.exitCode).toBe(1); + }); + + test("filters out internal sentry hook frames", () => { + const content = [ + "_sentry_err_trap:/script.sh:1", + "_sentry_exit_trap:/script.sh:2", + "_sentry_traceback:/script.sh:3", + "real_function:/script.sh:10", + "@command:failing_cmd", + "@exit_code:1", + ].join("\n"); + + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(1); + expect(result.frames[0]?.function).toBe("real_function"); + }); + + test("skips blank lines", () => { + const content = "\nmain:/script.sh:1\n\n\nhelper:/lib.sh:5\n\n"; + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(2); + }); + + test("skips unknown @-prefixed metadata lines", () => { + const content = "@unknown:value\nmain:/script.sh:1\n@command:test\n"; + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(1); + expect(result.command).toBe("test"); + }); + + test("handles non-matching lines gracefully", () => { + const content = "this is not a frame line\nmain:/script.sh:1\n"; + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(1); + }); + + test("handles empty content", () => { + const result = parseTracebackContent(""); + + expect(result.frames).toHaveLength(0); + expect(result.command).toBe("unknown"); + expect(result.exitCode).toBe(1); + }); + + test("handles frame with empty function name", () => { + const content = ":/script.sh:10\n"; + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(1); + // Empty function name becomes undefined + expect(result.frames[0]?.function).toBeUndefined(); + expect(result.frames[0]?.filename).toBe("/script.sh"); + }); + + test("handles invalid exit code gracefully", () => { + const content = "@exit_code:not_a_number\n"; + const result = parseTracebackContent(content); + + // Falls back to default + expect(result.exitCode).toBe(1); + }); + + test("handles file paths with colons", () => { + // The regex is non-greedy on function name, so C:\path works + const content = "main:C:\\Users\\test\\script.sh:42\n"; + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(1); + expect(result.frames[0]?.filename).toBe("C:\\Users\\test\\script.sh"); + expect(result.frames[0]?.lineno).toBe(42); + }); + + test("full realistic traceback", () => { + const content = [ + "deploy:/opt/ci/deploy.sh:45", + "run_tests:/opt/ci/test.sh:12", + "main:/opt/ci/pipeline.sh:8", + "@command:npm test", + "@exit_code:2", + ].join("\n"); + + const result = parseTracebackContent(content); + + expect(result.frames).toHaveLength(3); + expect(result.command).toBe("npm test"); + expect(result.exitCode).toBe(2); + expect(result.frames[0]?.function).toBe("deploy"); + expect(result.frames[2]?.function).toBe("main"); + }); +}); + +describe("buildBashErrorEvent", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "bash-hook-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("builds event from traceback file", async () => { + const tracebackPath = join(tempDir, "traceback"); + await writeFile( + tracebackPath, + "main:/script.sh:10\n@command:curl\n@exit_code:1\n" + ); + + const event = await buildBashErrorEvent({ tracebackPath }); + + expect(event.event_id).toBeDefined(); + expect(event.level).toBe("error"); + expect(event.platform).toBe("other"); + expect(event.exception?.values).toHaveLength(1); + expect(event.exception?.values?.[0]?.type).toBe("BashError"); + expect(event.exception?.values?.[0]?.value).toBe( + "command curl exited with status 1" + ); + expect(event.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); + }); + + test("attaches log file as breadcrumbs", async () => { + const tracebackPath = join(tempDir, "traceback"); + const logPath = join(tempDir, "log"); + await writeFile(tracebackPath, "main:/script.sh:1\n@command:test\n"); + await writeFile(logPath, "line 1\nline 2\nline 3\n"); + + const event = await buildBashErrorEvent({ tracebackPath, logPath }); + + expect(event.breadcrumbs).toHaveLength(3); + expect(event.breadcrumbs?.[0]?.message).toBe("line 1"); + expect(event.breadcrumbs?.[0]?.category).toBe("log"); + }); + + test("attaches tags to event", async () => { + const tracebackPath = join(tempDir, "traceback"); + await writeFile(tracebackPath, "@command:test\n@exit_code:1\n"); + + const event = await buildBashErrorEvent({ + tracebackPath, + tags: { env: "prod", tier: "backend" }, + }); + + expect(event.tags).toEqual({ env: "prod", tier: "backend" }); + }); + + test("attaches release to event", async () => { + const tracebackPath = join(tempDir, "traceback"); + await writeFile(tracebackPath, "@command:test\n"); + + const event = await buildBashErrorEvent({ + tracebackPath, + release: "1.0.0", + }); + + expect(event.release).toBe("1.0.0"); + }); + + test("throws ValidationError for missing traceback file", async () => { + await expect( + buildBashErrorEvent({ tracebackPath: join(tempDir, "nonexistent") }) + ).rejects.toThrow("Traceback file not found"); + }); + + test("omits breadcrumbs when no log file", async () => { + const tracebackPath = join(tempDir, "traceback"); + await writeFile(tracebackPath, "@command:test\n"); + + const event = await buildBashErrorEvent({ tracebackPath }); + + expect(event.breadcrumbs).toBeUndefined(); + }); + + test("omits tags when empty", async () => { + const tracebackPath = join(tempDir, "traceback"); + await writeFile(tracebackPath, "@command:test\n"); + + const event = await buildBashErrorEvent({ tracebackPath, tags: {} }); + + expect(event.tags).toBeUndefined(); + }); + + test("handles empty log file gracefully", async () => { + const tracebackPath = join(tempDir, "traceback"); + const logPath = join(tempDir, "log"); + await writeFile(tracebackPath, "@command:test\n"); + await writeFile(logPath, ""); + + const event = await buildBashErrorEvent({ tracebackPath, logPath }); + + // Empty log file means no breadcrumbs + expect(event.breadcrumbs).toBeUndefined(); + }); + + test("reverses frames to Sentry oldest-to-youngest order", async () => { + const tracebackPath = join(tempDir, "traceback"); + await writeFile( + tracebackPath, + "inner:/script.sh:10\nmiddle:/script.sh:5\nouter:/script.sh:1\n@command:test\n" + ); + + const event = await buildBashErrorEvent({ tracebackPath }); + + const frames = event.exception?.values?.[0]?.stacktrace?.frames; + expect(frames).toHaveLength(3); + // Sentry expects oldest (outer) first, crash frame (inner) last + expect(frames?.[0]?.function).toBe("outer"); + expect(frames?.[1]?.function).toBe("middle"); + expect(frames?.[2]?.function).toBe("inner"); + }); + + test("includes username in event", async () => { + const tracebackPath = join(tempDir, "traceback"); + await writeFile(tracebackPath, "@command:test\n"); + + const event = await buildBashErrorEvent({ tracebackPath }); + + // Should have a user with username (from os.userInfo) + expect(event.user?.username).toBeDefined(); + expect(event.user?.ip_address).toBe("{{auto}}"); + }); +}); diff --git a/packages/cli/test/lib/bench/generate.test.ts b/packages/cli/test/lib/bench/generate.test.ts new file mode 100644 index 000000000..5a621230e --- /dev/null +++ b/packages/cli/test/lib/bench/generate.test.ts @@ -0,0 +1,150 @@ +/** + * Unit tests for the bench fixture generator. + * + * Covers the four properties the bench harness relies on: + * 1. Determinism — same spec + seed produces identical trees. + * 2. Idempotency — re-running against an existing matching fixture is a no-op. + * 3. Sane content — text files contain DSNs, binary blobs contain NUL bytes. + * 4. Layout — monorepo mode creates package dirs + per-pkg .gitignore. + * + * Lives under test/lib/bench/ so it's picked up by the standard `test:unit` + * glob. The generator itself lives under test/fixtures/bench/ because it's + * consumed by script/bench.ts outside the test runner. + */ + +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import { + type FixtureMeta, + type FixtureSpec, + generateFixture, + hashSpec, +} from "../../fixtures/bench/generate.js"; +import { PRESETS } from "../../fixtures/bench/presets.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "bench-gen-test-")); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +function makeSpec( + name: "small" | "medium", + dir: string, + seed = 42 +): FixtureSpec { + return { ...PRESETS[name], seed, rootDir: dir }; +} + +describe("generateFixture", () => { + test("small preset produces a seeded tree with expected file count", () => { + const dir = mkdtempSync(join(ROOT, "small-")); + const meta = generateFixture(makeSpec("small", dir)); + // small preset targets exactly filesPerPackage for single-repo mode. + expect(meta.fileCount).toBe(PRESETS.small.filesPerPackage); + // dsnRatio=0.1 over 100 files; extremely unlikely to be 0. + expect(meta.dsnCount).toBeGreaterThan(0); + expect(meta.dsnCount).toBeLessThan(meta.fileCount); + const onDisk = JSON.parse( + readFileSync(join(dir, ".meta.json"), "utf8") + ) as FixtureMeta; + expect(onDisk.version).toBe(1); + expect(onDisk.spec.packages).toBe(0); + }); + + test("same seed yields identical file/DSN counts (deterministic)", () => { + const dirA = mkdtempSync(join(ROOT, "det-a-")); + const dirB = mkdtempSync(join(ROOT, "det-b-")); + const metaA = generateFixture(makeSpec("medium", dirA, 1337)); + const metaB = generateFixture(makeSpec("medium", dirB, 1337)); + expect(metaA.fileCount).toBe(metaB.fileCount); + expect(metaA.dsnCount).toBe(metaB.dsnCount); + expect(hashSpec(metaA.spec)).toBe(hashSpec(metaB.spec)); + }); + + test("different seeds produce different spec hashes", () => { + const base = PRESETS.small; + const a = hashSpec({ ...base, seed: 1 }); + const b = hashSpec({ ...base, seed: 2 }); + expect(a).not.toBe(b); + }); + + test("idempotent re-run on matching spec is a no-op", () => { + const dir = mkdtempSync(join(ROOT, "idem-")); + const meta1 = generateFixture(makeSpec("small", dir, 7)); + const mtime1 = statSync(join(dir, ".meta.json")).mtimeMs; + const meta2 = generateFixture(makeSpec("small", dir, 7)); + const mtime2 = statSync(join(dir, ".meta.json")).mtimeMs; + expect(meta1.generatedAt).toBe(meta2.generatedAt); + expect(mtime1).toBe(mtime2); + }); + + test("force: true regenerates even when meta matches", () => { + const dir = mkdtempSync(join(ROOT, "force-")); + const meta1 = generateFixture(makeSpec("small", dir, 9)); + // Spin briefly so Date.now() advances past meta1's timestamp. + const start = Date.now(); + while (Date.now() - start < 2) { + // busy-wait + } + const meta2 = generateFixture(makeSpec("small", dir, 9), { force: true }); + expect(meta2.generatedAt).toBeGreaterThanOrEqual(meta1.generatedAt); + }); + + test("monorepo mode creates package dirs with per-pkg .gitignore", () => { + const dir = mkdtempSync(join(ROOT, "mono-")); + generateFixture(makeSpec("medium", dir, 4242)); + const entries = readdirSync(dir); + const mono = entries.find((e) => ["packages", "apps", "libs"].includes(e)); + expect(mono).toBeDefined(); + const pkgs = readdirSync(join(dir, mono as string)); + expect(pkgs.length).toBe(PRESETS.medium.packages); + // medium preset uses nested gitignore; every pkg should have one. + for (const pkg of pkgs) { + const gi = join(dir, mono as string, pkg, ".gitignore"); + expect(statSync(gi).isFile()).toBe(true); + } + }); + + test("root .git and .gitignore always present (project-root anchor)", () => { + const dir = mkdtempSync(join(ROOT, "root-")); + generateFixture(makeSpec("small", dir, 1)); + expect(statSync(join(dir, ".git")).isDirectory()).toBe(true); + expect(statSync(join(dir, ".gitignore")).isFile()).toBe(true); + }); + + test("binary blobs contain at least one NUL byte", () => { + const dir = mkdtempSync(join(ROOT, "bin-")); + generateFixture({ + ...PRESETS.small, + seed: 1, + rootDir: dir, + binaryRatio: 1, // all-binary (generator still forces >=1 text file) + filesPerPackage: 10, + }); + const blobs = readdirSync(join(dir, "assets")); + // Generator clamps text files to >=1, so we get (filesPerPackage - 1) blobs. + expect(blobs.length).toBe(9); + for (const blob of blobs) { + const bytes = readFileSync(join(dir, "assets", blob)); + expect(bytes.includes(0)).toBe(true); + } + }); +}); + +describe("hashSpec", () => { + test("is stable regardless of extension ordering", () => { + const base = PRESETS.small; + const a = hashSpec({ ...base, fileExtensions: [".ts", ".js"] }); + const b = hashSpec({ ...base, fileExtensions: [".js", ".ts"] }); + expect(a).toBe(b); + }); +}); diff --git a/packages/cli/test/lib/bench/helpers.test.ts b/packages/cli/test/lib/bench/helpers.test.ts new file mode 100644 index 000000000..e87b2ae6a --- /dev/null +++ b/packages/cli/test/lib/bench/helpers.test.ts @@ -0,0 +1,238 @@ +/** + * Unit tests for the bench harness statistics + comparison code. + * + * We intentionally don't test `withBenchDb` or the production-code ops + * here — those go through full DSN detection and run in `script/bench.ts` + * under realistic conditions. This file only covers the pure-math bits + * where we actually have deterministic inputs to assert against. + */ + +import { describe, expect, test } from "vitest"; +import { + type BenchReport, + compareReports, + measure, + summarize, +} from "../../fixtures/bench/helpers.js"; + +describe("summarize", () => { + test("computes p50 and p95 via nearest-rank on sorted samples", () => { + const stats = summarize([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(stats.runs).toBe(10); + expect(stats.min).toBe(1); + expect(stats.max).toBe(10); + expect(stats.mean).toBe(5.5); + // nearest-rank p50 = ceil(0.5 * 10) = 5 → sorted[4] = 5 + expect(stats.p50).toBe(5); + // nearest-rank p95 = ceil(0.95 * 10) = 10 → sorted[9] = 10 + expect(stats.p95).toBe(10); + }); + + test("handles single-sample input", () => { + const stats = summarize([42]); + expect(stats.runs).toBe(1); + expect(stats.min).toBe(42); + expect(stats.max).toBe(42); + expect(stats.p50).toBe(42); + expect(stats.p95).toBe(42); + expect(stats.stddev).toBe(0); + }); + + test("returns zero-valued stats for empty input", () => { + const stats = summarize([]); + expect(stats).toEqual({ + runs: 0, + min: 0, + max: 0, + mean: 0, + stddev: 0, + p50: 0, + p95: 0, + }); + }); + + test("stddev is zero for a uniform sample", () => { + const stats = summarize([5, 5, 5, 5, 5]); + expect(stats.stddev).toBe(0); + }); + + test("accepts unsorted input", () => { + const a = summarize([3, 1, 4, 1, 5, 9, 2, 6, 5, 3]); + const b = summarize([1, 1, 2, 3, 3, 4, 5, 5, 6, 9]); + expect(a).toEqual(b); + }); +}); + +describe("measure", () => { + test("runs the function the requested number of times", async () => { + let calls = 0; + const samples = await measure( + () => { + calls += 1; + }, + { runs: 5, warmup: 2 } + ); + // 2 warmup + 5 measured = 7 total calls; 5 reported samples. + expect(calls).toBe(7); + expect(samples.length).toBe(5); + for (const s of samples) { + expect(s).toBeGreaterThanOrEqual(0); + } + }); + + test("runs beforeEach before each iteration (warmup + measured)", async () => { + const calls: string[] = []; + await measure( + () => { + calls.push("run"); + }, + { + runs: 3, + warmup: 1, + beforeEach: () => { + calls.push("setup"); + }, + } + ); + // expected: setup, run, setup, run, setup, run, setup, run + expect(calls).toEqual([ + "setup", + "run", + "setup", + "run", + "setup", + "run", + "setup", + "run", + ]); + }); + + test("awaits async beforeEach and run hooks", async () => { + const order: string[] = []; + await measure( + async () => { + order.push("run-start"); + await new Promise((r) => setTimeout(r, 1)); + order.push("run-end"); + }, + { + runs: 1, + warmup: 0, + beforeEach: async () => { + order.push("setup-start"); + await new Promise((r) => setTimeout(r, 1)); + order.push("setup-end"); + }, + } + ); + expect(order).toEqual(["setup-start", "setup-end", "run-start", "run-end"]); + }); +}); + +describe("compareReports", () => { + const baseline: BenchReport = { + version: 1, + generatedAt: "2026-01-01T00:00:00.000Z", + runtime: { bun: "1.3.13", platform: "linux", arch: "x64", cpus: 8 }, + entries: [ + { + fixture: "synthetic/small", + operation: "detectDsn.cold", + warm: false, + stats: { + runs: 10, + min: 9, + max: 12, + mean: 10, + stddev: 1, + p50: 10, + p95: 12, + }, + }, + { + fixture: "synthetic/small", + operation: "detectDsn.warm", + warm: true, + stats: { + runs: 10, + min: 0.5, + max: 1.2, + mean: 1, + stddev: 0.2, + p50: 1, + p95: 1.2, + }, + }, + ], + }; + + test("flags regressions above threshold", () => { + const current: BenchReport = { + ...baseline, + entries: [ + { + ...baseline.entries[0]!, + stats: { ...baseline.entries[0]!.stats, p50: 15 }, + }, + baseline.entries[1]!, + ], + }; + const rows = compareReports(baseline, current, 0.2); + const cold = rows.find((r) => r.operation === "detectDsn.cold"); + expect(cold?.verdict).toBe("regressed"); + expect(cold?.deltaMs).toBe(5); + expect(cold?.deltaPct).toBeCloseTo(0.5, 5); + }); + + test("flags improvements below negative threshold", () => { + const current: BenchReport = { + ...baseline, + entries: [ + { + ...baseline.entries[0]!, + stats: { ...baseline.entries[0]!.stats, p50: 7 }, + }, + baseline.entries[1]!, + ], + }; + const rows = compareReports(baseline, current, 0.2); + const cold = rows.find((r) => r.operation === "detectDsn.cold"); + expect(cold?.verdict).toBe("improved"); + }); + + test("reports missing current entry when op disappears", () => { + const current: BenchReport = { + ...baseline, + entries: [baseline.entries[1]!], + }; + const rows = compareReports(baseline, current, 0.2); + const cold = rows.find((r) => r.operation === "detectDsn.cold"); + expect(cold?.verdict).toBe("missing-current"); + }); + + test("reports missing baseline entry when op is new", () => { + const current: BenchReport = { + ...baseline, + entries: [ + ...baseline.entries, + { + fixture: "synthetic/small", + operation: "scan.grepFiles", + warm: false, + stats: { + runs: 10, + min: 1, + max: 2, + mean: 1.5, + stddev: 0.2, + p50: 1.5, + p95: 2, + }, + }, + ], + }; + const rows = compareReports(baseline, current, 0.2); + const grep = rows.find((r) => r.operation === "scan.grepFiles"); + expect(grep?.verdict).toBe("missing-baseline"); + }); +}); diff --git a/packages/cli/test/lib/binary.mocked.test.ts b/packages/cli/test/lib/binary.mocked.test.ts new file mode 100644 index 000000000..ce8611c04 --- /dev/null +++ b/packages/cli/test/lib/binary.mocked.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for installBinary's canonical() fallback when realpath throws on an + * existing path. Kept in a separate file so the node:fs/promises mock doesn't + * leak into binary.test.ts. + */ + +import { chmodSync, mkdirSync, rmSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock only realpath to throw; everything else stays real via importOriginal. +vi.mock("node:fs/promises", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + realpath: () => + Promise.reject(new Error("EACCES: permission denied (simulated)")), + }; +}); + +// Import AFTER the mock so binary.ts picks up the throwing realpath. +import { getBinaryFilename, installBinary } from "../../src/lib/binary.js"; +import { logger } from "../../src/lib/logger.js"; + +describe("installBinary canonical() fallback when realpath throws", () => { + let testDir: string; + let installDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `binary-mocked-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + installDir = join(testDir, "install"); + mkdirSync(installDir, { recursive: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(testDir, { recursive: true, force: true }); + }); + + test("falls back to resolve() and logs when realpath throws on an existing path", async () => { + if (process.platform === "win32") return; + + // Mirror the upgrade-spawn case: sourcePath IS the .download file, so the + // guard must recognize them as the same file. With realpath throwing, + // canonical() falls back to resolve() rather than unlinking the source. + const tempPath = join(installDir, `${getBinaryFilename()}.download`); + await writeFile(tempPath, "upgraded binary"); + chmodSync(tempPath, 0o755); + + const debugSpy = vi.spyOn(logger, "debug"); + + const result = await installBinary(tempPath, installDir); + + expect(result).toBe(join(installDir, getBinaryFilename())); + expect(await readFile(result, "utf-8")).toBe("upgraded binary"); + expect(debugSpy).toHaveBeenCalledWith( + "realpath failed, falling back to resolve()", + expect.any(Error) + ); + }); +}); diff --git a/packages/cli/test/lib/binary.test.ts b/packages/cli/test/lib/binary.test.ts new file mode 100644 index 000000000..29ead3828 --- /dev/null +++ b/packages/cli/test/lib/binary.test.ts @@ -0,0 +1,974 @@ +/** + * Binary Management Tests + * + * Tests for shared binary helpers: install directory selection, paths, + * download URLs, locking, and binary installation. + */ + +import { + chmodSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + acquireLock, + compareVersions, + determineInstallDir, + fetchWithUpgradeError, + getBinaryDownloadUrl, + getBinaryFilename, + getBinaryPaths, + getGitHubReleaseByTagUrl, + getLegacyInstallDirs, + getPlatformBinaryName, + installBinary, + isDowngrade, + isMusl, + parseUpgradeJson, + releaseLock, + replaceBinarySync, + resolveUpgradeSource, + samePath, + UPGRADE_SOURCES, + UpgradeSourceNotFoundError, +} from "../../src/lib/binary.js"; +import { UpgradeError } from "../../src/lib/errors.js"; + +describe("getBinaryDownloadUrl", () => { + test("builds correct URL for current platform", () => { + const url = getBinaryDownloadUrl("1.0.0"); + + expect(url).toContain("/cli@1.0.0/"); + expect(url).toStartWith( + "https://github.com/getsentry/toolkit/releases/download/" + ); + expect(url).toContain("sentry-"); + + const arch = process.arch === "arm64" ? "arm64" : "x64"; + expect(url).toContain(arch); + }); + + test("includes .exe suffix on Windows", () => { + // Can only truly test this on Windows, but we verify the format + const url = getBinaryDownloadUrl("2.0.0"); + if (process.platform === "win32") { + expect(url).toEndWith(".exe"); + } else { + expect(url).not.toEndWith(".exe"); + } + }); +}); + +describe("UPGRADE_SOURCES", () => { + test("checks Toolkit before the legacy CLI repository", () => { + expect(UPGRADE_SOURCES).toEqual([ + { + githubRepo: "getsentry/toolkit", + ghcrRepo: "getsentry/toolkit", + tagPrefix: "cli@", + }, + { + githubRepo: "getsentry/cli", + ghcrRepo: "getsentry/cli", + tagPrefix: "", + }, + ]); + }); +}); + +describe("resolveUpgradeSource", () => { + test("uses the first source when it exists", async () => { + const requests: string[] = []; + + const resolved = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response(JSON.stringify({ tag_name: "cli@0.45.0" }), { + status: 200, + }); + }, + }); + + expect(resolved).toEqual({ + source: UPGRADE_SOURCES[0], + response: expect.any(Response), + }); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + ]); + }); + + test("falls back to the legacy source only on HTTP 404", async () => { + const requests: string[] = []; + + const resolved = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response( + requests.length === 1 + ? "Not Found" + : JSON.stringify({ tag_name: "0.45.0" }), + { status: requests.length === 1 ? 404 : 200 } + ); + }, + }); + + expect(resolved.source).toBe(UPGRADE_SOURCES[1]); + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + "https://api.github.com/repos/getsentry/cli/releases/tags/0.45.0", + ]); + }); + + test.each([ + 401, 403, 429, 500, + ])("does not fall back on HTTP %i", async (status) => { + const requests: string[] = []; + + await expect( + resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response("failure", { status }); + }, + }) + ).rejects.toThrow(`HTTP ${status}`); + + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + ]); + }); + + test("does not fall back on a network failure", async () => { + const requests: string[] = []; + + await expect( + resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + throw new TypeError("fetch failed"); + }, + }) + ).rejects.toThrow("Failed to connect to GitHub: fetch failed"); + + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + ]); + }); + + test("fails after every source returns 404", async () => { + const requests: string[] = []; + + const error = await resolveUpgradeSource({ + getProbeUrl: (source) => getGitHubReleaseByTagUrl("0.45.0", source), + fetch: async (url) => { + requests.push(String(url)); + return new Response("Not Found", { status: 404 }); + }, + }).catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(UpgradeSourceNotFoundError); + expect(error).toMatchObject({ name: "UpgradeSourceNotFoundError" }); + + expect(requests).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases/tags/cli%400.45.0", + "https://api.github.com/repos/getsentry/cli/releases/tags/0.45.0", + ]); + }); +}); + +describe("getBinaryFilename", () => { + test("returns sentry on non-Windows", () => { + if (process.platform !== "win32") { + expect(getBinaryFilename()).toBe("sentry"); + } + }); +}); + +describe("getBinaryPaths", () => { + test("returns all derived paths from install path", () => { + const paths = getBinaryPaths("/usr/local/bin/sentry"); + + expect(paths.installPath).toBe("/usr/local/bin/sentry"); + expect(paths.tempPath).toBe("/usr/local/bin/sentry.download"); + expect(paths.oldPath).toBe("/usr/local/bin/sentry.old"); + expect(paths.lockPath).toBe("/usr/local/bin/sentry.lock"); + }); +}); + +describe("samePath", () => { + test("matches identical paths", () => { + expect(samePath("/home/user/.local/bin", "/home/user/.local/bin")).toBe( + true + ); + }); + + test("distinguishes genuinely different paths", () => { + expect(samePath("/home/user/.local/bin", "/home/user/.sentry/bin")).toBe( + false + ); + }); + + test("case sensitivity follows the platform", () => { + const result = samePath("/Home/User/bin", "/home/user/bin"); + if (process.platform === "win32" || process.platform === "darwin") { + expect(result).toBe(true); + } else { + expect(result).toBe(false); + } + }); + + test("tolerates a trailing separator on either side", () => { + const dir = join("/home/user", ".local", "bin"); + expect(samePath(dir + sep, dir)).toBe(true); + expect(samePath(dir, dir + sep)).toBe(true); + expect(samePath(dir + sep, dir + sep)).toBe(true); + }); + + test("does not treat root as equal to empty after stripping", () => { + // A bare root separator must not be stripped to "". + expect(samePath(sep, sep)).toBe(true); + expect(samePath(sep, "")).toBe(false); + }); +}); + +describe("getLegacyInstallDirs", () => { + test("returns only the pre-XDG ~/.sentry/bin, not current XDG targets", () => { + const dirs = getLegacyInstallDirs("/home/user"); + expect(dirs).toEqual([join("/home/user", ".sentry", "bin")]); + // ~/.local/bin and ~/bin are valid current targets, never migration sources + expect(dirs).not.toContain(join("/home/user", ".local", "bin")); + expect(dirs).not.toContain(join("/home/user", "bin")); + }); +}); + +describe("determineInstallDir", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `binary-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("uses SENTRY_INSTALL_DIR when set", () => { + const customDir = join(testDir, "custom"); + mkdirSync(customDir, { recursive: true }); + + const result = determineInstallDir(testDir, { + SENTRY_INSTALL_DIR: customDir, + PATH: "/usr/bin", + }); + + expect(result).toBe(customDir); + }); + + test("prefers ~/.local/bin when it exists and is in PATH", () => { + const localBin = join(testDir, ".local", "bin"); + mkdirSync(localBin, { recursive: true }); + + const result = determineInstallDir(testDir, { + PATH: `/usr/bin:${localBin}`, + }); + + expect(result).toBe(localBin); + }); + + test("matches a PATH entry case-insensitively on Windows/macOS", () => { + // Use ~/bin so the result is distinguishable from the ~/.local/bin fallback. + const homeBin = join(testDir, "bin"); + mkdirSync(homeBin, { recursive: true }); + + const result = determineInstallDir(testDir, { + PATH: `/usr/bin:${homeBin.toUpperCase()}`, + }); + + if (process.platform === "win32" || process.platform === "darwin") { + // Case-insensitive FS: the upper-cased PATH entry still matches ~/bin. + expect(result).toBe(homeBin); + } else { + // Case-sensitive FS: no match, so it falls back to the XDG default. + expect(result).toBe(join(testDir, ".local", "bin")); + } + }); + + test("uses ~/bin when it exists and is in PATH but ~/.local/bin is not", () => { + const homeBin = join(testDir, "bin"); + mkdirSync(homeBin, { recursive: true }); + + const result = determineInstallDir(testDir, { + PATH: `/usr/bin:${homeBin}`, + }); + + expect(result).toBe(homeBin); + }); + + test("falls back to ~/.local/bin when no candidates are in PATH", () => { + const result = determineInstallDir(testDir, { + PATH: "/usr/bin:/bin", + }); + + expect(result).toBe(join(testDir, ".local", "bin")); + }); + + test("falls back to ~/.local/bin when it exists but is not in PATH", () => { + const localBin = join(testDir, ".local", "bin"); + mkdirSync(localBin, { recursive: true }); + + const result = determineInstallDir(testDir, { + PATH: "/usr/bin:/bin", + }); + + expect(result).toBe(localBin); + }); + + test("handles empty PATH", () => { + const result = determineInstallDir(testDir, { + PATH: "", + }); + + expect(result).toBe(join(testDir, ".local", "bin")); + }); + + test("handles undefined PATH", () => { + const result = determineInstallDir(testDir, {}); + + expect(result).toBe(join(testDir, ".local", "bin")); + }); + + test("uses XDG_BIN_HOME when set to an absolute path", () => { + const xdgBin = join(testDir, "xdg", "bin"); + + const result = determineInstallDir(testDir, { + XDG_BIN_HOME: xdgBin, + PATH: "/usr/bin", + }); + + expect(result).toBe(xdgBin); + }); + + test("ignores a non-absolute XDG_BIN_HOME per the XDG spec", () => { + const result = determineInstallDir(testDir, { + XDG_BIN_HOME: "relative/bin", + PATH: "/usr/bin", + }); + + expect(result).toBe(join(testDir, ".local", "bin")); + }); + + test("XDG_BIN_HOME takes priority over ~/.local/bin in PATH", () => { + const localBin = join(testDir, ".local", "bin"); + mkdirSync(localBin, { recursive: true }); + const xdgBin = join(testDir, "xdg", "bin"); + + const result = determineInstallDir(testDir, { + XDG_BIN_HOME: xdgBin, + PATH: `/usr/bin:${localBin}`, + }); + + expect(result).toBe(xdgBin); + }); + + test("SENTRY_INSTALL_DIR takes priority over XDG_BIN_HOME", () => { + const xdgBin = join(testDir, "xdg", "bin"); + const customDir = join(testDir, "custom"); + mkdirSync(customDir, { recursive: true }); + + const result = determineInstallDir(testDir, { + SENTRY_INSTALL_DIR: customDir, + XDG_BIN_HOME: xdgBin, + PATH: "/usr/bin", + }); + + expect(result).toBe(customDir); + }); + + test("SENTRY_INSTALL_DIR takes priority over ~/.local/bin", () => { + const localBin = join(testDir, ".local", "bin"); + mkdirSync(localBin, { recursive: true }); + const customDir = join(testDir, "custom"); + mkdirSync(customDir, { recursive: true }); + + const result = determineInstallDir(testDir, { + SENTRY_INSTALL_DIR: customDir, + PATH: `/usr/bin:${localBin}`, + }); + + expect(result).toBe(customDir); + }); +}); + +describe("fetchWithUpgradeError", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns response on success", async () => { + globalThis.fetch = (async () => + new Response("ok", { status: 200 })) as typeof globalThis.fetch; + + const response = await fetchWithUpgradeError( + "https://example.com", + {}, + "Test" + ); + expect(response.status).toBe(200); + }); + + test("re-throws AbortError as-is", async () => { + globalThis.fetch = (async () => { + const err = new Error("Aborted"); + err.name = "AbortError"; + throw err; + }) as typeof globalThis.fetch; + + try { + await fetchWithUpgradeError("https://example.com", {}, "Test"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).name).toBe("AbortError"); + expect(error).not.toBeInstanceOf(UpgradeError); + } + }); + + test("preserves an arbitrary external abort reason", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + const request = resolveUpgradeSource({ + getProbeUrl: () => "https://example.com", + signal: controller.signal, + fetch: async () => { + controller.abort(reason); + throw reason; + }, + }); + + await expect(request).rejects.toBe(reason); + }); + + test("wraps network errors as UpgradeError", async () => { + globalThis.fetch = (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof globalThis.fetch; + + try { + await fetchWithUpgradeError("https://example.com", {}, "GitHub"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(UpgradeError); + expect((error as UpgradeError).message).toContain("GitHub"); + expect((error as UpgradeError).message).toContain("ECONNREFUSED"); + } + }); + + test("wraps non-Error thrown values as UpgradeError", async () => { + globalThis.fetch = (async () => { + // biome-ignore lint/style/useThrowOnlyError: intentionally testing non-Error throw + throw { code: "ECONNRESET", reason: "connection reset" }; + }) as typeof globalThis.fetch; + + try { + await fetchWithUpgradeError("https://example.com", {}, "Service"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(UpgradeError); + expect((error as UpgradeError).message).toContain("ECONNRESET"); + } + }); +}); + +describe("parseUpgradeJson", () => { + test("preserves cancellation during body consumption", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + const response = Response.json({}); + response.json = async () => { + controller.abort(reason); + throw new DOMException("aborted", "AbortError"); + }; + + await expect( + parseUpgradeJson(response, controller.signal, "invalid metadata") + ).rejects.toBe(reason); + }); + + test("classifies body termination as transport failure", async () => { + const response = Response.json({}); + response.json = async () => { + throw new TypeError("terminated"); + }; + + await expect( + parseUpgradeJson(response, undefined, "invalid metadata") + ).rejects.toMatchObject({ + name: "UpgradeTransportError", + reason: "network_error", + }); + }); + + test("classifies completed malformed JSON as metadata failure", async () => { + const response = new Response("not json"); + + await expect( + parseUpgradeJson(response, undefined, "invalid metadata") + ).rejects.toMatchObject({ + name: "UpgradeError", + reason: "network_error", + message: "invalid metadata", + }); + }); +}); + +describe("replaceBinarySync", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `replace-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("replaces existing binary atomically on Unix", async () => { + if (process.platform === "win32") return; + + const installPath = join(testDir, "sentry"); + const tempPath = join(testDir, "sentry.download"); + + // Write existing binary + await writeFile(installPath, "old binary"); + // Write new binary to temp location + await writeFile(tempPath, "new binary"); + + replaceBinarySync(tempPath, installPath); + + // New content should be at install path + const content = await readFile(installPath, "utf-8"); + expect(content).toBe("new binary"); + + // Temp file should no longer exist (it was renamed) + expect( + await access(tempPath).then( + () => true, + () => false + ) + ).toBe(false); + }); + + test("works when no existing binary (fresh install)", async () => { + if (process.platform === "win32") return; + + const installPath = join(testDir, "sentry"); + const tempPath = join(testDir, "sentry.download"); + + // Only write temp, no existing binary + await writeFile(tempPath, "fresh binary"); + + replaceBinarySync(tempPath, installPath); + + const content = await readFile(installPath, "utf-8"); + expect(content).toBe("fresh binary"); + }); +}); + +describe("installBinary", () => { + let testDir: string; + let sourceDir: string; + let installDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `binary-install-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + sourceDir = join(testDir, "source"); + installDir = join(testDir, "install"); + mkdirSync(sourceDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("copies binary to install directory", async () => { + const sourcePath = join(sourceDir, "sentry-temp"); + const content = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); // ELF magic + await writeFile(sourcePath, content); + chmodSync(sourcePath, 0o755); + + const result = await installBinary(sourcePath, installDir); + + expect(result).toBe(join(installDir, getBinaryFilename())); + expect( + await access(result).then( + () => true, + () => false + ) + ).toBe(true); + + const installed = await readFile(result); + expect(new Uint8Array(installed)).toEqual(content); + }); + + test("creates install directory if it does not exist", async () => { + const sourcePath = join(sourceDir, "sentry-temp"); + await writeFile(sourcePath, "binary content"); + chmodSync(sourcePath, 0o755); + + const nestedDir = join(installDir, "deep", "nested"); + const result = await installBinary(sourcePath, nestedDir); + + expect(result).toBe(join(nestedDir, getBinaryFilename())); + expect( + await access(result).then( + () => true, + () => false + ) + ).toBe(true); + }); + + test("cleans up lock file after installation", async () => { + const sourcePath = join(sourceDir, "sentry-temp"); + await writeFile(sourcePath, "binary content"); + chmodSync(sourcePath, 0o755); + + const installPath = await installBinary(sourcePath, installDir); + const lockPath = `${installPath}.lock`; + + expect( + await access(lockPath).then( + () => true, + () => false + ) + ).toBe(false); + }); + + test("cleans up temp .download file after installation", async () => { + const sourcePath = join(sourceDir, "sentry-temp"); + await writeFile(sourcePath, "binary content"); + chmodSync(sourcePath, 0o755); + + const installPath = await installBinary(sourcePath, installDir); + const tempPath = `${installPath}.download`; + + expect( + await access(tempPath).then( + () => true, + () => false + ) + ).toBe(false); + }); + + test("overwrites existing binary", async () => { + // Install initial binary + mkdirSync(installDir, { recursive: true }); + const existingPath = join(installDir, getBinaryFilename()); + await writeFile(existingPath, "old content"); + + // Install new binary over it + const sourcePath = join(sourceDir, "sentry-temp"); + await writeFile(sourcePath, "new content"); + chmodSync(sourcePath, 0o755); + + await installBinary(sourcePath, installDir); + + const content = await readFile(existingPath, "utf-8"); + expect(content).toBe("new content"); + }); + + test("handles sourcePath === tempPath (upgrade spawn case)", async () => { + if (process.platform === "win32") return; + + // Simulate the upgrade flow: the source binary IS already the .download file + // (this happens when upgrade downloads to installPath.download, then spawns + // setup --install where execPath is that .download file) + mkdirSync(installDir, { recursive: true }); + const tempPath = join(installDir, `${getBinaryFilename()}.download`); + await writeFile(tempPath, "upgraded binary"); + chmodSync(tempPath, 0o755); + + const result = await installBinary(tempPath, installDir); + + expect(result).toBe(join(installDir, getBinaryFilename())); + const content = await readFile(result, "utf-8"); + expect(content).toBe("upgraded binary"); + }); + + test("handles sourcePath === tempPath reached through a symlinked install dir", async () => { + if (process.platform === "win32") return; + + // Reproduces the macOS /tmp -> /private/tmp case: installDir is given via a + // symlink, but the source binary's path is canonicalized (as process.execPath + // would be). The two paths point at the same file but differ as strings, so a + // naive resolve() comparison would unlink the source before copying it. + const realDir = join(testDir, "real-install"); + mkdirSync(realDir, { recursive: true }); + const symlinkedDir = join(testDir, "symlinked-install"); + symlinkSync(realDir, symlinkedDir); + + // The .download file lives in the real dir; sourcePath uses the canonical path. + const tempName = `${getBinaryFilename()}.download`; + const sourcePath = join(realpathSync(realDir), tempName); + await writeFile(sourcePath, "upgraded binary"); + chmodSync(sourcePath, 0o755); + + // installBinary is called with the symlinked dir, so tempPath resolves to the + // same file as sourcePath via the symlink. + const result = await installBinary(sourcePath, symlinkedDir); + + expect(result).toBe(join(symlinkedDir, getBinaryFilename())); + const content = await readFile(result, "utf-8"); + expect(content).toBe("upgraded binary"); + }); +}); + +describe("acquireLock", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `lock-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("creates lock file with current PID", () => { + const lockPath = join(testDir, "test.lock"); + acquireLock(lockPath); + + const content = readFileSync(lockPath, "utf-8").trim(); + expect(content).toBe(String(process.pid)); + + releaseLock(lockPath); + }); + + test("creates the parent directory if it does not exist", () => { + // Regression for CLI-1E1 / CLI-1RV: the install dir (e.g. ~/.sentry/bin, + // or a stale SENTRY_INSTALL_DIR that was later purged) may not exist when + // the upgrade pipeline tries to acquire the lock. acquireLock must create + // the parent directory instead of crashing with ENOENT on writeFileSync. + const missingDir = join(testDir, "nested", "install-dir"); + const lockPath = join(missingDir, "sentry.lock"); + + expect(() => acquireLock(lockPath)).not.toThrow(); + + const content = readFileSync(lockPath, "utf-8").trim(); + expect(content).toBe(String(process.pid)); + + releaseLock(lockPath); + }); + + test("propagates the mkdir EEXIST when the parent path is a regular file", () => { + // Edge case from review: if the lock's parent path is an existing regular + // file, mkdirSync throws EEXIST. Because mkdir runs OUTSIDE the + // writeFileSync try/catch, that EEXIST propagates directly. If mkdir were + // inside the try, the EEXIST would be routed into handleExistingLock, + // which would then fail reading the lock under a non-directory with a + // misleading ENOTDIR. Asserting EEXIST therefore guards mkdir's placement. + const fileAsDir = join(testDir, "not-a-dir"); + writeFileSync(fileAsDir, "i am a file"); + const lockPath = join(fileAsDir, "sentry.lock"); + + let caught: NodeJS.ErrnoException | undefined; + try { + acquireLock(lockPath); + } catch (error) { + caught = error as NodeJS.ErrnoException; + } + expect(caught).toBeDefined(); + expect(caught?.code).toBe("EEXIST"); + }); + + test("throws when lock held by another running process", () => { + const lockPath = join(testDir, "test.lock"); + // PID 1 (init/systemd) is always running + writeFileSync(lockPath, "1"); + + expect(() => acquireLock(lockPath)).toThrow( + "Another upgrade is already in progress" + ); + }); + + test("takes over stale lock from dead process", () => { + const lockPath = join(testDir, "test.lock"); + // Use an absurdly high PID that won't exist + writeFileSync(lockPath, "999999999"); + + acquireLock(lockPath); + + const content = readFileSync(lockPath, "utf-8").trim(); + expect(content).toBe(String(process.pid)); + + releaseLock(lockPath); + }); + + test("allows child process to take over parent lock via process.ppid", () => { + const lockPath = join(testDir, "test.lock"); + // Write the current process's parent PID — simulates the upgrade command + // holding the lock when the child (setup --install) tries to acquire it + writeFileSync(lockPath, String(process.ppid)); + + // Should NOT throw — recognizes parent PID and takes over + acquireLock(lockPath); + + const content = readFileSync(lockPath, "utf-8").trim(); + expect(content).toBe(String(process.pid)); + + releaseLock(lockPath); + }); +}); + +describe("compareVersions", () => { + test("stable: newer > older", () => { + expect(compareVersions("0.15.0", "0.14.0")).toBe(1); + }); + + test("stable: older < newer", () => { + expect(compareVersions("0.14.0", "0.15.0")).toBe(-1); + }); + + test("stable: equal", () => { + expect(compareVersions("0.14.0", "0.14.0")).toBe(0); + }); + + test("nightly: later timestamp > earlier timestamp", () => { + expect( + compareVersions("0.14.0-dev.1772732047", "0.14.0-dev.1772724107") + ).toBe(1); + }); + + test("nightly: earlier timestamp < later timestamp", () => { + expect( + compareVersions("0.14.0-dev.1772724107", "0.14.0-dev.1772732047") + ).toBe(-1); + }); + + test("nightly: equal", () => { + expect( + compareVersions("0.14.0-dev.1772724107", "0.14.0-dev.1772724107") + ).toBe(0); + }); + + test("stable > nightly with same base (semver: release > pre-release)", () => { + expect(compareVersions("0.14.0", "0.14.0-dev.1772732047")).toBe(1); + }); +}); + +describe("isDowngrade", () => { + test("returns true when target is older stable version", () => { + expect(isDowngrade("0.15.0", "0.14.0")).toBe(true); + }); + + test("returns false when target is newer stable version", () => { + expect(isDowngrade("0.14.0", "0.15.0")).toBe(false); + }); + + test("returns false when versions are equal", () => { + expect(isDowngrade("0.14.0", "0.14.0")).toBe(false); + }); + + test("returns true when target is older nightly (earlier timestamp)", () => { + expect(isDowngrade("0.14.0-dev.1772732047", "0.14.0-dev.1772724107")).toBe( + true + ); + }); + + test("returns false when target is newer nightly (later timestamp)", () => { + expect(isDowngrade("0.14.0-dev.1772724107", "0.14.0-dev.1772732047")).toBe( + false + ); + }); +}); + +describe("isMusl", () => { + test("returns false on non-Linux platforms", () => { + if (process.platform !== "linux") { + expect(isMusl()).toBe(false); + } + }); + + test("returns a boolean on Linux", () => { + if (process.platform === "linux") { + expect(typeof isMusl()).toBe("boolean"); + } + }); + + test("result is cached (calling twice returns same value)", () => { + const first = isMusl(); + const second = isMusl(); + expect(first).toBe(second); + }); +}); + +describe("getPlatformBinaryName", () => { + test("starts with sentry- prefix", () => { + expect(getPlatformBinaryName()).toStartWith("sentry-"); + }); + + test("includes correct architecture", () => { + const name = getPlatformBinaryName(); + const expectedArch = process.arch === "arm64" ? "arm64" : "x64"; + expect(name).toContain(expectedArch); + }); + + test("includes correct OS", () => { + const name = getPlatformBinaryName(); + if (process.platform === "darwin") { + expect(name).toContain("darwin"); + } else if (process.platform === "win32") { + expect(name).toContain("windows"); + expect(name).toEndWith(".exe"); + } else { + expect(name).toContain("linux"); + } + }); + + test("on CI (glibc), does not contain -musl suffix", () => { + // CI runners use glibc-based Ubuntu. This test verifies the + // musl detection correctly identifies glibc systems. + if (process.platform === "linux" && !isMusl()) { + expect(getPlatformBinaryName()).not.toContain("-musl"); + } + }); + + test("includes -musl suffix on musl systems", () => { + // Only runs on musl-based systems (e.g., Alpine CI containers) + if (process.platform === "linux" && isMusl()) { + expect(getPlatformBinaryName()).toContain("-musl"); + } + }); +}); diff --git a/packages/cli/test/lib/bspatch.property.test.ts b/packages/cli/test/lib/bspatch.property.test.ts new file mode 100644 index 000000000..f6f7fd636 --- /dev/null +++ b/packages/cli/test/lib/bspatch.property.test.ts @@ -0,0 +1,131 @@ +/** + * Property-Based Tests for TRDIFF10 Patch Parsing + * + * Uses fast-check to verify invariants of the offtin and parsePatchHeader + * functions across random inputs. + */ + +import { offtin, parsePatchHeader } from "binpatch"; +import { assert as fcAssert, integer, property, uint8Array } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("property: offtin", () => { + test("magnitude is always non-negative", () => { + fcAssert( + property(uint8Array({ minLength: 8, maxLength: 8 }), (buf) => { + const value = offtin(buf, 0); + // The magnitude is always >= 0; the sign makes the result negative + // |value| should be representable + expect(Number.isFinite(value)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("zero bytes produce zero", () => { + const buf = new Uint8Array(8); + expect(offtin(buf, 0)).toBe(0); + }); + + test("sign bit only affects sign, not magnitude", () => { + fcAssert( + property(uint8Array({ minLength: 8, maxLength: 8 }), (buf) => { + // Clear sign bit and read + const cleared = new Uint8Array(buf); + cleared[7] %= 128; // Clear bit 7 + const positive = offtin(cleared, 0); + + // Set sign bit and read + const negated = new Uint8Array(buf); + negated[7] = (negated[7] % 128) + 128; // Set bit 7 (can't use %= here) + const negative = offtin(negated, 0); + + // Magnitude should be the same + expect(Math.abs(positive)).toBe(Math.abs(negative)); + // Signs should be correct + expect(positive).toBeGreaterThanOrEqual(0); + expect(negative).toBeLessThanOrEqual(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("round-trip: value → LE bytes → offtin recovers original", () => { + // Test with safe integer range values that fit in 53 bits + fcAssert( + property( + integer({ min: 0, max: Number.MAX_SAFE_INTEGER }), + (magnitude) => { + const buf = new Uint8Array(8); + const view = new DataView(buf.buffer); + // Write as unsigned LE: low 32 bits at offset 0, high at offset 4 + view.setUint32(0, magnitude % 0x1_00_00_00_00, true); + view.setUint32(4, Math.floor(magnitude / 0x1_00_00_00_00), true); + + const result = offtin(buf, 0); + expect(result).toBe(magnitude); + + // Now test negative + buf[7] += 128; // Set sign bit + const negResult = offtin(buf, 0); + if (magnitude === 0) { + expect(negResult).toBe(0); // -0 === 0 + } else { + expect(negResult).toBe(-magnitude); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: parsePatchHeader", () => { + test("always rejects buffers shorter than 32 bytes", () => { + fcAssert( + property(uint8Array({ minLength: 0, maxLength: 31 }), (buf) => { + expect(() => parsePatchHeader(buf)).toThrow("Patch too small"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always rejects non-TRDIFF10 magic", () => { + fcAssert( + property(uint8Array({ minLength: 32, maxLength: 64 }), (buf) => { + // Ensure magic is NOT TRDIFF10 by checking first 8 bytes + const magic = new TextDecoder().decode(buf.subarray(0, 8)); + if (magic === "TRDIFF10") { + return; // Skip valid magic (unlikely but possible) + } + expect(() => parsePatchHeader(buf)).toThrow("Invalid patch format"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("parsed values are always non-negative", () => { + fcAssert( + property(uint8Array({ minLength: 32, maxLength: 256 }), (buf) => { + // Set valid magic + const patched = new Uint8Array(buf); + patched.set(new TextEncoder().encode("TRDIFF10"), 0); + // Clear sign bits in header fields + patched[15] %= 128; // controlLen sign + patched[23] %= 128; // diffLen sign + patched[31] %= 128; // newSize sign + + try { + const header = parsePatchHeader(patched); + expect(header.controlLen).toBeGreaterThanOrEqual(0); + expect(header.diffLen).toBeGreaterThanOrEqual(0); + expect(header.newSize).toBeGreaterThanOrEqual(0); + } catch { + // May throw for other reasons (length overflow) — that's fine + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/bspatch.test.ts b/packages/cli/test/lib/bspatch.test.ts new file mode 100644 index 000000000..d474b8655 --- /dev/null +++ b/packages/cli/test/lib/bspatch.test.ts @@ -0,0 +1,616 @@ +/** + * Unit Tests for TRDIFF10 Binary Patch Application + * + * Note: Core invariants (offtin sign/magnitude, parsePatchHeader non-negative + * values) are tested via property-based tests in bspatch.property.test.ts. + * These tests focus on fixture-based patch application, negative-value rejection + * (property generator clears sign bits), and error messages. + * + * Tests the bspatch implementation against real TRDIFF10 patches + * generated by zig-bsdiff v0.1.19 (stored as test fixtures). + */ + +import { createHash } from "node:crypto"; +import { existsSync, unlinkSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { zstdCompressSync } from "node:zlib"; +import { + addDiffChunk, + applyPatch, + applyPatchChainInMemory, + applyPatchToMemory, + MAX_OUTPUT_SIZE, + parsePatchHeader, +} from "binpatch"; +import { describe, expect, test } from "vitest"; + +const FIXTURES_DIR = join(import.meta.dirname, "../fixtures/patches"); + +/** + * Write a non-negative integer as a little-endian i64, matching the + * sign-magnitude encoding that {@link offtin} reads (sign bit clear). + */ +function writeI64LE(buf: Uint8Array, offset: number, value: number): void { + const view = new DataView(buf.buffer, buf.byteOffset + offset, 8); + view.setUint32(0, value % 0x1_00_00_00_00, true); + view.setUint32(4, Math.floor(value / 0x1_00_00_00_00), true); +} + +/** + * Build a minimal "diff-only" TRDIFF10 patch transforming `oldBytes` → `newBytes` + * (which must be equal length). Emits one or more control tuples, each copying + * up to `chunkSize` diff bytes (added to the old bytes), with no extra bytes and + * no seek. Splitting into many small tuples forces the patcher to issue many + * small windowed reads against the base — exercising {@link FileOldReader}'s + * block cache across boundaries. Lets chain/cache tests run in CI without the + * external zig-bsdiff encoder. + * + * @param chunkSize - Max diff bytes per control tuple (defaults to the whole file) + */ +function buildDiffOnlyPatch( + oldBytes: Uint8Array, + newBytes: Uint8Array, + chunkSize = newBytes.length +): Uint8Array { + if (oldBytes.length !== newBytes.length) { + throw new Error("diff-only patch requires equal lengths"); + } + const len = newBytes.length; + + // One control tuple per chunk: readDiffBy=chunk, readExtraBy=0, seekBy=0. + const chunks: number[] = []; + for (let remaining = len; remaining > 0; ) { + const c = Math.min(chunkSize, remaining); + chunks.push(c); + remaining -= c; + } + const control = new Uint8Array(chunks.length * 24); + for (let t = 0; t < chunks.length; t++) { + writeI64LE(control, t * 24, chunks[t] ?? 0); + writeI64LE(control, t * 24 + 8, 0); + writeI64LE(control, t * 24 + 16, 0); + } + + // diff[i] = (new[i] - old[i]) mod 256, so wrapping (old + diff) == new. + const diff = new Uint8Array(len); + for (let i = 0; i < len; i++) { + diff[i] = ((newBytes[i] ?? 0) - (oldBytes[i] ?? 0) + 256) % 256; + } + + const controlZ = new Uint8Array(zstdCompressSync(control)); + const diffZ = new Uint8Array(zstdCompressSync(diff)); + const extraZ = new Uint8Array(zstdCompressSync(new Uint8Array(0))); + + const patch = new Uint8Array( + 32 + controlZ.length + diffZ.length + extraZ.length + ); + patch.set(new TextEncoder().encode("TRDIFF10"), 0); + writeI64LE(patch, 8, controlZ.length); + writeI64LE(patch, 16, diffZ.length); + writeI64LE(patch, 24, len); + patch.set(controlZ, 32); + patch.set(diffZ, 32 + controlZ.length); + patch.set(extraZ, 32 + controlZ.length + diffZ.length); + return patch; +} + +/** + * Generate deterministic pseudo-random bytes via a 32-bit LCG. Uses modular + * arithmetic (no bitwise ops) — intermediate products stay under 2^53. + */ +function makeBytes(seed: number, len: number): Uint8Array { + const modulus = 0x1_00_00_00_00; + const out = new Uint8Array(len); + let x = seed % modulus; + for (let i = 0; i < len; i++) { + x = (x * 1_664_525 + 1_013_904_223) % modulus; + out[i] = x % 256; + } + return out; +} + +describe("parsePatchHeader: fixtures", () => { + test("parses valid small fixture header", async () => { + const patchData = new Uint8Array( + await readFile(join(FIXTURES_DIR, "small.trdiff10")) + ); + const header = parsePatchHeader(patchData); + expect(header.controlLen).toBeGreaterThan(0); + expect(header.diffLen).toBeGreaterThan(0); + expect(header.newSize).toBe(54); // "Hello, World! This is the new file content. Version 2." + }); + + test("parses valid large fixture header", async () => { + const patchData = new Uint8Array( + await readFile(join(FIXTURES_DIR, "large.trdiff10")) + ); + const header = parsePatchHeader(patchData); + expect(header.controlLen).toBeGreaterThan(0); + expect(header.diffLen).toBeGreaterThanOrEqual(0); + expect(header.newSize).toBe(65_536); + }); + + test("rejects truncated patch (header claims more data than exists)", () => { + const truncated = new Uint8Array(32); + truncated.set(new TextEncoder().encode("TRDIFF10")); + truncated[8] = 100; // controlLen = 100 + expect(() => parsePatchHeader(truncated)).toThrow("exceed file size"); + }); + + test("rejects negative header values", () => { + // Property generator clears sign bits (patched[15] %= 128) so never + // exercises the negative-length error path — this test fills that gap + const buf = new Uint8Array(64); + buf.set(new TextEncoder().encode("TRDIFF10")); + // offtin reads sign from bit 7 of byte 7 within each 8-byte field. + // controlLen is at offset 8, so sign bit is byte 15. + // Need non-zero magnitude + sign bit for a negative result. + buf[8] = 1; // non-zero low byte → magnitude > 0 + buf[15] = 0x80; // Set sign bit → negative controlLen + expect(() => parsePatchHeader(buf)).toThrow("negative"); + }); + + test("rejects an attacker-controlled newSize above MAX_OUTPUT_SIZE", () => { + // The header's newSize is used to preallocate the output buffer before the + // patch content is verified. An unbounded value lets a malicious/corrupt + // patch force an OOM via `new Uint8Array(newSize)` in applyReaderToMemory. + // Regression test: a header claiming newSize > MAX_OUTPUT_SIZE must be + // rejected here, before any allocation. + const buf = new Uint8Array(64); + buf.set(new TextEncoder().encode("TRDIFF10")); + writeI64LE(buf, 8, 0); // controlLen = 0 + writeI64LE(buf, 16, 0); // diffLen = 0 + writeI64LE(buf, 24, MAX_OUTPUT_SIZE + 1); // newSize just over the cap + expect(() => parsePatchHeader(buf)).toThrow("exceeds maximum"); + }); + + test("accepts a newSize at MAX_OUTPUT_SIZE (boundary)", () => { + const buf = new Uint8Array(64); + buf.set(new TextEncoder().encode("TRDIFF10")); + writeI64LE(buf, 8, 0); + writeI64LE(buf, 16, 0); + writeI64LE(buf, 24, MAX_OUTPUT_SIZE); // exactly the cap — allowed + const header = parsePatchHeader(buf); + expect(header.newSize).toBe(MAX_OUTPUT_SIZE); + }); +}); + +describe("applyPatch", () => { + function tempFile(name: string): string { + return join(tmpdir(), `bspatch-test-${Date.now()}-${name}`); + } + + test("patches small text files correctly", async () => { + const oldPath = join(FIXTURES_DIR, "small-old.bin"); + const patchData = new Uint8Array( + await readFile(join(FIXTURES_DIR, "small.trdiff10")) + ); + const destPath = tempFile("small-out.bin"); + + try { + const sha256 = await applyPatch(oldPath, patchData, destPath); + + const expected = await readFile(join(FIXTURES_DIR, "small-new.bin")); + const actual = await readFile(destPath); + expect(new Uint8Array(actual)).toEqual(new Uint8Array(expected)); + + const expectedHash = createHash("sha256").update(expected).digest("hex"); + expect(sha256).toBe(expectedHash); + } finally { + try { + unlinkSync(destPath); + } catch { + // Ignore cleanup errors + } + } + }); + + test("patches large binary files correctly", async () => { + const oldPath = join(FIXTURES_DIR, "large-old.bin"); + const patchData = new Uint8Array( + await readFile(join(FIXTURES_DIR, "large.trdiff10")) + ); + const destPath = tempFile("large-out.bin"); + + try { + const sha256 = await applyPatch(oldPath, patchData, destPath); + + const expected = await readFile(join(FIXTURES_DIR, "large-new.bin")); + const actual = await readFile(destPath); + expect(new Uint8Array(actual)).toEqual(new Uint8Array(expected)); + + const expectedHash = createHash("sha256").update(expected).digest("hex"); + expect(sha256).toBe(expectedHash); + } finally { + try { + unlinkSync(destPath); + } catch { + // Ignore cleanup errors + } + } + }); + + test("returns correct SHA-256 hex digest", async () => { + const oldPath = join(FIXTURES_DIR, "small-old.bin"); + const patchData = new Uint8Array( + await readFile(join(FIXTURES_DIR, "small.trdiff10")) + ); + const destPath = tempFile("sha256-out.bin"); + + try { + const sha256 = await applyPatch(oldPath, patchData, destPath); + expect(sha256).toMatch(/^[0-9a-f]{64}$/); + expect(sha256.length).toBe(64); + } finally { + try { + unlinkSync(destPath); + } catch { + // Ignore cleanup errors + } + } + }); + + test("rejects non-TRDIFF10 magic", async () => { + const oldPath = join(FIXTURES_DIR, "small-old.bin"); + const buf = new Uint8Array(64); + buf.set(new TextEncoder().encode("BSDIFF40")); + const destPath = tempFile("badmagic-out.bin"); + + try { + await expect(applyPatch(oldPath, buf, destPath)).rejects.toThrow( + "Invalid patch format" + ); + } finally { + try { + unlinkSync(destPath); + } catch { + // Ignore cleanup errors + } + } + }); +}); + +describe("applyPatchChainInMemory", () => { + function tempFile(name: string): string { + return join( + tmpdir(), + `bspatch-chain-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}` + ); + } + + test("applies a multi-hop chain in memory, writing only the final binary", async () => { + // old --p1--> mid --p2--> new. The first hop reads the on-disk base via the + // fd-backed reader; the second reads the in-memory intermediate. Exercises + // both reader paths plus the chaining loop without touching scratch files. + const oldBytes = makeBytes(1, 4096); + const midBytes = makeBytes(2, 4096); + const newBytes = makeBytes(3, 4096); + const p1 = buildDiffOnlyPatch(oldBytes, midBytes); + const p2 = buildDiffOnlyPatch(midBytes, newBytes); + + const oldPath = tempFile("old.bin"); + const destPath = tempFile("out.bin"); + await writeFile(oldPath, oldBytes); + + try { + const sha256 = await applyPatchChainInMemory(oldPath, [p1, p2], destPath); + + const out = new Uint8Array(await readFile(destPath)); + expect(out).toEqual(newBytes); + expect(sha256).toBe(createHash("sha256").update(newBytes).digest("hex")); + + // Intermediates are held in memory — no scratch files on disk. + expect(existsSync(`${destPath}.patching.a`)).toBe(false); + expect(existsSync(`${destPath}.patching.b`)).toBe(false); + } finally { + for (const p of [oldPath, destPath]) { + if (existsSync(p)) { + unlinkSync(p); + } + } + } + }); + + test("onBytes reports every hop's output — total = sum of all newSizes", async () => { + // The progress callback fires for the output bytes of EVERY hop (the + // in-memory intermediate AND the final disk write), so a multi-hop total + // must be the sum of all hops' newSize, not just the final binary's. + // (Guards the delta-upgrade progress-bar total; a last-hop-only total + // would freeze the bar at 100% after hop 1.) + const oldBytes = makeBytes(1, 4096); + const midBytes = makeBytes(2, 4096); + const newBytes = makeBytes(3, 4096); + const p1 = buildDiffOnlyPatch(oldBytes, midBytes); + const p2 = buildDiffOnlyPatch(midBytes, newBytes); + + const oldPath = tempFile("old-ob.bin"); + const destPath = tempFile("out-ob.bin"); + await writeFile(oldPath, oldBytes); + + try { + let reported = 0; + await applyPatchChainInMemory(oldPath, [p1, p2], destPath, (n) => { + reported += n; + }); + // Two hops, each emitting 4096 output bytes. + expect(reported).toBe(midBytes.length + newBytes.length); + } finally { + for (const p of [oldPath, destPath]) { + if (existsSync(p)) { + unlinkSync(p); + } + } + } + }); + + test("single-element chain matches the on-disk patcher on a real fixture", async () => { + // Exercises the fd-backed reader against a real zig-bsdiff patch whose + // control entries include diff, extra, and seek operations. + const oldPath = join(FIXTURES_DIR, "large-old.bin"); + const patchData = new Uint8Array( + await readFile(join(FIXTURES_DIR, "large.trdiff10")) + ); + const destPath = tempFile("single-chain.bin"); + + try { + const sha256 = await applyPatchChainInMemory( + oldPath, + [patchData], + destPath + ); + const expected = await readFile(join(FIXTURES_DIR, "large-new.bin")); + expect(new Uint8Array(await readFile(destPath))).toEqual( + new Uint8Array(expected) + ); + expect(sha256).toBe(createHash("sha256").update(expected).digest("hex")); + } finally { + if (existsSync(destPath)) { + unlinkSync(destPath); + } + } + }); + + test("rejects an empty patch chain", async () => { + const oldPath = join(FIXTURES_DIR, "small-old.bin"); + const destPath = tempFile("empty-chain.bin"); + + try { + await expect( + applyPatchChainInMemory(oldPath, [], destPath) + ).rejects.toThrow("empty patch chain"); + } finally { + if (existsSync(destPath)) { + unlinkSync(destPath); + } + } + }); +}); + +describe("applyPatchToMemory", () => { + function tempFile(name: string): string { + return join( + tmpdir(), + `bspatch-mem-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}` + ); + } + + test("produces the same bytes and hash as the on-disk patcher", async () => { + // Cross-checks the memory-backed reader against the fd-backed reader on a + // real fixture patch (diff + extra + seek). + const oldPath = join(FIXTURES_DIR, "large-old.bin"); + const oldBytes = new Uint8Array(await readFile(oldPath)); + const patchData = new Uint8Array( + await readFile(join(FIXTURES_DIR, "large.trdiff10")) + ); + const destPath = tempFile("disk.bin"); + + try { + const memOut = await applyPatchToMemory(oldBytes, patchData); + const diskSha = await applyPatch(oldPath, patchData, destPath); + const diskOut = new Uint8Array(await readFile(destPath)); + + expect(memOut).toEqual(diskOut); + expect(createHash("sha256").update(memOut).digest("hex")).toBe(diskSha); + } finally { + if (existsSync(destPath)) { + unlinkSync(destPath); + } + } + }); +}); + +describe("addDiffChunk SWAR wrapping-add", () => { + // The diff-add loop is the hot path of bspatch apply. The SWAR (Uint32Array) + // implementation must match a per-byte `(old + diff) % 256` reference for + // EVERY byte value, every 4-byte alignment, and every tail length 0-3 — a + // carry bug (e.g. a BigUint64Array variant, where carries cross byte lanes) + // corrupts the output silently. + + /** Per-byte wrapping-add reference (the specification addDiffChunk must meet). */ + function referenceAdd(old: Uint8Array, diff: Uint8Array): Uint8Array { + const out = new Uint8Array(old.length); + for (let i = 0; i < old.length; i++) { + out[i] = ((old[i] ?? 0) + (diff[i] ?? 0)) % 256; + } + return out; + } + + test("matches the byte-loop reference for all byte values and alignments", async () => { + // Build a base covering many window sizes so every (old, diff) byte pair + // appears across several 4-byte SWAR boundaries and tail lengths 0-3. + // newBytes is derived from old+diff, so applyPatchToMemory must reproduce + // exactly referenceAdd(old, diff). + const total = 4096 + 3; // odd length hits a 3-byte tail + const oldBytes = new Uint8Array(total); + for (let i = 0; i < total; i++) { + // Knuth multiplicative hash spread, folded to a byte — hits all 256 values. + oldBytes[i] = Math.floor((i * 2_654_435_761) / 256) % 256; + } + // Vary the diff so (old, diff) pairs cover many high/low-bit combos. + const diffBytes = new Uint8Array(total); + for (let i = 0; i < total; i++) { + diffBytes[i] = (i * 31 + (i % 256 >= 128 ? 128 : 0) + 7) % 256; + } + const expected = referenceAdd(oldBytes, diffBytes); + const patch = buildDiffOnlyPatch(oldBytes, expected); + + const out = await applyPatchToMemory(oldBytes, patch); + expect(out).toEqual(expected); + }); + + test("handles wrap-around at the byte max (255 + k) in every lane", async () => { + // old=255 forces a carry in every byte; ensure it wraps to (255+diff)%256, + // not into the adjacent lane. A cross-lane carry bug shows up here. + const n = 64; + const oldBytes = new Uint8Array(n).fill(0xff); + const expected = new Uint8Array(n); + for (let i = 0; i < n; i++) expected[i] = (0xff + i) % 256; + const patch = buildDiffOnlyPatch(oldBytes, expected); + + const out = await applyPatchToMemory(oldBytes, patch); + expect(out).toEqual(expected); + }); + + test("handles tail lengths 0-3 across many window sizes", async () => { + for (const n of [1, 2, 3, 4, 5, 7, 8, 9, 21, 1023]) { + const oldBytes = new Uint8Array(n); + for (let i = 0; i < n; i++) oldBytes[i] = (i * 17) % 256; + const expected = referenceAdd( + oldBytes, + Uint8Array.from({ length: n }, (_, i) => (i * 29) % 256) + ); + const patch = buildDiffOnlyPatch(oldBytes, expected); + const out = await applyPatchToMemory(oldBytes, patch); + expect(out).toEqual(expected); + } + }); + + test("produces correct output when a buffer view is misaligned (SWAR fallback)", () => { + // The SWAR fast path needs a 4-byte-aligned byteOffset; addDiffChunk falls + // back to the byte loop otherwise. No current caller passes a misaligned + // view (MemoryOldReader/FileOldReader and the output chunk are all fresh, + // offset-0 allocations), so this directly unit-tests the guard against a + // future caller that might pass a pooled/subarray view. Every offset combo + // (0-3 on each of old/diff/output) must match the per-byte reference. + const n = 4096 + 3; // exercises a 3-byte tail too + for (const oldOff of [0, 1, 2, 3]) { + for (const diffOff of [0, 3]) { + for (const outOff of [0, 2]) { + const oldBuf = new Uint8Array(n + oldOff).subarray(oldOff); + const diffBuf = new Uint8Array(n + diffOff).subarray(diffOff); + const outBuf = new Uint8Array(n + outOff).subarray(outOff); + for (let i = 0; i < n; i++) { + oldBuf[i] = (i * 37) % 256; + diffBuf[i] = (i * 53 + 3) % 256; + } + const expected = referenceAdd(oldBuf, diffBuf); + addDiffChunk(outBuf, oldBuf, diffBuf, n); + expect(outBuf).toEqual(expected); + } + } + } + }); +}); + +describe("FileOldReader block cache", () => { + const ONE_MIB = 1024 * 1024; + + function tempFile(name: string): string { + return join( + tmpdir(), + `bspatch-cache-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}` + ); + } + + /** SHA-256 hex of `bytes`. */ + function sha(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); + } + + /** + * Apply `patch` to `oldBytes` via both the fd-backed reader (`applyPatch`, + * which uses the block cache) and the in-memory reader (`applyPatchToMemory`, + * cacheless), returning their result hashes plus `applyPatch`'s self-reported + * hash. Any cache offset/staleness bug shows up as a hash divergence. + * + * Compares via SHA-256 rather than `toEqual` — Vitest's deep-equality is far + * too slow on multi-MiB typed arrays (seconds per call), whereas native + * hashing is O(n) and fast. + */ + async function runBothReaders( + oldBytes: Uint8Array, + patch: Uint8Array + ): Promise<{ fileSha: string; fileBytesSha: string; memSha: string }> { + const oldPath = tempFile("old.bin"); + const destPath = tempFile("out.bin"); + await writeFile(oldPath, oldBytes); + try { + const fileSha = await applyPatch(oldPath, patch, destPath); + const fileBytesSha = sha(new Uint8Array(await readFile(destPath))); + const memSha = sha(await applyPatchToMemory(oldBytes, patch)); + return { fileSha, fileBytesSha, memSha }; + } finally { + for (const p of [oldPath, destPath]) { + if (existsSync(p)) { + unlinkSync(p); + } + } + } + } + + test("serves many small windows spanning multiple blocks", async () => { + // 3 MiB base read in ~100 KiB windows → reads cross the 1 MiB block + // boundaries repeatedly, forcing several cache refills. + const len = 3 * ONE_MIB + 12_345; + const oldBytes = makeBytes(11, len); + const newBytes = makeBytes(22, len); + const patch = buildDiffOnlyPatch(oldBytes, newBytes, 100 * 1024); + + const expected = sha(newBytes); + const { fileSha, fileBytesSha, memSha } = await runBothReaders( + oldBytes, + patch + ); + expect(fileSha).toBe(expected); + expect(fileBytesSha).toBe(expected); + expect(memSha).toBe(expected); + }); + + test("reads a single window larger than the cache block", async () => { + // One 1.5 MiB diff window > 1 MiB block → exercises the direct-read bypass. + const len = ONE_MIB + ONE_MIB / 2; + const oldBytes = makeBytes(33, len); + const newBytes = makeBytes(44, len); + const patch = buildDiffOnlyPatch(oldBytes, newBytes); // single tuple + + const expected = sha(newBytes); + const { fileSha, fileBytesSha, memSha } = await runBothReaders( + oldBytes, + patch + ); + expect(fileSha).toBe(expected); + expect(fileBytesSha).toBe(expected); + expect(memSha).toBe(expected); + }); + + test("handles windows landing exactly on a block boundary", async () => { + // Window size divides the block size evenly, so a read lands precisely at + // the 1 MiB boundary (start === blockStart + blockLen) — the off-by-one + // boundary case for blockCovers. + const len = 2 * ONE_MIB; + const oldBytes = makeBytes(55, len); + const newBytes = makeBytes(66, len); + const patch = buildDiffOnlyPatch(oldBytes, newBytes, ONE_MIB / 4); + + const expected = sha(newBytes); + const { fileSha, fileBytesSha, memSha } = await runBothReaders( + oldBytes, + patch + ); + expect(fileSha).toBe(expected); + expect(fileBytesSha).toBe(expected); + expect(memSha).toBe(expected); + }); +}); diff --git a/packages/cli/test/lib/build/index.test.ts b/packages/cli/test/lib/build/index.test.ts new file mode 100644 index 000000000..a46264428 --- /dev/null +++ b/packages/cli/test/lib/build/index.test.ts @@ -0,0 +1,472 @@ +/** + * Tests for mobile build detection + normalization. + * + * Fixtures are built in-memory with fflate (no committed binaries): a "fake + * APK/AAB" is just a ZIP carrying the marker entry names the detector keys on. + */ + +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { strToU8, unzipSync, zipSync } from "fflate"; +import { afterEach, describe, expect, test } from "vitest"; +import { + detectBuildFormat, + detectBuildFormatFromFile, + extractIpaAppName, + normalizeBuildDirectory, + normalizeBuildFile, + normalizeIpa, + parsePluginFromPipeline, + validateXcarchiveDirectory, +} from "../../../src/lib/build/index.js"; + +/** Temp dirs created by tests; cleaned up via {@link cleanupTmp}. */ +const tmpDirs: string[] = []; + +/** Create a tracked temp directory that {@link cleanupTmp} will remove. */ +function makeTmpDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +} + +/** Remove every tracked temp directory. */ +function cleanupTmp(): void { + while (tmpDirs.length > 0) { + const d = tmpDirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } +} + +/** Write bytes to a fresh temp file and return its path. */ +function writeTmpFile(name: string, data: Uint8Array): string { + const dir = makeTmpDir("build-fixture-"); + const path = join(dir, name); + writeFileSync(path, Buffer.from(data)); + return path; +} + +/** + * Run a normalizer that writes a wrapper ZIP to disk and return the bytes. + * The output path lives in a tracked temp dir. + */ +async function normalizeToBuffer( + run: (outPath: string) => Promise +): Promise { + const dir = makeTmpDir("build-out-"); + const outPath = join(dir, "normalized.zip"); + await run(outPath); + return readFileSync(outPath); +} + +function fakeApk(): Uint8Array { + return zipSync({ "AndroidManifest.xml": strToU8("binary-xml") }); +} + +function fakeAab(): Uint8Array { + return zipSync({ + "BundleConfig.pb": strToU8("cfg"), + "base/manifest/AndroidManifest.xml": strToU8("xml"), + }); +} + +function fakeIpa(): Uint8Array { + return zipSync({ "Payload/MyApp.app/Info.plist": strToU8("plist") }); +} + +describe("detectBuildFormat", () => { + test("detects an APK by its root AndroidManifest.xml", () => { + expect(detectBuildFormat(fakeApk())).toBe("apk"); + }); + + test("detects an AAB by BundleConfig.pb + base manifest", () => { + expect(detectBuildFormat(fakeAab())).toBe("aab"); + }); + + test("detects an IPA by its Payload/*.app/Info.plist", () => { + expect(detectBuildFormat(fakeIpa())).toBe("ipa"); + }); + + test("returns null for a ZIP without build markers", () => { + expect(detectBuildFormat(zipSync({ "readme.txt": strToU8("hi") }))).toBe( + null + ); + }); + + test("returns null for non-ZIP bytes", () => { + expect(detectBuildFormat(strToU8("not a zip"))).toBe(null); + }); +}); + +describe("parsePluginFromPipeline", () => { + test("parses the gradle plugin", () => { + expect(parsePluginFromPipeline("sentry-gradle-plugin/4.12.0")).toEqual({ + name: "sentry-gradle-plugin", + version: "4.12.0", + }); + }); + + test("parses the fastlane plugin", () => { + expect(parsePluginFromPipeline("sentry-fastlane-plugin/1.2.3")).toEqual({ + name: "sentry-fastlane-plugin", + version: "1.2.3", + }); + }); + + test("ignores unrecognized plugins", () => { + expect(parsePluginFromPipeline("some-other-tool/9.9.9")).toBe(null); + }); + + test("returns null for malformed or empty input", () => { + expect(parsePluginFromPipeline(undefined)).toBe(null); + expect(parsePluginFromPipeline("")).toBe(null); + expect(parsePluginFromPipeline("no-slash")).toBe(null); + expect(parsePluginFromPipeline("sentry-gradle-plugin/")).toBe(null); + }); +}); + +describe("normalizeBuildFile", () => { + afterEach(cleanupTmp); + + test("wraps the build under its basename plus a metadata file", async () => { + const apk = fakeApk(); + const src = writeTmpFile("app-release.apk", apk); + const zip = await normalizeToBuffer((out) => + normalizeBuildFile(src, out, null) + ); + + const entries = unzipSync(zip); + expect(Object.keys(entries).sort()).toEqual([ + ".sentry-cli-metadata.txt", + "app-release.apk", + ]); + // The build bytes are stored verbatim. + expect(entries["app-release.apk"]).toEqual(apk); + const metadata = new TextDecoder().decode(entries[".sentry-cli-metadata.txt"]); + expect(metadata).toContain("sentry-cli-version:"); + }); + + test("records a recognized plugin in the metadata file", async () => { + const src = writeTmpFile("app.aab", fakeAab()); + const zip = await normalizeToBuffer((out) => + normalizeBuildFile(src, out, { + name: "sentry-gradle-plugin", + version: "4.12.0", + }) + ); + const metadata = new TextDecoder().decode( + unzipSync(zip)[".sentry-cli-metadata.txt"] + ); + expect(metadata).toContain("sentry-gradle-plugin: 4.12.0"); + }); + + test("is deterministic (identical input → identical bytes)", async () => { + const src = writeTmpFile("app.apk", fakeApk()); + const a = await normalizeToBuffer((out) => + normalizeBuildFile(src, out, null) + ); + const b = await normalizeToBuffer((out) => + normalizeBuildFile(src, out, null) + ); + expect(a.equals(b)).toBe(true); + }); +}); + +describe("normalizeBuildDirectory", () => { + const dirs: string[] = []; + afterEach(() => { + cleanupTmp(); + while (dirs.length > 0) { + const d = dirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } + }); + + function fakeXcarchive(): string { + const base = mkdtempSync(join(tmpdir(), "xc-")); + dirs.push(base); + const xc = join(base, "MyApp.xcarchive"); + mkdirSync(join(xc, "Products", "Applications", "MyApp.app"), { + recursive: true, + }); + writeFileSync(join(xc, "Info.plist"), ""); + writeFileSync( + join(xc, "Products", "Applications", "MyApp.app", "MyApp"), + "binary" + ); + return xc; + } + + test("zips files under the directory basename plus a root metadata file", async () => { + const zip = await normalizeToBuffer((out) => + normalizeBuildDirectory(fakeXcarchive(), out, null) + ); + const entries = unzipSync(zip); + expect(Object.keys(entries).sort()).toEqual([ + ".sentry-cli-metadata.txt", + "MyApp.xcarchive/Info.plist", + "MyApp.xcarchive/Products/Applications/MyApp.app/MyApp", + ]); + expect(entries["MyApp.xcarchive/Info.plist"]).toEqual(strToU8("")); + }); + + test("is deterministic (identical tree → identical bytes)", async () => { + const xc = fakeXcarchive(); + const a = await normalizeToBuffer((out) => + normalizeBuildDirectory(xc, out, null) + ); + const b = await normalizeToBuffer((out) => + normalizeBuildDirectory(xc, out, null) + ); + expect(a.equals(b)).toBe(true); + }); + + // Symlinks require privileges on Windows; the unit suite runs on Linux. + test.skipIf(process.platform === "win32")( + "preserves symlinks as entries (stores the target path, not followed content)", + async () => { + const base = mkdtempSync(join(tmpdir(), "xc-sym-")); + dirs.push(base); + const xc = join(base, "App.xcarchive"); + mkdirSync(xc, { recursive: true }); + writeFileSync(join(xc, "real.txt"), "REAL"); + symlinkSync("real.txt", join(xc, "link.txt")); + + const entries = unzipSync( + await normalizeToBuffer((out) => normalizeBuildDirectory(xc, out, null)) + ); + // The symlink entry stores its target path — proof it was NOT followed + // (following would store "REAL", the target's file content). + expect(new TextDecoder().decode(entries["App.xcarchive/link.txt"])).toBe( + "real.txt" + ); + expect(new TextDecoder().decode(entries["App.xcarchive/real.txt"])).toBe( + "REAL" + ); + } + ); +}); + +describe("validateXcarchiveDirectory", () => { + const dirs: string[] = []; + afterEach(() => { + while (dirs.length > 0) { + const d = dirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } + }); + + function makeArchive(build: (xc: string) => void): string { + const base = mkdtempSync(join(tmpdir(), "xc-val-")); + dirs.push(base); + const xc = join(base, "MyApp.xcarchive"); + mkdirSync(xc, { recursive: true }); + build(xc); + return xc; + } + + test("accepts a valid XCArchive", () => { + const xc = makeArchive((dir) => { + const app = join(dir, "Products", "Applications", "MyApp.app"); + mkdirSync(app, { recursive: true }); + writeFileSync(join(dir, "Info.plist"), ""); + writeFileSync(join(app, "Info.plist"), ""); + }); + expect(() => validateXcarchiveDirectory(xc)).not.toThrow(); + }); + + test("rejects a directory missing the root Info.plist", () => { + const xc = makeArchive((dir) => { + mkdirSync(join(dir, "Products"), { recursive: true }); + }); + expect(() => validateXcarchiveDirectory(xc)).toThrow("Info.plist"); + }); + + test("rejects a directory missing Products/", () => { + const xc = makeArchive((dir) => { + writeFileSync(join(dir, "Info.plist"), ""); + }); + expect(() => validateXcarchiveDirectory(xc)).toThrow("Products/"); + }); + + test("rejects when a .app bundle has no Info.plist", () => { + const xc = makeArchive((dir) => { + writeFileSync(join(dir, "Info.plist"), ""); + mkdirSync(join(dir, "Products", "Applications", "MyApp.app"), { + recursive: true, + }); + }); + expect(() => validateXcarchiveDirectory(xc)).toThrow(".app bundle"); + }); +}); + +describe("extractIpaAppName", () => { + test("returns the single app name", () => { + expect( + extractIpaAppName([ + "Payload/MyApp.app/Info.plist", + "Payload/MyApp.app/MyApp", + ]) + ).toBe("MyApp"); + }); + + test("throws when there is no .app", () => { + expect(() => extractIpaAppName(["readme.txt"])).toThrow("exactly one"); + }); + + test("throws when there are multiple .apps", () => { + expect(() => + extractIpaAppName([ + "Payload/A.app/Info.plist", + "Payload/B.app/Info.plist", + ]) + ).toThrow("exactly one"); + }); +}); + +describe("normalizeIpa", () => { + afterEach(cleanupTmp); + + function fakeIpaBytes(): Uint8Array { + return zipSync({ + "Payload/MyApp.app/Info.plist": strToU8(""), + "Payload/MyApp.app/MyApp": strToU8("binary"), + "Payload/MyApp.app/Assets.car": strToU8("carbytes"), + }); + } + + /** Write IPA bytes to a temp file and normalize it, returning the wrapper. */ + async function normalizeIpaBytes(ipa: Uint8Array): Promise { + const src = writeTmpFile("app.ipa", ipa); + return normalizeToBuffer((out) => normalizeIpa(src, out, null)); + } + + test("remaps Payload into an XCArchive layout with a generated Info.plist", async () => { + const zip = await normalizeIpaBytes(fakeIpaBytes()); + const entries = unzipSync(zip); + expect(Object.keys(entries).sort()).toEqual([ + ".sentry-cli-metadata.txt", + "archive.xcarchive/Info.plist", + "archive.xcarchive/Products/Applications/MyApp.app/Assets.car", + "archive.xcarchive/Products/Applications/MyApp.app/Info.plist", + "archive.xcarchive/Products/Applications/MyApp.app/MyApp", + ]); + const plist = new TextDecoder().decode( + entries["archive.xcarchive/Info.plist"] + ); + expect(plist).toContain("Applications/MyApp.app"); + // Assets.car is carried through verbatim (not parsed). + expect( + entries["archive.xcarchive/Products/Applications/MyApp.app/Assets.car"] + ).toEqual(strToU8("carbytes")); + }); + + test("remaps nested framework entries under the app", async () => { + const entries = unzipSync( + await normalizeIpaBytes( + zipSync({ + "Payload/MyApp.app/Info.plist": strToU8(""), + "Payload/MyApp.app/Frameworks/X.framework/X": strToU8("fw"), + }) + ) + ); + expect( + entries[ + "archive.xcarchive/Products/Applications/MyApp.app/Frameworks/X.framework/X" + ] + ).toEqual(strToU8("fw")); + }); + + test("includes only the identified app's entries", async () => { + const names = Object.keys( + unzipSync( + await normalizeIpaBytes( + zipSync({ + "Payload/MyApp.app/Info.plist": strToU8(""), + "Payload/MyApp.app/MyApp": strToU8("bin"), + // A stray second .app (no Info.plist so extractIpaAppName still + // sees one) must not be bundled. + "Payload/Stray.app/junk": strToU8("junk"), + }) + ) + ) + ); + expect(names.some((n) => n.includes("Stray"))).toBe(false); + expect( + names.includes( + "archive.xcarchive/Products/Applications/MyApp.app/MyApp" + ) + ).toBe(true); + }); + + test("skips path-traversal entries", async () => { + const names = Object.keys( + unzipSync( + await normalizeIpaBytes( + zipSync({ + "Payload/MyApp.app/Info.plist": strToU8(""), + "Payload/MyApp.app/../../evil": strToU8("x"), + }) + ) + ) + ); + expect(names.some((n) => n.includes("evil"))).toBe(false); + }); + + test("is deterministic (identical IPA → identical bytes)", async () => { + const src = writeTmpFile("app.ipa", fakeIpaBytes()); + const a = await normalizeToBuffer((out) => normalizeIpa(src, out, null)); + const b = await normalizeToBuffer((out) => normalizeIpa(src, out, null)); + expect(a.equals(b)).toBe(true); + }); + + test("throws when the IPA has no single .app", async () => { + const src = writeTmpFile("bad.ipa", zipSync({ "readme.txt": strToU8("x") })); + await expect( + normalizeToBuffer((out) => normalizeIpa(src, out, null)) + ).rejects.toThrow("exactly one"); + }); +}); + +describe("detectBuildFormatFromFile", () => { + afterEach(cleanupTmp); + + test("detects an APK from a file on disk", async () => { + const src = writeTmpFile("app.apk", fakeApk()); + expect(await detectBuildFormatFromFile(src)).toBe("apk"); + }); + + test("detects an AAB from a file on disk", async () => { + const src = writeTmpFile("app.aab", fakeAab()); + expect(await detectBuildFormatFromFile(src)).toBe("aab"); + }); + + test("detects an IPA from a file on disk", async () => { + const src = writeTmpFile("app.ipa", fakeIpa()); + expect(await detectBuildFormatFromFile(src)).toBe("ipa"); + }); + + test("returns null for a ZIP without build markers", async () => { + const src = writeTmpFile("x.zip", zipSync({ "readme.txt": strToU8("hi") })); + expect(await detectBuildFormatFromFile(src)).toBe(null); + }); + + test("returns null for a non-ZIP file", async () => { + const src = writeTmpFile("x.bin", strToU8("not a zip")); + expect(await detectBuildFormatFromFile(src)).toBe(null); + }); +}); diff --git a/packages/cli/test/lib/build/vcs.test.ts b/packages/cli/test/lib/build/vcs.test.ts new file mode 100644 index 000000000..9ab2ca2c3 --- /dev/null +++ b/packages/cli/test/lib/build/vcs.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for VCS metadata collection. + * + * `git.js` is mocked so collection is deterministic (no dependency on the + * checkout's real branch/remote). Covers flag precedence, GitHub Actions env + * inference, the base==head skip, and the flattened body mapping. + */ + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const gitMock = vi.hoisted(() => ({ + getHeadCommit: vi.fn(() => "a".repeat(40)), + getRemoteUrl: vi.fn(() => "https://github.com/acme/app.git"), + getRepositoryName: vi.fn(() => "acme/app"), + getCurrentBranch: vi.fn(() => "feature/x"), + getMergeBase: vi.fn(() => "b".repeat(40)), + getRemoteDefaultBranch: vi.fn(() => "main"), +})); + +vi.mock("../../../src/lib/git.js", () => gitMock); + +import { ValidationError } from "../../../src/lib/errors.js"; +import { + collectVcsMetadata, + isCi, + vcsInfoToBody, +} from "../../../src/lib/build/vcs.js"; + +// The git mock is shared across tests; clear call history (keeps default +// implementations) so `.not.toHaveBeenCalled()` assertions are per-test. +beforeEach(() => { + vi.clearAllMocks(); +}); + +/** Temp dirs holding GitHub event payloads, cleaned up after each test. */ +const eventDirs: string[] = []; +afterEach(() => { + while (eventDirs.length > 0) { + const dir = eventDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("isCi", () => { + test("true when a CI variable is set", () => { + expect(isCi({ GITHUB_ACTIONS: "true" })).toBe(true); + expect(isCi({ CI: "1" })).toBe(true); + }); + + test("false with no CI variables (and ignores opt-out values)", () => { + expect(isCi({})).toBe(false); + expect(isCi({ CI: "" })).toBe(false); + expect(isCi({ CI: "false" })).toBe(false); + expect(isCi({ CI: "0" })).toBe(false); + }); +}); + +describe("vcsInfoToBody", () => { + test("flattens to snake_case and renames provider", () => { + expect( + vcsInfoToBody({ + headSha: "h", + vcsProvider: "github", + headRepoName: "o/r", + prNumber: 5, + }) + ).toEqual({ + head_sha: "h", + provider: "github", + head_repo_name: "o/r", + pr_number: 5, + }); + }); + + test("omits empty fields", () => { + expect(vcsInfoToBody({})).toEqual({}); + }); + + test("keeps pr_number 0", () => { + expect(vcsInfoToBody({ prNumber: 0 })).toEqual({ pr_number: 0 }); + }); +}); + +describe("collectVcsMetadata", () => { + test("uses explicit flags and skips git introspection when autoCollect is false", () => { + const sha = "abcdef01".repeat(5); // 40 hex chars + const vcs = collectVcsMetadata( + { "head-sha": sha.toUpperCase(), "head-ref": "main", "pr-number": 7 }, + "/repo", + {}, + false + ); + + expect(vcs.headSha).toBe(sha); // normalized to lowercase + expect(vcs.headRef).toBe("main"); + expect(vcs.prNumber).toBe(7); + expect(gitMock.getHeadCommit).not.toHaveBeenCalled(); + expect(gitMock.getRemoteUrl).not.toHaveBeenCalled(); + }); + + test("infers metadata from a GitHub Actions pull_request run", () => { + const env: NodeJS.ProcessEnv = { + GITHUB_EVENT_NAME: "pull_request", + GITHUB_HEAD_REF: "feature/x", + GITHUB_BASE_REF: "main", + GITHUB_REPOSITORY: "acme/app", + GITHUB_REF: "refs/pull/42/merge", + }; + + const vcs = collectVcsMetadata({}, "/repo", env, true); + + expect(vcs.headRef).toBe("feature/x"); + expect(vcs.baseRef).toBe("main"); + expect(vcs.headRepoName).toBe("acme/app"); + expect(vcs.prNumber).toBe(42); + expect(vcs.vcsProvider).toBe("github"); + expect(vcs.headSha).toBe("a".repeat(40)); + expect(vcs.baseSha).toBe("b".repeat(40)); + }); + + test("drops base_sha/base_ref when base equals head and both were auto-inferred", () => { + gitMock.getHeadCommit.mockReturnValueOnce("c".repeat(40)); + gitMock.getMergeBase.mockReturnValueOnce("c".repeat(40)); + + const vcs = collectVcsMetadata( + {}, + "/repo", + { GITHUB_EVENT_NAME: "pull_request", GITHUB_BASE_REF: "main" }, + true + ); + + expect(vcs.headSha).toBe("c".repeat(40)); + expect(vcs.baseSha).toBeUndefined(); + expect(vcs.baseRef).toBeUndefined(); + }); + + test("prefers head/base SHAs from the GitHub Actions event payload", () => { + const dir = mkdtempSync(join(tmpdir(), "vcs-event-")); + eventDirs.push(dir); + const eventPath = join(dir, "event.json"); + const headSha = "1".repeat(40); + const baseSha = "2".repeat(40); + writeFileSync( + eventPath, + JSON.stringify({ + pull_request: { head: { sha: headSha }, base: { sha: baseSha } }, + }) + ); + + const vcs = collectVcsMetadata( + {}, + "/repo", + { GITHUB_EVENT_NAME: "pull_request", GITHUB_EVENT_PATH: eventPath }, + true + ); + + // Event payload wins over `git rev-parse HEAD` / merge-base. + expect(vcs.headSha).toBe(headSha); + expect(vcs.baseSha).toBe(baseSha); + expect(gitMock.getHeadCommit).not.toHaveBeenCalled(); + expect(gitMock.getMergeBase).not.toHaveBeenCalled(); + }); + + test("an empty --head-sha explicitly clears and suppresses auto-inference", () => { + const vcs = collectVcsMetadata({ "head-sha": "" }, "/repo", {}, true); + expect(vcs.headSha).toBeUndefined(); + expect(gitMock.getHeadCommit).not.toHaveBeenCalled(); + }); + + test("rejects a malformed --head-sha", () => { + expect(() => + collectVcsMetadata({ "head-sha": "not-a-sha" }, "/repo", {}, false) + ).toThrow(ValidationError); + }); + + test("lowercases an auto merge-base base SHA", () => { + gitMock.getMergeBase.mockReturnValueOnce("D".repeat(40)); + const vcs = collectVcsMetadata( + {}, + "/repo", + { GITHUB_EVENT_NAME: "push" }, + true + ); + expect(vcs.baseSha).toBe("d".repeat(40)); + }); + + test("computes base_sha via merge-base with an explicit --base-ref", () => { + gitMock.getMergeBase.mockReturnValueOnce("e".repeat(40)); + const vcs = collectVcsMetadata( + { "base-ref": "release/2.0" }, + "/repo", + {}, + true + ); + expect(vcs.baseRef).toBe("release/2.0"); + expect(vcs.baseSha).toBe("e".repeat(40)); + expect(gitMock.getMergeBase).toHaveBeenCalledWith( + "origin/release/2.0", + "/repo" + ); + }); + + test("derives provider from an SCP-style remote and GHE hosts", () => { + gitMock.getRemoteUrl.mockReturnValueOnce("git@github.com:acme/app.git"); + expect(collectVcsMetadata({}, "/repo", {}, true).vcsProvider).toBe("github"); + + gitMock.getRemoteUrl.mockReturnValueOnce("https://acme.ghe.com/o/r.git"); + expect(collectVcsMetadata({}, "/repo", {}, true).vcsProvider).toBe( + "github_enterprise" + ); + }); +}); diff --git a/packages/cli/test/lib/build/zip-writer.mocked.test.ts b/packages/cli/test/lib/build/zip-writer.mocked.test.ts new file mode 100644 index 000000000..d2c3e8a1c --- /dev/null +++ b/packages/cli/test/lib/build/zip-writer.mocked.test.ts @@ -0,0 +1,43 @@ +/** + * DeterministicZipWriter compatibility tests for Node.js versions without + * `zlib.crc32`. + */ + +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { unzipSync } from "fflate"; +import { expect, test, vi } from "vitest"; + +vi.mock("node:zlib", async (importOriginal) => { + const actual = await importOriginal>(); + const withoutCrc32 = { ...actual, crc32: undefined }; + return { ...withoutCrc32, default: withoutCrc32 }; +}); + +// Import after the mock so crc32.ts selects its portable implementation. +import { DeterministicZipWriter } from "../../../src/lib/build/zip-writer.js"; + +test("writes valid CRCs when zlib.crc32 is unavailable", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "zip-writer-no-crc32-")); + try { + const outputPath = join(tmpDir, "bundle.zip"); + const sourcePath = join(tmpDir, "source.bin"); + await writeFile(sourcePath, Buffer.from("streamed contents")); + + const zip = await DeterministicZipWriter.create(outputPath); + await zip.addData("memory.txt", Buffer.from("in-memory contents")); + await zip.addFile("streamed.txt", sourcePath); + await zip.finalize(); + + const entries = unzipSync(await readFile(outputPath)); + expect(Buffer.from(entries["memory.txt"])).toEqual( + Buffer.from("in-memory contents") + ); + expect(Buffer.from(entries["streamed.txt"])).toEqual( + Buffer.from("streamed contents") + ); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/lib/build/zip-writer.test.ts b/packages/cli/test/lib/build/zip-writer.test.ts new file mode 100644 index 000000000..e67ebbe72 --- /dev/null +++ b/packages/cli/test/lib/build/zip-writer.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for the deterministic streaming ZIP writer. + * + * The wrapper output must stay byte-for-byte identical to the previous + * in-memory `fflate.zipSync(..., { level: 0, mtime, os, attrs })` encoding so + * chunk dedup across re-uploads is unaffected. These tests pin that parity for + * both the in-memory ({@link DeterministicZipWriter.addData}) and streamed + * ({@link DeterministicZipWriter.addFile}) entry paths across the cases the + * build normalizer relies on: plain files, nested paths, empty entries, + * multi-byte names, and Unix symlink/permission attributes. + */ + +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { strToU8, unzipSync, zipSync } from "fflate"; +import { afterEach, describe, expect, test } from "vitest"; +import { + DeterministicZipWriter, + FIXED_MTIME, +} from "../../../src/lib/build/zip-writer.js"; + +type Entry = { + name: string; + data: Uint8Array; + os?: number; + attrs?: number; +}; + +const tmpDirs: string[] = []; +afterEach(() => { + while (tmpDirs.length > 0) { + const d = tmpDirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } +}); + +function makeTmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "zip-writer-")); + tmpDirs.push(dir); + return dir; +} + +/** Reference bytes from fflate's in-memory zipSync (the encoding to match). */ +function referenceZip(entries: Entry[]): Buffer { + const map: Record]> = {}; + for (const e of entries) { + map[e.name] = [ + e.data, + { level: 0, mtime: FIXED_MTIME, os: e.os, attrs: e.attrs }, + ]; + } + return Buffer.from(zipSync(map)); +} + +/** Build a ZIP with the writer using in-memory `addData` for every entry. */ +async function writeWithData(entries: Entry[]): Promise { + const out = join(makeTmpDir(), "out.zip"); + const zip = await DeterministicZipWriter.create(out); + for (const e of entries) { + await zip.addData(e.name, e.data, { os: e.os, attrs: e.attrs }); + } + await zip.finalize(); + return readFileSync(out); +} + +/** Build a ZIP with the writer streaming every entry from a source file. */ +async function writeWithFiles(entries: Entry[]): Promise { + const dir = makeTmpDir(); + const out = join(dir, "out.zip"); + const zip = await DeterministicZipWriter.create(out); + let i = 0; + for (const e of entries) { + const src = join(dir, `src-${i++}`); + writeFileSync(src, Buffer.from(e.data)); + await zip.addFile(e.name, src, { os: e.os, attrs: e.attrs }); + } + await zip.finalize(); + return readFileSync(out); +} + +const cases: Array<[string, Entry[]]> = [ + ["a single plain entry", [{ name: "a.txt", data: strToU8("hello") }]], + [ + "multiple entries with nested paths", + [ + { name: "a.txt", data: strToU8("hello") }, + { name: "b/c.txt", data: strToU8("another entry") }, + ], + ], + ["an empty entry", [{ name: "empty", data: new Uint8Array(0) }]], + [ + "a multi-byte (UTF-8) entry name", + [{ name: "café/naïve.txt", data: strToU8("résumé") }], + ], + [ + "a Unix symlink entry (os=3 + mode attrs)", + [ + { + name: "link", + data: strToU8("target/path"), + os: 3, + attrs: ((0o120_777 & 0xff_ff) << 16) >>> 0, + }, + ], + ], + [ + "an executable entry (os=3 + mode attrs)", + [ + { + name: "bin", + data: strToU8("ELF"), + os: 3, + attrs: ((0o100_755 & 0xff_ff) << 16) >>> 0, + }, + ], + ], + [ + "a larger entry followed by a tiny one", + [ + { name: "big", data: strToU8("x".repeat(100_000)) }, + { name: "z", data: strToU8("z") }, + ], + ], +]; + +describe("DeterministicZipWriter byte-parity with zipSync", () => { + for (const [label, entries] of cases) { + test(`addData matches zipSync for ${label}`, async () => { + expect((await writeWithData(entries)).equals(referenceZip(entries))).toBe( + true + ); + }); + + test(`addFile matches zipSync for ${label}`, async () => { + expect( + (await writeWithFiles(entries)).equals(referenceZip(entries)) + ).toBe(true); + }); + } +}); + +describe("DeterministicZipWriter round-trips", () => { + test("produces an archive fflate can extract", async () => { + const zip = await writeWithFiles([ + { name: "one.txt", data: strToU8("first") }, + { name: "dir/two.bin", data: strToU8("second value") }, + ]); + const entries = unzipSync(zip); + expect(new TextDecoder().decode(entries["one.txt"])).toBe("first"); + expect(new TextDecoder().decode(entries["dir/two.bin"])).toBe( + "second value" + ); + }); + + test("streams a payload larger than the internal chunk buffer", async () => { + // 3 MiB exceeds the 1 MiB stream buffer, exercising multi-read CRC + copy. + const big = new Uint8Array(3 * 1024 * 1024); + for (let i = 0; i < big.length; i++) { + big[i] = i & 0xff; + } + const dataZip = await writeWithData([{ name: "big.bin", data: big }]); + const fileZip = await writeWithFiles([{ name: "big.bin", data: big }]); + // Both entry paths must agree and extract to the original bytes. + expect(fileZip.equals(dataZip)).toBe(true); + expect(Buffer.from(unzipSync(fileZip)["big.bin"]).equals(Buffer.from(big))).toBe( + true + ); + }); +}); diff --git a/packages/cli/test/lib/cache-hint.test.ts b/packages/cli/test/lib/cache-hint.test.ts new file mode 100644 index 000000000..35f2565d9 --- /dev/null +++ b/packages/cli/test/lib/cache-hint.test.ts @@ -0,0 +1,117 @@ +/** + * Cache-hint formatting tests. + * + * Tests for the human-readable cache-age hint shown in command footers. + * Core invariants (round-trips, validation) are tested here since the + * module is small and the formatting has specific boundary values. + */ + +import { describe, expect, test } from "vitest"; +import { + appendCacheHint, + formatAge, + formatCacheHint, +} from "../../src/lib/cache-hint.js"; +import { + clearLastCacheHitAge, + getLastCacheHitAge, + setLastCacheHitAgeForTesting, +} from "../../src/lib/response-cache.js"; + +describe("formatAge", () => { + test("returns 'just now' for ages under 5 seconds", () => { + expect(formatAge(0)).toBe("just now"); + expect(formatAge(1000)).toBe("just now"); + expect(formatAge(4999)).toBe("just now"); + }); + + test("returns seconds for ages 5s–59s", () => { + expect(formatAge(5000)).toBe("5s ago"); + expect(formatAge(30_000)).toBe("30s ago"); + expect(formatAge(59_999)).toBe("59s ago"); + }); + + test("returns minutes for ages 1m–59m", () => { + expect(formatAge(60_000)).toBe("1m ago"); + expect(formatAge(180_000)).toBe("3m ago"); + expect(formatAge(59 * 60_000 + 59_999)).toBe("59m ago"); + }); + + test("returns hours for ages 1h–23h", () => { + expect(formatAge(60 * 60_000)).toBe("1h ago"); + expect(formatAge(3 * 60 * 60_000)).toBe("3h ago"); + expect(formatAge(23 * 60 * 60_000 + 59 * 60_000)).toBe("23h ago"); + }); + + test("returns days for ages >= 24h", () => { + expect(formatAge(24 * 60 * 60_000)).toBe("1d ago"); + expect(formatAge(48 * 60 * 60_000)).toBe("2d ago"); + expect(formatAge(7 * 24 * 60 * 60_000)).toBe("7d ago"); + }); +}); + +describe("formatCacheHint", () => { + test("returns undefined when no cache hit recorded", () => { + clearLastCacheHitAge(); + expect(formatCacheHint()).toBeUndefined(); + }); + + test("returns undefined when getLastCacheHitAge is undefined", () => { + clearLastCacheHitAge(); + expect(getLastCacheHitAge()).toBeUndefined(); + expect(formatCacheHint()).toBeUndefined(); + }); + + test("returns formatted hint when cache hit recorded (3m)", () => { + setLastCacheHitAgeForTesting(180_000); + expect(formatCacheHint()).toBe("cached · 3m ago · use -f to refresh"); + clearLastCacheHitAge(); + }); + + test("returns formatted hint when cache hit recorded (just now)", () => { + setLastCacheHitAgeForTesting(0); + expect(formatCacheHint()).toBe("cached · just now · use -f to refresh"); + clearLastCacheHitAge(); + }); + + test("returns formatted hint when cache hit recorded (2h)", () => { + setLastCacheHitAgeForTesting(2 * 60 * 60_000); + expect(formatCacheHint()).toBe("cached · 2h ago · use -f to refresh"); + clearLastCacheHitAge(); + }); +}); + +describe("appendCacheHint", () => { + test("returns undefined when no existing hint and no cache hit", () => { + clearLastCacheHitAge(); + expect(appendCacheHint(undefined)).toBeUndefined(); + }); + + test("returns existing hint unchanged when no cache hit", () => { + clearLastCacheHitAge(); + expect(appendCacheHint("Tip: use -v")).toBe("Tip: use -v"); + }); + + test("returns just the cache hint when no existing hint", () => { + setLastCacheHitAgeForTesting(180_000); + expect(appendCacheHint(undefined)).toBe( + "cached · 3m ago · use -f to refresh" + ); + clearLastCacheHitAge(); + }); + + test("joins existing and cache hints with ' | ' separator", () => { + setLastCacheHitAgeForTesting(180_000); + expect(appendCacheHint("Showing 5 issues")).toBe( + "Showing 5 issues | cached · 3m ago · use -f to refresh" + ); + clearLastCacheHitAge(); + }); + + test("treats empty existing hint as falsy (no separator)", () => { + setLastCacheHitAgeForTesting(180_000); + // Empty string is falsy — appendCacheHint returns just the cache hint. + expect(appendCacheHint("")).toBe("cached · 3m ago · use -f to refresh"); + clearLastCacheHitAge(); + }); +}); diff --git a/packages/cli/test/lib/cache-keys.test.ts b/packages/cli/test/lib/cache-keys.test.ts new file mode 100644 index 000000000..ed49bd76a --- /dev/null +++ b/packages/cli/test/lib/cache-keys.test.ts @@ -0,0 +1,170 @@ +/** + * Unit tests for `computeInvalidationPrefixes`. + */ + +import { describe, expect, test } from "vitest"; +import { computeInvalidationPrefixes as computeInvalidationPrefixesRaw } from "../../src/lib/cache-keys.js"; + +const BASE = "https://us.sentry.io/api/0/"; +const API_BASE_URL = "https://sentry.io"; + +/** Test wrapper that pins `apiBaseUrl` to the control-silo default. */ +function computeInvalidationPrefixes(fullUrl: string): string[] { + return computeInvalidationPrefixesRaw(fullUrl, API_BASE_URL); +} + +describe("computeInvalidationPrefixes — hierarchy walk", () => { + test("detail URL yields self + ancestors down to owner", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + // The bare `organizations/` root is deliberately NOT swept — + // it would evict other orgs' caches. + expect(prefixes).not.toContain(`${BASE}organizations/`); + }); + + test("deeply nested path yields every ancestor down to owner", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/releases/1.0.0/deploys/` + ); + expect(prefixes).toContain( + `${BASE}organizations/acme/releases/1.0.0/deploys/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/releases/1.0.0/`); + expect(prefixes).toContain(`${BASE}organizations/acme/releases/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + // Stop at the owner level, don't sweep bare `organizations/`. + expect(prefixes).not.toContain(`${BASE}organizations/`); + }); + + test("single-segment path yields only the segment itself", () => { + const prefixes = computeInvalidationPrefixes(`${BASE}organizations/`); + expect(prefixes).toEqual([`${BASE}organizations/`]); + }); + + test("two-segment path walks to the top (owner-level mutation)", () => { + // The floor only kicks in at length > 2. + const prefixes = computeInvalidationPrefixes(`${BASE}organizations/acme/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + expect(prefixes).toContain(`${BASE}organizations/`); + }); + + test("query string is stripped from the prefix set", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/?collapse=stats&collapse=lifetime` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect( + prefixes.every((p) => !(p.includes("collapse=") || p.includes("?"))) + ).toBe(true); + }); + + test("trailing-slashless URL still works", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + }); +}); + +describe("computeInvalidationPrefixes — cross-endpoint rules", () => { + test("POST /teams/{org}/{team}/projects/ invalidates org project list", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}teams/acme/backend/projects/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}teams/acme/backend/projects/`); + expect(prefixes).toContain(`${BASE}teams/acme/backend/`); + expect(prefixes).toContain(`${BASE}teams/acme/`); + expect(prefixes).not.toContain(`${BASE}teams/`); + }); + + test("DELETE /projects/{org}/{project}/ invalidates org project list", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}projects/acme/frontend/` + ); + expect(prefixes).toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}projects/acme/frontend/`); + expect(prefixes).toContain(`${BASE}projects/acme/`); + expect(prefixes).not.toContain(`${BASE}projects/`); + }); + + test("org-scoped issue mutation invalidates cross-origin legacy endpoint", () => { + // `updateIssueStatus` / `mergeIssues` hit the org-scoped endpoint + // at the region URL, but `getIssue()` caches under the control-silo + // URL at a DIFFERENT origin. Without a cross-origin rule, stale + // legacy cache entries survive org-scoped mutations. + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/` + ); + expect(prefixes).toContain(`${API_BASE_URL}/api/0/issues/12345/`); + // Plus the hierarchy walk under the mutation's own origin: + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + }); + + test("unrelated paths get no cross-endpoint sweep", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/teams/` + ); + expect(prefixes).not.toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}organizations/acme/teams/`); + }); +}); + +describe("computeInvalidationPrefixes — edge cases", () => { + test("returns [] for URLs not under /api/0/", () => { + expect( + computeInvalidationPrefixes("https://example.com/some/path/") + ).toEqual([]); + expect( + computeInvalidationPrefixes("https://uploads.sentry.io/x/y") + ).toEqual([]); + }); + + test("returns [] for unparseable URLs", () => { + expect(computeInvalidationPrefixes("not-a-url")).toEqual([]); + expect(computeInvalidationPrefixes("")).toEqual([]); + }); + + test("returns [] when the mutation hits /api/0/ root itself", () => { + expect(computeInvalidationPrefixes(BASE)).toEqual([]); + }); + + test("deduplicates prefixes across hierarchy + rule-table", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}teams/acme/backend/projects/` + ); + expect(prefixes.length).toBe(new Set(prefixes).size); + }); + + test("write-only endpoints skip invalidation entirely", () => { + // Sourcemap chunk uploads and bundle assembly don't modify any + // cacheable state; without this skip, every chunk POST would + // sweep the org's cache hierarchy. + expect( + computeInvalidationPrefixes(`${BASE}organizations/acme/chunk-upload/`) + ).toEqual([]); + expect( + computeInvalidationPrefixes( + `${BASE}organizations/acme/artifactbundle/assemble/` + ) + ).toEqual([]); + }); + + test("self-hosted base URLs are preserved", () => { + const prefixes = computeInvalidationPrefixes( + "https://sentry.example.com/api/0/organizations/acme/issues/12345/" + ); + expect(prefixes).toContain( + "https://sentry.example.com/api/0/organizations/acme/issues/12345/" + ); + expect(prefixes).toContain( + "https://sentry.example.com/api/0/organizations/acme/" + ); + }); +}); diff --git a/packages/cli/test/lib/checkin-builder.property.test.ts b/packages/cli/test/lib/checkin-builder.property.test.ts new file mode 100644 index 000000000..6716aa7c7 --- /dev/null +++ b/packages/cli/test/lib/checkin-builder.property.test.ts @@ -0,0 +1,174 @@ +/** + * Property-Based Tests for cron monitor check-in builders. + * + * Verifies invariants of `buildMonitorConfig` and `buildCheckIn` that should + * hold for any input: dependent-flag validation, schedule round-tripping, + * and which fields appear on open vs. close check-ins. + */ + +import { + constantFrom, + assert as fcAssert, + integer, + option, + property, + record, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + buildCheckIn, + buildMonitorConfig, + type CheckInConfigFlags, +} from "../../src/lib/envelope/checkin-builder.js"; +import { ValidationError } from "../../src/lib/errors.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const crontabArb = constantFrom( + "0 * * * *", + "*/5 * * * *", + "0 0 * * *", + "30 2 * * 1" +); + +const positiveIntArb = integer({ min: 1, max: 1440 }); + +describe("property: buildMonitorConfig", () => { + test("returns undefined when --schedule is absent and no dependent flags", () => { + fcAssert( + property(option(string(), { nil: undefined }), (timezone) => { + // Only timezone might be set; if it is, expect a throw, else undefined + const flags: CheckInConfigFlags = {}; + if (timezone !== undefined) { + flags.timezone = timezone; + expect(() => buildMonitorConfig(flags)).toThrow(ValidationError); + } else { + expect(buildMonitorConfig(flags)).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("throws when any dependent flag is set without --schedule", () => { + const dependentFlagSetters: Array<(f: CheckInConfigFlags) => void> = [ + (f) => { + f["check-in-margin"] = 5; + }, + (f) => { + f["max-runtime"] = 30; + }, + (f) => { + f.timezone = "UTC"; + }, + (f) => { + f["failure-issue-threshold"] = 2; + }, + (f) => { + f["recovery-threshold"] = 3; + }, + ]; + for (const setter of dependentFlagSetters) { + const flags: CheckInConfigFlags = {}; + setter(flags); + expect(() => buildMonitorConfig(flags)).toThrow(ValidationError); + } + }); + + const thresholdsArb = record({ + margin: option(positiveIntArb, { nil: undefined }), + maxRuntime: option(positiveIntArb, { nil: undefined }), + failure: option(positiveIntArb, { nil: undefined }), + recovery: option(positiveIntArb, { nil: undefined }), + }); + + test("round-trips schedule and thresholds when --schedule is set", () => { + fcAssert( + property(crontabArb, thresholdsArb, (schedule, thresholds) => { + const flags: CheckInConfigFlags = { schedule }; + if (thresholds.margin !== undefined) { + flags["check-in-margin"] = thresholds.margin; + } + if (thresholds.maxRuntime !== undefined) { + flags["max-runtime"] = thresholds.maxRuntime; + } + if (thresholds.failure !== undefined) { + flags["failure-issue-threshold"] = thresholds.failure; + } + if (thresholds.recovery !== undefined) { + flags["recovery-threshold"] = thresholds.recovery; + } + + const config = buildMonitorConfig(flags); + expect(config).toBeDefined(); + expect(config?.schedule).toEqual({ type: "crontab", value: schedule }); + expect(config?.checkin_margin).toBe(thresholds.margin); + expect(config?.max_runtime).toBe(thresholds.maxRuntime); + expect(config?.failure_issue_threshold).toBe(thresholds.failure); + expect(config?.recovery_threshold).toBe(thresholds.recovery); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: buildCheckIn", () => { + const slugArb = string({ minLength: 1, maxLength: 40 }); + const statusArb = constantFrom("in_progress", "ok", "error" as const); + + test("always carries the provided check_in_id, slug, and status", () => { + fcAssert( + property( + slugArb, + slugArb, + statusArb, + (checkInId, monitorSlug, status) => { + const checkIn = buildCheckIn({ + checkInId, + monitorSlug, + status: status as "in_progress" | "ok" | "error", + }); + expect(checkIn.check_in_id).toBe(checkInId); + expect(checkIn.monitor_slug).toBe(monitorSlug); + expect(checkIn.status).toBe(status); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("duration is only present when explicitly provided", () => { + const open = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "in_progress", + }); + expect(open.duration).toBeUndefined(); + + const close = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "ok", + duration: 12.5, + }); + expect(close.duration).toBe(12.5); + }); + + test("monitor_config is only present when explicitly provided", () => { + const withConfig = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "in_progress", + monitorConfig: { schedule: { type: "crontab", value: "0 * * * *" } }, + }); + expect(withConfig.monitor_config).toBeDefined(); + + const withoutConfig = buildCheckIn({ + checkInId: "a", + monitorSlug: "job", + status: "ok", + duration: 1, + }); + expect(withoutConfig.monitor_config).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/cli-startup.test.ts b/packages/cli/test/lib/cli-startup.test.ts new file mode 100644 index 000000000..96c66f175 --- /dev/null +++ b/packages/cli/test/lib/cli-startup.test.ts @@ -0,0 +1,37 @@ +import { expect, test, vi } from "vitest"; +import { startCli } from "../../src/cli.js"; +// biome-ignore lint/performance/noNamespaceImport: spy on the startup dependency +import * as upgrade from "../../src/lib/upgrade.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("cli-startup-"); + +test("fatal errors preserve their name while redacting credentials", async () => { + const argv = process.argv; + const exitCode = process.exitCode; + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + const cleanup = vi + .spyOn(upgrade, "startCleanupOldBinary") + .mockImplementation(() => { + throw new TypeError( + 'Headers.set: "Bearer SYNTHETIC_PREFIX\nSYNTHETIC_SECRET" is an invalid header value.' + ); + }); + + try { + process.argv = ["node", "sentry", "--help"]; + await startCli(); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(stderr).toHaveBeenLastCalledWith( + 'Fatal: TypeError: Headers.set: "Bearer [REDACTED]" is an invalid header value.\n' + ); + expect(process.exitCode).toBe(1); + } finally { + process.argv = argv; + process.exitCode = exitCode; + vi.restoreAllMocks(); + } +}); diff --git a/packages/cli/test/lib/close-dispatcher.test.ts b/packages/cli/test/lib/close-dispatcher.test.ts new file mode 100644 index 000000000..36ae4ce81 --- /dev/null +++ b/packages/cli/test/lib/close-dispatcher.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { closeGlobalDispatcher } from "../../src/lib/close-dispatcher.js"; + +const GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1"); +const global = globalThis as Record; +const original = global[GLOBAL_DISPATCHER]; + +afterEach(() => { + if (original === undefined) { + delete global[GLOBAL_DISPATCHER]; + } else { + global[GLOBAL_DISPATCHER] = original; + } +}); + +describe("closeGlobalDispatcher", () => { + test("destroys the global dispatcher when one is registered", async () => { + let destroyed = false; + global[GLOBAL_DISPATCHER] = { + destroy: () => { + destroyed = true; + return Promise.resolve(); + }, + }; + + await closeGlobalDispatcher(); + + expect(destroyed).toBe(true); + }); + + test("resolves without throwing when no dispatcher is registered", async () => { + delete global[GLOBAL_DISPATCHER]; + + await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); + }); + + test("resolves when the dispatcher has no destroy method", async () => { + global[GLOBAL_DISPATCHER] = {}; + + await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); + }); + + test("swallows a rejection from destroy so the exit path is never disrupted", async () => { + global[GLOBAL_DISPATCHER] = { + destroy: () => Promise.reject(new Error("socket teardown failed")), + }; + + await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/command-suggestions.test.ts b/packages/cli/test/lib/command-suggestions.test.ts new file mode 100644 index 000000000..8dc8c067c --- /dev/null +++ b/packages/cli/test/lib/command-suggestions.test.ts @@ -0,0 +1,167 @@ +/** + * Unit tests for the command synonym suggestion registry. + * + * Core invariants (case-insensitivity, lookup consistency) could be + * property-tested, but the map is small and static — unit tests are + * sufficient here. These verify each telemetry-driven pattern category. + */ + +import { describe, expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import { getCommandSuggestion } from "../../src/lib/command-suggestions.js"; +import { isRouteMap, type RouteMap } from "../../src/lib/introspect.js"; + +describe("getCommandSuggestion", () => { + // --- Pattern 1: issue events (most common) --- + test("suggests issue view for 'issue/events'", () => { + const s = getCommandSuggestion("issue", "events"); + expect(s).toBeDefined(); + expect(s!.command).toContain("issue view"); + expect(s!.explanation).toBeDefined(); + }); + + // --- Pattern 2: view synonyms --- + test("suggests issue view for 'issue/get'", () => { + const s = getCommandSuggestion("issue", "get"); + expect(s).toBeDefined(); + expect(s!.command).toContain("issue view"); + }); + + test("suggests issue view for 'issue/details'", () => { + expect(getCommandSuggestion("issue", "details")?.command).toContain( + "issue view" + ); + }); + + test("suggests issue view for 'issue/info'", () => { + expect(getCommandSuggestion("issue", "info")?.command).toContain( + "issue view" + ); + }); + + // --- Pattern 3: mutation commands --- + test("suggests sentry api for 'issue/resolve'", () => { + const s = getCommandSuggestion("issue", "resolve"); + expect(s).toBeDefined(); + expect(s!.command).toContain("sentry api"); + expect(s!.command).toContain("resolved"); + }); + + test("suggests sentry api for 'issue/update'", () => { + const s = getCommandSuggestion("issue", "update"); + expect(s).toBeDefined(); + expect(s!.command).toContain("sentry api"); + }); + + test("suggests sentry api for 'issue/assign'", () => { + const s = getCommandSuggestion("issue", "assign"); + expect(s).toBeDefined(); + expect(s!.command).toContain("assignedTo"); + }); + + // --- Pattern 4: event list is now a real command (no longer a synonym) --- + test("returns undefined for 'event/list' (now a real command)", () => { + expect(getCommandSuggestion("event", "list")).toBeUndefined(); + }); + + // --- Pattern 5: old sentry-cli commands --- + test("suggests auth status for 'cli/info'", () => { + expect(getCommandSuggestion("cli", "info")?.command).toContain( + "auth status" + ); + }); + + test("suggests issue list for 'cli/issues'", () => { + expect(getCommandSuggestion("cli", "issues")?.command).toContain( + "issue list" + ); + }); + + test("suggests log list for 'cli/logs'", () => { + expect(getCommandSuggestion("cli", "logs")?.command).toContain("log list"); + }); + + test("suggests send-event for 'cli/send-event'", () => { + expect(getCommandSuggestion("cli", "send-event")?.command).toContain( + "sentry event send" + ); + }); + + // --- Pattern 6: dashboard synonyms --- + test("suggests dashboard list for 'dashboard/default-overview'", () => { + expect( + getCommandSuggestion("dashboard", "default-overview")?.command + ).toContain("dashboard list"); + }); + + // --- Case insensitivity --- + test("is case-insensitive on the unknown token", () => { + expect(getCommandSuggestion("issue", "Events")).toBeDefined(); + expect(getCommandSuggestion("issue", "RESOLVE")).toBeDefined(); + expect(getCommandSuggestion("cli", "INFO")).toBeDefined(); + }); + + // --- No match --- + test("returns undefined for unrecognized token", () => { + expect(getCommandSuggestion("issue", "foobar")).toBeUndefined(); + }); + + test("returns undefined for empty route context with unknown token", () => { + expect(getCommandSuggestion("", "foobar")).toBeUndefined(); + }); + + test("returns undefined for unknown route context", () => { + expect(getCommandSuggestion("nonexistent", "events")).toBeUndefined(); + }); +}); + +describe("routes with defaultCommand", () => { + /** Derive the set the same way app.ts does — via introspection */ + const routesWithDefault = new Set( + routes + .getAllEntries() + .filter( + (e) => + isRouteMap(e.target as unknown) && + (e.target as unknown as RouteMap).getDefaultCommand?.() + ) + .map((e) => e.name.original) + ); + + test("all route groups with a view subcommand have defaultCommand set", () => { + const expected = [ + "issue", + "event", + "org", + "project", + "dashboard", + "trace", + "span", + "log", + ]; + for (const route of expected) { + expect(routesWithDefault.has(route)).toBe(true); + } + }); + + test("auth route group has defaultCommand (smart default)", () => { + expect(routesWithDefault.has("auth")).toBe(true); + const authEntry = routes + .getAllEntries() + .find((e) => e.name.original === "auth"); + const authMap = authEntry?.target as unknown as RouteMap; + const defaultCmd = authMap.getDefaultCommand?.(); + const defaultEntry = authMap + .getAllEntries() + .find((e) => e.target === defaultCmd); + expect(defaultEntry?.name.original).toBe("default"); + expect(defaultEntry?.hidden).toBe(true); + }); + + test("route groups without defaultCommand", () => { + expect(routesWithDefault.has("cli")).toBe(false); + expect(routesWithDefault.has("sourcemap")).toBe(false); + expect(routesWithDefault.has("repo")).toBe(false); + expect(routesWithDefault.has("team")).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/command.test.ts b/packages/cli/test/lib/command.test.ts new file mode 100644 index 000000000..a251151e5 --- /dev/null +++ b/packages/cli/test/lib/command.test.ts @@ -0,0 +1,1892 @@ +/** + * Command Builder Tests + * + * Tests for the buildCommand wrapper that adds automatic flag telemetry. + * Uses Stricli's run() to invoke commands end-to-end, verifying the wrapper + * captures flags/args and calls the original function. + */ + +import { setTimeout as sleep } from "node:timers/promises"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { + buildApplication, + type CommandContext, + run, + text_en, +} from "@stricli/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + applyLoggingFlags, + applyOrgProjectFlags, + buildCommand, + FIELDS_FLAG, + JSON_FLAG, + LOG_LEVEL_FLAG, + numberParser, + VERBOSE_FLAG, +} from "../../src/lib/command.js"; +import { EXIT, OutputError } from "../../src/lib/errors.js"; +import { CommandOutput } from "../../src/lib/formatters/output.js"; +import { + interactivePromptsAllowed, + setInteractivePromptsAllowed, +} from "../../src/lib/interactive-prompts.js"; +import { LOG_LEVEL_NAMES, logger, setLogLevel } from "../../src/lib/logger.js"; +import { DRY_RUN_FLAG, YES_FLAG } from "../../src/lib/mutate-command.js"; +import { resolveOrgAndProject } from "../../src/lib/resolve-target.js"; +import { buildRouteMap } from "../../src/lib/route-map.js"; + +/** Minimal context for test commands */ +type TestContext = CommandContext & { + process: { stdout: { write: (s: string) => boolean } }; + /** stdout on context — used by buildCommand's return-based output handler */ + stdout: { write: (s: string) => boolean }; +}; + +/** + * Creates a minimal test context with writable streams. + * Returns both `process` (Stricli needs this) and `stdout` (return-based output handler needs this). + * + * Use as: `const ctx = createTestContext();` + * Then pass to `run(app, args, ctx as TestContext)`. + * Access collected output via `ctx.output`. + */ +function createTestContext() { + const stdoutCollected: string[] = []; + const stderrCollected: string[] = []; + const stdoutWriter = { + write: (s: string) => { + stdoutCollected.push(s); + return true; + }, + }; + return { + process: { + stdout: stdoutWriter, + stderr: { + write: (s: string) => { + stderrCollected.push(s); + return true; + }, + }, + }, + /** stdout on context — used by buildCommand's return-based output handler */ + stdout: stdoutWriter, + /** stdout output chunks only */ + output: stdoutCollected, + /** stderr output chunks only */ + errors: stderrCollected, + }; +} + +test("derives prompt availability from parsed flags instead of ambiguous aliases", async () => { + type InitPromptFlags = { yes: boolean; "dry-run": boolean }; + type DataFlags = { limit?: number; row?: number }; + const promptStates: boolean[] = []; + + const initCommand = buildCommand({ + auth: false, + docs: { brief: "Init" }, + parameters: { + flags: { yes: YES_FLAG, "dry-run": DRY_RUN_FLAG }, + aliases: { y: "yes", n: "dry-run" }, + }, + // biome-ignore lint/correctness/useYield: test command only observes wrapper state + async *func() { + promptStates.push(interactivePromptsAllowed()); + }, + }); + const dataCommand = buildCommand({ + auth: false, + docs: { brief: "Data" }, + parameters: { + flags: { + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + optional: true, + }, + row: { + kind: "parsed", + parse: numberParser, + brief: "Row", + optional: true, + }, + }, + aliases: { n: "limit", y: "row" }, + }, + // biome-ignore lint/correctness/useYield: test command only observes wrapper state + async *func() { + promptStates.push(interactivePromptsAllowed()); + }, + }); + const routeMap = buildRouteMap({ + routes: { init: initCommand, data: dataCommand }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + const cases: Array<{ + args: string[]; + expected: boolean; + }> = [ + { args: ["init", "-y"], expected: false }, + { args: ["init", "-n"], expected: false }, + { args: ["init", "--yes=false"], expected: true }, + { args: ["data", "-n", "10"], expected: true }, + { args: ["data", "-y", "2"], expected: true }, + ]; + + try { + for (const testCase of cases) { + await run(app, testCase.args, ctx as TestContext); + expect(promptStates.at(-1)).toBe(testCase.expected); + } + } finally { + setInteractivePromptsAllowed(true); + } +}); + +describe("buildCommand", () => { + test("builds a valid command object", () => { + const command = buildCommand({ + auth: false, + docs: { brief: "Test command" }, + parameters: { + flags: { + verbose: { kind: "boolean", brief: "Verbose", default: false }, + }, + }, + async *func(_flags: { verbose: boolean }) { + // no-op + }, + }); + expect(command).toBeDefined(); + }); + + test("handles commands with empty parameters", () => { + const command = buildCommand({ + auth: false, + docs: { brief: "Simple command" }, + parameters: {}, + async *func() { + // no-op + }, + }); + expect(command).toBeDefined(); + }); + + test("retains the primary custom usage line for introspection", () => { + const command = buildCommand, string[]>({ + auth: false, + docs: { + brief: "Create things", + customUsage: [":..."], + }, + parameters: { + positional: { + kind: "array", + parameter: { brief: "Name and kind pairs", parse: String }, + }, + }, + async *func() { + yield null; + }, + }); + + expect( + (command as unknown as { __primaryUsage?: string }).__primaryUsage + ).toBe(":..."); + }); + + test("re-exports numberParser from Stricli", () => { + expect(numberParser).toBeDefined(); + expect(typeof numberParser).toBe("function"); + }); +}); + +describe("buildCommand telemetry integration", () => { + let setTagSpy: ReturnType; + let setContextSpy: ReturnType; + + beforeEach(() => { + setTagSpy = vi.spyOn(Sentry, "setTag"); + setContextSpy = vi.spyOn(Sentry, "setContext"); + }); + + afterEach(() => { + setTagSpy.mockRestore(); + setContextSpy.mockRestore(); + }); + + test("captures flags as Sentry tags when command runs", async () => { + let calledWith: unknown = null; + + const command = buildCommand< + { verbose: boolean; limit: number }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + verbose: { kind: "boolean", brief: "Verbose", default: false }, + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + default: "10", + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { verbose: boolean; limit: number } + ) { + calledWith = flags; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--verbose", "--limit", "50"], ctx as TestContext); + + // Original func was called with parsed flags + expect(calledWith).toEqual({ verbose: true, limit: 50 }); + + // Sentry.setTag was called for meaningful flag values + expect(setTagSpy).toHaveBeenCalledWith("flag.verbose", "true"); + expect(setTagSpy).toHaveBeenCalledWith("flag.limit", "50"); + }); + + test("passes string defaults through parse() — numberParser default is number at runtime", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand<{ limit: number }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + default: "10", + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext, flags: { limit: number }) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + // Run WITHOUT --limit to exercise the default path + await run(app, ["test"], ctx as TestContext); + + expect(receivedFlags).not.toBeNull(); + // Critical: must be number 10, not string "10" + expect(receivedFlags!.limit).toBe(10); + expect(typeof receivedFlags!.limit).toBe("number"); + }); + + test("skips false boolean flags in telemetry", async () => { + const command = buildCommand<{ json: boolean }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + json: { kind: "boolean", brief: "JSON output", default: false }, + }, + }, + async *func(_flags: { json: boolean }) { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test"], ctx as TestContext); + + // Should not set tag for default false boolean + const flagCalls = setTagSpy.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].startsWith("flag.") + ); + expect(flagCalls).toHaveLength(0); + }); + + test("captures positional args as Sentry context", async () => { + let calledArgs: unknown = null; + + const command = buildCommand, [string], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Issue ID", parse: String }], + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + _flags: Record, + issueId: string + ) { + calledArgs = issueId; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "PROJECT-123"], ctx as TestContext); + + expect(calledArgs).toBe("PROJECT-123"); + expect(setContextSpy).toHaveBeenCalledWith("args", { + values: ["PROJECT-123"], + count: 1, + }); + }); + + test("preserves this context for command functions", async () => { + let capturedStdout = false; + + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { + // Verify 'this' is correctly bound to context + capturedStdout = typeof this.process.stdout.write === "function"; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test"], ctx as TestContext); + + expect(capturedStdout).toBe(true); + }); + + test("handles async command functions", async () => { + let executed = false; + + const command = buildCommand<{ delay: number }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + delay: { + kind: "parsed", + parse: numberParser, + brief: "Delay ms", + default: "1", + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(_flags: { delay: number }) { + await sleep(1); + executed = true; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--delay", "1"], ctx as TestContext); + + expect(executed).toBe(true); + expect(setTagSpy).toHaveBeenCalledWith("flag.delay", "1"); + }); +}); + +// --------------------------------------------------------------------------- +// Global logging flag definitions +// --------------------------------------------------------------------------- + +describe("LOG_LEVEL_FLAG", () => { + test("is an enum flag with all log level names", () => { + expect(LOG_LEVEL_FLAG.kind).toBe("enum"); + expect(LOG_LEVEL_FLAG.values).toEqual([...LOG_LEVEL_NAMES]); + }); + + test("is optional and hidden", () => { + expect(LOG_LEVEL_FLAG.optional).toBe(true); + expect(LOG_LEVEL_FLAG.hidden).toBe(true); + }); +}); + +describe("VERBOSE_FLAG", () => { + test("is a boolean flag defaulting to false", () => { + expect(VERBOSE_FLAG.kind).toBe("boolean"); + expect(VERBOSE_FLAG.default).toBe(false); + }); + + test("is hidden", () => { + expect(VERBOSE_FLAG.hidden).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// applyLoggingFlags +// --------------------------------------------------------------------------- + +describe("applyLoggingFlags", () => { + let originalLevel: number; + + beforeEach(() => { + originalLevel = logger.level; + }); + + afterEach(() => { + setLogLevel(originalLevel); + }); + + test("sets level from logLevel flag", () => { + applyLoggingFlags("debug", false); + expect(logger.level).toBe(4); + }); + + test("sets level from verbose flag", () => { + applyLoggingFlags(undefined, true); + expect(logger.level).toBe(4); // debug + }); + + test("logLevel takes priority over verbose", () => { + applyLoggingFlags("error", true); + expect(logger.level).toBe(0); // error, not debug + }); + + test("does nothing when both are unset/false", () => { + const before = logger.level; + applyLoggingFlags(undefined, false); + expect(logger.level).toBe(before); + }); + + test("accepts all valid level names", () => { + for (const name of LOG_LEVEL_NAMES) { + applyLoggingFlags(name, false); + expect(logger.level).toBe(LOG_LEVEL_NAMES.indexOf(name)); + } + }); +}); + +// --------------------------------------------------------------------------- +// buildCommand +// --------------------------------------------------------------------------- + +describe("buildCommand", () => { + let originalLevel: number; + + beforeEach(() => { + originalLevel = logger.level; + }); + + afterEach(() => { + setLogLevel(originalLevel); + }); + + test("builds a valid command object", () => { + const command = buildCommand({ + auth: false, + docs: { brief: "Test command" }, + parameters: { + flags: { + json: { kind: "boolean", brief: "JSON output", default: false }, + }, + }, + async *func(_flags: { json: boolean }) { + // no-op + }, + }); + expect(command).toBeDefined(); + }); + + test("accepts --verbose and --log-level flags without error", async () => { + let calledFlags: Record | null = null; + + const command = buildCommand<{ json: boolean }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + json: { kind: "boolean", brief: "JSON output", default: false }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext, flags: { json: boolean }) { + calledFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + // Should NOT throw "No flag registered for --verbose" + await run(app, ["test", "--verbose", "--json"], ctx as TestContext); + + // Original func receives json flag but NOT verbose/log-level + expect(calledFlags).toBeDefined(); + expect(calledFlags!.json).toBe(true); + expect(calledFlags!.verbose).toBeUndefined(); + expect(calledFlags!["log-level"]).toBeUndefined(); + }); + + test("--verbose sets logger to debug level", async () => { + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--verbose"], ctx as TestContext); + + expect(logger.level).toBe(4); // debug + }); + + test("--log-level sets logger to specified level", async () => { + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--log-level", "trace"], ctx as TestContext); + + expect(logger.level).toBe(5); // trace + }); + + test("--log-level=value (equals form) works", async () => { + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--log-level=error"], ctx as TestContext); + + expect(logger.level).toBe(0); // error + }); + + test("strips logging flags from func's flags parameter", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand<{ limit: number }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + default: "10", + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext, flags: { limit: number }) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--verbose", "--log-level", "debug", "--limit", "50"], + ctx as TestContext + ); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.limit).toBe(50); + // Logging flags must be stripped + expect(receivedFlags!.verbose).toBeUndefined(); + expect(receivedFlags!["log-level"]).toBeUndefined(); + }); + + test("preserves command's own --verbose flag when already defined", async () => { + let receivedFlags: Record | null = null; + + // Simulates the api command: defines its own --verbose with HTTP semantics + const command = buildCommand< + { verbose: boolean; silent: boolean }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + verbose: { + kind: "boolean", + brief: "Show HTTP details", + default: false, + }, + silent: { + kind: "boolean", + brief: "Suppress output", + default: false, + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { verbose: boolean; silent: boolean } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--verbose", "--log-level", "trace"], + ctx as TestContext + ); + + // Command's own --verbose is passed through (not stripped) + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.verbose).toBe(true); + expect(receivedFlags!.silent).toBe(false); + // --log-level is always stripped (it's ours) + expect(receivedFlags!["log-level"]).toBeUndefined(); + // --verbose also sets debug-level logging as a side-effect + // but --log-level=trace takes priority + expect(logger.level).toBe(5); // trace + }); + + test("command's own --verbose sets debug log level as side-effect", async () => { + const command = buildCommand<{ verbose: boolean }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + verbose: { + kind: "boolean", + brief: "Show HTTP details", + default: false, + }, + }, + }, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--verbose"], ctx as TestContext); + + // Even though verbose is command-owned, it triggers debug-level logging + expect(logger.level).toBe(4); // debug + }); +}); + +// --------------------------------------------------------------------------- +// JSON_FLAG and FIELDS_FLAG definitions +// --------------------------------------------------------------------------- + +describe("JSON_FLAG", () => { + test("is a boolean flag defaulting to false", () => { + expect(JSON_FLAG.kind).toBe("boolean"); + expect(JSON_FLAG.default).toBe(false); + }); + + test("is not hidden", () => { + expect("hidden" in JSON_FLAG).toBe(false); + }); +}); + +describe("FIELDS_FLAG", () => { + test("is a parsed optional flag", () => { + expect(FIELDS_FLAG.kind).toBe("parsed"); + expect(FIELDS_FLAG.optional).toBe(true); + }); + + test("parses input as string", () => { + expect(FIELDS_FLAG.parse("id,title")).toBe("id,title"); + }); +}); + +// --------------------------------------------------------------------------- +// buildCommand output config injection +// --------------------------------------------------------------------------- + +describe("buildCommand output config", () => { + let originalLevel: number; + + beforeEach(() => { + originalLevel = logger.level; + }); + + afterEach(() => { + setLogLevel(originalLevel); + }); + + test("injects --json flag when output: 'json'", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused" }, + parameters: { + flags: { + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + default: "10", + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + // --json should be accepted without "No flag registered" error + await run(app, ["test", "--json"], ctx as TestContext); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.json).toBe(true); + }); + + test("injects --fields flag when output: 'json'", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused" }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--json", "--fields", "id,title,status"], + ctx as TestContext + ); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.json).toBe(true); + // --fields is pre-parsed from comma-string to string[] + expect(receivedFlags!.fields).toEqual(["id", "title", "status"]); + }); + + test("pre-parses --fields with whitespace and deduplication", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused" }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--fields", " id , title , id "], + ctx as TestContext + ); + + expect(receivedFlags).toBeDefined(); + // Whitespace trimmed, duplicates removed + expect(receivedFlags!.fields).toEqual(["id", "title"]); + }); + + test("fields is undefined when --fields not passed", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused" }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--json"], ctx as TestContext); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.json).toBe(true); + expect(receivedFlags!.fields).toBeUndefined(); + }); + + test("does not inject --json/--fields without output: 'json'", async () => { + let funcCalled = false; + + // Command WITHOUT output config — --json should be rejected by Stricli + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func() { + funcCalled = true; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + // Stricli writes error to stderr and resolves — func is never called + await run(app, ["test", "--json"], ctx as TestContext); + + expect(funcCalled).toBe(false); + expect( + ctx.errors.some((s) => s.includes("No flag registered for --json")) + ).toBe(true); + }); + + test("preserves command's own --json flag when already defined", async () => { + let receivedFlags: Record | null = null; + + // Command defines its own --json with custom brief + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused" }, + parameters: { + flags: { + json: { + kind: "boolean", + brief: "Custom JSON brief text", + default: false, + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--json", "--fields", "id"], ctx as TestContext); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.json).toBe(true); + // --fields is still injected and pre-parsed even when command owns --json + expect(receivedFlags!.fields).toEqual(["id"]); + }); + + test("supports dot-notation in --fields", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused" }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[] } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--fields", "id,metadata.value,contexts.trace.traceId"], + ctx as TestContext + ); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.fields).toEqual([ + "id", + "metadata.value", + "contexts.trace.traceId", + ]); + }); + + test("--json and --fields coexist with other command flags", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand< + { json: boolean; fields?: string[]; limit: number }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused" }, + parameters: { + flags: { + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + default: "10", + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func( + this: TestContext, + flags: { json: boolean; fields?: string[]; limit: number } + ) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--json", "--fields", "id", "--limit", "50", "--verbose"], + ctx as TestContext + ); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.json).toBe(true); + expect(receivedFlags!.fields).toEqual(["id"]); + expect(receivedFlags!.limit).toBe(50); + // --verbose is stripped (we injected it) + expect(receivedFlags!.verbose).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// buildCommand return-based output integration +// --------------------------------------------------------------------------- + +describe("buildCommand return-based output", () => { + test("renders human output when func returns data", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput({ name: "Alice", role: "admin" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test"], ctx as TestContext); + + expect(ctx.output.join("")).toContain("Alice (admin)"); + }); + + test("renders JSON output when --json is passed", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput({ name: "Alice", role: "admin" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--json"], ctx as TestContext); + + const jsonOutput = JSON.parse(ctx.output.join("")); + expect(jsonOutput).toEqual({ name: "Alice", role: "admin" }); + }); + + test("applies --fields filtering to JSON output", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { id: number; name: string; role: string }) => `${d.name}`, + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput({ id: 1, name: "Alice", role: "admin" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--json", "--fields", "id,name"], + ctx as TestContext + ); + + const jsonOutput = JSON.parse(ctx.output.join("")); + expect(jsonOutput).toEqual({ id: 1, name: "Alice" }); + expect(jsonOutput).not.toHaveProperty("role"); + }); + + test("shows hint in human mode, suppresses in JSON mode", async () => { + const makeCommand = () => + buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { value: number }) => `Value: ${d.value}`, + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput({ value: 42 }); + return { hint: "Run 'sentry help' for more info" }; + }, + }); + + // Human mode — hint should appear + const routeMap1 = buildRouteMap({ + routes: { test: makeCommand() }, + docs: { brief: "Test app" }, + }); + const app1 = buildApplication(routeMap1, { name: "test" }); + const ctx1 = createTestContext(); + await run(app1, ["test"], ctx1 as TestContext); + + const humanOutput = ctx1.output.join(""); + expect(humanOutput).toContain("Value: 42"); + expect(humanOutput).toContain("Run 'sentry help' for more info"); + + // JSON mode — hint should be suppressed + const routeMap2 = buildRouteMap({ + routes: { test: makeCommand() }, + docs: { brief: "Test app" }, + }); + const app2 = buildApplication(routeMap2, { name: "test" }); + const ctx2 = createTestContext(); + await run(app2, ["test", "--json"], ctx2 as TestContext); + + const jsonRaw = ctx2.output.join(""); + expect(jsonRaw).not.toContain("Run 'sentry help' for more info"); + const jsonOutput = JSON.parse(jsonRaw); + expect(jsonOutput).toEqual({ value: 42 }); + }); + + test("void return does nothing (no crash)", async () => { + let executed = false; + + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: () => "unused", + }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { + executed = true; + // Void return — simulates --web early exit + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test"], ctx as TestContext); + + expect(executed).toBe(true); + // No output written — void return is silently ignored + expect(ctx.output).toHaveLength(0); + }); + + test("data return is ignored without output config", async () => { + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + // Deliberately no output config + parameters: {}, + async *func(this: TestContext) { + // This returns data, but without output config + // the wrapper should NOT render it + yield { value: 42 }; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test"], ctx as TestContext); + + // No output written — data return was silently ignored + expect(ctx.output).toHaveLength(0); + }); + + test("works with async command functions", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { name: string }) => `Hello, ${d.name}!`, + }, + parameters: {}, + async *func(this: TestContext) { + await sleep(1); + yield new CommandOutput({ name: "Bob" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--json"], ctx as TestContext); + + const jsonOutput = JSON.parse(ctx.output.join("")); + expect(jsonOutput).toEqual({ name: "Bob" }); + }); + + test("array data works correctly via commandOutput wrapper", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: Array<{ id: number }>) => d.map(((x) => x.id).join(", ")), + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput([{ id: 1 }, { id: 2 }]); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--json"], ctx as TestContext); + + const jsonOutput = JSON.parse(ctx.output.join("")); + expect(Array.isArray(jsonOutput)).toBe(true); + expect(jsonOutput).toHaveLength(2); + expect(jsonOutput[0]).toEqual({ id: 1 }); + expect(jsonOutput[1]).toEqual({ id: 2 }); + }); + + test("hint shown in human mode only", async () => { + const makeCommand = () => + buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { org: string }) => `Org: ${d.org}`, + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput({ org: "sentry" }); + return { hint: "Detected from .env file" }; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: makeCommand() }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + + // Human mode + const ctx1 = createTestContext(); + await run(app, ["test"], ctx1 as TestContext); + const humanOutput = ctx1.output.join(""); + expect(humanOutput).toContain("Org: sentry"); + expect(humanOutput).toContain("Detected from .env file"); + + // JSON mode + const ctx2 = createTestContext(); + await run(app, ["test", "--json"], ctx2 as TestContext); + const jsonRaw = ctx2.output.join(""); + expect(jsonRaw).not.toContain("Detected from"); + expect(JSON.parse(jsonRaw)).toEqual({ org: "sentry" }); + }); + + test("OutputError renders data and exits with error code", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { error: string }) => `Error: ${d.error}`, + }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { + throw new OutputError({ error: "not found" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + // Re-throw OutputError from Stricli's error handler (matches production app.ts) + const app = buildApplication(routeMap, { + name: "test", + localization: { + loadText: () => ({ + ...text_en, + exceptionWhileRunningCommand: (exc: unknown) => { + if (exc instanceof OutputError) throw exc; + return text_en.exceptionWhileRunningCommand(exc, false); + }, + }), + }, + }); + const ctx = createTestContext(); + + // OutputError is now thrown (not process.exit) — catch it + try { + await run(app, ["test"], ctx as TestContext); + expect.unreachable("Expected OutputError to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(OutputError); + expect((err as OutputError).exitCode).toBe(EXIT.OUTPUT_ERROR); + } + // Output was rendered BEFORE the throw + expect(ctx.output.join("")).toContain("Error: not found"); + }); + + test("OutputError renders JSON in --json mode", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { error: string }) => `Error: ${d.error}`, + }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { + throw new OutputError({ error: "not found" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + // Re-throw OutputError from Stricli's error handler (matches production app.ts) + const app = buildApplication(routeMap, { + name: "test", + localization: { + loadText: () => ({ + ...text_en, + exceptionWhileRunningCommand: (exc: unknown) => { + if (exc instanceof OutputError) throw exc; + return text_en.exceptionWhileRunningCommand(exc, false); + }, + }), + }, + }); + const ctx = createTestContext(); + + // OutputError is now thrown (not process.exit) — catch it + try { + await run(app, ["test", "--json"], ctx as TestContext); + expect.unreachable("Expected OutputError to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(OutputError); + expect((err as OutputError).exitCode).toBe(EXIT.OUTPUT_ERROR); + } + const jsonOutput = JSON.parse(ctx.output.join("")); + expect(jsonOutput).toEqual({ error: "not found" }); + }); +}); + +// --------------------------------------------------------------------------- +// applyOrgProjectFlags +// --------------------------------------------------------------------------- + +describe("applyOrgProjectFlags", () => { + let savedOrg: string | undefined; + let savedProject: string | undefined; + + beforeEach(() => { + savedOrg = process.env.SENTRY_ORG; + savedProject = process.env.SENTRY_PROJECT; + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + afterEach(() => { + if (savedOrg !== undefined) { + process.env.SENTRY_ORG = savedOrg; + } else { + delete process.env.SENTRY_ORG; + } + if (savedProject !== undefined) { + process.env.SENTRY_PROJECT = savedProject; + } else { + delete process.env.SENTRY_PROJECT; + } + }); + + test("sets both env vars when flags provided", () => { + applyOrgProjectFlags("my-org", "my-proj", false, false); + expect(process.env.SENTRY_ORG).toBe("my-org"); + expect(process.env.SENTRY_PROJECT).toBe("my-proj"); + }); + + test("skips when command owns the flag", () => { + applyOrgProjectFlags("my-org", "my-proj", true, true); + expect(process.env.SENTRY_ORG).toBeUndefined(); + expect(process.env.SENTRY_PROJECT).toBeUndefined(); + }); + + test("sets only org when project is undefined", () => { + applyOrgProjectFlags("my-org", undefined, false, false); + expect(process.env.SENTRY_ORG).toBe("my-org"); + expect(process.env.SENTRY_PROJECT).toBeUndefined(); + }); + + test("sets only project when org is undefined", () => { + applyOrgProjectFlags(undefined, "my-proj", false, false); + expect(process.env.SENTRY_ORG).toBeUndefined(); + expect(process.env.SENTRY_PROJECT).toBe("my-proj"); + }); + + test("overwrites existing env var", () => { + process.env.SENTRY_ORG = "old"; + applyOrgProjectFlags("new", undefined, false, false); + expect(process.env.SENTRY_ORG).toBe("new"); + }); + + test("does nothing when both flags are undefined", () => { + applyOrgProjectFlags(undefined, undefined, false, false); + expect(process.env.SENTRY_ORG).toBeUndefined(); + expect(process.env.SENTRY_PROJECT).toBeUndefined(); + }); + + test("trims whitespace from flag values before writing", () => { + applyOrgProjectFlags(" my-org ", " my-proj ", false, false); + expect(process.env.SENTRY_ORG).toBe("my-org"); + expect(process.env.SENTRY_PROJECT).toBe("my-proj"); + }); + + test("treats empty string as not provided — preserves existing env var", () => { + process.env.SENTRY_ORG = "preserved-org"; + applyOrgProjectFlags("", undefined, false, false); + // Empty string must NOT clobber a real pre-existing env var + expect(process.env.SENTRY_ORG).toBe("preserved-org"); + }); + + test("treats whitespace-only as not provided — preserves existing env var", () => { + process.env.SENTRY_PROJECT = "preserved-proj"; + applyOrgProjectFlags(undefined, " ", false, false); + // Whitespace-only must NOT clobber a real pre-existing env var + expect(process.env.SENTRY_PROJECT).toBe("preserved-proj"); + }); +}); + +// --------------------------------------------------------------------------- +// buildCommand --org/--project compat flags (end-to-end) +// --------------------------------------------------------------------------- + +describe("buildCommand --org/--project compat flags", () => { + let savedOrg: string | undefined; + let savedProject: string | undefined; + + beforeEach(() => { + savedOrg = process.env.SENTRY_ORG; + savedProject = process.env.SENTRY_PROJECT; + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + afterEach(() => { + if (savedOrg !== undefined) { + process.env.SENTRY_ORG = savedOrg; + } else { + delete process.env.SENTRY_ORG; + } + if (savedProject !== undefined) { + process.env.SENTRY_PROJECT = savedProject; + } else { + delete process.env.SENTRY_PROJECT; + } + }); + + test("--org sets SENTRY_ORG env var", async () => { + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--org", "my-org"], ctx as TestContext); + + expect(process.env.SENTRY_ORG).toBe("my-org"); + }); + + test("--project sets SENTRY_PROJECT env var", async () => { + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--project", "my-project"], ctx as TestContext); + + expect(process.env.SENTRY_PROJECT).toBe("my-project"); + }); + + test("--org overwrites existing SENTRY_ORG", async () => { + process.env.SENTRY_ORG = "original-org"; + + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--org", "new-org"], ctx as TestContext); + + expect(process.env.SENTRY_ORG).toBe("new-org"); + }); + + test("--org and --project are stripped from func flags", async () => { + let receivedFlags: Record | null = null; + + const command = buildCommand<{ limit: number }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + default: "10", + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command + async *func(this: TestContext, flags: { limit: number }) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--org", "sentry", "--project", "cli", "--limit", "50"], + ctx as TestContext + ); + + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.limit).toBe(50); + expect(receivedFlags!.org).toBeUndefined(); + expect(receivedFlags!.project).toBeUndefined(); + // Both env vars set + expect(process.env.SENTRY_ORG).toBe("sentry"); + expect(process.env.SENTRY_PROJECT).toBe("cli"); + }); + + test("command-owned --project is preserved and NOT written to env", async () => { + let receivedFlags: Record | null = null; + + // Simulates release create which has its own --project flag + const command = buildCommand<{ project?: string }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: { + flags: { + project: { + kind: "parsed", + parse: String, + brief: "Project slug", + optional: true, + }, + }, + }, + // biome-ignore lint/correctness/useYield: test command + async *func(this: TestContext, flags: { project?: string }) { + receivedFlags = flags as unknown as Record; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--project", "my-project"], ctx as TestContext); + + // Command's own --project is passed through (not stripped) + expect(receivedFlags).toBeDefined(); + expect(receivedFlags!.project).toBe("my-project"); + // Should NOT be written to env (command owns the flag) + expect(process.env.SENTRY_PROJECT).toBeUndefined(); + }); + + test("--org=value equals form works", async () => { + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--org=my-org"], ctx as TestContext); + + expect(process.env.SENTRY_ORG).toBe("my-org"); + }); + + test("--project overwrites existing SENTRY_PROJECT", async () => { + process.env.SENTRY_PROJECT = "original-proj"; + + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + async *func() { + // no-op + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--project", "new-proj"], ctx as TestContext); + + expect(process.env.SENTRY_PROJECT).toBe("new-proj"); + }); + + test("--org/--project values flow into resolveOrgAndProject (end-to-end)", async () => { + // Integration test: verifies the full plumbing from CLI flag → env var + // → resolveFromEnvVars (priority #2 in the resolution chain). This is the + // contract this PR exists to deliver: LLM-generated `--org foo --project + // bar` should make org/project resolution succeed without an explicit + // positional arg. + let resolved: Awaited> = null; + + const command = buildCommand, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + parameters: {}, + // biome-ignore lint/correctness/useYield: test command — no output to yield + async *func(this: TestContext) { + resolved = await resolveOrgAndProject({ cwd: "/tmp" }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run( + app, + ["test", "--org", "flag-org", "--project", "flag-proj"], + ctx as TestContext + ); + + expect(resolved).not.toBeNull(); + expect(resolved?.org).toBe("flag-org"); + expect(resolved?.project).toBe("flag-proj"); + // detectedFrom proves the resolution went through the env-var path, + // not some other branch. + expect(resolved?.detectedFrom).toContain("env var"); + }); +}); diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts new file mode 100644 index 000000000..545ad3861 --- /dev/null +++ b/packages/cli/test/lib/complete.test.ts @@ -0,0 +1,372 @@ +/** + * Completion Engine Tests + * + * Tests for the shell completion engine (src/lib/complete.ts). + * Uses a real SQLite database via useTestConfigDir for realistic + * cache interaction tests. + */ + +import { describe, expect, test } from "vitest"; +import { + completeAliases, + completeOrgSlashProject, + completeOrgSlugs, + completeProjectCreateSpec, + completeProjectSlugs, + getCompletions, +} from "../../src/lib/complete.js"; +import { setProjectAliases } from "../../src/lib/db/project-aliases.js"; +import { setCachedProject } from "../../src/lib/db/project-cache.js"; +import { + type OrgRegionEntry, + setOrgRegions, +} from "../../src/lib/db/regions.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-complete-"); + +// -- Helpers -- + +async function seedOrgs( + orgs: { slug: string; name: string; regionUrl?: string }[] +): Promise { + const entries: OrgRegionEntry[] = orgs.map((o) => ({ + slug: o.slug, + regionUrl: o.regionUrl ?? "https://us.sentry.io", + orgId: o.slug, + orgName: o.name, + })); + setOrgRegions(entries); +} + +async function seedProjects( + projects: { + orgId: string; + projectId: string; + orgSlug: string; + projectSlug: string; + projectName: string; + }[] +): Promise { + for (const p of projects) { + setCachedProject(p.orgId, p.projectId, { + orgSlug: p.orgSlug, + orgName: p.orgSlug, + projectSlug: p.projectSlug, + projectName: p.projectName, + }); + } +} + +// -- Tests -- + +describe("getCompletions: context detection", () => { + test("returns empty for unknown commands", async () => { + const result = getCompletions(["unknown", "cmd"], ""); + expect(result).toEqual([]); + }); + + test("returns org/project completions for issue list", async () => { + await seedOrgs([{ slug: "my-org", name: "My Organization" }]); + const result = getCompletions(["issue", "list"], ""); + expect(result.length).toBeGreaterThan(0); + expect(result[0].value).toBe("my-org/"); + }); + + test("returns org-only completions for org view", async () => { + await seedOrgs([{ slug: "my-org", name: "My Organization" }]); + const result = getCompletions(["org", "view"], ""); + expect(result.length).toBeGreaterThan(0); + expect(result[0].value).toBe("my-org"); // no trailing slash + }); + + test("still provides completions after boolean flags", async () => { + await seedOrgs([{ slug: "my-org", name: "My Organization" }]); + // After --verbose (a boolean flag), completions should still work + const result = getCompletions(["issue", "list", "--verbose"], ""); + expect(result.length).toBeGreaterThan(0); + }); + + test("returns org/project completions for project list", async () => { + await seedOrgs([{ slug: "test-org", name: "Test" }]); + const result = getCompletions(["project", "list"], "test"); + expect(result.some((c) => c.value === "test-org/")).toBe(true); + }); + + test("returns org-only completions for team list", async () => { + await seedOrgs([{ slug: "acme", name: "Acme Inc" }]); + const result = getCompletions(["team", "list"], ""); + expect(result.some((c) => c.value === "acme")).toBe(true); + }); + + test("returns only valid project create prefixes and pairs", async () => { + await seedOrgs([{ slug: "acme", name: "Acme Inc" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "acme", + projectSlug: "existing", + projectName: "Existing Project", + }, + ]); + setProjectAliases( + { app: { orgSlug: "acme", projectSlug: "existing" } }, + "fingerprint" + ); + + expect(getCompletions(["project", "create"], "")).toEqual([ + { value: "acme/", description: "Acme Inc" }, + ]); + expect(getCompletions(["project", "create"], "acme/new")).toEqual([]); + expect(getCompletions(["project", "create"], "web:java")).toContainEqual({ + value: "web:javascript", + description: "Platform", + }); + // Platform must always be attached with ":" — a bare positional never + // gets legacy space-separated platform completions. + expect(getCompletions(["project", "create", "web"], "java")).toEqual([]); + }); + + test("completes a new batch pair's platform regardless of preceding pairs", () => { + // Completion only ever looks at the current partial — a self-contained + // colon pair completes the same way no matter what preceded it. + expect( + getCompletions(["project", "create", "api:node"], "worker:java") + ).toContainEqual({ + value: "worker:javascript", + description: "Platform", + }); + expect(getCompletions(["project", "create", "--team"], "back")).toEqual([]); + }); +}); + +describe("completeProjectCreateSpec", () => { + test("suggests common platforms while preserving the project name", () => { + const result = completeProjectCreateSpec("my-project:"); + + expect(result).toContainEqual({ + value: "my-project:javascript", + description: "Platform", + }); + expect(result.every((completion) => completion.value.includes(":"))).toBe( + true + ); + }); + + test("matches every valid platform after a partial", () => { + const result = completeProjectCreateSpec("acme/app:nintendo"); + + expect(result).toEqual([ + { value: "acme/app:nintendo-switch", description: "Platform" }, + ]); + }); + + test("does not complete existing project names before the colon", () => { + expect(completeProjectCreateSpec("acme/new-project")).toEqual([]); + }); + + test("does not complete project specifications containing whitespace", () => { + expect(completeProjectCreateSpec("My App:java")).toEqual([]); + }); + + test.each([ + ":java", + "acme/:java", + "/app:java", + "acme/app/child:java", + ])("does not complete a malformed project target in %s", (partial) => { + expect(completeProjectCreateSpec(partial)).toEqual([]); + }); +}); + +describe("completeOrgSlugs", () => { + test("returns empty when no orgs cached", async () => { + const result = completeOrgSlugs(""); + expect(result).toEqual([]); + }); + + test("returns all orgs for empty partial", async () => { + await seedOrgs([ + { slug: "alpha", name: "Alpha Org" }, + { slug: "beta", name: "Beta Org" }, + ]); + const result = completeOrgSlugs(""); + expect(result).toHaveLength(2); + expect(result.map((c) => c.value).sort()).toEqual(["alpha", "beta"]); + }); + + test("prefix matches", async () => { + await seedOrgs([ + { slug: "sentry", name: "Sentry" }, + { slug: "other", name: "Other" }, + ]); + const result = completeOrgSlugs("sen"); + expect(result).toHaveLength(1); + expect(result[0].value).toBe("sentry"); + expect(result[0].description).toBe("Sentry"); + }); + + test("fuzzy matches", async () => { + await seedOrgs([ + { slug: "sentry", name: "Sentry" }, + { slug: "other", name: "Other" }, + ]); + // "senry" → "sentry" (distance 1, within threshold) + const result = completeOrgSlugs("senry"); + expect(result.some((c) => c.value === "sentry")).toBe(true); + }); +}); + +describe("completeOrgSlashProject", () => { + test("no slash returns org slugs with trailing slash + aliases", async () => { + await seedOrgs([{ slug: "my-org", name: "My Org" }]); + const result = completeOrgSlashProject(""); + expect(result.some((c) => c.value === "my-org/")).toBe(true); + }); + + test("with slash returns projects for the org", async () => { + await seedOrgs([{ slug: "my-org", name: "My Org" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "my-org", + projectSlug: "frontend", + projectName: "Frontend App", + }, + { + orgId: "1", + projectId: "20", + orgSlug: "my-org", + projectSlug: "backend", + projectName: "Backend API", + }, + ]); + + const result = completeOrgSlashProject("my-org/"); + expect(result.map((c) => c.value).sort()).toEqual([ + "my-org/backend", + "my-org/frontend", + ]); + }); + + test("with fuzzy org slug before slash resolves to correct org", async () => { + await seedOrgs([{ slug: "sentry", name: "Sentry" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "sentry", + projectSlug: "cli", + projectName: "CLI", + }, + ]); + + // "senry/" has a typo in the org — should fuzzy-resolve to "sentry" + const result = completeOrgSlashProject("senry/"); + expect(result).toHaveLength(1); + expect(result[0].value).toBe("sentry/cli"); + }); + + test("with unresolvable org slug before slash returns empty", async () => { + await seedOrgs([{ slug: "sentry", name: "Sentry" }]); + const result = completeOrgSlashProject("zzzzzzz/"); + expect(result).toEqual([]); + }); + + test("with slash and partial project filters", async () => { + await seedOrgs([{ slug: "my-org", name: "My Org" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "my-org", + projectSlug: "frontend", + projectName: "Frontend", + }, + { + orgId: "1", + projectId: "20", + orgSlug: "my-org", + projectSlug: "backend", + projectName: "Backend", + }, + ]); + + const result = completeOrgSlashProject("my-org/fro"); + expect(result).toHaveLength(1); + expect(result[0].value).toBe("my-org/frontend"); + }); +}); + +describe("completeProjectSlugs", () => { + test("returns empty when no projects cached for org", async () => { + const result = completeProjectSlugs("", "nonexistent"); + expect(result).toEqual([]); + }); + + test("returns matching projects", async () => { + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "my-org", + projectSlug: "web", + projectName: "Web App", + }, + { + orgId: "1", + projectId: "20", + orgSlug: "my-org", + projectSlug: "api", + projectName: "API Service", + }, + ]); + + const result = completeProjectSlugs("we", "my-org"); + expect(result).toHaveLength(1); + expect(result[0].value).toBe("my-org/web"); + expect(result[0].description).toBe("Web App"); + }); +}); + +describe("completeAliases", () => { + test("returns empty when no aliases exist", async () => { + const result = completeAliases(""); + expect(result).toEqual([]); + }); + + test("returns matching aliases", async () => { + setProjectAliases( + { + a: { orgSlug: "my-org", projectSlug: "frontend" }, + b: { orgSlug: "my-org", projectSlug: "backend" }, + }, + "fingerprint" + ); + + const result = completeAliases(""); + expect(result).toHaveLength(2); + + const aCompletion = result.find((c) => c.value === "a"); + expect(aCompletion).toBeDefined(); + expect(aCompletion!.description).toBe("my-org/frontend"); + }); + + test("filters aliases by prefix (exact match ranks first)", async () => { + setProjectAliases( + { + a: { orgSlug: "org", projectSlug: "proj-a" }, + b: { orgSlug: "org", projectSlug: "proj-b" }, + abc: { orgSlug: "org", projectSlug: "proj-abc" }, + }, + "fingerprint" + ); + + const result = completeAliases("a"); + // "a" is exact match, "abc" is prefix match, "b" is fuzzy match + expect(result[0].value).toBe("a"); // exact match first + expect(result.some((c) => c.value === "abc")).toBe(true); // prefix match + }); +}); diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts new file mode 100644 index 000000000..098b7aa4e --- /dev/null +++ b/packages/cli/test/lib/completions.property.test.ts @@ -0,0 +1,482 @@ +/** + * Property-Based Tests for Shell Completions + * + * Verifies invariants of the completion generation system: + * - Cross-shell consistency (every command appears in all three scripts) + * - Binary name parametrization + * - Structural invariants of the command tree + * + * Also includes integration tests using Stricli's proposeCompletions API + * and a real bash simulation of the generated completion script. + */ + +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { proposeCompletions } from "@stricli/core"; +import { constantFrom, assert as fcAssert, property } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { app, routes } from "../../src/app.js"; +import { + ORG_ONLY_COMMANDS, + ORG_PROJECT_COMMANDS, +} from "../../src/lib/complete.js"; +import { + extractCommandTree, + generateBashCompletion, + generateFishCompletion, + generateZshCompletion, +} from "../../src/lib/completions.js"; +import { + isCommand, + isRouteMap, + type RouteMap, +} from "../../src/lib/introspect.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// -- Arbitraries -- + +/** Generate valid binary names (lowercase alphanumeric + hyphens) */ +const binaryNameArb = constantFrom( + "sentry", + "my-cli", + "test-tool", + "acme", + "dev-helper", + "s" +); + +// -- Helpers -- + +/** Minimal context for proposeCompletions */ +const completionContext = { + process: { + stdout: { + write: () => true, + }, + stderr: { + write: () => true, + }, + stdin: process.stdin, + }, + forCommand: () => ({}), +}; + +// -- Tests -- + +describe("property: extractCommandTree invariants", () => { + const tree = extractCommandTree(); + + test("every group has at least one subcommand", () => { + for (const group of tree.groups) { + expect(group.subcommands.length).toBeGreaterThan(0); + } + }); + + test("no duplicate group names", () => { + const names = tree.groups.map((g) => g.name); + expect(new Set(names).size).toBe(names.length); + }); + + test("no duplicate subcommand names within a group", () => { + for (const group of tree.groups) { + const names = group.subcommands.map((s) => s.name); + expect(new Set(names).size).toBe(names.length); + } + }); + + test("all names are non-empty strings", () => { + for (const group of tree.groups) { + expect(group.name.length).toBeGreaterThan(0); + expect(group.brief.length).toBeGreaterThan(0); + for (const sub of group.subcommands) { + expect(sub.name.length).toBeGreaterThan(0); + expect(sub.brief.length).toBeGreaterThan(0); + } + } + for (const cmd of tree.standalone) { + expect(cmd.name.length).toBeGreaterThan(0); + expect(cmd.brief.length).toBeGreaterThan(0); + } + }); +}); + +describe("property: cross-shell consistency", () => { + const tree = extractCommandTree(); + + test("every group name appears in all three shell scripts", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + for (const group of tree.groups) { + expect(bash).toContain(group.name); + expect(zsh).toContain(group.name); + expect(fish).toContain(group.name); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("every subcommand name appears in all three shell scripts", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + for (const group of tree.groups) { + for (const sub of group.subcommands) { + expect(bash).toContain(sub.name); + expect(zsh).toContain(sub.name); + expect(fish).toContain(sub.name); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("every standalone command appears in all three shell scripts", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + for (const cmd of tree.standalone) { + expect(bash).toContain(cmd.name); + expect(zsh).toContain(cmd.name); + expect(fish).toContain(cmd.name); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: binary name parametrization", () => { + test("generated scripts reference the given binary name", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + // Each script should reference the binary name + expect(bash).toContain(`_${name}_completions`); + expect(zsh).toContain(`_${name}()`); + expect(fish).toContain(`complete -c ${name}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("proposeCompletions: Stricli integration", () => { + const tree = extractCommandTree(); + + // Route groups with defaultCommand set don't propose subcommand names + // through proposeCompletions — Stricli treats the empty string as a + // potential positional arg for the default command and proposes flags + // instead. These groups are tested separately below. + const groupsWithDefaultCommand = new Set([ + "auth", + "agent-conversation", + "issue", + "event", + "org", + "project", + "replay", + "dashboard", + "docs", + "trace", + "span", + "status", + "log", + "local", + "monitor", + "feedback", + ]); + + test("subcommands match extractCommandTree for each group without defaultCommand", async () => { + for (const group of tree.groups) { + if (groupsWithDefaultCommand.has(group.name)) { + continue; + } + const completions = await proposeCompletions( + app, + [group.name, ""], + completionContext + ); + const actual = completions.map((c) => c.completion).sort(); + const expected = group.subcommands.map((s) => s.name).sort(); + expect(actual).toEqual(expected); + } + }); + + test("groups with defaultCommand propose flags for the default view command", async () => { + for (const group of tree.groups) { + if (!groupsWithDefaultCommand.has(group.name)) { + continue; + } + const completions = await proposeCompletions( + app, + [group.name, ""], + completionContext + ); + const actual = completions.map((c) => c.completion); + // With defaultCommand: "view", Stricli proposes flags for the view + // command rather than subcommand names. Verify it returns flags. + for (const completion of actual) { + expect(completion.startsWith("--")).toBe(true); + } + } + }); + + test("partial prefix filters subcommands correctly", async () => { + for (const group of tree.groups) { + if ( + group.subcommands.length === 0 || + groupsWithDefaultCommand.has(group.name) + ) { + continue; + } + // Pick the first subcommand's first character as prefix + const prefix = group.subcommands[0].name[0]; + const completions = await proposeCompletions( + app, + [group.name, prefix], + completionContext + ); + + // Every returned completion should start with the prefix + for (const c of completions) { + expect(c.completion.startsWith(prefix)).toBe(true); + } + + // Every expected subcommand starting with the prefix should be returned + const expected = group.subcommands + .filter((s) => s.name.startsWith(prefix)) + .map((s) => s.name) + .sort(); + const actual = completions.map((c) => c.completion).sort(); + expect(actual).toEqual(expected); + } + }); +}); + +describe("property: flag extraction", () => { + const tree = extractCommandTree(); + + test("flags are non-empty arrays of objects with name and brief", () => { + for (const group of tree.groups) { + for (const sub of group.subcommands) { + for (const flag of sub.flags) { + expect(flag.name.length).toBeGreaterThan(0); + expect(typeof flag.brief).toBe("string"); + } + } + } + }); + + test("no duplicate flag names within a command", () => { + for (const group of tree.groups) { + for (const sub of group.subcommands) { + const names = sub.flags.map((f) => f.name); + expect(new Set(names).size).toBe(names.length); + } + } + }); + + test("hidden flags are excluded", () => { + for (const group of tree.groups) { + for (const sub of group.subcommands) { + // log-level and verbose are hidden — should not appear + const names = sub.flags.map((f) => f.name); + expect(names).not.toContain("log-level"); + expect(names).not.toContain("verbose"); + } + } + }); +}); + +describe("property: dynamic completion callback", () => { + test("all three shell scripts contain __complete callback", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + expect(bash).toContain("__complete"); + expect(zsh).toContain("__complete"); + expect(fish).toContain("__complete"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("bash completion: real shell simulation", () => { + test("top-level completion returns all known commands", async () => { + const tree = extractCommandTree(); + const script = generateBashCompletion("sentry"); + + const tmpScript = join("/tmp", `completion-test-${Date.now()}.bash`); + writeFileSync(tmpScript, script); + + const result = spawnSync( + "bash", + [ + "-c", + ` +# Stub _init_completion (not available outside bash-completion package) +_init_completion() { + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + return 0 +} +source "${tmpScript}" + +COMP_WORDS=(sentry "") +COMP_CWORD=1 +_sentry_completions +echo "\${COMPREPLY[*]}" +`, + ], + { stdio: ["pipe", "pipe", "pipe"] } + ); + const output = result.stdout.toString().trim(); + const completions = output.split(/\s+/); + + // Verify all groups and standalone commands appear + for (const group of tree.groups) { + expect(completions).toContain(group.name); + } + for (const cmd of tree.standalone) { + expect(completions).toContain(cmd.name); + } + }); + + test("subcommand completion returns correct subcommands", async () => { + const tree = extractCommandTree(); + const script = generateBashCompletion("sentry"); + + const tmpScript = join("/tmp", `completion-test-${Date.now()}.bash`); + writeFileSync(tmpScript, script); + + // Test a few representative groups + for (const group of tree.groups) { + const result = spawnSync( + "bash", + [ + "-c", + ` +_init_completion() { + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + return 0 +} +source "${tmpScript}" + +COMP_WORDS=(sentry "${group.name}" "") +COMP_CWORD=2 +_sentry_completions +echo "\${COMPREPLY[*]}" +`, + ], + { stdio: ["pipe", "pipe", "pipe"] } + ); + const output = result.stdout.toString().trim(); + const completions = output.split(/\s+/); + + const expected = group.subcommands.map((s) => s.name).sort(); + expect(completions.sort()).toEqual(expected); + } + }); +}); + +describe("complete.ts: command set drift detection", () => { + /** + * Walk the Stricli route tree and collect all "group subcommand" paths + * that have any positional parameter with an org-related placeholder. + */ + function collectOrgCommands(routeMap: RouteMap): Set { + const orgCommands = new Set(); + + for (const entry of routeMap.getAllEntries()) { + if (entry.hidden) continue; + const name = entry.name.original; + + if (isRouteMap(entry.target)) { + for (const sub of entry.target.getAllEntries()) { + if (sub.hidden || !isCommand(sub.target)) continue; + + const pos = sub.target.parameters.positional; + if (!pos) continue; + + const placeholder = + pos.kind === "tuple" + ? pos.parameters.map((p) => p.placeholder ?? "").join(" ") + : (pos.parameter?.placeholder ?? ""); + + // Any placeholder mentioning "org" suggests the command accepts + // an org target — it should be in one of the completion sets. + if (/\borg\b/i.test(placeholder) || placeholder === "issue") { + orgCommands.add(`${name} ${sub.name.original}`); + } + } + } + } + + return orgCommands; + } + + // Use the ESM import (routes) instead of require() which fails under + // vitest because Node's CJS require can't resolve .js→.ts for transitive imports. + const appRoutes = routes as unknown as RouteMap; + + test("every command in ORG_PROJECT_COMMANDS exists in the route tree", () => { + const tree = extractCommandTree(); + const allPaths = new Set(); + for (const g of tree.groups) { + for (const s of g.subcommands) { + allPaths.add(`${g.name} ${s.name}`); + } + } + for (const cmd of ORG_PROJECT_COMMANDS) { + expect(allPaths.has(cmd)).toBe(true); + } + }); + + test("every command in ORG_ONLY_COMMANDS exists in the route tree", () => { + const tree = extractCommandTree(); + const allPaths = new Set(); + for (const g of tree.groups) { + for (const s of g.subcommands) { + allPaths.add(`${g.name} ${s.name}`); + } + } + for (const cmd of ORG_ONLY_COMMANDS) { + expect(allPaths.has(cmd)).toBe(true); + } + }); + + test("org-positional commands are in at least one set", () => { + const orgCommands = collectOrgCommands(appRoutes); + const combined = new Set([...ORG_PROJECT_COMMANDS, ...ORG_ONLY_COMMANDS]); + for (const cmd of orgCommands) { + expect(combined.has(cmd)).toBe(true); + } + }); + + test("no commands are in both sets", () => { + for (const cmd of ORG_PROJECT_COMMANDS) { + expect(ORG_ONLY_COMMANDS.has(cmd)).toBe(false); + } + }); +}); diff --git a/packages/cli/test/lib/completions.test.ts b/packages/cli/test/lib/completions.test.ts new file mode 100644 index 000000000..4aaeabef2 --- /dev/null +++ b/packages/cli/test/lib/completions.test.ts @@ -0,0 +1,217 @@ +/** + * Completion Utilities Tests + * + * Unit tests for completion dispatch logic, path resolution, and file + * installation. Command tree invariants, cross-shell consistency, and + * bash simulation are in completions.property.test.ts. + */ + +import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + extractCommandTree, + getCompletionPath, + getCompletionScript, + installCompletions, +} from "../../src/lib/completions.js"; + +describe("completions", () => { + describe("getCompletionScript", () => { + test("returns bash script for bash", () => { + const script = getCompletionScript("bash"); + expect(script).toContain("_sentry_completions"); + }); + + test("returns zsh script for zsh", () => { + const script = getCompletionScript("zsh"); + expect(script).toContain("#compdef sentry"); + }); + + test("returns fish script for fish", () => { + const script = getCompletionScript("fish"); + expect(script).toContain("complete -c sentry"); + }); + + test("returns null for unsupported shells", () => { + expect(getCompletionScript("sh")).toBeNull(); + expect(getCompletionScript("ash")).toBeNull(); + expect(getCompletionScript("unknown")).toBeNull(); + }); + + test("bash script includes __complete callback", () => { + const script = getCompletionScript("bash"); + expect(script).toContain("__complete"); + }); + + test("zsh script includes __complete callback", () => { + const script = getCompletionScript("zsh"); + expect(script).toContain("__complete"); + }); + + test("zsh script escapes colons in dynamic completion values", () => { + const script = getCompletionScript("zsh"); + expect(script).toContain(`escaped_value="\${value//:/\\\\:}"`); + }); + + test("fish script includes __complete callback", () => { + const script = getCompletionScript("fish"); + expect(script).toContain("__complete"); + }); + + test("bash script includes flag completion variables", () => { + const script = getCompletionScript("bash"); + // Should contain flag variables for at least some commands + expect(script).toContain("_flags="); + }); + }); + + describe("extractCommandTree", () => { + test("includes flags for subcommands", () => { + const tree = extractCommandTree(); + // At least one group should have subcommands with flags + const hasFlags = tree.groups.some((g) => + g.subcommands.some((s) => s.flags.length > 0) + ); + expect(hasFlags).toBe(true); + }); + + test("standalone commands include flags", () => { + const tree = extractCommandTree(); + const hasFlags = tree.standalone.some((s) => s.flags.length > 0); + expect(hasFlags).toBe(true); + }); + }); + + describe("getCompletionPath", () => { + const homeDir = "/home/user"; + + test("returns bash completion path", () => { + const path = getCompletionPath("bash", homeDir); + expect(path).toBe( + "/home/user/.local/share/bash-completion/completions/sentry" + ); + }); + + test("returns zsh completion path", () => { + const path = getCompletionPath("zsh", homeDir); + expect(path).toBe("/home/user/.local/share/zsh/site-functions/_sentry"); + }); + + test("returns fish completion path", () => { + const path = getCompletionPath("fish", homeDir); + expect(path).toBe("/home/user/.config/fish/completions/sentry.fish"); + }); + + test("uses custom XDG_DATA_HOME", () => { + const path = getCompletionPath("bash", homeDir, "/custom/data"); + expect(path).toBe("/custom/data/bash-completion/completions/sentry"); + }); + + test("returns null for unsupported shells", () => { + expect(getCompletionPath("sh", homeDir)).toBeNull(); + expect(getCompletionPath("unknown", homeDir)).toBeNull(); + }); + }); + + describe("installCompletions", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `completions-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("installs bash completions", async () => { + const result = await installCompletions("bash", testDir); + + expect(result).not.toBeNull(); + expect(result!.created).toBe(true); + expect(result!.path).toContain("bash-completion"); + expect(existsSync(result!.path)).toBe(true); + + const content = await readFile(result!.path, "utf-8"); + expect(content).toContain("_sentry_completions"); + }); + + test("installs zsh completions", async () => { + const result = await installCompletions("zsh", testDir); + + expect(result).not.toBeNull(); + expect(result!.path).toContain("_sentry"); + expect(existsSync(result!.path)).toBe(true); + }); + + test("installs fish completions", async () => { + const fishDir = join(testDir, ".config", "fish", "completions"); + mkdirSync(fishDir, { recursive: true }); + + const result = await installCompletions("fish", testDir); + + expect(result).not.toBeNull(); + expect(result!.path).toContain("sentry.fish"); + }); + + test("returns null for unsupported shells", async () => { + const result = await installCompletions("sh", testDir); + expect(result).toBeNull(); + }); + + test("reports update when file already exists", async () => { + const first = await installCompletions("bash", testDir); + expect(first!.created).toBe(true); + + const second = await installCompletions("bash", testDir); + expect(second!.created).toBe(false); + expect(second!.path).toBe(first!.path); + }); + + test("returns null when directory creation fails with EACCES", async () => { + // Create a read-only parent directory so mkdir inside it fails + const restrictedDir = join(testDir, "restricted"); + mkdirSync(restrictedDir, { recursive: true, mode: 0o755 }); + // Make it non-writable so child directory creation fails + chmodSync(restrictedDir, 0o444); + + try { + // XDG_DATA_HOME points into the restricted directory + const result = await installCompletions( + "bash", + "/nonexistent", + join(restrictedDir, "subdir") + ); + expect(result).toBeNull(); + } finally { + // Restore write permission for cleanup + chmodSync(restrictedDir, 0o755); + } + }); + + test("returns null when parent directory is read-only (EPERM scenario)", async () => { + // Simulate the macOS EPERM scenario: a parent directory exists but + // we can't create subdirectories inside it + const lockedParent = join(testDir, "locked"); + mkdirSync(lockedParent, { recursive: true }); + chmodSync(lockedParent, 0o555); + + try { + const result = await installCompletions( + "zsh", + "/nonexistent", + join(lockedParent, "deep", "nested") + ); + expect(result).toBeNull(); + } finally { + chmodSync(lockedParent, 0o755); + } + }); + }); +}); diff --git a/packages/cli/test/lib/config.test.ts b/packages/cli/test/lib/config.test.ts new file mode 100644 index 000000000..31bd445f4 --- /dev/null +++ b/packages/cli/test/lib/config.test.ts @@ -0,0 +1,670 @@ +/** + * Configuration Management Tests + * + * Integration tests for SQLite-based config storage. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { access, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + clearAuth, + getAuthConfig, + getAuthToken, + isAuthenticated, + refreshToken, + setAuthToken, +} from "../../src/lib/db/auth.js"; +import { + getDefaultOrganization, + getDefaultProject, + setDefaultOrganization, + setDefaultProject, +} from "../../src/lib/db/defaults.js"; +import { + CONFIG_DIR_ENV_VAR, + closeDatabase, + getDbPath, + resolveConfigDir, +} from "../../src/lib/db/index.js"; +import { + clearProjectAliases, + getProjectAliases, + getProjectByAlias, + setProjectAliases, +} from "../../src/lib/db/project-aliases.js"; +import { + clearProjectCache, + getCachedProject, + getCachedProjectByDsnKey, + setCachedProject, + setCachedProjectByDsnKey, +} from "../../src/lib/db/project-cache.js"; +import { useTestConfigDir } from "../helpers.js"; + +/** + * Test isolation: Each test gets its own config directory via useTestConfigDir(). + * The helper creates a unique temp directory in beforeEach and restores + * the env var in afterEach (never deleting it). + */ +const getConfigDir = useTestConfigDir("test-config-"); + +let savedAuthToken: string | undefined; +beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; +}); +afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } +}); + +describe("auth token management", () => { + test("setAuthToken stores token", async () => { + await setAuthToken("test-token-123"); + + const token = await getAuthToken(); + expect(token).toBe("test-token-123"); + }); + + test("setAuthToken with expiration sets expiresAt", async () => { + const before = Date.now(); + await setAuthToken("expiring-token", 3600); // 1 hour + const after = Date.now(); + + const auth = await getAuthConfig(); + expect(auth?.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000); + expect(auth?.expiresAt).toBeLessThanOrEqual(after + 3600 * 1000); + }); + + test("setAuthToken stores refresh token", async () => { + await setAuthToken("access-token", 3600, "refresh-token"); + + const auth = await getAuthConfig(); + expect(auth?.refreshToken).toBe("refresh-token"); + }); + + test("getAuthToken returns undefined for expired token", async () => { + // Set a token that expires in the future, then we'll manipulate it + await setAuthToken("expired-token", -1); // Negative expires immediately + + const token = await getAuthToken(); + expect(token).toBeUndefined(); + }); + + test("getAuthToken returns token if not expired", async () => { + await setAuthToken("valid-token", 3600); // 1 hour + + const token = await getAuthToken(); + expect(token).toBe("valid-token"); + }); + + test("clearAuth removes auth data", async () => { + await setAuthToken("token-to-clear"); + expect(await getAuthToken()).toBe("token-to-clear"); + + await clearAuth(); + expect(await getAuthToken()).toBeUndefined(); + }); + + test("isAuthenticated returns true with valid token", async () => { + await setAuthToken("valid-token"); + expect(isAuthenticated()).toBe(true); + }); + + test("isAuthenticated returns false without token", async () => { + expect(isAuthenticated()).toBe(false); + }); + + test("isAuthenticated returns false with expired token", async () => { + await setAuthToken("expired", -1); // Negative expires immediately + expect(isAuthenticated()).toBe(false); + }); +}); + +describe("defaults management", () => { + test("setDefaultOrganization stores organization", async () => { + setDefaultOrganization("my-org"); + + const org = getDefaultOrganization(); + expect(org).toBe("my-org"); + }); + + test("setDefaultProject stores project", async () => { + setDefaultProject("my-project"); + + const project = getDefaultProject(); + expect(project).toBe("my-project"); + }); + + test("individual setters store both org and project", async () => { + setDefaultOrganization("my-org"); + setDefaultProject("my-project"); + + expect(getDefaultOrganization()).toBe("my-org"); + expect(getDefaultProject()).toBe("my-project"); + }); + + test("individual setters preserve other defaults", async () => { + setDefaultOrganization("org1"); + setDefaultProject("project1"); + setDefaultOrganization("org2"); // Only update org + + expect(getDefaultOrganization()).toBe("org2"); + expect(getDefaultProject()).toBe("project1"); + }); + + test("getDefaultOrganization returns null when not set", async () => { + const org = getDefaultOrganization(); + expect(org).toBeNull(); + }); + + test("getDefaultProject returns null when not set", async () => { + const project = getDefaultProject(); + expect(project).toBeNull(); + }); +}); + +describe("refreshToken error handling", () => { + test("network error during refresh does not clear auth", async () => { + // Set up a token that needs refresh (expired but has refresh token) + await setAuthToken("still-valid-token", -1, "my-refresh-token"); // Expired + + // Set required env var for OAuth + process.env.SENTRY_CLIENT_ID = "test-client-id"; + + // Mock fetch to simulate network error + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("fetch failed: network error"); + }; + + try { + await refreshToken(); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + // Expected to throw ApiError for network failure + expect(error).toBeDefined(); + } finally { + globalThis.fetch = originalFetch; + } + + // Auth should NOT be cleared on network error + const auth = await getAuthConfig(); + expect(auth?.token).toBe("still-valid-token"); + expect(auth?.refreshToken).toBe("my-refresh-token"); + }); + + test("auth error during refresh clears auth", async () => { + // Set up a token that needs refresh + await setAuthToken("revoked-token", -1, "invalid-refresh-token"); // Expired + + process.env.SENTRY_CLIENT_ID = "test-client-id"; + + // Mock fetch to simulate server rejecting refresh token + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Token revoked", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + + try { + await refreshToken(); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeDefined(); + } finally { + globalThis.fetch = originalFetch; + } + + // Auth SHOULD be cleared when server rejects refresh token + const auth = await getAuthConfig(); + expect(auth).toBeUndefined(); + }); +}); + +describe("project aliases", () => { + test("setProjectAliases stores aliases", async () => { + setProjectAliases({ + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + w: { orgSlug: "sentry", projectSlug: "spotlight-website" }, + }); + + const aliases = getProjectAliases(); + expect(aliases).toEqual({ + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + w: { orgSlug: "sentry", projectSlug: "spotlight-website" }, + }); + }); + + test("getProjectAliases returns stored aliases", async () => { + setProjectAliases({ + f: { orgSlug: "my-org", projectSlug: "frontend" }, + b: { orgSlug: "my-org", projectSlug: "backend" }, + }); + + const aliases = getProjectAliases(); + expect(aliases).toEqual({ + f: { orgSlug: "my-org", projectSlug: "frontend" }, + b: { orgSlug: "my-org", projectSlug: "backend" }, + }); + }); + + test("getProjectAliases returns undefined when not set", async () => { + const aliases = getProjectAliases(); + expect(aliases).toBeUndefined(); + }); + + test("getProjectByAlias returns correct project", async () => { + setProjectAliases({ + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + w: { orgSlug: "sentry", projectSlug: "spotlight-website" }, + s: { orgSlug: "sentry", projectSlug: "spotlight" }, + }); + + const project = getProjectByAlias("e"); + expect(project).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + }); + + test("getProjectByAlias is case-insensitive", async () => { + setProjectAliases({ + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }); + + expect(getProjectByAlias("E")).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + expect(getProjectByAlias("e")).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + }); + + test("getProjectByAlias returns undefined for unknown alias", async () => { + setProjectAliases({ + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }); + + const project = getProjectByAlias("x"); + expect(project).toBeUndefined(); + }); + + test("getProjectByAlias returns undefined when no aliases set", async () => { + const project = getProjectByAlias("e"); + expect(project).toBeUndefined(); + }); + + test("clearProjectAliases removes all aliases", async () => { + setProjectAliases({ + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }); + + clearProjectAliases(); + + const aliases = getProjectAliases(); + expect(aliases).toBeUndefined(); + }); + + test("setProjectAliases overwrites existing aliases", async () => { + setProjectAliases({ + old: { orgSlug: "org1", projectSlug: "project1" }, + }); + + setProjectAliases({ + new: { orgSlug: "org2", projectSlug: "project2" }, + }); + + const aliases = getProjectAliases(); + expect(aliases).toEqual({ + new: { orgSlug: "org2", projectSlug: "project2" }, + }); + expect(aliases?.old).toBeUndefined(); + }); +}); + +describe("DSN-fingerprinted project aliases", () => { + test("getProjectByAlias returns alias when fingerprint matches", async () => { + setProjectAliases( + { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }, + "123:456,123:789" + ); + + // Same fingerprint + const project = getProjectByAlias("e", "123:456,123:789"); + expect(project).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + }); + + test("getProjectByAlias returns undefined when fingerprint mismatches", async () => { + setProjectAliases( + { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }, + "123:456,123:789" + ); + + // Different fingerprint (different DSN context) + const project = getProjectByAlias("e", "999:111"); + expect(project).toBeUndefined(); + }); + + test("getProjectByAlias returns alias when no fingerprint stored (legacy cache)", async () => { + // No fingerprint stored + setProjectAliases({ + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }); + + // Should work without fingerprint validation (legacy cache) + const project = getProjectByAlias("e", "123:456"); + expect(project).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + }); + + test("getProjectByAlias returns alias when no current fingerprint provided", async () => { + setProjectAliases( + { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }, + "123:456,123:789" + ); + + // No current fingerprint provided - skip validation + const project = getProjectByAlias("e"); + expect(project).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + }); + + test("fingerprint does not affect case-insensitive lookup", async () => { + setProjectAliases( + { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }, + "123:456" + ); + + // Uppercase alias with matching fingerprint + const project = getProjectByAlias("E", "123:456"); + expect(project).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + }); + + test("getProjectByAlias rejects when current fingerprint is empty but cached is not", async () => { + // Cache was created with SaaS DSNs + setProjectAliases( + { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }, + "123:456" + ); + + // Current context has no SaaS DSNs (empty fingerprint) + // This should reject - different workspace/context + const project = getProjectByAlias("e", ""); + expect(project).toBeUndefined(); + }); + + test("getProjectByAlias rejects when cached fingerprint is empty but current is not", async () => { + // Cache was created with only self-hosted DSNs (empty fingerprint) + setProjectAliases( + { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }, + "" + ); + + // Current context has SaaS DSNs + // This should reject - different workspace/context + const project = getProjectByAlias("e", "123:456"); + expect(project).toBeUndefined(); + }); + + test("getProjectByAlias accepts when both fingerprints are empty", async () => { + // Cache was created with only self-hosted DSNs + setProjectAliases( + { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + }, + "" + ); + + // Current context also has only self-hosted DSNs + const project = getProjectByAlias("e", ""); + expect(project).toEqual({ + orgSlug: "sentry", + projectSlug: "spotlight-electron", + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DSN Key-based Project Cache +// ───────────────────────────────────────────────────────────────────────────── + +describe("getCachedProjectByDsnKey / setCachedProjectByDsnKey", () => { + test("caches and retrieves project by DSN public key", async () => { + setCachedProjectByDsnKey("abc123publickey", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const cached = getCachedProjectByDsnKey("abc123publickey"); + expect(cached).toBeDefined(); + expect(cached?.orgSlug).toBe("my-org"); + expect(cached?.projectSlug).toBe("my-project"); + expect(cached?.cachedAt).toBeDefined(); + }); + + test("returns undefined for unknown DSN key", async () => { + const cached = getCachedProjectByDsnKey("nonexistent-key"); + expect(cached).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Project Cache (by orgId:projectId) +// ───────────────────────────────────────────────────────────────────────────── + +describe("getCachedProject / setCachedProject / clearProjectCache", () => { + test("caches and retrieves project by orgId and projectId", async () => { + setCachedProject("123", "456", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const cached = getCachedProject("123", "456"); + expect(cached).toBeDefined(); + expect(cached?.orgSlug).toBe("my-org"); + expect(cached?.projectSlug).toBe("my-project"); + expect(cached?.cachedAt).toBeDefined(); + }); + + test("returns undefined for unknown orgId:projectId", async () => { + const cached = getCachedProject("999", "999"); + expect(cached).toBeUndefined(); + }); + + test("clearProjectCache removes all cached projects", async () => { + setCachedProject("123", "456", { + orgSlug: "org1", + orgName: "Org 1", + projectSlug: "project1", + projectName: "Project 1", + }); + setCachedProjectByDsnKey("key1", { + orgSlug: "org2", + orgName: "Org 2", + projectSlug: "project2", + projectName: "Project 2", + }); + + clearProjectCache(); + + expect(getCachedProject("123", "456")).toBeUndefined(); + expect(getCachedProjectByDsnKey("key1")).toBeUndefined(); + }); + + test("multiple projects can be cached independently", async () => { + setCachedProject("123", "456", { + orgSlug: "org1", + orgName: "Org 1", + projectSlug: "project1", + projectName: "Project 1", + }); + setCachedProject("123", "789", { + orgSlug: "org1", + orgName: "Org 1", + projectSlug: "project2", + projectName: "Project 2", + }); + + const cached1 = getCachedProject("123", "456"); + const cached2 = getCachedProject("123", "789"); + + expect(cached1?.projectSlug).toBe("project1"); + expect(cached2?.projectSlug).toBe("project2"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Config Path +// ───────────────────────────────────────────────────────────────────────────── + +describe("getDbPath", () => { + test("returns the database file path", () => { + const path = getDbPath(); + expect(path).toContain("cli.db"); + expect(path).toContain(getConfigDir()); + }); +}); + +describe("resolveConfigDir", () => { + let home: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "resolve-config-home-")); + }); + + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + test("prefers the SENTRY_CONFIG_DIR override over everything", () => { + const override = join(home, "custom-config"); + mkdirSync(join(home, ".sentry")); + expect( + resolveConfigDir( + { + [CONFIG_DIR_ENV_VAR]: override, + XDG_CONFIG_HOME: join(home, "xdg"), + }, + home + ) + ).toBe(override); + }); + + test("uses the legacy ~/.sentry directory when it already exists", () => { + const legacy = join(home, ".sentry"); + mkdirSync(legacy); + writeFileSync(join(legacy, "cli.db"), ""); // simulate a prior config install + expect(resolveConfigDir({}, home)).toBe(legacy); + }); + + test("ignores a bare ~/.sentry/bin (installer artifact) and falls back to XDG", () => { + const legacy = join(home, ".sentry"); + mkdirSync(join(legacy, "bin"), { recursive: true }); + expect(resolveConfigDir({}, home)).toBe(join(home, ".config", "sentry")); + }); + + test("uses XDG_CONFIG_HOME/sentry when set to an absolute path", () => { + const xdg = join(home, "xdg-config"); + expect(resolveConfigDir({ XDG_CONFIG_HOME: xdg }, home)).toBe( + join(xdg, "sentry") + ); + }); + + test("falls back to ~/.config/sentry when XDG_CONFIG_HOME is unset", () => { + expect(resolveConfigDir({}, home)).toBe(join(home, ".config", "sentry")); + }); + + test("ignores a non-absolute XDG_CONFIG_HOME per the XDG spec", () => { + expect(resolveConfigDir({ XDG_CONFIG_HOME: "relative/path" }, home)).toBe( + join(home, ".config", "sentry") + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// JSON Migration +// ───────────────────────────────────────────────────────────────────────────── + +describe("JSON to SQLite migration", () => { + test("migrates existing config.json on first access", async () => { + // Close any existing database + closeDatabase(); + + // Create a config.json file in the test directory + const testConfigDir = process.env[CONFIG_DIR_ENV_VAR]!; + const configPath = join(testConfigDir, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + auth: { + token: "migrated-token", + refreshToken: "migrated-refresh", + expiresAt: Date.now() + 3_600_000, + issuedAt: Date.now(), + }, + defaults: { + organization: "migrated-org", + project: "migrated-project", + }, + }) + ); + + // Access the database (triggers migration) + const token = await getAuthToken(); + expect(token).toBe("migrated-token"); + + const auth = await getAuthConfig(); + expect(auth?.refreshToken).toBe("migrated-refresh"); + + const org = getDefaultOrganization(); + expect(org).toBe("migrated-org"); + + const project = getDefaultProject(); + expect(project).toBe("migrated-project"); + + // config.json should be deleted after migration + const configExists = await access(configPath).then( + () => true, + () => false + ); + expect(configExists).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/constants.property.test.ts b/packages/cli/test/lib/constants.property.test.ts new file mode 100644 index 000000000..6bfebd9e4 --- /dev/null +++ b/packages/cli/test/lib/constants.property.test.ts @@ -0,0 +1,109 @@ +/** + * Property-based tests for normalizeUrl. + * + * Core invariant: the return value of normalizeUrl is always either + * undefined (empty input) or a string starting with http:// or https://. + * This prevents the "Invalid URL" TypeError when constructing API requests. + */ + +import { + array, + constantFrom, + assert as fcAssert, + oneof, + property, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { normalizeUrl } from "../../src/lib/constants.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** + * Arbitrary for realistic hostname-like strings without protocol. + * Labels are 2+ alphanumeric chars (no leading/trailing dashes), separated by dots. + */ +const hostnameArb = array( + array(constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), { + minLength: 2, + maxLength: 12, + }).map((chars) => chars.join("")), + { minLength: 2, maxLength: 4 } +).map((labels) => labels.join(".")); + +/** Arbitrary for strings that already have a protocol */ +const withProtocolArb = oneof( + hostnameArb.map((h) => `https://${h}`), + hostnameArb.map((h) => `http://${h}`) +); + +/** Arbitrary for any input (bare hostname, with protocol, empty, whitespace) */ +const anyInputArb = oneof( + hostnameArb, + withProtocolArb, + constantFrom("", " ", " \t\n"), + string({ minLength: 0, maxLength: 5 }) +); + +describe("property: normalizeUrl", () => { + test("result always has protocol or is undefined", () => { + fcAssert( + property(anyInputArb, (input) => { + const result = normalizeUrl(input); + if (result === undefined) { + return; + } + expect( + result.startsWith("https://") || result.startsWith("http://") + ).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("bare hostnames get https:// prepended", () => { + fcAssert( + property(hostnameArb, (hostname) => { + const result = normalizeUrl(hostname); + expect(result).toBe(`https://${hostname}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("URLs with protocol are returned unchanged", () => { + fcAssert( + property(withProtocolArb, (url) => { + const result = normalizeUrl(url); + expect(result).toBe(url); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent: normalizeUrl(normalizeUrl(x)) === normalizeUrl(x)", () => { + fcAssert( + property(anyInputArb, (input) => { + const once = normalizeUrl(input); + const twice = normalizeUrl(once); + expect(twice).toBe(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result always starts with https:// for bare hostnames", () => { + fcAssert( + property(hostnameArb, (hostname) => { + const result = normalizeUrl(hostname); + expect(result).toBeDefined(); + // normalizeUrl guarantees a protocol prefix — downstream URL + // construction (`${baseUrl}/api/0/...`) produces a valid URL + // when baseUrl has a protocol. We don't validate the hostname + // itself (e.g., numeric TLDs like `aa.52` are unlikely but + // harmless — the HTTP request will simply fail with a DNS error). + expect(result).toStartWith("https://"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/constants.test.ts b/packages/cli/test/lib/constants.test.ts new file mode 100644 index 000000000..0e6e020f3 --- /dev/null +++ b/packages/cli/test/lib/constants.test.ts @@ -0,0 +1,164 @@ +/** + * Tests for normalizeUrl and getConfiguredSentryUrl. + * + * The primary invariant — bare hostnames get `https://` prepended so that + * downstream URL construction produces valid URLs — is tested via property-based + * tests in constants.property.test.ts. These unit tests cover specific edge + * cases and the env-var integration path. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + getCliEnvironment, + getConfiguredSentryUrl, + normalizeUrl, +} from "../../src/lib/constants.js"; + +describe("normalizeUrl", () => { + test("returns undefined for undefined", () => { + expect(normalizeUrl(undefined)).toBeUndefined(); + }); + + test("returns undefined for empty string", () => { + expect(normalizeUrl("")).toBeUndefined(); + }); + + test("returns undefined for whitespace-only string", () => { + expect(normalizeUrl(" ")).toBeUndefined(); + }); + + test("prepends https:// to bare hostname", () => { + expect(normalizeUrl("sentry.example.com")).toBe( + "https://sentry.example.com" + ); + }); + + test("prepends https:// to bare sentry.io", () => { + expect(normalizeUrl("sentry.io")).toBe("https://sentry.io"); + }); + + test("prepends https:// to hostname with port", () => { + expect(normalizeUrl("sentry.example.com:9000")).toBe( + "https://sentry.example.com:9000" + ); + }); + + test("preserves https:// URLs", () => { + expect(normalizeUrl("https://sentry.example.com")).toBe( + "https://sentry.example.com" + ); + }); + + test("preserves http:// URLs", () => { + expect(normalizeUrl("http://sentry.example.com")).toBe( + "http://sentry.example.com" + ); + }); + + test("handles case-insensitive protocol", () => { + expect(normalizeUrl("HTTPS://sentry.example.com")).toBe( + "HTTPS://sentry.example.com" + ); + expect(normalizeUrl("HTTP://sentry.example.com")).toBe( + "HTTP://sentry.example.com" + ); + }); + + test("trims whitespace", () => { + expect(normalizeUrl(" sentry.example.com ")).toBe( + "https://sentry.example.com" + ); + expect(normalizeUrl(" https://sentry.example.com ")).toBe( + "https://sentry.example.com" + ); + }); + + test("preserves path and trailing slash", () => { + expect(normalizeUrl("sentry.example.com/")).toBe( + "https://sentry.example.com/" + ); + expect(normalizeUrl("https://sentry.example.com/")).toBe( + "https://sentry.example.com/" + ); + }); +}); + +describe("getCliEnvironment", () => { + test("returns 'development' in dev mode (no build injection)", () => { + // CLI_VERSION is "0.0.0-dev" when SENTRY_CLI_VERSION is not injected + expect(getCliEnvironment()).toBe("development"); + }); + + test("returns 'development' for '0.0.0-dev'", () => { + expect(getCliEnvironment("0.0.0-dev")).toBe("development"); + }); + + test("returns 'nightly' for nightly versions", () => { + expect(getCliEnvironment("0.24.0-dev.1740000000")).toBe("nightly"); + expect(getCliEnvironment("1.0.0-dev.1700000000")).toBe("nightly"); + }); + + test("returns 'production' for stable versions", () => { + expect(getCliEnvironment("0.20.0")).toBe("production"); + expect(getCliEnvironment("1.0.0")).toBe("production"); + expect(getCliEnvironment("0.23.0")).toBe("production"); + }); +}); + +describe("getConfiguredSentryUrl", () => { + let originalHost: string | undefined; + let originalUrl: string | undefined; + + beforeEach(() => { + originalHost = process.env.SENTRY_HOST; + originalUrl = process.env.SENTRY_URL; + }); + + afterEach(() => { + // Restore original values (set or delete) + if (originalHost !== undefined) { + process.env.SENTRY_HOST = originalHost; + } else { + delete process.env.SENTRY_HOST; + } + if (originalUrl !== undefined) { + process.env.SENTRY_URL = originalUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("returns undefined when no env vars set", () => { + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + expect(getConfiguredSentryUrl()).toBeUndefined(); + }); + + test("normalizes bare SENTRY_HOST", () => { + process.env.SENTRY_HOST = "sentry.example.com"; + delete process.env.SENTRY_URL; + expect(getConfiguredSentryUrl()).toBe("https://sentry.example.com"); + }); + + test("normalizes bare SENTRY_URL", () => { + delete process.env.SENTRY_HOST; + process.env.SENTRY_URL = "sentry.example.com"; + expect(getConfiguredSentryUrl()).toBe("https://sentry.example.com"); + }); + + test("SENTRY_HOST takes precedence over SENTRY_URL", () => { + process.env.SENTRY_HOST = "host.example.com"; + process.env.SENTRY_URL = "url.example.com"; + expect(getConfiguredSentryUrl()).toBe("https://host.example.com"); + }); + + test("preserves protocol when already present", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect(getConfiguredSentryUrl()).toBe("https://sentry.example.com"); + }); + + test("preserves http:// for local development", () => { + process.env.SENTRY_HOST = "http://localhost:8000"; + expect(getConfiguredSentryUrl()).toBe("http://localhost:8000"); + }); +}); diff --git a/packages/cli/test/lib/context.test.ts b/packages/cli/test/lib/context.test.ts new file mode 100644 index 000000000..2baf37202 --- /dev/null +++ b/packages/cli/test/lib/context.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { buildContext } from "../../src/context.js"; + +/** Mirrors the error Node throws when the working directory was removed. */ +function deletedCwdError(): NodeJS.ErrnoException { + return Object.assign( + new Error( + "ENOENT: process.cwd failed with error no such file or directory, the current working directory was likely removed without changing the working directory, uv_cwd" + ), + { code: "ENOENT", errno: -2, syscall: "uv_cwd" } + ); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("buildContext cwd", () => { + test("falls back to PWD when the working directory was deleted", () => { + vi.spyOn(process, "cwd").mockImplementation(() => { + throw deletedCwdError(); + }); + vi.stubEnv("PWD", "/tmp/removed-worktree"); + + const context = buildContext(process); + + expect(context.cwd).toBe("/tmp/removed-worktree"); + expect(context.forCommand({ prefix: ["sentry"] }).cwd).toBe( + "/tmp/removed-worktree" + ); + }); + + test("rethrows when PWD cannot stand in for the working directory", () => { + const error = deletedCwdError(); + vi.spyOn(process, "cwd").mockImplementation(() => { + throw error; + }); + + vi.stubEnv("PWD", ""); + expect(() => buildContext(process)).toThrow(error); + + vi.stubEnv("PWD", "relative/dir"); + expect(() => buildContext(process)).toThrow(error); + }); +}); diff --git a/packages/cli/test/lib/crc32.property.test.ts b/packages/cli/test/lib/crc32.property.test.ts new file mode 100644 index 000000000..656ddf33d --- /dev/null +++ b/packages/cli/test/lib/crc32.property.test.ts @@ -0,0 +1,39 @@ +/** + * Property-Based Tests for the portable CRC-32 fallback. + * + * The fallback must be byte-for-byte compatible with `zlib.crc32` (the + * native implementation on Node.js >= 20.15 / >= 22.2), including when a + * checksum is continued across chunks. + */ + +import { crc32 as nativeCrc32 } from "node:zlib"; +import { assert as fcAssert, property, uint8Array } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { crc32Fallback } from "../../src/lib/crc32.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("property: crc32Fallback", () => { + test("matches zlib.crc32 for arbitrary bytes", () => { + fcAssert( + property(uint8Array({ maxLength: 4096 }), (data) => { + expect(crc32Fallback(data)).toBe(nativeCrc32(data)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("continuing from a previous checksum equals hashing the concatenation", () => { + fcAssert( + property(uint8Array(), uint8Array(), (a, b) => { + const joined = Buffer.concat([a, b]); + expect(crc32Fallback(b, crc32Fallback(a))).toBe(crc32Fallback(joined)); + expect(crc32Fallback(b, nativeCrc32(a))).toBe(nativeCrc32(joined)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("produces the standard CRC-32 check value", () => { + expect(crc32Fallback(Buffer.from("123456789"))).toBe(0xcb_f4_39_26); + }); +}); diff --git a/packages/cli/test/lib/credential-redaction.test.ts b/packages/cli/test/lib/credential-redaction.test.ts new file mode 100644 index 000000000..d35fec976 --- /dev/null +++ b/packages/cli/test/lib/credential-redaction.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "vitest"; +import { redactCredentialText } from "../../src/lib/credential-redaction.js"; +import { ApiError, formatError, getExitCode } from "../../src/lib/errors.js"; +import { SentryError } from "../../src/lib/sdk-types.js"; + +describe("credential redaction", () => { + test.each([ + "sntrys_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET", + "sntryu_SYNTHETIC_PAYLOAD\r\n_SYNTHETIC_SECRET", + "legacy_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET", + "legacy_SYNTHETIC_PAYLOAD\\n_SYNTHETIC_SECRET", + 'legacy_SYNTHETIC_"PAYLOAD\n_SYNTHETIC_SECRET', + 'legacy_SYNTHETIC_" is an invalid header value\n_SYNTHETIC_SECRET', + ])("redacts invalid Bearer headers through JSON escaping: %j", (token) => { + let input = `Headers.set: "Bearer ${token}" is an invalid header value.`; + let expected = + 'Headers.set: "Bearer [REDACTED]" is an invalid header value.'; + for (let level = 0; level <= 3; level++) { + expect(redactCredentialText(input)).toBe(expected); + input = JSON.stringify({ error: input }); + expected = JSON.stringify({ error: expected }); + } + }); + + test("handles quotes escaped by JSON serialization", () => { + const message = JSON.stringify({ + error: 'Headers.set: "Bearer legacy_SYNTHETIC\n_SECRET" is invalid.', + }); + expect(JSON.parse(redactCredentialText(message))).toEqual({ + error: 'Headers.set: "Bearer [REDACTED]" is invalid.', + }); + }); + + test("preserves nested JSON escaping around quoted Sentry credentials", () => { + const input = JSON.stringify({ + error: JSON.stringify({ error: 'Rejected "sntryu_SYNTHETIC_SECRET".' }), + }); + const redacted = redactCredentialText(input); + expect(JSON.parse(JSON.parse(redacted).error)).toEqual({ + error: 'Rejected "[REDACTED]".', + }); + }); + + test("handles many incomplete runtime diagnostics", () => { + const diagnostic = 'Headers.set: "Bearer SYNTHETIC_SECRET"; '; + const expected = 'Headers.set: "Bearer [REDACTED]"; '; + expect(redactCredentialText(diagnostic.repeat(10_000))).toBe( + expected.repeat(10_000) + ); + }); + + test.each([ + "\t", + "\v", + "\f", + "\0", + "\b", + "\u0085", + "\u00a0", + "\u2028", + "\u2029", + "\\t", + "\\b", + "\\u0000", + "\\u2028", + ])("redacts token fragments separated by %j", (separator) => { + for (const prefix of ["sntryu_", "sntrys_", "Bearer opaque_"]) { + const input = `Rejected ${prefix}SYNTHETIC_FIRST${separator}SYNTHETIC_TAIL`; + expect(redactCredentialText(input)).not.toContain("SYNTHETIC"); + expect( + redactCredentialText(JSON.stringify({ error: input })) + ).not.toContain("SYNTHETIC"); + } + }); + + test.each([ + [ + "sntrys_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET", + "Rejected [REDACTED]; try again.", + ], + [ + "sntryu_SYNTHETIC_PAYLOAD\\n_SYNTHETIC_SECRET", + "Rejected [REDACTED]; try again.", + ], + [ + "Bearer legacy_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET", + "Rejected Bearer [REDACTED] try again.", + ], + ])("redacts recognizable unquoted credentials: %j", (token, expected) => { + const redacted = redactCredentialText(`Rejected ${token}; try again.`); + expect(redacted).toBe(expected); + expect(redactCredentialText(redacted)).toBe(redacted); + }); + + test.each([ + "opaque:SYNTHETIC_SECRET", + "opaque!SYNTHETIC_SECRET", + "opaque@SYNTHETIC_SECRET", + "opaque;SYNTHETIC_SECRET", + "opaque,[SYNTHETIC_SECRET]{}", + "opaque\\SYNTHETIC_SECRET", + "opaque:SYNTHETIC_PAYLOAD\n !SYNTHETIC_SECRET", + "opaque@SYNTHETIC_PAYLOAD\\n :SYNTHETIC_SECRET", + ])("redacts punctuation in an unquoted Bearer value: %j", (token) => { + let input = `Authorization: Bearer ${token}`; + let expected = "Authorization: Bearer [REDACTED]"; + for (let level = 0; level <= 2; level++) { + const redacted = redactCredentialText(input); + expect(redacted).toBe(expected); + expect(redactCredentialText(redacted)).toBe(expected); + input = JSON.stringify({ error: input, status: 401 }); + expected = JSON.stringify({ error: expected, status: 401 }); + } + }); + + test("redacts a quoted header truncated before its closing quote", () => { + expect( + redactCredentialText('Headers.set: "Bearer legacy_PAYLOAD\n_SECRET...') + ).toBe('Headers.set: "Bearer [REDACTED]"'); + }); + + test("leaves ordinary diagnostic text intact", () => { + const text = 'GET /api/0/projects/ failed: "Not found" (HTTP 404).'; + expect(redactCredentialText(text)).toBe(text); + }); +}); + +describe("error output boundaries", () => { + const token = "sntrys_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET"; + const message = `Headers.set: "Bearer ${token}" is an invalid header value.`; + + test("formats errors safely without changing the error or exit code", () => { + const error = new ApiError(message, 500, `Details: ${token}`); + expect(formatError(error)).not.toContain("SYNTHETIC"); + expect(error).toBeInstanceOf(ApiError); + expect(error.message).toBe(message); + expect(error.status).toBe(500); + expect(getExitCode(error)).toBe(30); + }); + + test("formats non-Error thrown objects safely", () => { + const formatted = formatError({ error: message }); + expect(JSON.parse(formatted)).toEqual({ + error: 'Headers.set: "Bearer [REDACTED]" is an invalid header value.', + }); + }); + + test("SDK error message, stack, stderr, and JSON are safe", () => { + const error = new SentryError(message, 12, `${message}\n`); + expect(error.name).toBe("SentryError"); + expect(error.exitCode).toBe(12); + expect(error.message).not.toContain("SYNTHETIC"); + expect(error.stack).not.toContain("SYNTHETIC"); + expect(error.stderr).not.toContain("SYNTHETIC"); + expect(JSON.stringify({ message: error.message, ...error })).not.toContain( + "SYNTHETIC" + ); + }); +}); diff --git a/packages/cli/test/lib/custom-ca.test.ts b/packages/cli/test/lib/custom-ca.test.ts new file mode 100644 index 000000000..fbd1a0f23 --- /dev/null +++ b/packages/cli/test/lib/custom-ca.test.ts @@ -0,0 +1,407 @@ +/** + * Tests for custom CA certificate loading and TLS error detection. + * + * isTlsCertError is a pure function tested thoroughly here. + * CA loading tests use temp files and env sandboxing. + */ + +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + __resetForTests, + customFetch, + getCustomCaCerts, + getCustomCaSource, + getCustomTlsOptions, + getTlsCertErrorMessage, + isTlsCertError, + warnIfSaasWithEnvCa, +} from "../../src/lib/custom-ca.js"; +import { setDefaultCaCert } from "../../src/lib/db/defaults.js"; +import { useTestConfigDir } from "../helpers.js"; + +// --------------------------------------------------------------------------- +// isTlsCertError — pure detection tests +// --------------------------------------------------------------------------- + +describe("isTlsCertError", () => { + test("detects 'unable to get local issuer certificate'", () => { + expect( + isTlsCertError(new Error("unable to get local issuer certificate")) + ).toBe(true); + }); + + test("detects 'unable to verify the first certificate'", () => { + expect( + isTlsCertError(new Error("unable to verify the first certificate")) + ).toBe(true); + }); + + test("detects UNABLE_TO_VERIFY_LEAF_SIGNATURE", () => { + expect(isTlsCertError(new Error("UNABLE_TO_VERIFY_LEAF_SIGNATURE"))).toBe( + true + ); + }); + + test("does NOT detect CERT_HAS_EXPIRED (not a CA trust issue)", () => { + expect(isTlsCertError(new Error("CERT_HAS_EXPIRED"))).toBe(false); + }); + + test("does NOT detect ERR_TLS_CERT_ALTNAME_INVALID (not a CA trust issue)", () => { + expect(isTlsCertError(new Error("ERR_TLS_CERT_ALTNAME_INVALID"))).toBe( + false + ); + }); + + test("detects DEPTH_ZERO_SELF_SIGNED_CERT", () => { + expect(isTlsCertError(new Error("DEPTH_ZERO_SELF_SIGNED_CERT"))).toBe(true); + }); + + test("detects SELF_SIGNED_CERT_IN_CHAIN", () => { + expect(isTlsCertError(new Error("SELF_SIGNED_CERT_IN_CHAIN"))).toBe(true); + }); + + test("detects pattern within larger message", () => { + expect( + isTlsCertError( + new Error( + "request to https://sentry.io failed, reason: unable to get local issuer certificate" + ) + ) + ).toBe(true); + }); + + test("returns false for non-TLS errors", () => { + expect(isTlsCertError(new Error("ECONNREFUSED"))).toBe(false); + expect(isTlsCertError(new Error("fetch failed"))).toBe(false); + expect(isTlsCertError(new Error("timeout"))).toBe(false); + expect(isTlsCertError(new Error("network error"))).toBe(false); + }); + + test("getTlsCertErrorMessage extracts root cause from wrapped error", () => { + const cause = new Error("unable to get local issuer certificate"); + const wrapper = new TypeError("fetch failed"); + wrapper.cause = cause; + expect(getTlsCertErrorMessage(wrapper)).toBe( + "unable to get local issuer certificate" + ); + }); + + test("getTlsCertErrorMessage returns undefined for non-TLS errors", () => { + expect(getTlsCertErrorMessage(new Error("ECONNREFUSED"))).toBeUndefined(); + }); + + test("detects TLS error wrapped in error.cause (Node.js fetch pattern)", () => { + const cause = new Error("unable to get local issuer certificate"); + const wrapper = new TypeError("fetch failed"); + wrapper.cause = cause; + expect(isTlsCertError(wrapper)).toBe(true); + }); + + test("detects deeply nested error.cause chain", () => { + const root = new Error("SELF_SIGNED_CERT_IN_CHAIN"); + const mid = new Error("request failed"); + mid.cause = root; + const outer = new TypeError("fetch failed"); + outer.cause = mid; + expect(isTlsCertError(outer)).toBe(true); + }); + + test("returns false for non-TLS error.cause", () => { + const cause = new Error("ECONNREFUSED"); + const wrapper = new TypeError("fetch failed"); + wrapper.cause = cause; + expect(isTlsCertError(wrapper)).toBe(false); + }); + + test("returns false for non-Error values", () => { + expect(isTlsCertError("unable to get local issuer certificate")).toBe( + false + ); + expect(isTlsCertError(null)).toBe(false); + expect(isTlsCertError(undefined)).toBe(false); + expect(isTlsCertError(42)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// CA loading — requires DB + env sandboxing +// --------------------------------------------------------------------------- + +describe("custom CA loading", () => { + const getConfigDir = useTestConfigDir("custom-ca-"); + const CERT_PEM = + "-----BEGIN CERTIFICATE-----\nMIIBxyz...\n-----END CERTIFICATE-----\n"; + + let savedNodeExtra: string | undefined; + + beforeEach(() => { + __resetForTests(); + savedNodeExtra = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + afterEach(() => { + if (savedNodeExtra !== undefined) { + process.env.NODE_EXTRA_CA_CERTS = savedNodeExtra; + } else { + delete process.env.NODE_EXTRA_CA_CERTS; + } + }); + + test("returns undefined when no CAs configured", () => { + const result = getCustomTlsOptions(); + expect(result).toBeUndefined(); + expect(getCustomCaSource()).toBe("none"); + }); + + test("loads CA from stored default (highest priority)", () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + setDefaultCaCert(certPath); + + const result = getCustomTlsOptions(); + expect(result).toBeDefined(); + // Custom CA is concatenated with root certificates (additive semantics) + expect(result?.tls.ca).toContain(CERT_PEM); + expect(result?.tls.ca).toContain("-----BEGIN CERTIFICATE-----"); + expect(getCustomCaSource()).toBe("default"); + }); + + test("loads CA from NODE_EXTRA_CA_CERTS", () => { + const certPath = join(getConfigDir(), "extra-ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + const result = getCustomTlsOptions(); + expect(result).toBeDefined(); + expect(result?.tls.ca).toContain(CERT_PEM); + expect(getCustomCaSource()).toBe("env"); + }); + + test("stored default takes priority over env vars", () => { + const defaultPath = join(getConfigDir(), "default-ca.pem"); + const envPath = join(getConfigDir(), "env-ca.pem"); + const ENV_PEM = + "-----BEGIN CERTIFICATE-----\nDIFFERENT\n-----END CERTIFICATE-----\n"; + writeFileSync(defaultPath, CERT_PEM); + writeFileSync(envPath, ENV_PEM); + setDefaultCaCert(defaultPath); + process.env.NODE_EXTRA_CA_CERTS = envPath; + + const result = getCustomTlsOptions(); + expect(result?.tls.ca).toContain(CERT_PEM); + expect(result?.tls.ca).not.toContain("DIFFERENT"); + expect(getCustomCaSource()).toBe("default"); + }); + + test("returns undefined when cert file does not exist", () => { + process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path/ca.pem"; + + const result = getCustomTlsOptions(); + expect(result).toBeUndefined(); + expect(getCustomCaSource()).toBe("none"); + }); + + test("returns undefined when cert file is not valid PEM", () => { + const certPath = join(getConfigDir(), "not-pem.txt"); + writeFileSync(certPath, "this is not a PEM file"); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + const result = getCustomTlsOptions(); + expect(result).toBeUndefined(); + expect(getCustomCaSource()).toBe("none"); + }); + + test("caches result across calls", () => { + const certPath = join(getConfigDir(), "cached.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + const first = getCustomTlsOptions(); + // Even if env var changes, cached result stays + process.env.NODE_EXTRA_CA_CERTS = "/different/path"; + const second = getCustomTlsOptions(); + expect(first).toBe(second); + }); +}); + +// --------------------------------------------------------------------------- +// SaaS warning +// --------------------------------------------------------------------------- + +describe("warnIfSaasWithEnvCa", () => { + const getConfigDir = useTestConfigDir("saas-warn-"); + const CERT_PEM = + "-----BEGIN CERTIFICATE-----\nMIIBxyz...\n-----END CERTIFICATE-----\n"; + + let savedNodeExtra: string | undefined; + + beforeEach(() => { + __resetForTests(); + savedNodeExtra = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + afterEach(() => { + if (savedNodeExtra !== undefined) { + process.env.NODE_EXTRA_CA_CERTS = savedNodeExtra; + } else { + delete process.env.NODE_EXTRA_CA_CERTS; + } + }); + + test("does not warn for stored default CAs targeting SaaS", () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + setDefaultCaCert(certPath); + getCustomTlsOptions(); + + // Should not throw or warn — source is "default" not "env" + warnIfSaasWithEnvCa("https://us.sentry.io/api/0/organizations/"); + expect(getCustomCaSource()).toBe("default"); + }); + + test("does not warn for env CAs targeting self-hosted", () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + getCustomTlsOptions(); + + // Self-hosted URL — no warning + warnIfSaasWithEnvCa("https://sentry.example.com/api/0/"); + expect(getCustomCaSource()).toBe("env"); + }); +}); + +// --------------------------------------------------------------------------- +// getCustomCaCerts — raw PEM string for Node http.request() +// --------------------------------------------------------------------------- + +describe("getCustomCaCerts", () => { + const getConfigDir = useTestConfigDir("ca-certs-"); + const CERT_PEM = + "-----BEGIN CERTIFICATE-----\nMIIBxyz...\n-----END CERTIFICATE-----\n"; + + let savedNodeExtra: string | undefined; + + beforeEach(() => { + __resetForTests(); + savedNodeExtra = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + afterEach(() => { + if (savedNodeExtra !== undefined) { + process.env.NODE_EXTRA_CA_CERTS = savedNodeExtra; + } else { + delete process.env.NODE_EXTRA_CA_CERTS; + } + }); + + test("returns undefined when no CAs configured", () => { + expect(getCustomCaCerts()).toBeUndefined(); + }); + + test("returns PEM string when CA is loaded", () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + const caCerts = getCustomCaCerts(); + expect(caCerts).toBeDefined(); + expect(caCerts).toContain(CERT_PEM); + }); + + test("returns same value as getCustomTlsOptions().tls.ca", () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + const caCerts = getCustomCaCerts(); + const tlsOpts = getCustomTlsOptions(); + expect(caCerts).toBe(tlsOpts?.tls.ca); + }); +}); + +// --------------------------------------------------------------------------- +// customFetch — TLS-aware fetch wrapper +// --------------------------------------------------------------------------- + +describe("customFetch", () => { + const getConfigDir = useTestConfigDir("custom-fetch-"); + const CERT_PEM = + "-----BEGIN CERTIFICATE-----\nMIIBxyz...\n-----END CERTIFICATE-----\n"; + + let savedNodeExtra: string | undefined; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + __resetForTests(); + savedNodeExtra = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (savedNodeExtra !== undefined) { + process.env.NODE_EXTRA_CA_CERTS = savedNodeExtra; + } else { + delete process.env.NODE_EXTRA_CA_CERTS; + } + }); + + test("calls fetch without tls option when no custom CA", async () => { + let capturedInit: RequestInit | undefined; + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + capturedInit = init; + return Promise.resolve(new Response("ok")); + }) as typeof fetch; + + await customFetch("https://example.com", { headers: { "X-Test": "1" } }); + expect(capturedInit).toBeDefined(); + expect(capturedInit?.headers).toEqual({ "X-Test": "1" }); + expect((capturedInit as Record).tls).toBeUndefined(); + }); + + test("spreads tls options when custom CA is loaded", async () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + let capturedInit: Record | undefined; + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + capturedInit = init as Record; + return Promise.resolve(new Response("ok")); + }) as typeof fetch; + + await customFetch("https://example.com", { headers: { "X-Test": "1" } }); + expect(capturedInit).toBeDefined(); + expect(capturedInit?.tls).toBeDefined(); + expect((capturedInit?.tls as { ca: string }).ca).toContain(CERT_PEM); + }); + + test("preserves caller init options alongside tls", async () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + let capturedInit: Record | undefined; + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + capturedInit = init as Record; + return Promise.resolve(new Response("ok")); + }) as typeof fetch; + + await customFetch("https://example.com", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + expect(capturedInit?.method).toBe("POST"); + expect(capturedInit?.headers).toEqual({ + "Content-Type": "application/json", + }); + expect(capturedInit?.tls).toBeDefined(); + }); +}); diff --git a/packages/cli/test/lib/custom-headers.property.test.ts b/packages/cli/test/lib/custom-headers.property.test.ts new file mode 100644 index 000000000..76f900f6b --- /dev/null +++ b/packages/cli/test/lib/custom-headers.property.test.ts @@ -0,0 +1,140 @@ +/** + * Property-Based Tests for Custom Headers Parsing + * + * Uses fast-check to verify parsing invariants that should hold + * for any valid header input. + */ + +import { + array, + constantFrom, + assert as fcAssert, + nat, + property, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { parseCustomHeaders } from "../../src/lib/custom-headers.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Characters valid in HTTP header names (subset of RFC 7230 token chars) */ +const headerNameChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!#$%&'*+^`|~"; + +/** Header names that cannot be used (reserved by the CLI) */ +const FORBIDDEN_NAMES = new Set([ + "authorization", + "host", + "content-type", + "content-length", + "user-agent", + "sentry-trace", + "baggage", +]); + +/** Generate a valid header name (1-30 chars, not forbidden) */ +const headerNameArb = array(constantFrom(...headerNameChars.split("")), { + minLength: 1, + maxLength: 30, +}) + .map((chars) => chars.join("")) + .filter((name) => !FORBIDDEN_NAMES.has(name.toLowerCase())); + +/** Characters valid in header values (printable ASCII, no semicolons or newlines) */ +const headerValueChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()-_=+[]{}|:',.<>?/`~"; + +/** Generate a header value (0-80 chars, no semicolons/newlines) */ +const headerValueArb = array(constantFrom(...headerValueChars.split("")), { + minLength: 0, + maxLength: 80, +}).map((chars) => chars.join("")); + +/** Generate a single valid header pair */ +const headerPairArb = tuple(headerNameArb, headerValueArb); + +/** Generate a list of 1-5 header pairs */ +const headerListArb = array(headerPairArb, { minLength: 1, maxLength: 5 }); + +/** Separator: semicolon or newline */ +const separatorArb = constantFrom("; ", ";\n", "\n"); + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +describe("property: parseCustomHeaders", () => { + test("round-trip: format then parse returns same name/value pairs", () => { + fcAssert( + property(headerPairArb, ([name, value]) => { + const formatted = `${name}: ${value}`; + const result = parseCustomHeaders(formatted); + expect(result).toEqual([[name, value.trim()]]); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("count: number of valid segments equals number of returned headers", () => { + fcAssert( + property(headerListArb, separatorArb, (pairs, sep) => { + const formatted = pairs + .map(([name, value]) => `${name}: ${value}`) + .join(sep); + const result = parseCustomHeaders(formatted); + expect(result.length).toBe(pairs.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("order: headers appear in the same order as input", () => { + fcAssert( + property(headerListArb, (pairs) => { + const formatted = pairs + .map(([name, value]) => `${name}: ${value}`) + .join("; "); + const result = parseCustomHeaders(formatted); + for (let i = 0; i < pairs.length; i++) { + expect(result[i]?.[0]).toBe(pairs[i]?.[0]); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("values are always trimmed", () => { + fcAssert( + property( + headerNameArb, + headerValueArb, + nat({ max: 5 }), + nat({ max: 5 }), + (name, value, leadingSpaces, trailingSpaces) => { + const padded = `${" ".repeat(leadingSpaces)}${value}${" ".repeat(trailingSpaces)}`; + const formatted = `${name}:${padded}`; + const result = parseCustomHeaders(formatted); + expect(result[0]?.[1]).toBe(value.trim()); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty segments between separators are skipped", () => { + fcAssert( + property(headerPairArb, ([name, value]) => { + // Add empty segments via double-separators + const formatted = `; ;${name}: ${value}; ; `; + const result = parseCustomHeaders(formatted); + expect(result.length).toBe(1); + expect(result[0]?.[0]).toBe(name); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/custom-headers.test.ts b/packages/cli/test/lib/custom-headers.test.ts new file mode 100644 index 000000000..722e0d779 --- /dev/null +++ b/packages/cli/test/lib/custom-headers.test.ts @@ -0,0 +1,435 @@ +/** + * Unit Tests for Custom Headers + * + * Tests parseCustomHeaders() parsing, getCustomHeaders() env/DB integration, + * and applyCustomHeaders() header injection. + * + * Note: Core round-trip and invariant properties are tested via property-based + * tests in custom-headers.property.test.ts. These tests focus on edge cases, + * error messages, and integration behavior not covered by property generators. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + _resetCustomHeadersCache, + applyCustomHeaders, + getCustomHeaders, + parseCustomHeaders, + setCustomHeadersOverride, +} from "../../src/lib/custom-headers.js"; +import { setDefaultHeaders } from "../../src/lib/db/defaults.js"; +import { useTestConfigDir } from "../helpers.js"; + +// --------------------------------------------------------------------------- +// parseCustomHeaders — parsing logic +// --------------------------------------------------------------------------- + +describe("parseCustomHeaders", () => { + test("parses single header", () => { + const result = parseCustomHeaders("X-Custom: value"); + expect(result).toEqual([["X-Custom", "value"]]); + }); + + test("parses multiple headers separated by semicolon", () => { + const result = parseCustomHeaders( + "X-First: one; X-Second: two; X-Third: three" + ); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ["X-Third", "three"], + ]); + }); + + test("parses multiple headers separated by newline", () => { + const result = parseCustomHeaders("X-First: one\nX-Second: two"); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ]); + }); + + test("parses mixed semicolon and newline separators", () => { + const result = parseCustomHeaders( + "X-First: one; X-Second: two\nX-Third: three" + ); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ["X-Third", "three"], + ]); + }); + + test("handles value containing colons", () => { + const result = parseCustomHeaders("X-Token: abc:def:ghi"); + expect(result).toEqual([["X-Token", "abc:def:ghi"]]); + }); + + test("trims whitespace from name and value", () => { + const result = parseCustomHeaders(" X-Custom : some value "); + expect(result).toEqual([["X-Custom", "some value"]]); + }); + + test("skips empty segments", () => { + const result = parseCustomHeaders("X-First: one;; ;X-Second: two"); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ]); + }); + + test("handles Windows line endings (\\r\\n)", () => { + const result = parseCustomHeaders("X-First: one\r\nX-Second: two"); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ]); + }); + + test("returns empty array for empty string", () => { + expect(parseCustomHeaders("")).toEqual([]); + }); + + test("returns empty array for whitespace-only string", () => { + expect(parseCustomHeaders(" \n ; ")).toEqual([]); + }); + + test("allows header value to be empty", () => { + const result = parseCustomHeaders("X-Empty:"); + expect(result).toEqual([["X-Empty", ""]]); + }); + + // Error cases + + test("throws ConfigError on segment without colon", () => { + expect(() => parseCustomHeaders("bad-header-no-colon")).toThrow( + /Expected 'Name: Value' format/ + ); + }); + + test("throws ConfigError on empty header name", () => { + expect(() => parseCustomHeaders(": value-only")).toThrow( + /empty header name/ + ); + }); + + test("throws ConfigError on header name with spaces", () => { + expect(() => parseCustomHeaders("Bad Name: value")).toThrow( + /Header names must contain only/ + ); + }); + + // Forbidden headers + const forbiddenHeaders = [ + "Authorization", + "Host", + "Content-Type", + "Content-Length", + "User-Agent", + "sentry-trace", + "baggage", + ]; + + for (const header of forbiddenHeaders) { + test(`throws ConfigError for forbidden header: ${header}`, () => { + expect(() => parseCustomHeaders(`${header}: some-value`)).toThrow( + /Cannot override reserved header/ + ); + }); + } + + test("forbidden header check is case-insensitive", () => { + expect(() => parseCustomHeaders("AUTHORIZATION: token")).toThrow( + /Cannot override reserved header/ + ); + expect(() => parseCustomHeaders("content-type: json")).toThrow( + /Cannot override reserved header/ + ); + }); +}); + +// --------------------------------------------------------------------------- +// getCustomHeaders — env var and DB integration +// --------------------------------------------------------------------------- + +describe("getCustomHeaders", () => { + useTestConfigDir("custom-headers-test-", { isolateProjectRoot: true }); + + let savedHeaders: string | undefined; + let savedHost: string | undefined; + let savedUrl: string | undefined; + + beforeEach(() => { + savedHeaders = process.env.SENTRY_CUSTOM_HEADERS; + savedHost = process.env.SENTRY_HOST; + savedUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_CUSTOM_HEADERS; + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + _resetCustomHeadersCache(); + }); + + afterEach(() => { + if (savedHeaders !== undefined) { + process.env.SENTRY_CUSTOM_HEADERS = savedHeaders; + } else { + delete process.env.SENTRY_CUSTOM_HEADERS; + } + if (savedHost !== undefined) { + process.env.SENTRY_HOST = savedHost; + } else { + delete process.env.SENTRY_HOST; + } + if (savedUrl !== undefined) { + process.env.SENTRY_URL = savedUrl; + } else { + delete process.env.SENTRY_URL; + } + _resetCustomHeadersCache(); + }); + + test("returns empty array when no env var or defaults set", () => { + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns empty array when headers set but no SENTRY_HOST (SaaS mode)", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: value"; + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns empty array when SENTRY_HOST is sentry.io", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: value"; + process.env.SENTRY_HOST = "sentry.io"; + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns empty array when SENTRY_HOST is subdomain of sentry.io", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: value"; + process.env.SENTRY_HOST = "us.sentry.io"; + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns parsed headers when SENTRY_HOST is self-hosted", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: abc123"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect(getCustomHeaders()).toEqual([["X-IAP-Token", "abc123"]]); + }); + + test("returns parsed headers when SENTRY_URL is self-hosted", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: abc123"; + process.env.SENTRY_URL = "https://sentry.example.com"; + expect(getCustomHeaders()).toEqual([["X-IAP-Token", "abc123"]]); + }); + + test("env var takes priority over SQLite defaults", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + process.env.SENTRY_CUSTOM_HEADERS = "X-Env: from-env"; + setDefaultHeaders("X-Db: from-db"); + expect(getCustomHeaders()).toEqual([["X-Env", "from-env"]]); + }); + + test("falls back to SQLite defaults when env var not set", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + setDefaultHeaders("X-Db: from-db"); + expect(getCustomHeaders()).toEqual([["X-Db", "from-db"]]); + }); + + test("caches parsed result across calls", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Cache: test"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + const first = getCustomHeaders(); + const second = getCustomHeaders(); + // Same reference (cached) + expect(first).toBe(second); + }); +}); + +// --------------------------------------------------------------------------- +// setCustomHeadersOverride — structured headers from SentryOptions.headers +// --------------------------------------------------------------------------- + +describe("setCustomHeadersOverride", () => { + useTestConfigDir("custom-headers-override-test-", { + isolateProjectRoot: true, + }); + + let savedHeaders: string | undefined; + let savedHost: string | undefined; + + beforeEach(() => { + savedHeaders = process.env.SENTRY_CUSTOM_HEADERS; + savedHost = process.env.SENTRY_HOST; + delete process.env.SENTRY_CUSTOM_HEADERS; + process.env.SENTRY_HOST = "https://sentry.example.com"; + _resetCustomHeadersCache(); + }); + + afterEach(() => { + if (savedHeaders !== undefined) { + process.env.SENTRY_CUSTOM_HEADERS = savedHeaders; + } else { + delete process.env.SENTRY_CUSTOM_HEADERS; + } + if (savedHost !== undefined) { + process.env.SENTRY_HOST = savedHost; + } else { + delete process.env.SENTRY_HOST; + } + _resetCustomHeadersCache(); + }); + + test("returns the structured headers without a string round-trip", () => { + setCustomHeadersOverride({ + "X-IAP-Token": "abc123", + "X-Url": "https://example.com/a;b", + }); + expect(getCustomHeaders()).toEqual([ + ["X-IAP-Token", "abc123"], + ["X-Url", "https://example.com/a;b"], + ]); + }); + + test("takes precedence over SENTRY_CUSTOM_HEADERS", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Env: from-env"; + setCustomHeadersOverride({ "X-Option": "from-option" }); + expect(getCustomHeaders()).toEqual([["X-Option", "from-option"]]); + }); + + test("an empty map disables inherited env headers", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Env: from-env"; + setCustomHeadersOverride({}); + expect(getCustomHeaders()).toEqual([]); + }); + + test("clearing falls back to SENTRY_CUSTOM_HEADERS", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Env: from-env"; + setCustomHeadersOverride({ "X-Option": "from-option" }); + setCustomHeadersOverride(undefined); + expect(getCustomHeaders()).toEqual([["X-Env", "from-env"]]); + }); + + test("is ignored when targeting SaaS", () => { + delete process.env.SENTRY_HOST; + setCustomHeadersOverride({ "X-IAP-Token": "abc123" }); + expect(getCustomHeaders()).toEqual([]); + }); + + test("trims names and values", () => { + setCustomHeadersOverride({ " X-Trim ": " value " }); + expect(getCustomHeaders()).toEqual([["X-Trim", "value"]]); + }); + + test("throws ConfigError on empty header name", () => { + expect(() => setCustomHeadersOverride({ " ": "value" })).toThrow( + "empty header name" + ); + }); + + test("throws ConfigError on invalid header name", () => { + expect(() => setCustomHeadersOverride({ "X Bad": "value" })).toThrow( + "Invalid header name 'X Bad' in SentryOptions.headers" + ); + }); + + test("throws ConfigError on reserved header name", () => { + expect(() => setCustomHeadersOverride({ Authorization: "x" })).toThrow( + "Cannot override reserved header 'Authorization' in SentryOptions.headers" + ); + }); +}); + +// --------------------------------------------------------------------------- +// applyCustomHeaders — header injection +// --------------------------------------------------------------------------- + +describe("applyCustomHeaders", () => { + let savedHeaders: string | undefined; + let savedHost: string | undefined; + + beforeEach(async () => { + savedHeaders = process.env.SENTRY_CUSTOM_HEADERS; + savedHost = process.env.SENTRY_HOST; + _resetCustomHeadersCache(); + const { resetEnvTokenHostForTesting } = await import( + "../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + }); + + afterEach(async () => { + if (savedHeaders !== undefined) { + process.env.SENTRY_CUSTOM_HEADERS = savedHeaders; + } else { + delete process.env.SENTRY_CUSTOM_HEADERS; + } + if (savedHost !== undefined) { + process.env.SENTRY_HOST = savedHost; + } else { + delete process.env.SENTRY_HOST; + } + _resetCustomHeadersCache(); + const { resetEnvTokenHostForTesting } = await import( + "../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + }); + + test("applies custom headers to Headers instance when request host matches", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: hello; X-Other: world"; + // Pin env-token scope to the self-hosted instance so headers apply. + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const headers = new Headers({ Accept: "application/json" }); + applyCustomHeaders( + headers, + "https://sentry.example.com/api/0/organizations/" + ); + + expect(headers.get("X-Test")).toBe("hello"); + expect(headers.get("X-Other")).toBe("world"); + }); + + test("does not clobber unrelated existing headers", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: hello"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const headers = new Headers({ Accept: "application/json" }); + applyCustomHeaders( + headers, + "https://sentry.example.com/api/0/organizations/" + ); + + expect(headers.get("Accept")).toBe("application/json"); + expect(headers.get("X-Test")).toBe("hello"); + }); + + test("no-op when no custom headers configured", () => { + delete process.env.SENTRY_CUSTOM_HEADERS; + delete process.env.SENTRY_HOST; + + const headers = new Headers({ Accept: "application/json" }); + applyCustomHeaders(headers, "https://sentry.io/api/0/"); + + // Only the original header + const keys = [...headers.keys()]; + expect(keys).toEqual(["accept"]); + }); + + test("skips custom headers when request URL host does not match trusted host (CVE defense)", () => { + // Self-hosted user has IAP token configured for their proxy: + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: secret"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + // A share URL from an attacker resolves to evil.example.com. Even though + // applyCustomHeaders is on a non-SaaS codepath (isSelfHosted()==true for + // this host-config), the request's destination is the untrusted URL, so + // the IAP token must NOT attach. + const headers = new Headers({ Accept: "application/json" }); + applyCustomHeaders( + headers, + "https://evil.example.com/api/0/shared/issues/deadbeef/" + ); + + expect(headers.get("X-IAP-Token")).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/db/auth.host.test.ts b/packages/cli/test/lib/db/auth.host.test.ts new file mode 100644 index 000000000..1cab411fd --- /dev/null +++ b/packages/cli/test/lib/db/auth.host.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for host-scoped auth: setAuthToken persistence, getStoredAuthHost, + * NULL-host lazy migration, host preservation across refresh-style updates. + */ + +import { describe, expect, test } from "vitest"; +import { + getStoredAuthHost, + hasUsableStoredToken, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { useTestConfigDir } from "../../helpers.js"; + +describe("db/auth host scoping", () => { + useTestConfigDir("auth-host-test-"); + + test("setAuthToken persists explicit host", () => { + setAuthToken("tok-1", undefined, undefined, { + host: "https://sentry.acme.com", + }); + expect(getStoredAuthHost()).toBe("https://sentry.acme.com"); + }); + + test("setAuthToken normalizes host (lowercases + strips trailing slash)", () => { + setAuthToken("tok-1", undefined, undefined, { + host: "https://SENTRY.Acme.com/", + }); + // normalizeOrigin lowercases + strips trailing slash + expect(getStoredAuthHost()).toBe("https://sentry.acme.com"); + }); + + test("setAuthToken without host explicit uses configured host", () => { + const prevHost = process.env.SENTRY_HOST; + process.env.SENTRY_HOST = "https://env-host.example.com"; + try { + setAuthToken("tok-2"); + expect(getStoredAuthHost()).toBe("https://env-host.example.com"); + } finally { + if (prevHost === undefined) { + delete process.env.SENTRY_HOST; + } else { + process.env.SENTRY_HOST = prevHost; + } + } + }); + + test("setAuthToken without host falls back to DEFAULT_SENTRY_URL", () => { + const prevHost = process.env.SENTRY_HOST; + const prevUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + try { + setAuthToken("tok-3"); + expect(getStoredAuthHost()).toBe("https://sentry.io"); + } finally { + if (prevHost !== undefined) { + process.env.SENTRY_HOST = prevHost; + } + if (prevUrl !== undefined) { + process.env.SENTRY_URL = prevUrl; + } + } + }); + + test("refresh-style update preserves existing host when options.host omitted", () => { + setAuthToken("tok-initial", 3600, "refresh-tok", { + host: "https://sentry.acme.com", + }); + expect(getStoredAuthHost()).toBe("https://sentry.acme.com"); + + // Simulate a refresh: new access token + same refresh token, no host + setAuthToken("tok-refreshed", 3600, "refresh-tok"); + expect(getStoredAuthHost()).toBe("https://sentry.acme.com"); + }); + + test("lazy migration: NULL host is backfilled from BOOT-TIME env on first access", async () => { + // Simulate a pre-v16 row: direct INSERT bypassing setAuthToken + const db = getDatabase(); + db.query( + "INSERT OR REPLACE INTO auth (id, token, host, updated_at) VALUES (1, 'legacy-token', NULL, ?)" + ).run(Date.now()); + + // Simulate the boot ordering: SHELL-exports SENTRY_HOST, then + // captureEnvTokenHost snapshots it. Migration reads this snapshot + // (not the current env, which could be rc-poisoned by the shim). + const { captureEnvTokenHost, resetEnvTokenHostForTesting } = await import( + "../../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + const prevHost = process.env.SENTRY_HOST; + process.env.SENTRY_HOST = "https://legacy-configured.example.com"; + captureEnvTokenHost(); + + try { + expect(getStoredAuthHost()).toBe("https://legacy-configured.example.com"); + // Second call reads the now-populated host + expect(getStoredAuthHost()).toBe("https://legacy-configured.example.com"); + // Verify it was actually written to the DB + const row = db.query("SELECT host FROM auth WHERE id = 1").get() as { + host: string | null; + }; + expect(row.host).toBe("https://legacy-configured.example.com"); + } finally { + if (prevHost === undefined) { + delete process.env.SENTRY_HOST; + } else { + process.env.SENTRY_HOST = prevHost; + } + resetEnvTokenHostForTesting(); + } + }); + + test("lazy migration: NULL host + no boot-time env falls back to SaaS", async () => { + const db = getDatabase(); + db.query( + "INSERT OR REPLACE INTO auth (id, token, host, updated_at) VALUES (1, 'legacy-token', NULL, ?)" + ).run(Date.now()); + + const { captureEnvTokenHost, resetEnvTokenHostForTesting } = await import( + "../../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + const prevHost = process.env.SENTRY_HOST; + const prevUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + captureEnvTokenHost(); // captures DEFAULT_SENTRY_URL (SaaS) + + try { + expect(getStoredAuthHost()).toBe("https://sentry.io"); + } finally { + if (prevHost !== undefined) { + process.env.SENTRY_HOST = prevHost; + } + if (prevUrl !== undefined) { + process.env.SENTRY_URL = prevUrl; + } + resetEnvTokenHostForTesting(); + } + }); + + test("lazy migration: ignores rc-poisoned current env (uses boot snapshot instead)", async () => { + // Critical regression: previously migration called getConfiguredSentryUrl() + // which reads CURRENT env. If .sentryclirc shim wrote env.SENTRY_URL + // before migration fired, the token would be migrated to the + // rc-sourced (potentially attacker) host. Now migration uses the + // boot snapshot so rc writes don't affect it. + const db = getDatabase(); + db.query( + "INSERT OR REPLACE INTO auth (id, token, host, updated_at) VALUES (1, 'legacy-token', NULL, ?)" + ).run(Date.now()); + + const { captureEnvTokenHost, resetEnvTokenHostForTesting } = await import( + "../../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + const prevHost = process.env.SENTRY_HOST; + const prevUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + captureEnvTokenHost(); // snapshots SaaS default + + // Simulate rc shim writing env.SENTRY_URL AFTER boot + process.env.SENTRY_URL = "https://rc-sourced.example.com"; + + try { + // Must migrate to the BOOT snapshot (SaaS), not the rc-sourced URL + expect(getStoredAuthHost()).toBe("https://sentry.io"); + } finally { + if (prevHost !== undefined) { + process.env.SENTRY_HOST = prevHost; + } else { + delete process.env.SENTRY_HOST; + } + if (prevUrl !== undefined) { + process.env.SENTRY_URL = prevUrl; + } else { + delete process.env.SENTRY_URL; + } + resetEnvTokenHostForTesting(); + } + }); + + test("getStoredAuthHost returns undefined when no stored token", () => { + // Nothing persisted + expect(getStoredAuthHost()).toBeUndefined(); + }); + + test("hasUsableStoredToken reflects stored row status", () => { + expect(hasUsableStoredToken()).toBe(false); + + setAuthToken("tok-usable", 3600, "refresh", { + host: "https://sentry.io", + }); + expect(hasUsableStoredToken()).toBe(true); + }); + + test("clearAuth evicts region-URL allow-list but PRESERVES login trust anchor", async () => { + // The login anchor is set by `applyLoginUrl` at the start of the + // `auth login` command lifecycle. When the user runs `auth login + // --url ` while already authenticated, the flow is: + // 1. applyLoginUrl — registers the new login trust anchor + // 2. handleExistingAuth — calls clearAuth() if user confirms + // 3. login proceeds — needs the anchor for IAP custom headers + // If clearAuth wiped the anchor at step 2, step 3 would lose it + // and IAP-protected re-authentication would fail. The anchor is + // process-local and overwritten by the NEXT applyLoginUrl, so we + // don't need to clear it on logout. + const { isLoginTrustAnchorFor, registerLoginTrustAnchor } = await import( + "../../../src/lib/token-host.js" + ); + const { isTrustedRegionOrigin, registerTrustedRegionUrls } = await import( + "../../../src/lib/db/regions.js" + ); + setAuthToken("tok-A", 3600, "refresh", { + host: "https://sentry.host-a.com", + }); + registerLoginTrustAnchor("https://sentry.host-a.com"); + registerTrustedRegionUrls(["https://us.host-a.com"]); + expect(isLoginTrustAnchorFor("https://sentry.host-a.com")).toBe(true); + expect(isTrustedRegionOrigin("https://us.host-a.com")).toBe(true); + + const { clearAuth } = await import("../../../src/lib/db/auth.js"); + await clearAuth(); + + // Region-URL allow-list cleared (was identity-specific). + expect(isTrustedRegionOrigin("https://us.host-a.com")).toBe(false); + // Login anchor PRESERVED (the `auth login --url` flow sets this + // BEFORE clearAuth runs and needs it AFTER for the device flow). + expect(isLoginTrustAnchorFor("https://sentry.host-a.com")).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/db/auth.property.test.ts b/packages/cli/test/lib/db/auth.property.test.ts new file mode 100644 index 000000000..db27388ea --- /dev/null +++ b/packages/cli/test/lib/db/auth.property.test.ts @@ -0,0 +1,204 @@ +/** + * Property-Based Tests for Auth Environment Variable Priority + * + * Verifies invariants that must hold for any valid token values: + * - SENTRY_AUTH_TOKEN always takes priority over SENTRY_TOKEN + * - Stored OAuth takes priority unless SENTRY_FORCE_ENV_TOKEN is set + * - Env tokens never trigger refresh + * - AuthConfig.source correctly identifies the origin + */ + +import { + asyncProperty, + assert as fcAssert, + option, + property, + string, + stringMatching, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + type AuthSource, + getAuthConfig, + getAuthToken, + isEnvTokenActive, + refreshToken, + resetAuthRowCache, + resetAuthTokenCache, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +useTestConfigDir("auth-prop-"); +useEnvSandbox(["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN"]); + +/** Arbitrary for non-empty, trimmed token strings */ +const tokenArb = string({ minLength: 1, maxLength: 100 }).filter( + (s) => s.trim().length > 0 +); + +/** Stored tokens must satisfy the persistence boundary; malformed inputs have separate coverage. */ +const storedTokenArb = stringMatching(/^[\x21-\x7e]{1,100}$/); + +/** Invalidate between property iterations — env-var mutations bypass setAuthToken. */ +function resetAuthCaches() { + resetAuthTokenCache(); + resetAuthRowCache(); +} + +describe("property: env var priority", () => { + test("SENTRY_AUTH_TOKEN always wins over SENTRY_TOKEN", () => { + fcAssert( + property(tokenArb, tokenArb, (authToken, sentryToken) => { + resetAuthCaches(); + process.env.SENTRY_AUTH_TOKEN = authToken; + process.env.SENTRY_TOKEN = sentryToken; + + expect(getAuthToken()).toBe(authToken.trim()); + expect(getAuthConfig()?.source).toBe( + "env:SENTRY_AUTH_TOKEN" satisfies AuthSource + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("stored OAuth wins over env var (default behavior)", () => { + fcAssert( + property(tokenArb, storedTokenArb, (envToken, storedToken) => { + resetAuthCaches(); + setAuthToken(storedToken); + process.env.SENTRY_AUTH_TOKEN = envToken; + + // Stored OAuth takes priority — env token is for build tooling + expect(getAuthToken()).toBe(storedToken); + expect(getAuthConfig()?.source).toBe("oauth" satisfies AuthSource); + expect(isEnvTokenActive()).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("SENTRY_FORCE_ENV_TOKEN overrides stored OAuth", () => { + fcAssert( + property(tokenArb, storedTokenArb, (envToken, storedToken) => { + resetAuthCaches(); + setAuthToken(storedToken); + process.env.SENTRY_AUTH_TOKEN = envToken; + try { + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + resetAuthCaches(); + expect(getAuthToken()).toBe(envToken.trim()); + expect(getAuthConfig()?.source).toBe( + "env:SENTRY_AUTH_TOKEN" satisfies AuthSource + ); + } finally { + delete process.env.SENTRY_FORCE_ENV_TOKEN; + resetAuthCaches(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("stored token used when no env vars set", () => { + fcAssert( + property(storedTokenArb, (storedToken) => { + resetAuthCaches(); + setAuthToken(storedToken); + + expect(getAuthToken()).toBe(storedToken); + expect(getAuthConfig()?.source).toBe("oauth" satisfies AuthSource); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: env tokens never trigger refresh", () => { + test("refreshToken returns env token without refreshing", async () => { + await fcAssert( + asyncProperty(tokenArb, async (envToken) => { + resetAuthCaches(); + process.env.SENTRY_AUTH_TOKEN = envToken; + + const result = await refreshToken(); + expect(result.token).toBe(envToken.trim()); + expect(result.refreshed).toBe(false); + expect(result.expiresAt).toBeUndefined(); + expect(result.expiresIn).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("refreshToken with force=true still returns env token without refreshing", async () => { + await fcAssert( + asyncProperty(tokenArb, async (envToken) => { + resetAuthCaches(); + process.env.SENTRY_AUTH_TOKEN = envToken; + + const result = await refreshToken({ force: true }); + expect(result.token).toBe(envToken.trim()); + expect(result.refreshed).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: isEnvTokenActive consistency", () => { + test("when no env token, getAuthConfig never returns env source", () => { + fcAssert( + property(option(storedTokenArb), (storedTokenOpt) => { + resetAuthCaches(); + // Clean slate — no env tokens + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + + if (storedTokenOpt !== null) { + setAuthToken(storedTokenOpt); + } + + const config = getAuthConfig(); + const envActive = isEnvTokenActive(); + + expect(envActive).toBe(false); + if (config) { + expect(config.source).toBe("oauth"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: source round-trip", () => { + test("source correctly identifies SENTRY_AUTH_TOKEN", () => { + fcAssert( + property(tokenArb, (token) => { + resetAuthCaches(); + process.env.SENTRY_AUTH_TOKEN = token; + const config = getAuthConfig(); + expect(config?.source).toBe("env:SENTRY_AUTH_TOKEN"); + // Verify we can extract the env var name + expect(config?.source.slice(4)).toBe("SENTRY_AUTH_TOKEN"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("source correctly identifies SENTRY_TOKEN", () => { + fcAssert( + property(tokenArb, (token) => { + resetAuthCaches(); + process.env.SENTRY_TOKEN = token; + const config = getAuthConfig(); + expect(config?.source).toBe("env:SENTRY_TOKEN"); + expect(config?.source.slice(4)).toBe("SENTRY_TOKEN"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/db/auth.test.ts b/packages/cli/test/lib/db/auth.test.ts new file mode 100644 index 000000000..a06d48bff --- /dev/null +++ b/packages/cli/test/lib/db/auth.test.ts @@ -0,0 +1,478 @@ +/** + * Auth Environment Variable Tests + * + * Note: Core invariants (priority, source tracking, refresh skip, isEnvTokenActive) + * are tested via property-based tests in auth.property.test.ts. These tests focus on + * edge cases (whitespace, empty strings), shape assertions, and functions not covered + * by property tests (isAuthenticated, getActiveEnvVarName). + */ + +import { describe, expect, test } from "vitest"; +import { + ANON_IDENTITY, + clearAuth, + getActiveEnvVarName, + getAuthConfig, + getAuthToken, + getIdentityFingerprint, + getRawEnvToken, + hasStoredAuthCredentials, + isAuthenticated, + isEnvTokenActive, + refreshToken, + resetAuthTokenCache, + resetHasStoredCredsCache, + resetIdentityFingerprintCache, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { MalformedAuthTokenError } from "../../../src/lib/errors.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("auth-env-"); +useEnvSandbox(["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN"]); + +describe("env var auth: getAuthToken edge cases", () => { + test("ignores empty SENTRY_AUTH_TOKEN", () => { + process.env.SENTRY_AUTH_TOKEN = ""; + setAuthToken("stored_token"); + expect(getAuthToken()).toBe("stored_token"); + }); + + test("ignores whitespace-only SENTRY_AUTH_TOKEN", () => { + process.env.SENTRY_AUTH_TOKEN = " "; + setAuthToken("stored_token"); + expect(getAuthToken()).toBe("stored_token"); + }); + + test("trims whitespace from env var", () => { + process.env.SENTRY_AUTH_TOKEN = " token_with_spaces "; + expect(getAuthToken()).toBe("token_with_spaces"); + }); +}); + +describe("env var auth: getAuthConfig shape", () => { + test("env config has no refreshToken or expiry", () => { + process.env.SENTRY_AUTH_TOKEN = "test_token"; + const config = getAuthConfig(); + expect(config?.refreshToken).toBeUndefined(); + expect(config?.expiresAt).toBeUndefined(); + expect(config?.issuedAt).toBeUndefined(); + }); +}); + +describe("env var auth: isAuthenticated", () => { + test("returns true when env var is set", async () => { + process.env.SENTRY_AUTH_TOKEN = "test_token"; + expect(isAuthenticated()).toBe(true); + }); + + test("returns false when nothing is set", async () => { + expect(isAuthenticated()).toBe(false); + }); +}); + +describe("env var auth: isEnvTokenActive edge case", () => { + test("returns false for empty env var", () => { + process.env.SENTRY_AUTH_TOKEN = ""; + expect(isEnvTokenActive()).toBe(false); + }); +}); + +describe("env var auth: getActiveEnvVarName", () => { + test("prefers SENTRY_AUTH_TOKEN when both are set", () => { + process.env.SENTRY_AUTH_TOKEN = "primary"; + process.env.SENTRY_TOKEN = "secondary"; + expect(getActiveEnvVarName()).toBe("SENTRY_AUTH_TOKEN"); + }); + + test("falls back to SENTRY_AUTH_TOKEN when no env var is set", () => { + expect(getActiveEnvVarName()).toBe("SENTRY_AUTH_TOKEN"); + }); +}); + +describe("env var auth: refreshToken edge cases", () => { + test("env token used when no stored OAuth exists", async () => { + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const result = await refreshToken(); + expect(result.token).toBe("env_token"); + expect(result.refreshed).toBe(false); + }); + + test("stored OAuth preferred over env token", async () => { + setAuthToken("stored_token", 3600); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const result = await refreshToken(); + expect(result.token).toBe("stored_token"); + expect(result.refreshed).toBe(false); + }); + + test("SENTRY_FORCE_ENV_TOKEN overrides stored OAuth in refreshToken", async () => { + setAuthToken("stored_token", 3600); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + try { + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + const result = await refreshToken(); + expect(result.token).toBe("env_token"); + expect(result.refreshed).toBe(false); + } finally { + delete process.env.SENTRY_FORCE_ENV_TOKEN; + } + }); + + test("has no expiresAt or expiresIn for env tokens", async () => { + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const result = await refreshToken(); + expect(result.expiresAt).toBeUndefined(); + expect(result.expiresIn).toBeUndefined(); + }); +}); + +describe("env var auth: getRawEnvToken", () => { + test.each([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + ] as const)("shares normalization and source selection for %s", (source) => { + process.env[source] = "\x01\u00a0synthetic-token\x7f"; + expect(getRawEnvToken()).toBe("synthetic-token"); + expect(getAuthToken()).toBe("synthetic-token"); + expect(getAuthConfig()).toMatchObject({ + token: "synthetic-token", + source: `env:${source}`, + }); + expect(getActiveEnvVarName()).toBe(source); + }); + + test("keeps a control-only primary credential selected over the alias", () => { + process.env.SENTRY_AUTH_TOKEN = "\x01\x7f"; + process.env.SENTRY_TOKEN = "secondary-token"; + expect(getRawEnvToken()).toBe("\x01\x7f"); + expect(getAuthConfig()).toMatchObject({ + token: "\x01\x7f", + source: "env:SENTRY_AUTH_TOKEN", + }); + expect(getActiveEnvVarName()).toBe("SENTRY_AUTH_TOKEN"); + }); + + test("still selects the alias when the primary is only whitespace", () => { + process.env.SENTRY_AUTH_TOKEN = " \t\n\u00a0"; + process.env.SENTRY_TOKEN = "\x01secondary-token\x7f"; + expect(getRawEnvToken()).toBe("secondary-token"); + expect(getActiveEnvVarName()).toBe("SENTRY_TOKEN"); + }); + + test("returns undefined when no env var is set", () => { + expect(getRawEnvToken()).toBeUndefined(); + }); +}); + +describe("stored credential validation", () => { + test("normalizes before SQLite can truncate surrounding NULs", () => { + setAuthToken("\0\u00a0stored-token\x7f\0", 3600, "refresh-token"); + expect(getAuthConfig()).toMatchObject({ + token: "stored-token", + refreshToken: "refresh-token", + }); + }); + + test.each([ + "prefix\0secret-tail", + "prefix\nsecret-tail", + "", + "\x01\x7f", + ])("rejects a malformed replacement without changing stored credentials %#", (token) => { + setAuthToken("previous-token", 3600, "previous-refresh-token"); + const before = getDatabase().query("SELECT * FROM auth").get(); + expect(() => setAuthToken(token, 60, "new-refresh-token")).toThrow( + MalformedAuthTokenError + ); + expect(getDatabase().query("SELECT * FROM auth").get()).toEqual(before); + expect(getAuthToken()).toBe("previous-token"); + }); +}); + +describe("OAuth-preferred auth (#646)", () => { + test("getAuthConfig prefers stored OAuth over env token", () => { + setAuthToken("stored_oauth", 3600); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const config = getAuthConfig(); + expect(config?.source).toBe("oauth"); + expect(config?.token).toBe("stored_oauth"); + }); + + test("getAuthConfig falls back to env token when no stored OAuth", () => { + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const config = getAuthConfig(); + expect(config?.source).toBe("env:SENTRY_AUTH_TOKEN"); + expect(config?.token).toBe("env_token"); + }); + + test("getAuthToken skips expired stored token and falls to env", () => { + setAuthToken("expired_token", -1); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + expect(getAuthToken()).toBe("env_token"); + }); + + test("SENTRY_FORCE_ENV_TOKEN makes getAuthConfig prefer env token", () => { + setAuthToken("stored_oauth", 3600); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + try { + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + const config = getAuthConfig(); + expect(config?.source).toBe("env:SENTRY_AUTH_TOKEN"); + expect(config?.token).toBe("env_token"); + } finally { + delete process.env.SENTRY_FORCE_ENV_TOKEN; + } + }); +}); + +describe("clearAuth: integration with per-account caches", () => { + test("clearAuth drops issue_org_cache entries (prevents cross-account leakage)", async () => { + const { setCachedIssueOrg, getCachedIssueOrg } = await import( + "../../../src/lib/db/issue-org-cache.js" + ); + + // Seed a mapping as if a previous session resolved this issue. + await setAuthToken("test-token"); + setCachedIssueOrg("12345", "previous-account-org"); + expect(getCachedIssueOrg("12345")).toBe("previous-account-org"); + + await clearAuth(); + + // Mapping must be gone — otherwise the next account would leak into + // their `issue view` fallback routing. + expect(getCachedIssueOrg("12345")).toBeUndefined(); + }); +}); + +describe("getIdentityFingerprint", () => { + test("returns the anonymous fingerprint when no token is present", () => { + expect(getIdentityFingerprint()).toBe(ANON_IDENTITY); + }); + + test("returns a stable 16-char hex fingerprint for a given env token", () => { + process.env.SENTRY_AUTH_TOKEN = "sntrys_alice"; + const fp1 = getIdentityFingerprint(); + const fp2 = getIdentityFingerprint(); + expect(fp1).toMatch(/^[0-9a-f]{16}$/); + expect(fp1).toBe(fp2); + }); + + test("different env tokens produce different fingerprints", () => { + process.env.SENTRY_AUTH_TOKEN = "sntrys_alice"; + const aliceFp = getIdentityFingerprint(); + process.env.SENTRY_AUTH_TOKEN = "sntrys_bob"; + resetIdentityFingerprintCache(); + const bobFp = getIdentityFingerprint(); + expect(aliceFp).not.toBe(bobFp); + }); + + test("SENTRY_AUTH_TOKEN and SENTRY_TOKEN produce the same fingerprint", () => { + // Same secret value → same fingerprint regardless of which env var + // holds it (the variable name is not part of the identity). + process.env.SENTRY_AUTH_TOKEN = "same_token"; + const authFp = getIdentityFingerprint(); + delete process.env.SENTRY_AUTH_TOKEN; + process.env.SENTRY_TOKEN = "same_token"; + resetIdentityFingerprintCache(); + const legacyFp = getIdentityFingerprint(); + expect(authFp).toBe(legacyFp); + }); + + test("OAuth refresh token is the identity root (stable across access-token rotation)", () => { + // Simulate two consecutive access tokens backed by the same refresh + // token — an hourly OAuth refresh must not churn the cache. + setAuthToken("access_token_1", 3600, "shared_refresh"); + const fp1 = getIdentityFingerprint(); + setAuthToken("access_token_2", 3600, "shared_refresh"); + const fp2 = getIdentityFingerprint(); + expect(fp1).toBe(fp2); + }); + + test("different OAuth refresh tokens produce different fingerprints", () => { + setAuthToken("access_token", 3600, "refresh_alice"); + const aliceFp = getIdentityFingerprint(); + setAuthToken("access_token", 3600, "refresh_bob"); + const bobFp = getIdentityFingerprint(); + expect(aliceFp).not.toBe(bobFp); + }); + + test("SENTRY_FORCE_ENV_TOKEN switches the fingerprint source to the env token", () => { + setAuthToken("stored_oauth", 3600, "stored_refresh"); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + const storedFp = getIdentityFingerprint(); + try { + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + resetIdentityFingerprintCache(); + const envFp = getIdentityFingerprint(); + expect(envFp).not.toBe(storedFp); + } finally { + delete process.env.SENTRY_FORCE_ENV_TOKEN; + } + }); + + test("env and OAuth fingerprints with the same secret value are distinct", () => { + // The `kind` prefix in hashIdentity keeps env/oauth namespaces + // distinct even when secrets happen to collide. + process.env.SENTRY_AUTH_TOKEN = "shared_secret"; + const envFp = getIdentityFingerprint(); + delete process.env.SENTRY_AUTH_TOKEN; + setAuthToken("shared_secret", 3600, "shared_secret"); + const oauthFp = getIdentityFingerprint(); + expect(envFp).not.toBe(oauthFp); + }); + + test("expired access-only OAuth token falls through to env token", () => { + // Mirrors getAuthConfig: an expired access token with no + // refresh_token is unusable — the API client sends the env token, + // so the fingerprint must match. + setAuthToken("expired_access", -1); + process.env.SENTRY_AUTH_TOKEN = "env_token"; + resetIdentityFingerprintCache(); + const fp = getIdentityFingerprint(); + + // With no DB row, same env token should produce the same fingerprint. + getDatabase().query("DELETE FROM auth").run(); + resetIdentityFingerprintCache(); + expect(getIdentityFingerprint()).toBe(fp); + }); + + test("expired access-only OAuth token with refresh_token uses the refresh token", () => { + // An expired access token + refresh_token is still usable; the + // fingerprint keys off the stable refresh_token. + setAuthToken("expired_access", -1, "live_refresh"); + const fp = getIdentityFingerprint(); + setAuthToken("fresh_access", 3600, "live_refresh"); + expect(getIdentityFingerprint()).toBe(fp); + }); +}); + +describe("getAuthToken memoization", () => { + test("returns cached value on repeated calls without re-reading the DB", () => { + setAuthToken("stored_token"); + const first = getAuthToken(); + expect(first).toBe("stored_token"); + + // Mutate the DB row directly behind getAuthToken's back — a cached + // read must not reflect this change until the cache is invalidated. + getDatabase().query("UPDATE auth SET token = 'mutated' WHERE id = 1").run(); + + // Still returns the cached value + expect(getAuthToken()).toBe("stored_token"); + + // After reset, the new value is read + resetAuthTokenCache(); + expect(getAuthToken()).toBe("mutated"); + }); + + test("caches the logged-out state (undefined) without re-reading", () => { + expect(getAuthToken()).toBeUndefined(); + + // Write a token directly to DB, bypassing setAuthToken. The cached + // undefined must persist until invalidated. + getDatabase() + .query("INSERT INTO auth (id, token, updated_at) VALUES (1, 'sneaky', ?)") + .run(Date.now()); + + expect(getAuthToken()).toBeUndefined(); + + resetAuthTokenCache(); + expect(getAuthToken()).toBe("sneaky"); + }); + + test("setAuthToken invalidates the cache", () => { + setAuthToken("token_a"); + expect(getAuthToken()).toBe("token_a"); + + setAuthToken("token_b"); + // No manual reset — setAuthToken must have invalidated the cache + expect(getAuthToken()).toBe("token_b"); + }); + + test("clearAuth invalidates the cache", async () => { + setAuthToken("token_to_clear"); + expect(getAuthToken()).toBe("token_to_clear"); + + await clearAuth(); + expect(getAuthToken()).toBeUndefined(); + }); + + test("env-var change requires manual cache reset (documented contract)", () => { + expect(getAuthToken()).toBeUndefined(); + + // Env mutation without reset: cache stays stale (by design). + process.env.SENTRY_AUTH_TOKEN = "env_token"; + expect(getAuthToken()).toBeUndefined(); + + resetAuthTokenCache(); + expect(getAuthToken()).toBe("env_token"); + }); +}); + +describe("refreshToken row-read memoization", () => { + test("setAuthToken between refreshToken calls is reflected", async () => { + // refreshToken reads the full row; invalidation must propagate so the + // second call sees the freshly stored token. + setAuthToken("first_token", 3600, "refresh_1"); + const r1 = await refreshToken(); + expect(r1.token).toBe("first_token"); + + setAuthToken("second_token", 3600, "refresh_2"); + const r2 = await refreshToken(); + expect(r2.token).toBe("second_token"); + }); + + test("clearAuth invalidates the row cache", async () => { + setAuthToken("will_be_cleared", 3600, "refresh_x"); + const r1 = await refreshToken(); + expect(r1.token).toBe("will_be_cleared"); + + await clearAuth(); + // With nothing stored and no env var, refreshToken throws not_authenticated + await expect(refreshToken()).rejects.toThrow(); + }); +}); + +describe("hasStoredAuthCredentials memoization", () => { + test("returns false and caches when no stored token", () => { + expect(hasStoredAuthCredentials()).toBe(false); + // Second call hits cache — would be identical even without memoization, + // but we verify the function is stable across calls. + expect(hasStoredAuthCredentials()).toBe(false); + }); + + test("returns true after setAuthToken", () => { + expect(hasStoredAuthCredentials()).toBe(false); + + setAuthToken("oauth_tok", 3600, "refresh_tok"); + // setAuthToken invalidates the cache — next call sees the new row. + expect(hasStoredAuthCredentials()).toBe(true); + }); + + test("clearAuth invalidates the cached result", async () => { + setAuthToken("oauth_tok", 3600, "refresh_tok"); + expect(hasStoredAuthCredentials()).toBe(true); + + await clearAuth(); + expect(hasStoredAuthCredentials()).toBe(false); + }); + + test("manual resetHasStoredCredsCache forces re-read", () => { + expect(hasStoredAuthCredentials()).toBe(false); + + // Write directly to DB without going through setAuthToken + // (simulates a code path the cache doesn't know about). + const db = getDatabase(); + db.query( + "INSERT OR REPLACE INTO auth (id, token) VALUES (1, 'sneaky')" + ).run(); + + // Cache still returns stale false + expect(hasStoredAuthCredentials()).toBe(false); + + // Manual reset forces re-read + resetHasStoredCredsCache(); + expect(hasStoredAuthCredentials()).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/db/cache-corruption.test.ts b/packages/cli/test/lib/db/cache-corruption.test.ts new file mode 100644 index 000000000..d8eea0144 --- /dev/null +++ b/packages/cli/test/lib/db/cache-corruption.test.ts @@ -0,0 +1,64 @@ +/** + * Corruption-resilience tests for the SQLite cache readers. + * + * Cached JSON columns can be corrupted by partial writes, manual edits, or + * schema drift. These tests assert that the readers treat such corruption as a + * cache miss (returning undefined and clearing the bad row where applicable) + * instead of throwing a SyntaxError that would crash the command. + */ + +import { describe, expect, test } from "vitest"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { getPaginationState } from "../../../src/lib/db/pagination.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("cache-corruption-"); + +const CMD_KEY = "corrupt-list"; +const CTX_KEY = "org:test|sort:date"; + +/** Insert a raw pagination row with the given (possibly invalid) cursor_stack. */ +function insertPaginationRow(cursorStack: string): void { + const db = getDatabase(); + db.query( + `INSERT OR REPLACE INTO pagination_cursors + (command_key, context, cursor_stack, page_index, expires_at) + VALUES (?, ?, ?, ?, ?)` + ).run(CMD_KEY, CTX_KEY, cursorStack, 0, Date.now() + 60_000); +} + +function paginationRowCount(): number { + const db = getDatabase(); + const row = db + .query( + "SELECT COUNT(*) AS n FROM pagination_cursors WHERE command_key = ? AND context = ?" + ) + .get(CMD_KEY, CTX_KEY) as { n: number }; + return row.n; +} + +describe("getPaginationState corruption handling", () => { + test("returns undefined and deletes the row on unparseable JSON", () => { + insertPaginationRow("{not valid json"); + expect(paginationRowCount()).toBe(1); + + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeUndefined(); + expect(paginationRowCount()).toBe(0); + }); + + test("returns undefined and deletes the row when JSON is not an array", () => { + insertPaginationRow(JSON.stringify({ not: "an array" })); + expect(paginationRowCount()).toBe(1); + + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeUndefined(); + expect(paginationRowCount()).toBe(0); + }); + + test("returns the parsed stack for valid array JSON", () => { + insertPaginationRow(JSON.stringify(["", "cursor-1"])); + + const state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state?.stack).toEqual(["", "cursor-1"]); + expect(state?.index).toBe(0); + }); +}); diff --git a/packages/cli/test/lib/db/concurrent-worker.ts b/packages/cli/test/lib/db/concurrent-worker.ts new file mode 100644 index 000000000..8508c3ce4 --- /dev/null +++ b/packages/cli/test/lib/db/concurrent-worker.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env tsx +/** + * Worker script for concurrent database access tests. + * Spawned by concurrent.test.ts to simulate multiple CLI instances. + * + * Usage: tsx test/lib/db/concurrent-worker.ts + * + * Operations: + * write-dsn - Write a unique DSN cache entry + * write-project - Write a unique project cache entry + * read-write - Mixed read/write operations + */ + +import { mkdirSync } from "node:fs"; + +// Set config dir from CLI arg before importing db modules +const configDir = process.argv[2]; +const workerId = process.argv[3]; +const operation = process.argv[4]; + +if (!(configDir && workerId && operation)) { + console.error( + "Usage: tsx concurrent-worker.ts " + ); + process.exit(1); +} + +// Ensure config dir exists +mkdirSync(configDir, { recursive: true }); + +// Set env var before importing db modules +process.env.SENTRY_CONFIG_DIR = configDir; + +// Now import db modules (they'll use the env var) +const { setCachedDsn, getCachedDsn } = await import( + "../../../src/lib/db/dsn-cache.js" +); +const { setCachedProject, getCachedProject } = await import( + "../../../src/lib/db/project-cache.js" +); +const { closeDatabase } = await import("../../../src/lib/db/index.js"); + +type WorkerResult = { + workerId: string; + operation: string; + success: boolean; + error?: string; + data?: unknown; +}; + +async function writeDsn(): Promise { + const directory = `/test/project-${workerId}`; + const dsn = `https://key${workerId}@sentry.io/123${workerId}`; + + setCachedDsn(directory, { + dsn, + projectId: `123${workerId}`, + orgId: "456", + source: "env-file", + }); + + // Verify write + const cached = getCachedDsn(directory); + if (cached?.dsn !== dsn) { + return { + workerId, + operation: "write-dsn", + success: false, + error: `Verification failed: expected ${dsn}, got ${cached?.dsn}`, + }; + } + + return { + workerId, + operation: "write-dsn", + success: true, + data: { directory, dsn }, + }; +} + +async function writeProject(): Promise { + const orgId = "org-456"; + const projectId = `proj-${workerId}`; + + setCachedProject(orgId, projectId, { + orgSlug: "test-org", + orgName: "Test Org", + projectSlug: `project-${workerId}`, + projectName: `Project ${workerId}`, + }); + + // Verify write + const cached = getCachedProject(orgId, projectId); + if (cached?.projectSlug !== `project-${workerId}`) { + return { + workerId, + operation: "write-project", + success: false, + error: `Verification failed: expected project-${workerId}, got ${cached?.projectSlug}`, + }; + } + + return { + workerId, + operation: "write-project", + success: true, + data: { orgId, projectId }, + }; +} + +async function readWrite(): Promise { + // Mixed operations: write DSN, read it back, write project, read it back + const iterations = 5; + const results: string[] = []; + + for (let i = 0; i < iterations; i++) { + const directory = `/test/worker-${workerId}-iter-${i}`; + const dsn = `https://key${workerId}${i}@sentry.io/${workerId}${i}`; + + setCachedDsn(directory, { + dsn, + projectId: `${workerId}${i}`, + source: "env-file", + }); + + const cached = getCachedDsn(directory); + if (cached?.dsn !== dsn) { + return { + workerId, + operation: "read-write", + success: false, + error: `Iteration ${i}: expected ${dsn}, got ${cached?.dsn}`, + }; + } + + results.push(`iter-${i}-ok`); + } + + return { + workerId, + operation: "read-write", + success: true, + data: { iterations, results }, + }; +} + +async function main(): Promise { + let result: WorkerResult; + + try { + switch (operation) { + case "write-dsn": + result = await writeDsn(); + break; + case "write-project": + result = await writeProject(); + break; + case "read-write": + result = await readWrite(); + break; + default: + result = { + workerId, + operation, + success: false, + error: `Unknown operation: ${operation}`, + }; + } + } catch (error) { + result = { + workerId, + operation, + success: false, + error: + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error), + }; + } finally { + closeDatabase(); + } + + // Output result as JSON for parent process to parse + console.log(JSON.stringify(result)); +} + +main(); diff --git a/packages/cli/test/lib/db/concurrent.test.ts b/packages/cli/test/lib/db/concurrent.test.ts new file mode 100644 index 000000000..089baf087 --- /dev/null +++ b/packages/cli/test/lib/db/concurrent.test.ts @@ -0,0 +1,217 @@ +/** + * Concurrent Database Access Tests + * + * Tests that multiple CLI processes can safely access the SQLite database + * simultaneously without SQLITE_BUSY errors or data corruption. + * + * These tests spawn tsx subprocesses to simulate real concurrent + * CLI usage (e.g., multiple terminals, CI jobs, editor integrations). + */ + +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { beforeEach, describe, expect, test } from "vitest"; + +function noop(): void { + // Intentionally empty — absorbs async spawn errors +} + +import { getCachedDsn } from "../../../src/lib/db/dsn-cache.js"; +import { + CONFIG_DIR_ENV_VAR, + closeDatabase, +} from "../../../src/lib/db/index.js"; +import { getCachedProject } from "../../../src/lib/db/project-cache.js"; +import { useTestConfigDir } from "../../helpers.js"; + +const WORKER_SCRIPT = join(import.meta.dirname, "concurrent-worker.ts"); + +type WorkerResult = { + workerId: string; + operation: string; + success: boolean; + error?: string; + data?: unknown; +}; + +/** + * Spawn a worker process and wait for its result. + */ +async function spawnWorker( + configDir: string, + workerId: string, + operation: string +): Promise { + const requireShim = join( + import.meta.dirname, + "../../../script/require-shim.mjs" + ); + const proc = spawn( + process.execPath, + [ + "--import", + "tsx/esm", + "--import", + requireShim, + WORKER_SCRIPT, + configDir, + workerId, + operation, + ], + { stdio: ["pipe", "pipe", "pipe"] } + ); + proc.on("error", noop); + + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d: Buffer) => { + stdout += d; + }); + proc.stderr.on("data", (d: Buffer) => { + stderr += d; + }); + + const exitCode = await new Promise((done) => + proc.on("close", (code) => done(code ?? 1)) + ); + + if (exitCode !== 0) { + return { + workerId, + operation, + success: false, + error: `Process exited with code ${exitCode}: ${stderr || stdout}`, + }; + } + + try { + return JSON.parse(stdout.trim()); + } catch { + return { + workerId, + operation, + success: false, + error: `Failed to parse worker output: ${stdout}`, + }; + } +} + +/** + * Spawn multiple workers concurrently and collect results. + */ +async function spawnWorkersConcurrently( + configDir: string, + workerCount: number, + operation: string +): Promise { + const promises: Promise[] = []; + + for (let i = 0; i < workerCount; i++) { + promises.push(spawnWorker(configDir, String(i), operation)); + } + + return Promise.all(promises); +} + +describe("concurrent database access", () => { + const getConfigDir = useTestConfigDir("concurrent-"); + + beforeEach(async () => { + // Pre-create the database before spawning workers. + // This ensures schema initialization completes before concurrent access, + // which avoids lock contention during initial table creation. + const { getDatabase } = await import("../../../src/lib/db/index.js"); + getDatabase(); + closeDatabase(); + }); + + test("multiple processes can write DSN cache entries simultaneously", async () => { + const workerCount = 5; + const results = await spawnWorkersConcurrently( + getConfigDir(), + workerCount, + "write-dsn" + ); + + // All workers should succeed + expect(results.filter((r) => !r.success)).toHaveLength(0); + + // Verify all entries are present in the database + closeDatabase(); // Close to re-open with fresh connection + process.env[CONFIG_DIR_ENV_VAR] = getConfigDir(); + + for (let i = 0; i < workerCount; i++) { + const directory = `/test/project-${i}`; + const cached = getCachedDsn(directory); + expect(cached).toBeDefined(); + expect(cached?.dsn).toBe(`https://key${i}@sentry.io/123${i}`); + } + }); + + test("multiple processes can write project cache entries simultaneously", async () => { + const workerCount = 5; + const results = await spawnWorkersConcurrently( + getConfigDir(), + workerCount, + "write-project" + ); + + // All workers should succeed + expect(results.filter((r) => !r.success)).toHaveLength(0); + + // Verify all entries are present + closeDatabase(); + process.env[CONFIG_DIR_ENV_VAR] = getConfigDir(); + + for (let i = 0; i < workerCount; i++) { + const cached = getCachedProject("org-456", `proj-${i}`); + expect(cached).toBeDefined(); + expect(cached?.projectSlug).toBe(`project-${i}`); + } + }); + + test("mixed read/write operations from multiple processes succeed", async () => { + const workerCount = 5; + const results = await spawnWorkersConcurrently( + getConfigDir(), + workerCount, + "read-write" + ); + + // All workers should succeed + expect(results.filter((r) => !r.success)).toHaveLength(0); + + // Each worker did 5 iterations, verify some entries + closeDatabase(); + process.env[CONFIG_DIR_ENV_VAR] = getConfigDir(); + + for (let w = 0; w < workerCount; w++) { + for (let i = 0; i < 5; i++) { + const directory = `/test/worker-${w}-iter-${i}`; + const cached = getCachedDsn(directory); + expect(cached).toBeDefined(); + expect(cached?.dsn).toBe(`https://key${w}${i}@sentry.io/${w}${i}`); + } + } + }); + + test("handles contention without SQLITE_BUSY errors", async () => { + // Run a larger batch to increase contention likelihood + const workerCount = 10; + const results = await spawnWorkersConcurrently( + getConfigDir(), + workerCount, + "write-dsn" + ); + + // Check for SQLITE_BUSY errors specifically + const busyErrors = results.filter( + (r) => !r.success && r.error?.includes("SQLITE_BUSY") + ); + expect(busyErrors).toHaveLength(0); + + // All should succeed thanks to WAL mode and busy_timeout + const failures = results.filter((r) => !r.success); + expect(failures).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/lib/db/dsn-cache.model-based.test.ts b/packages/cli/test/lib/db/dsn-cache.model-based.test.ts new file mode 100644 index 000000000..e2b5dc7c3 --- /dev/null +++ b/packages/cli/test/lib/db/dsn-cache.model-based.test.ts @@ -0,0 +1,585 @@ +/** + * Model-Based Testing for DSN and Project Cache + * + * Uses fast-check to generate random sequences of cache operations + * and verify the system behaves correctly against a simplified model. + */ + +// biome-ignore-all lint/suspicious/noMisplacedAssertion: Model-based testing uses expect() inside command classes + +import { + type AsyncCommand, + asyncModelRun, + asyncProperty, + commands, + constant, + constantFrom, + assert as fcAssert, + option, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + clearDsnCache, + getCachedDsn, + setCachedDsn, + updateCachedResolution, +} from "../../../src/lib/db/dsn-cache.js"; +import { + clearProjectCache, + getCachedProject, + getCachedProjectByDsnKey, + setCachedProject, + setCachedProjectByDsnKey, +} from "../../../src/lib/db/project-cache.js"; +import type { CachedDsnEntry } from "../../../src/lib/dsn/types.js"; +import { + createIsolatedDbContext, + DEFAULT_NUM_RUNS, +} from "../../model-based/helpers.js"; + +/** + * Model for DSN cache state + */ +type DsnCacheModel = { + entries: Map< + string, + { + dsn: string; + projectId: string; + orgId?: string; + source: CachedDsnEntry["source"]; + sourcePath?: string; + resolved?: { + orgSlug: string; + orgName: string; + projectSlug: string; + projectName: string; + }; + } + >; +}; + +/** + * Model for project cache state + */ +type ProjectCacheModel = { + entries: Map< + string, + { + orgSlug: string; + orgName: string; + projectSlug: string; + projectName: string; + } + >; +}; + +type CacheModel = { + dsnCache: DsnCacheModel; + projectCache: ProjectCacheModel; +}; + +type RealCache = Record; + +function createEmptyModel(): CacheModel { + return { + dsnCache: { entries: new Map() }, + projectCache: { entries: new Map() }, + }; +} + +type ResolvedInfo = { + orgSlug: string; + orgName: string; + projectSlug: string; + projectName: string; +}; + +type DsnEntryInput = { + directory: string; + dsn: string; + projectId: string; + orgId: string | undefined; + source: CachedDsnEntry["source"]; + sourcePath: string | undefined; +}; + +// Arbitraries + +const directoryArb = constantFrom( + "/home/user/project1", + "/home/user/project2", + "/tmp/test", + "/var/app" +); + +const dsnArb = tuple( + constantFrom("abc123", "def456", "xyz789"), + constantFrom("12345", "67890", "11111"), + constantFrom("sentry.io", "us.sentry.io", "de.sentry.io") +).map(([key, projectId, host]) => `https://${key}@${host}/${projectId}`); + +const sourceArb = constantFrom( + "env" as CachedDsnEntry["source"], + "env_file" as CachedDsnEntry["source"], + "code" as CachedDsnEntry["source"] +); + +const slugArb = constantFrom( + "my-org", + "acme-corp", + "test-org", + "my-project", + "backend", + "frontend" +); + +const projectIdArb = constantFrom("12345", "67890", "11111", "22222"); +const orgIdArb = constantFrom("100", "200", "300"); +const publicKeyArb = constantFrom("abc123", "def456", "xyz789", "key111"); + +const resolvedInfoArb = tuple(slugArb, slugArb, slugArb, slugArb).map( + ([orgSlug, orgName, projectSlug, projectName]) => ({ + orgSlug, + orgName, + projectSlug, + projectName, + }) +); + +// DSN Cache Commands + +class SetCachedDsnCommand implements AsyncCommand { + private readonly entry: DsnEntryInput; + + constructor(entry: DsnEntryInput) { + this.entry = entry; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + const { directory, dsn, projectId, orgId, source, sourcePath } = this.entry; + + setCachedDsn(directory, { + dsn, + projectId, + orgId, + source, + sourcePath, + }); + + model.dsnCache.entries.set(directory, { + dsn, + projectId, + orgId, + source, + sourcePath, + }); + } + + toString(): string { + return `setCachedDsn("${this.entry.directory}", {dsn: "${this.entry.dsn}", ...})`; + } +} + +class GetCachedDsnCommand implements AsyncCommand { + private readonly directory: string; + + constructor(directory: string) { + this.directory = directory; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + const realEntry = getCachedDsn(this.directory); + const modelEntry = model.dsnCache.entries.get(this.directory); + + if (modelEntry) { + expect(realEntry).toBeDefined(); + expect(realEntry?.dsn).toBe(modelEntry.dsn); + expect(realEntry?.projectId).toBe(modelEntry.projectId); + expect(realEntry?.orgId).toBe(modelEntry.orgId); + expect(realEntry?.source).toBe(modelEntry.source); + expect(realEntry?.sourcePath).toBe(modelEntry.sourcePath); + expect(realEntry?.resolved).toEqual(modelEntry.resolved); + } else { + expect(realEntry).toBeUndefined(); + } + } + + toString(): string { + return `getCachedDsn("${this.directory}")`; + } +} + +class UpdateCachedResolutionCommand + implements AsyncCommand +{ + private readonly directory: string; + private readonly resolved: ResolvedInfo; + + constructor(directory: string, resolved: ResolvedInfo) { + this.directory = directory; + this.resolved = resolved; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + updateCachedResolution(this.directory, this.resolved); + + // Only updates if entry exists + const existing = model.dsnCache.entries.get(this.directory); + if (existing) { + existing.resolved = this.resolved; + } + } + + toString(): string { + return `updateCachedResolution("${this.directory}", {orgSlug: "${this.resolved.orgSlug}", ...})`; + } +} + +class ClearDsnCacheCommand implements AsyncCommand { + private readonly directory: string | undefined; + + constructor(directory: string | undefined) { + this.directory = directory; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + clearDsnCache(this.directory); + + if (this.directory) { + model.dsnCache.entries.delete(this.directory); + } else { + model.dsnCache.entries.clear(); + } + } + + toString(): string { + return this.directory + ? `clearDsnCache("${this.directory}")` + : "clearDsnCache()"; + } +} + +// Project Cache Commands + +type ProjectCacheInput = { + orgId: string; + projectId: string; + info: ResolvedInfo; +}; + +class SetCachedProjectCommand implements AsyncCommand { + private readonly input: ProjectCacheInput; + + constructor(input: ProjectCacheInput) { + this.input = input; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + const { orgId, projectId, info } = this.input; + setCachedProject(orgId, projectId, info); + + const key = `${orgId}:${projectId}`; + model.projectCache.entries.set(key, info); + } + + toString(): string { + return `setCachedProject("${this.input.orgId}", "${this.input.projectId}", {...})`; + } +} + +type ProjectLookup = { + orgId: string; + projectId: string; +}; + +class GetCachedProjectCommand implements AsyncCommand { + private readonly lookup: ProjectLookup; + + constructor(lookup: ProjectLookup) { + this.lookup = lookup; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + const { orgId, projectId } = this.lookup; + const realEntry = getCachedProject(orgId, projectId); + const key = `${orgId}:${projectId}`; + const modelEntry = model.projectCache.entries.get(key); + + if (modelEntry) { + expect(realEntry).toBeDefined(); + expect(realEntry?.orgSlug).toBe(modelEntry.orgSlug); + expect(realEntry?.orgName).toBe(modelEntry.orgName); + expect(realEntry?.projectSlug).toBe(modelEntry.projectSlug); + expect(realEntry?.projectName).toBe(modelEntry.projectName); + } else { + expect(realEntry).toBeUndefined(); + } + } + + toString(): string { + return `getCachedProject("${this.lookup.orgId}", "${this.lookup.projectId}")`; + } +} + +type DsnKeyInput = { + publicKey: string; + info: ResolvedInfo; +}; + +class SetCachedProjectByDsnKeyCommand + implements AsyncCommand +{ + private readonly input: DsnKeyInput; + + constructor(input: DsnKeyInput) { + this.input = input; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + const { publicKey, info } = this.input; + setCachedProjectByDsnKey(publicKey, info); + + const key = `dsn:${publicKey}`; + model.projectCache.entries.set(key, info); + } + + toString(): string { + return `setCachedProjectByDsnKey("${this.input.publicKey}", {...})`; + } +} + +class GetCachedProjectByDsnKeyCommand + implements AsyncCommand +{ + private readonly publicKey: string; + + constructor(publicKey: string) { + this.publicKey = publicKey; + } + + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + const realEntry = getCachedProjectByDsnKey(this.publicKey); + const key = `dsn:${this.publicKey}`; + const modelEntry = model.projectCache.entries.get(key); + + if (modelEntry) { + expect(realEntry).toBeDefined(); + expect(realEntry?.orgSlug).toBe(modelEntry.orgSlug); + expect(realEntry?.orgName).toBe(modelEntry.orgName); + expect(realEntry?.projectSlug).toBe(modelEntry.projectSlug); + expect(realEntry?.projectName).toBe(modelEntry.projectName); + } else { + expect(realEntry).toBeUndefined(); + } + } + + toString(): string { + return `getCachedProjectByDsnKey("${this.publicKey}")`; + } +} + +class ClearProjectCacheCommand implements AsyncCommand { + check = () => true; + + async run(model: CacheModel, _real: RealCache): Promise { + clearProjectCache(); + model.projectCache.entries.clear(); + } + + toString = () => "clearProjectCache()"; +} + +// Command Arbitraries + +const dsnEntryArb = tuple( + directoryArb, + dsnArb, + projectIdArb, + option(orgIdArb, { nil: undefined }), + sourceArb, + option(constantFrom(".env", "src/index.ts", "config.py"), { nil: undefined }) +).map(([directory, dsn, projectId, orgId, source, sourcePath]) => ({ + directory, + dsn, + projectId, + orgId, + source, + sourcePath, +})); + +const setCachedDsnCmdArb = dsnEntryArb.map( + (entry) => new SetCachedDsnCommand(entry) +); + +const getCachedDsnCmdArb = directoryArb.map( + (dir) => new GetCachedDsnCommand(dir) +); + +const updateResolutionCmdArb = tuple(directoryArb, resolvedInfoArb).map( + ([dir, resolved]) => new UpdateCachedResolutionCommand(dir, resolved) +); + +const clearDsnCacheCmdArb = option(directoryArb, { nil: undefined }).map( + (dir) => new ClearDsnCacheCommand(dir) +); + +const setCachedProjectCmdArb = tuple( + orgIdArb, + projectIdArb, + resolvedInfoArb +).map( + ([orgId, projectId, info]) => + new SetCachedProjectCommand({ orgId, projectId, info }) +); + +const getCachedProjectCmdArb = tuple(orgIdArb, projectIdArb).map( + ([orgId, projectId]) => new GetCachedProjectCommand({ orgId, projectId }) +); + +const setCachedProjectByDsnKeyCmdArb = tuple(publicKeyArb, resolvedInfoArb).map( + ([publicKey, info]) => + new SetCachedProjectByDsnKeyCommand({ publicKey, info }) +); + +const getCachedProjectByDsnKeyCmdArb = publicKeyArb.map( + (key) => new GetCachedProjectByDsnKeyCommand(key) +); + +const clearProjectCacheCmdArb = constant(new ClearProjectCacheCommand()); + +// All Commands + +const allCommands = [ + // DSN cache commands + setCachedDsnCmdArb, + getCachedDsnCmdArb, + updateResolutionCmdArb, + clearDsnCacheCmdArb, + // Project cache commands + setCachedProjectCmdArb, + getCachedProjectCmdArb, + setCachedProjectByDsnKeyCmdArb, + getCachedProjectByDsnKeyCmdArb, + clearProjectCacheCmdArb, +]; + +// Tests + +describe("model-based: DSN and project cache", () => { + test("random sequences of cache operations maintain consistency", async () => { + await fcAssert( + asyncProperty(commands(allCommands, { size: "+1" }), async (cmds) => { + const cleanup = createIsolatedDbContext(); + try { + const setup = () => ({ + model: createEmptyModel(), + real: {} as RealCache, + }); + + await asyncModelRun(setup, cmds); + } finally { + cleanup(); + } + }), + { + numRuns: DEFAULT_NUM_RUNS, + verbose: false, + } + ); + }); + + test("updateCachedResolution only updates existing entries", async () => { + await fcAssert( + asyncProperty( + tuple(directoryArb, resolvedInfoArb), + async ([directory, resolved]) => { + const cleanup = createIsolatedDbContext(); + try { + // Try to update resolution for non-existent entry + updateCachedResolution(directory, resolved); + + // Entry should still not exist + const entry = getCachedDsn(directory); + expect(entry).toBeUndefined(); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("clearDsnCache with directory only clears that entry", async () => { + await fcAssert( + asyncProperty( + tuple(directoryArb, directoryArb, dsnArb, projectIdArb, sourceArb), + async ([dir1, dir2, dsn, projectId, source]) => { + // Skip if directories are the same + if (dir1 === dir2) return; + + const cleanup = createIsolatedDbContext(); + try { + // Set up two entries + setCachedDsn(dir1, { dsn, projectId, source }); + setCachedDsn(dir2, { dsn, projectId, source }); + + // Clear only dir1 + clearDsnCache(dir1); + + // dir1 should be gone, dir2 should still exist + expect(getCachedDsn(dir1)).toBeUndefined(); + expect(getCachedDsn(dir2)).toBeDefined(); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("project cache by DSN key is separate from org:project key", async () => { + await fcAssert( + asyncProperty( + tuple(orgIdArb, projectIdArb, publicKeyArb, resolvedInfoArb), + async ([orgId, projectId, publicKey, info]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set by org:project + setCachedProject(orgId, projectId, info); + + // Getting by DSN key should return undefined + const byDsnKey = getCachedProjectByDsnKey(publicKey); + expect(byDsnKey).toBeUndefined(); + + // Getting by org:project should return the entry + const byOrgProject = getCachedProject(orgId, projectId); + expect(byOrgProject).toBeDefined(); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/db/dsn-cache.test.ts b/packages/cli/test/lib/db/dsn-cache.test.ts new file mode 100644 index 000000000..e80520cd2 --- /dev/null +++ b/packages/cli/test/lib/db/dsn-cache.test.ts @@ -0,0 +1,562 @@ +/** + * DSN Cache Tests + * + * Tests for both single-DSN caching and full detection caching with mtime validation. + */ + +import { mkdirSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + clearDsnCache, + disableDsnCache, + enableDsnCache, + getCachedDetection, + getCachedDsn, + setCachedDetection, + setCachedDsn, + updateCachedResolution, +} from "../../../src/lib/db/dsn-cache.js"; +import type { DetectedDsn } from "../../../src/lib/dsn/types.js"; +import { useTestConfigDir } from "../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-dsn-cache-"); +let testProjectDir: string; + +beforeEach(() => { + // Create a test project directory with source files + testProjectDir = join(getConfigDir(), "project"); + mkdirSync(testProjectDir, { recursive: true }); + mkdirSync(join(testProjectDir, "src"), { recursive: true }); + writeFileSync( + join(testProjectDir, "src/app.ts"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); +}); + +afterEach(() => { + enableDsnCache(); +}); + +// ============================================================================= +// Single DSN Cache Tests (Original functionality) +// ============================================================================= + +describe("getCachedDsn", () => { + test("returns undefined when no cache entry exists", async () => { + const result = getCachedDsn("/nonexistent/path"); + expect(result).toBeUndefined(); + }); + + test("returns cached entry when it exists", async () => { + setCachedDsn(testProjectDir, { + dsn: "https://abc@o123.ingest.sentry.io/456", + projectId: "456", + orgId: "123", + source: "env", + sourcePath: ".env", + }); + + const result = getCachedDsn(testProjectDir); + expect(result?.dsn).toBe("https://abc@o123.ingest.sentry.io/456"); + expect(result?.projectId).toBe("456"); + expect(result?.orgId).toBe("123"); + expect(result?.source).toBe("env"); + expect(result?.sourcePath).toBe(".env"); + }); + + test("treats empty DSN rows from setCachedDetection as cache misses", async () => { + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "fp-empty", + allDsns: [], + sourceMtimes: {}, + dirMtimes: {}, + rootDirMtime, + }); + + // Single-DSN callers must not receive dsn="". + expect(getCachedDsn(testProjectDir)).toBeUndefined(); + + // Full-detection cache still records a valid empty scan so we don't rescan. + const detection = await getCachedDetection(testProjectDir); + expect(detection?.allDsns).toHaveLength(0); + }); +}); + +describe("setCachedDsn", () => { + test("stores DSN cache entry", async () => { + setCachedDsn(testProjectDir, { + dsn: "https://key@o999.ingest.sentry.io/111", + projectId: "111", + orgId: "999", + source: "code", + sourcePath: "src/index.ts", + }); + + const result = getCachedDsn(testProjectDir); + expect(result?.dsn).toBe("https://key@o999.ingest.sentry.io/111"); + expect(result?.projectId).toBe("111"); + }); + + test("overwrites existing cache entry", async () => { + setCachedDsn(testProjectDir, { + dsn: "https://first@o1.ingest.sentry.io/1", + projectId: "1", + source: "env", + }); + + setCachedDsn(testProjectDir, { + dsn: "https://second@o2.ingest.sentry.io/2", + projectId: "2", + source: "code", + }); + + const result = getCachedDsn(testProjectDir); + expect(result?.dsn).toBe("https://second@o2.ingest.sentry.io/2"); + expect(result?.projectId).toBe("2"); + }); +}); + +describe("updateCachedResolution", () => { + test("updates resolved org/project info", async () => { + setCachedDsn(testProjectDir, { + dsn: "https://abc@o123.ingest.sentry.io/456", + projectId: "456", + source: "env", + }); + + updateCachedResolution(testProjectDir, { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const result = getCachedDsn(testProjectDir); + expect(result?.resolved?.orgSlug).toBe("my-org"); + expect(result?.resolved?.orgName).toBe("My Organization"); + expect(result?.resolved?.projectSlug).toBe("my-project"); + expect(result?.resolved?.projectName).toBe("My Project"); + }); + + test("does nothing if cache entry does not exist", async () => { + // Should not throw + updateCachedResolution("/nonexistent", { + orgSlug: "org", + orgName: "Org", + projectSlug: "project", + projectName: "Project", + }); + }); +}); + +describe("clearDsnCache", () => { + test("removes specific directory cache", async () => { + setCachedDsn(testProjectDir, { + dsn: "https://abc@o123.ingest.sentry.io/456", + projectId: "456", + source: "env", + }); + + clearDsnCache(testProjectDir); + + const result = getCachedDsn(testProjectDir); + expect(result).toBeUndefined(); + }); + + test("removes all caches when no directory specified", async () => { + const dir1 = join(getConfigDir(), "dir1"); + const dir2 = join(getConfigDir(), "dir2"); + mkdirSync(dir1); + mkdirSync(dir2); + + setCachedDsn(dir1, { + dsn: "https://a@o1.ingest.sentry.io/1", + projectId: "1", + source: "env", + }); + setCachedDsn(dir2, { + dsn: "https://b@o2.ingest.sentry.io/2", + projectId: "2", + source: "code", + }); + + clearDsnCache(); + + expect(getCachedDsn(dir1)).toBeUndefined(); + expect(getCachedDsn(dir2)).toBeUndefined(); + }); +}); + +const createTestDsn = (overrides: Partial = {}): DetectedDsn => ({ + protocol: "https", + publicKey: "testkey", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + raw: "https://testkey@o123.ingest.sentry.io/456", + source: "code", + sourcePath: "src/app.ts", + ...overrides, +}); + +// ============================================================================= +// Cache Bypass Tests (--fresh flag support) +// ============================================================================= + +describe("disableDsnCache / enableDsnCache", () => { + test("getCachedDsn returns undefined when cache is disabled", async () => { + setCachedDsn(testProjectDir, { + dsn: "https://abc@o123.ingest.sentry.io/456", + projectId: "456", + orgId: "123", + source: "code", + }); + + // Verify it exists before disabling + expect(getCachedDsn(testProjectDir)).toBeDefined(); + + disableDsnCache(); + expect(getCachedDsn(testProjectDir)).toBeUndefined(); + + // Re-enable and verify it's still there + enableDsnCache(); + expect(getCachedDsn(testProjectDir)).toBeDefined(); + }); + + test("getCachedDetection returns undefined when cache is disabled", async () => { + const testDsn = createTestDsn(); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "test-fp", + allDsns: [testDsn], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + // Verify it exists before disabling + expect(await getCachedDetection(testProjectDir)).toBeDefined(); + + disableDsnCache(); + expect(await getCachedDetection(testProjectDir)).toBeUndefined(); + + enableDsnCache(); + expect(await getCachedDetection(testProjectDir)).toBeDefined(); + }); + + test("cache writes still work when disabled", async () => { + disableDsnCache(); + + // Write while disabled + setCachedDsn(testProjectDir, { + dsn: "https://abc@o123.ingest.sentry.io/456", + projectId: "456", + source: "code", + }); + + // Can't read while disabled + expect(getCachedDsn(testProjectDir)).toBeUndefined(); + + // Re-enable and verify the write persisted + enableDsnCache(); + const result = getCachedDsn(testProjectDir); + expect(result?.dsn).toBe("https://abc@o123.ingest.sentry.io/456"); + }); +}); + +// ============================================================================= +// Full Detection Cache Tests (v4 functionality) +// ============================================================================= + +describe("getCachedDetection", () => { + test("returns undefined when no cache entry exists", async () => { + const result = await getCachedDetection("/nonexistent/path"); + expect(result).toBeUndefined(); + }); + + test("returns cached detection when valid", async () => { + const testDsn = createTestDsn(); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + // Get current root dir mtime + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "test-fingerprint", + allDsns: [testDsn], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + const result = await getCachedDetection(testProjectDir); + expect(result).toBeDefined(); + expect(result?.fingerprint).toBe("test-fingerprint"); + expect(result?.allDsns).toHaveLength(1); + expect(result?.allDsns[0].raw).toBe(testDsn.raw); + }); + + test("invalidates cache when source file mtime changes", async () => { + const testDsn = createTestDsn(); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "test-fingerprint", + allDsns: [testDsn], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + // Verify cache exists + const before = await getCachedDetection(testProjectDir); + expect(before).toBeDefined(); + + // Modify source file and set mtime to cachedMtime + 5s to guarantee + // a detectable difference regardless of OS clock resolution. + const srcFile = join(testProjectDir, "src/app.ts"); + writeFileSync( + srcFile, + 'const DSN = "https://changed@o123.ingest.sentry.io/789";' + ); + const futureTime = new Date(sourceMtimes["src/app.ts"] + 5000); + utimesSync(srcFile, futureTime, futureTime); + + // Cache should be invalidated + const after = await getCachedDetection(testProjectDir); + expect(after).toBeUndefined(); + }); + + test("invalidates cache when source file is deleted", async () => { + const testDsn = createTestDsn(); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "test-fingerprint", + allDsns: [testDsn], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + // Delete the source file + const { rm } = await import("node:fs/promises"); + await rm(join(testProjectDir, "src/app.ts")); + + // Cache should be invalidated + const result = await getCachedDetection(testProjectDir); + expect(result).toBeUndefined(); + }); + + test("invalidates cache when root directory mtime changes", async () => { + const testDsn = createTestDsn(); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "test-fingerprint", + allDsns: [testDsn], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + // Verify cache exists + const before = await getCachedDetection(testProjectDir); + expect(before).toBeDefined(); + + // Add a new file and set root dir mtime to cachedMtime + 5s + writeFileSync(join(testProjectDir, "new-file.txt"), "test"); + const futureTime = new Date(rootDirMtime + 5000); + utimesSync(testProjectDir, futureTime, futureTime); + + // Cache should be invalidated + const after = await getCachedDetection(testProjectDir); + expect(after).toBeUndefined(); + }); + + test("invalidates cache when tracked subdirectory mtime changes", async () => { + const testDsn = createTestDsn(); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + const srcStats = await stat(join(testProjectDir, "src")); + const srcDirMtime = Math.floor(srcStats.mtimeMs); + + // Store cache with tracked src/ directory mtime + setCachedDetection(testProjectDir, { + fingerprint: "test-fingerprint", + allDsns: [testDsn], + sourceMtimes, + dirMtimes: { src: srcDirMtime }, + rootDirMtime, + }); + + // Verify cache exists + const before = await getCachedDetection(testProjectDir); + expect(before).toBeDefined(); + + // Add a new file to src/ and set mtime to cachedMtime + 5s + const srcDir = join(testProjectDir, "src"); + writeFileSync(join(srcDir, "new-config.ts"), "export default {}"); + const futureTime = new Date(srcDirMtime + 5000); + utimesSync(srcDir, futureTime, futureTime); + + // Cache should be invalidated because src/ mtime changed + const after = await getCachedDetection(testProjectDir); + expect(after).toBeUndefined(); + }); +}); + +describe("setCachedDetection", () => { + test("stores full detection result", async () => { + const testDsn = createTestDsn(); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "fp-123", + allDsns: [testDsn], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + const result = await getCachedDetection(testProjectDir); + expect(result?.fingerprint).toBe("fp-123"); + expect(result?.allDsns).toHaveLength(1); + expect(Object.keys(result?.sourceMtimes ?? {})).toContain("src/app.ts"); + }); + + test("stores multiple DSNs", async () => { + const dsn1 = createTestDsn({ raw: "https://a@o1.ingest.sentry.io/1" }); + const dsn2 = createTestDsn({ raw: "https://b@o2.ingest.sentry.io/2" }); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "fp-multi", + allDsns: [dsn1, dsn2], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + const result = await getCachedDetection(testProjectDir); + expect(result?.allDsns).toHaveLength(2); + }); + + test("stores empty DSN array", async () => { + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "fp-empty", + allDsns: [], + sourceMtimes: {}, + dirMtimes: {}, + rootDirMtime, + }); + + const result = await getCachedDetection(testProjectDir); + expect(result?.fingerprint).toBe("fp-empty"); + expect(result?.allDsns).toHaveLength(0); + }); + + test("overwrites existing detection cache", async () => { + const dsn1 = createTestDsn({ raw: "https://first@o1.ingest.sentry.io/1" }); + const dsn2 = createTestDsn({ raw: "https://second@o2.ingest.sentry.io/2" }); + const sourceMtimes = { + "src/app.ts": Math.floor( + statSync(join(testProjectDir, "src/app.ts")).mtimeMs + ), + }; + + const { stat } = await import("node:fs/promises"); + const rootStats = await stat(testProjectDir); + const rootDirMtime = Math.floor(rootStats.mtimeMs); + + setCachedDetection(testProjectDir, { + fingerprint: "fp-first", + allDsns: [dsn1], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + setCachedDetection(testProjectDir, { + fingerprint: "fp-second", + allDsns: [dsn2], + sourceMtimes, + dirMtimes: {}, + rootDirMtime, + }); + + const result = await getCachedDetection(testProjectDir); + expect(result?.fingerprint).toBe("fp-second"); + expect(result?.allDsns[0].raw).toBe("https://second@o2.ingest.sentry.io/2"); + }); +}); diff --git a/packages/cli/test/lib/db/install-info.test.ts b/packages/cli/test/lib/db/install-info.test.ts new file mode 100644 index 000000000..85c801508 --- /dev/null +++ b/packages/cli/test/lib/db/install-info.test.ts @@ -0,0 +1,150 @@ +/** + * Install Info Storage Tests + */ + +import { describe, expect, test } from "vitest"; +import { + clearInstallInfo, + getInstallInfo, + setInstallInfo, +} from "../../../src/lib/db/install-info.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-install-info-"); + +describe("getInstallInfo", () => { + test("returns null when no install info stored", () => { + const result = getInstallInfo(); + expect(result).toBeNull(); + }); + + test("returns stored install info", () => { + setInstallInfo({ + method: "curl", + path: "/home/user/.local/bin/sentry", + version: "1.0.0", + }); + + const result = getInstallInfo(); + expect(result).not.toBeNull(); + expect(result?.method).toBe("curl"); + expect(result?.path).toBe("/home/user/.local/bin/sentry"); + expect(result?.version).toBe("1.0.0"); + expect(result?.recordedAt).toBeGreaterThan(0); + }); +}); + +describe("setInstallInfo", () => { + test("stores curl install info", () => { + setInstallInfo({ + method: "curl", + path: "/home/user/.sentry/bin/sentry", + version: "0.5.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("curl"); + expect(result?.path).toBe("/home/user/.sentry/bin/sentry"); + expect(result?.version).toBe("0.5.0"); + }); + + test("stores npm install info", () => { + setInstallInfo({ + method: "npm", + path: "/usr/local/bin/sentry", + version: "0.6.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("npm"); + expect(result?.path).toBe("/usr/local/bin/sentry"); + expect(result?.version).toBe("0.6.0"); + }); + + test("stores pnpm install info", () => { + setInstallInfo({ + method: "pnpm", + path: "/home/user/.local/share/pnpm/sentry", + version: "0.7.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("pnpm"); + }); + + test("stores bun install info", () => { + setInstallInfo({ + method: "bun", + path: "/home/user/.bun/bin/sentry", + version: "0.8.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("bun"); + }); + + test("stores yarn install info", () => { + setInstallInfo({ + method: "yarn", + path: "/home/user/.yarn/bin/sentry", + version: "0.9.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("yarn"); + }); + + test("overwrites existing install info", () => { + setInstallInfo({ + method: "curl", + path: "/first/path", + version: "1.0.0", + }); + setInstallInfo({ + method: "npm", + path: "/second/path", + version: "2.0.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("npm"); + expect(result?.path).toBe("/second/path"); + expect(result?.version).toBe("2.0.0"); + }); + + test("sets recordedAt timestamp", () => { + const before = Date.now(); + setInstallInfo({ + method: "curl", + path: "/test/path", + version: "1.0.0", + }); + const after = Date.now(); + + const result = getInstallInfo(); + expect(result?.recordedAt).toBeGreaterThanOrEqual(before); + expect(result?.recordedAt).toBeLessThanOrEqual(after); + }); +}); + +describe("clearInstallInfo", () => { + test("removes stored install info", () => { + setInstallInfo({ + method: "curl", + path: "/test/path", + version: "1.0.0", + }); + + expect(getInstallInfo()).not.toBeNull(); + + clearInstallInfo(); + + expect(getInstallInfo()).toBeNull(); + }); + + test("does nothing when no info stored", () => { + // Should not throw + clearInstallInfo(); + expect(getInstallInfo()).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/db/issue-org-cache.test.ts b/packages/cli/test/lib/db/issue-org-cache.test.ts new file mode 100644 index 000000000..6eac53251 --- /dev/null +++ b/packages/cli/test/lib/db/issue-org-cache.test.ts @@ -0,0 +1,115 @@ +/** + * Issue-Org Cache Tests + * + * Covers caching of numeric-issue-id → org-slug mappings used by + * `resolveNumericIssue` to skip the legacy unscoped `/api/0/issues/{id}/` + * endpoint on subsequent runs. + */ + +import { describe, expect, test } from "vitest"; +import { + clearAllIssueOrgCache, + clearCachedIssueOrg, + getCachedIssueOrg, + setCachedIssueOrg, +} from "../../../src/lib/db/issue-org-cache.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-issue-org-cache-"); + +describe("getCachedIssueOrg", () => { + test("returns undefined when no entry exists", () => { + expect(getCachedIssueOrg("123456789")).toBeUndefined(); + }); + + test("returns the stored org slug", () => { + setCachedIssueOrg("7413562541", "brandai"); + expect(getCachedIssueOrg("7413562541")).toBe("brandai"); + }); + + test("distinguishes between different issue IDs", () => { + setCachedIssueOrg("111", "org-a"); + setCachedIssueOrg("222", "org-b"); + + expect(getCachedIssueOrg("111")).toBe("org-a"); + expect(getCachedIssueOrg("222")).toBe("org-b"); + expect(getCachedIssueOrg("333")).toBeUndefined(); + }); +}); + +describe("setCachedIssueOrg", () => { + test("overwrites the mapping for the same issue ID", () => { + setCachedIssueOrg("42", "first-org"); + setCachedIssueOrg("42", "second-org"); + + expect(getCachedIssueOrg("42")).toBe("second-org"); + }); + + test("is a no-op when numericId is empty", () => { + setCachedIssueOrg("", "org"); + // No assertion on internal state — it just must not throw or pollute + // the cache. Use a sentinel read that would never match an empty key. + expect(getCachedIssueOrg("")).toBeUndefined(); + }); + + test("is a no-op when org is empty", () => { + setCachedIssueOrg("99", ""); + expect(getCachedIssueOrg("99")).toBeUndefined(); + }); +}); + +describe("clearCachedIssueOrg", () => { + test("removes a single mapping", () => { + setCachedIssueOrg("101", "org-x"); + setCachedIssueOrg("102", "org-y"); + + clearCachedIssueOrg("101"); + + expect(getCachedIssueOrg("101")).toBeUndefined(); + // Other mappings untouched + expect(getCachedIssueOrg("102")).toBe("org-y"); + }); + + test("is idempotent on missing IDs", () => { + expect(() => clearCachedIssueOrg("nonexistent")).not.toThrow(); + }); + + test("is a no-op when numericId is empty", () => { + setCachedIssueOrg("50", "org"); + clearCachedIssueOrg(""); + // Empty key must not wipe unrelated entries. + expect(getCachedIssueOrg("50")).toBe("org"); + }); +}); + +describe("clearAllIssueOrgCache", () => { + test("removes all cached mappings", () => { + setCachedIssueOrg("1", "a"); + setCachedIssueOrg("2", "b"); + setCachedIssueOrg("3", "c"); + + clearAllIssueOrgCache(); + + expect(getCachedIssueOrg("1")).toBeUndefined(); + expect(getCachedIssueOrg("2")).toBeUndefined(); + expect(getCachedIssueOrg("3")).toBeUndefined(); + }); + + test("does not touch unrelated tables (metadata, org_regions, etc.)", async () => { + // Seed an unrelated metadata entry + an unrelated table row so we can + // confirm clearAllIssueOrgCache only drops its own table. + const { getDatabase } = await import("../../../src/lib/db/index.js"); + const { getMetadata, setMetadata } = await import( + "../../../src/lib/db/utils.js" + ); + setMetadata(getDatabase(), { "unrelated.key": "keep-me" }); + setCachedIssueOrg("1", "a"); + + clearAllIssueOrgCache(); + + expect(getCachedIssueOrg("1")).toBeUndefined(); + expect( + getMetadata(getDatabase(), ["unrelated.key"]).get("unrelated.key") + ).toBe("keep-me"); + }); +}); diff --git a/packages/cli/test/lib/db/json.test.ts b/packages/cli/test/lib/db/json.test.ts new file mode 100644 index 000000000..f30110d80 --- /dev/null +++ b/packages/cli/test/lib/db/json.test.ts @@ -0,0 +1,32 @@ +/** + * Unit tests for the safeParseJson cache helper. + */ + +import { describe, expect, test } from "vitest"; +import { safeParseJson } from "../../../src/lib/db/json.js"; + +describe("safeParseJson", () => { + test("parses valid JSON", () => { + expect(safeParseJson<{ a: number }>('{"a":1}')).toEqual({ a: 1 }); + expect(safeParseJson("[1,2,3]")).toEqual([1, 2, 3]); + }); + + test("returns undefined for null/undefined input", () => { + expect(safeParseJson(null)).toBeUndefined(); + expect(safeParseJson(undefined)).toBeUndefined(); + }); + + test("returns undefined for unparseable JSON instead of throwing", () => { + expect(safeParseJson("{not json")).toBeUndefined(); + expect(safeParseJson("")).toBeUndefined(); + }); + + test("returns undefined when the validator rejects the parsed value", () => { + const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every((x) => typeof x === "string"); + + expect(safeParseJson('["a","b"]', isStringArray)).toEqual(["a", "b"]); + expect(safeParseJson('{"a":1}', isStringArray)).toBeUndefined(); + expect(safeParseJson("[1,2]", isStringArray)).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/db/migration.test.ts b/packages/cli/test/lib/db/migration.test.ts new file mode 100644 index 000000000..745cf5700 --- /dev/null +++ b/packages/cli/test/lib/db/migration.test.ts @@ -0,0 +1,132 @@ +/** Regression coverage for credentials in legacy JSON configuration. */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + clearAuth, + getAuthConfig, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { + getDefaultOrganization, + getDefaultProject, +} from "../../../src/lib/db/defaults.js"; +import { closeDatabase, getDatabase } from "../../../src/lib/db/index.js"; +import { clearMetadata, getMetadata } from "../../../src/lib/db/utils.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +const getConfigDir = useTestConfigDir("json-auth-migration-"); +useEnvSandbox(["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_FORCE_ENV_TOKEN"]); + +let stderr: ReturnType>; + +beforeEach(() => { + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); +}); + +afterEach(() => { + stderr.mockRestore(); +}); + +function writeLegacyConfig(token: unknown) { + const path = join(getConfigDir(), "config.json"); + const contents = JSON.stringify({ + auth: { token, refreshToken: "synthetic-legacy-refresh" }, + defaults: { organization: "synthetic-org", project: "synthetic-project" }, + projectCache: { + "synthetic-cache-key": { + orgSlug: "synthetic-org", + orgName: "Synthetic organization", + projectSlug: "synthetic-project", + projectName: "Synthetic project", + cachedAt: Date.now(), + }, + }, + }); + writeFileSync(path, contents); + return { path, contents }; +} + +describe("legacy auth migration", () => { + test("normalizes surrounding ASCII controls before writing credentials", () => { + const { path } = writeLegacyConfig("\0\x01\t synthetic-token \r\n\0"); + + expect(getAuthConfig()).toMatchObject({ + token: "synthetic-token", + refreshToken: "synthetic-legacy-refresh", + }); + expect(existsSync(path)).toBe(false); + }); + + test.each([ + ["internal NUL", "synthetic-secret\0tail"], + ["internal LF", "synthetic-secret\ntail"], + ["empty string", ""], + ["number", 123], + ["null", null], + ["object", { value: "synthetic-secret" }], + ])("preserves the original config and migrates other settings for %s", (_, token) => { + const { path, contents } = writeLegacyConfig(token); + + // Opening the DB must remain possible so login/logout can recover. + const db = getDatabase(); + expect(getAuthConfig()).toBeUndefined(); + expect(db.query("SELECT * FROM auth").get()).toBeNull(); + expect(getDefaultOrganization()).toBe("synthetic-org"); + expect(getDefaultProject()).toBe("synthetic-project"); + expect( + db + .query("SELECT org_slug FROM project_cache WHERE cache_key = ?") + .get("synthetic-cache-key") + ).toEqual({ org_slug: "synthetic-org" }); + expect( + getMetadata(db, ["json_migration_completed"]).get( + "json_migration_completed" + ) + ).toBe("true"); + expect(readFileSync(path, "utf8")).toBe(contents); + + const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(output).toContain( + "Malformed authentication credentials were not migrated" + ); + expect(output).toContain("config.json was kept"); + expect(output).toContain("sentry auth login"); + expect(output).not.toContain("synthetic-secret"); + expect(output).not.toContain("synthetic-legacy-refresh"); + }); + + test("keeps an existing SQLite session when legacy credentials are invalid", () => { + setAuthToken( + "synthetic-existing-token", + 3600, + "synthetic-existing-refresh" + ); + const before = getAuthConfig(); + const db = getDatabase(); + clearMetadata(db, ["json_migration_completed"]); + closeDatabase(); + const { path, contents } = writeLegacyConfig("synthetic-secret\0tail"); + + expect(getAuthConfig()).toEqual(before); + expect(getDefaultOrganization()).toBe("synthetic-org"); + expect(readFileSync(path, "utf8")).toBe(contents); + }); + + test("does not re-import the retained file after a new login or logout", async () => { + const { path } = writeLegacyConfig("synthetic-secret\0tail"); + expect(getAuthConfig()).toBeUndefined(); + + setAuthToken("synthetic-replacement-token"); + // Even fixing the retained file must not overwrite the new session. + writeLegacyConfig("synthetic-old-token"); + closeDatabase(); + expect(getAuthConfig()?.token).toBe("synthetic-replacement-token"); + + await clearAuth(); + closeDatabase(); + expect(getAuthConfig()).toBeUndefined(); + expect(existsSync(path)).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/db/model-based.test.ts b/packages/cli/test/lib/db/model-based.test.ts new file mode 100644 index 000000000..1091f2b8d --- /dev/null +++ b/packages/cli/test/lib/db/model-based.test.ts @@ -0,0 +1,1051 @@ +/** + * Model-Based Testing for SQLite Database Layer + * + * Uses fast-check to generate random sequences of database operations + * and verify the system behaves correctly against a simplified model. + * + * This catches edge cases that handwritten tests miss, such as: + * - Unexpected state transitions + * - Race conditions in caching logic + * - Invariant violations (e.g., clearAuth also clears regions) + */ + +// biome-ignore-all lint/suspicious/noMisplacedAssertion: Model-based testing uses expect() inside command classes, not directly in test() functions. This is the standard fast-check pattern for stateful testing. + +import { + type AsyncCommand, + array, + asyncModelRun, + asyncProperty, + boolean, + commands, + constant, + constantFrom, + assert as fcAssert, + integer, + nat, + option, + property, + string, + stringMatching, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + clearAuth, + getAuthConfig, + getAuthToken, + isAuthenticated, + isEnvTokenActive, + resetAuthRowCache, + resetAuthTokenCache, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { + advancePaginationState, + getPaginationState, +} from "../../../src/lib/db/pagination.js"; +import { + clearProjectAliases, + getProjectAliases, + getProjectByAlias, + setProjectAliases, +} from "../../../src/lib/db/project-aliases.js"; +import { + clearOrgRegions, + getAllOrgRegions, + getOrgRegion, + setOrgRegion, + setOrgRegions, +} from "../../../src/lib/db/regions.js"; +import { + getVersionCheckInfo, + setVersionCheckInfo, +} from "../../../src/lib/db/version-check.js"; +import { + createIsolatedDbContext, + DEFAULT_NUM_RUNS, +} from "../../model-based/helpers.js"; + +/** + * Model representing the expected state of the database. + * This is a simplified version of the real database state. + */ +type DbModel = { + auth: { + token: string | null; + refreshToken: string | null; + expiresAt: number | null; + issuedAt: number | null; + }; + /** Simulated SENTRY_AUTH_TOKEN env var (null = unset) */ + envAuthToken: string | null; + /** Simulated SENTRY_TOKEN env var (null = unset) */ + envSentryToken: string | null; + regions: Map; + aliases: { + entries: Map; + fingerprint: string | null; + }; + versionCheck: { + lastChecked: number | null; + latestVersion: string | null; + }; +}; + +/** Real database handle (we just use the module functions directly) */ +type RealDb = Record; + +/** Create initial empty model */ +function createEmptyModel(): DbModel { + return { + auth: { + token: null, + refreshToken: null, + expiresAt: null, + issuedAt: null, + }, + envAuthToken: null, + envSentryToken: null, + regions: new Map(), + aliases: { + entries: new Map(), + fingerprint: null, + }, + versionCheck: { + lastChecked: null, + latestVersion: null, + }, + }; +} + +// Auth Commands (All async for asyncModelRun compatibility) + +class SetAuthTokenCommand implements AsyncCommand { + readonly token: string; + readonly expiresIn: number | undefined; + readonly refreshToken: string | undefined; + + constructor( + token: string, + expiresIn: number | undefined, + refreshToken: string | undefined + ) { + this.token = token; + this.expiresIn = expiresIn; + this.refreshToken = refreshToken; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const now = Date.now(); + + // Apply to real system + setAuthToken(this.token, this.expiresIn, this.refreshToken); + + // Update model + model.auth.token = this.token; + model.auth.refreshToken = this.refreshToken ?? null; + + // Use truthy check to match real setAuthToken behavior (0 is treated as no expiry) + if (this.expiresIn) { + model.auth.expiresAt = now + this.expiresIn * 1000; + model.auth.issuedAt = now; + } else { + model.auth.expiresAt = null; + model.auth.issuedAt = null; + } + } + + toString(): string { + return `setAuthToken("${this.token}", ${this.expiresIn}, ${this.refreshToken ? `"${this.refreshToken}"` : undefined})`; + } +} + +class GetAuthTokenCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realToken = getAuthToken(); + const now = Date.now(); + + // Stored OAuth wins over env token (default since #646). + const hasUsableOAuth = + model.auth.token !== null && + (model.auth.expiresAt === null || model.auth.expiresAt > now); + if (hasUsableOAuth) { + expect(realToken).toBe(model.auth.token as string); + return; + } + + // No usable stored token — fall back to env token + const envToken = model.envAuthToken ?? model.envSentryToken; + if (envToken) { + expect(realToken).toBe(envToken); + return; + } + + expect(realToken).toBeUndefined(); + } + + toString = () => "getAuthToken()"; +} + +class GetAuthConfigCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realConfig = getAuthConfig(); + + // Stored OAuth wins over env token (default since #646). + // Skip expired tokens without a refresh token (consistent with getAuthToken). + const now = Date.now(); + const isExpired = + model.auth.expiresAt !== null && model.auth.expiresAt <= now; + const hasRefresh = model.auth.refreshToken !== null; + const hasUsableOAuth = + model.auth.token !== null && (!isExpired || hasRefresh); + + if (hasUsableOAuth) { + expect(realConfig).toBeDefined(); + expect(realConfig?.token).toBe(model.auth.token as string); + expect(realConfig?.source).toBe("oauth"); + expect(realConfig?.refreshToken).toBe( + (model.auth.refreshToken ?? undefined) as string | undefined + ); + if (model.auth.expiresAt !== null) { + expect(realConfig?.expiresAt).toBeDefined(); + } + return; + } + + // No usable stored OAuth — fall back to env token + if (model.envAuthToken) { + expect(realConfig).toBeDefined(); + expect(realConfig?.token).toBe(model.envAuthToken); + expect(realConfig?.source).toBe("env:SENTRY_AUTH_TOKEN"); + expect(realConfig?.refreshToken).toBeUndefined(); + expect(realConfig?.expiresAt).toBeUndefined(); + return; + } + if (model.envSentryToken) { + expect(realConfig).toBeDefined(); + expect(realConfig?.token).toBe(model.envSentryToken); + expect(realConfig?.source).toBe("env:SENTRY_TOKEN"); + expect(realConfig?.refreshToken).toBeUndefined(); + expect(realConfig?.expiresAt).toBeUndefined(); + return; + } + + expect(realConfig).toBeUndefined(); + } + + toString = () => "getAuthConfig()"; +} + +class ClearAuthCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + await clearAuth(); + + // Clear auth state + model.auth.token = null; + model.auth.refreshToken = null; + model.auth.expiresAt = null; + model.auth.issuedAt = null; + + // KEY INVARIANT: clearAuth also clears org regions! + // This is specified in auth.ts lines 101-103 + model.regions.clear(); + } + + toString = () => "clearAuth()"; +} + +class IsAuthenticatedCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realResult = isAuthenticated(); + + // Env vars take priority + const envToken = model.envAuthToken ?? model.envSentryToken; + if (envToken) { + expect(realResult).toBe(true); + return; + } + + // Should be authenticated if we have a valid, non-expired token + const now = Date.now(); + const expectedResult = + model.auth.token !== null && + (model.auth.expiresAt === null || model.auth.expiresAt > now); + + expect(realResult).toBe(expectedResult); + } + + toString = () => "isAuthenticated()"; +} + +// Env Var Commands — simulate setting/clearing SENTRY_AUTH_TOKEN and SENTRY_TOKEN + +class SetEnvAuthTokenCommand implements AsyncCommand { + readonly token: string; + + constructor(token: string) { + this.token = token; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + process.env.SENTRY_AUTH_TOKEN = this.token; + // Env mutation bypasses setAuthToken's invalidation. + resetAuthTokenCache(); + resetAuthRowCache(); + // Model stores trimmed value — matches real getEnvToken() which trims + const trimmed = this.token.trim(); + model.envAuthToken = trimmed || null; + } + + toString = () => `setEnv(SENTRY_AUTH_TOKEN, "${this.token}")`; +} + +class ClearEnvAuthTokenCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + delete process.env.SENTRY_AUTH_TOKEN; + resetAuthTokenCache(); + resetAuthRowCache(); + model.envAuthToken = null; + } + + toString = () => "clearEnv(SENTRY_AUTH_TOKEN)"; +} + +class SetEnvSentryTokenCommand implements AsyncCommand { + readonly token: string; + + constructor(token: string) { + this.token = token; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + process.env.SENTRY_TOKEN = this.token; + resetAuthTokenCache(); + resetAuthRowCache(); + // Model stores trimmed value — matches real getEnvToken() which trims + const trimmed = this.token.trim(); + model.envSentryToken = trimmed || null; + } + + toString = () => `setEnv(SENTRY_TOKEN, "${this.token}")`; +} + +class ClearEnvSentryTokenCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + delete process.env.SENTRY_TOKEN; + resetAuthTokenCache(); + resetAuthRowCache(); + model.envSentryToken = null; + } + + toString = () => "clearEnv(SENTRY_TOKEN)"; +} + +class IsEnvTokenActiveCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realResult = isEnvTokenActive(); + const expectedResult = + model.envAuthToken !== null || model.envSentryToken !== null; + expect(realResult).toBe(expectedResult); + } + + toString = () => "isEnvTokenActive()"; +} + +// Region Commands + +class SetOrgRegionCommand implements AsyncCommand { + readonly orgSlug: string; + readonly regionUrl: string; + + constructor(orgSlug: string, regionUrl: string) { + this.orgSlug = orgSlug; + this.regionUrl = regionUrl; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + setOrgRegion(this.orgSlug, this.regionUrl); + model.regions.set(this.orgSlug, this.regionUrl); + } + + toString(): string { + return `setOrgRegion("${this.orgSlug}", "${this.regionUrl}")`; + } +} + +class GetOrgRegionCommand implements AsyncCommand { + readonly orgSlug: string; + + constructor(orgSlug: string) { + this.orgSlug = orgSlug; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realRegion = getOrgRegion(this.orgSlug); + const expectedRegion = model.regions.get(this.orgSlug); + + expect(realRegion).toBe(expectedRegion); + } + + toString(): string { + return `getOrgRegion("${this.orgSlug}")`; + } +} + +class SetOrgRegionsCommand implements AsyncCommand { + readonly entries: [string, string][]; + + constructor(entries: [string, string][]) { + this.entries = entries; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + setOrgRegions( + this.entries.map(([slug, regionUrl]) => ({ slug, regionUrl })) + ); + + for (const [orgSlug, regionUrl] of this.entries) { + model.regions.set(orgSlug, regionUrl); + } + } + + toString(): string { + return `setOrgRegions([${this.entries.map(([o, r]) => `["${o}", "${r}"]`).join(", ")}])`; + } +} + +class GetAllOrgRegionsCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realRegions = getAllOrgRegions(); + + expect(realRegions.size).toBe(model.regions.size); + + for (const [orgSlug, regionUrl] of model.regions) { + expect(realRegions.get(orgSlug)).toBe(regionUrl); + } + } + + toString = () => "getAllOrgRegions()"; +} + +class ClearOrgRegionsCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + clearOrgRegions(); + model.regions.clear(); + } + + toString = () => "clearOrgRegions()"; +} + +// Alias Commands + +class SetProjectAliasesCommand implements AsyncCommand { + readonly aliases: Record; + readonly fingerprint: string | undefined; + + constructor( + aliases: Record, + fingerprint: string | undefined + ) { + this.aliases = aliases; + this.fingerprint = fingerprint; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + setProjectAliases(this.aliases, this.fingerprint); + + // Clear and replace all aliases (this is the documented behavior) + model.aliases.entries.clear(); + for (const [alias, entry] of Object.entries(this.aliases)) { + // Aliases are stored lowercase + model.aliases.entries.set(alias.toLowerCase(), entry); + } + model.aliases.fingerprint = this.fingerprint ?? null; + } + + toString(): string { + const aliasStr = Object.entries(this.aliases) + .map( + ([a, e]) => `"${a}": {org: "${e.orgSlug}", project: "${e.projectSlug}"}` + ) + .join(", "); + return `setProjectAliases({${aliasStr}}, ${this.fingerprint ? `"${this.fingerprint}"` : undefined})`; + } +} + +class GetProjectAliasesCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realAliases = getProjectAliases(); + + if (model.aliases.entries.size === 0) { + expect(realAliases).toBeUndefined(); + } else { + expect(realAliases).toBeDefined(); + expect(Object.keys(realAliases!).length).toBe(model.aliases.entries.size); + + for (const [alias, entry] of model.aliases.entries) { + expect(realAliases![alias]).toEqual(entry); + } + } + } + + toString = () => "getProjectAliases()"; +} + +class GetProjectByAliasCommand implements AsyncCommand { + readonly alias: string; + readonly currentFingerprint: string | undefined; + + constructor(alias: string, currentFingerprint: string | undefined) { + this.alias = alias; + this.currentFingerprint = currentFingerprint; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realProject = getProjectByAlias(this.alias, this.currentFingerprint); + + // Lookup is case-insensitive + const modelEntry = model.aliases.entries.get(this.alias.toLowerCase()); + + if (!modelEntry) { + expect(realProject).toBeUndefined(); + return; + } + + // Fingerprint validation logic (from project-aliases.ts lines 103-109): + // - If currentFingerprint is undefined, skip validation (return entry) + // - If stored fingerprint is null, skip validation (legacy cache) + // - If both are defined, they must match exactly + const storedFp = model.aliases.fingerprint; + const currentFp = this.currentFingerprint; + + const shouldReject = + currentFp !== undefined && storedFp !== null && currentFp !== storedFp; + + if (shouldReject) { + expect(realProject).toBeUndefined(); + } else { + expect(realProject).toEqual(modelEntry); + } + } + + toString(): string { + return `getProjectByAlias("${this.alias}", ${this.currentFingerprint ? `"${this.currentFingerprint}"` : undefined})`; + } +} + +class ClearProjectAliasesCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + clearProjectAliases(); + model.aliases.entries.clear(); + model.aliases.fingerprint = null; + } + + toString = () => "clearProjectAliases()"; +} + +// Version Check Commands + +class SetVersionCheckCommand implements AsyncCommand { + readonly latestVersion: string; + + constructor(latestVersion: string) { + this.latestVersion = latestVersion; + } + + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const now = Date.now(); + setVersionCheckInfo(this.latestVersion); + + model.versionCheck.lastChecked = now; + model.versionCheck.latestVersion = this.latestVersion; + } + + toString(): string { + return `setVersionCheckInfo("${this.latestVersion}")`; + } +} + +class GetVersionCheckCommand implements AsyncCommand { + check = () => true; + + async run(model: DbModel, _real: RealDb): Promise { + const realInfo = getVersionCheckInfo(); + + expect(realInfo.latestVersion).toBe(model.versionCheck.latestVersion); + + // lastChecked timing may vary slightly, so check presence + if (model.versionCheck.lastChecked !== null) { + expect(realInfo.lastChecked).not.toBeNull(); + // Should be within 1 second of expected + expect( + Math.abs(realInfo.lastChecked! - model.versionCheck.lastChecked) + ).toBeLessThan(1000); + } else { + expect(realInfo.lastChecked).toBeNull(); + } + } + + toString = () => "getVersionCheckInfo()"; +} + +// Arbitraries (Random Data Generators) + +/** Env/refresh candidates retain their existing domain; stored access tokens must be valid. */ +const tokenArb = string({ minLength: 1, maxLength: 64 }); +const storedTokenArb = stringMatching(/^[\x21-\x7e]{1,64}$/); + +/** Generate org/project slugs (alphanumeric with hyphens) */ +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; +const slugArb = array(constantFrom(...slugChars.split("")), { + minLength: 1, + maxLength: 16, +}).map((chars) => chars.join("")); + +/** Generate region URLs */ +const regionUrlArb = constantFrom( + "https://us.sentry.io", + "https://de.sentry.io", + "https://eu.sentry.io", + "https://sentry.io" +); + +/** Generate alias (single letter) */ +const aliasChars = "abcdefghijklmnopqrstuvwxyz"; +const aliasArb = constantFrom(...aliasChars.split("")); + +/** Generate alias with optional uppercase */ +const aliasWithCaseArb = tuple(aliasArb, boolean()).map(([alias, upper]) => + upper ? alias.toUpperCase() : alias +); + +/** Generate DSN fingerprint */ +const fingerprintArb = option( + tuple(nat(1000), nat(1000)).map(([a, b]) => `${a}:${b}`), + { nil: undefined } +); + +/** Generate version string */ +const versionArb = tuple(nat(10), nat(20), nat(100)).map( + ([major, minor, patch]) => `${major}.${minor}.${patch}` +); + +/** Generate expiresIn (seconds) - can be negative for testing expiry */ +const expiresInArb = option(integer({ min: -10, max: 7200 }), { + nil: undefined, +}); + +// Command Arbitraries + +const setAuthTokenCmdArb = tuple( + storedTokenArb, + expiresInArb, + option(tokenArb, { nil: undefined }) +).map( + ([token, expiresIn, refreshToken]) => + new SetAuthTokenCommand(token, expiresIn, refreshToken) +); + +const getAuthTokenCmdArb = constant(new GetAuthTokenCommand()); + +const getAuthConfigCmdArb = constant(new GetAuthConfigCommand()); + +const clearAuthCmdArb = constant(new ClearAuthCommand()); + +const isAuthenticatedCmdArb = constant(new IsAuthenticatedCommand()); + +const setEnvAuthTokenCmdArb = tokenArb.map( + (t) => new SetEnvAuthTokenCommand(t) +); +const clearEnvAuthTokenCmdArb = constant(new ClearEnvAuthTokenCommand()); +const setEnvSentryTokenCmdArb = tokenArb.map( + (t) => new SetEnvSentryTokenCommand(t) +); +const clearEnvSentryTokenCmdArb = constant(new ClearEnvSentryTokenCommand()); +const isEnvTokenActiveCmdArb = constant(new IsEnvTokenActiveCommand()); + +const setOrgRegionCmdArb = tuple(slugArb, regionUrlArb).map( + ([org, url]) => new SetOrgRegionCommand(org, url) +); + +const getOrgRegionCmdArb = slugArb.map((org) => new GetOrgRegionCommand(org)); + +const setOrgRegionsCmdArb = array(tuple(slugArb, regionUrlArb), { + minLength: 0, + maxLength: 5, +}).map((entries) => new SetOrgRegionsCommand(entries)); + +const getAllOrgRegionsCmdArb = constant(new GetAllOrgRegionsCommand()); + +const clearOrgRegionsCmdArb = constant(new ClearOrgRegionsCommand()); + +const setProjectAliasesCmdArb = tuple( + array(tuple(aliasArb, slugArb, slugArb), { minLength: 0, maxLength: 5 }), + fingerprintArb +).map(([entries, fp]) => { + const aliases: Record = {}; + for (const [alias, org, project] of entries) { + aliases[alias] = { orgSlug: org, projectSlug: project }; + } + return new SetProjectAliasesCommand(aliases, fp); +}); + +const getProjectAliasesCmdArb = constant(new GetProjectAliasesCommand()); + +const getProjectByAliasCmdArb = tuple(aliasWithCaseArb, fingerprintArb).map( + ([alias, fp]) => new GetProjectByAliasCommand(alias, fp) +); + +const clearProjectAliasesCmdArb = constant(new ClearProjectAliasesCommand()); + +const setVersionCheckCmdArb = versionArb.map( + (v) => new SetVersionCheckCommand(v) +); + +const getVersionCheckCmdArb = constant(new GetVersionCheckCommand()); + +// All Commands Combined + +const allCommands = [ + // Auth commands + setAuthTokenCmdArb, + getAuthTokenCmdArb, + getAuthConfigCmdArb, + clearAuthCmdArb, + isAuthenticatedCmdArb, + // Env var auth commands + setEnvAuthTokenCmdArb, + clearEnvAuthTokenCmdArb, + setEnvSentryTokenCmdArb, + clearEnvSentryTokenCmdArb, + isEnvTokenActiveCmdArb, + // Region commands + setOrgRegionCmdArb, + getOrgRegionCmdArb, + setOrgRegionsCmdArb, + getAllOrgRegionsCmdArb, + clearOrgRegionsCmdArb, + // Alias commands + setProjectAliasesCmdArb, + getProjectAliasesCmdArb, + getProjectByAliasCmdArb, + clearProjectAliasesCmdArb, + // Version check commands + setVersionCheckCmdArb, + getVersionCheckCmdArb, +]; + +// Tests + +describe("model-based: database layer", () => { + test("random sequences of database operations maintain consistency", async () => { + await fcAssert( + asyncProperty(commands(allCommands, { size: "+1" }), async (cmds) => { + const cleanup = createIsolatedDbContext(); + // Save env vars so model commands that set them don't leak across runs + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + const savedSentryToken = process.env.SENTRY_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + resetAuthTokenCache(); + resetAuthRowCache(); + try { + const setup = () => ({ + model: createEmptyModel(), + real: {} as RealDb, + }); + + await asyncModelRun(setup, cmds); + } finally { + // Restore env vars before DB cleanup + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } else { + delete process.env.SENTRY_AUTH_TOKEN; + } + if (savedSentryToken !== undefined) { + process.env.SENTRY_TOKEN = savedSentryToken; + } else { + delete process.env.SENTRY_TOKEN; + } + cleanup(); + } + }), + { + numRuns: DEFAULT_NUM_RUNS, + verbose: false, // Set to true for debugging + } + ); + }); + + test("clearAuth also clears org regions (key invariant)", async () => { + await fcAssert( + asyncProperty( + array(tuple(slugArb, regionUrlArb), { minLength: 1, maxLength: 5 }), + async (entries) => { + const cleanup = createIsolatedDbContext(); + try { + // Set up auth and some regions + setAuthToken("test-token"); + setOrgRegions( + entries.map(([slug, regionUrl]) => ({ slug, regionUrl })) + ); + + // Verify regions were set (use unique count since setOrgRegions uses upsert) + const regionsBefore = getAllOrgRegions(); + const uniqueOrgSlugs = new Set(entries.map(([org]) => org)); + expect(regionsBefore.size).toBe(uniqueOrgSlugs.size); + + // Clear auth + await clearAuth(); + + // Verify regions were also cleared (this is the invariant!) + const regionsAfter = getAllOrgRegions(); + expect(regionsAfter.size).toBe(0); + } finally { + cleanup(); + } + } + ), + { numRuns: 50 } + ); + }); + + test("clearAuth also clears pagination cursors (key invariant)", async () => { + await fcAssert( + asyncProperty(tuple(slugArb, slugArb), async ([commandKey, context]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set up auth and a pagination cursor stack + setAuthToken("test-token"); + advancePaginationState( + commandKey, + context, + "next", + "1735689600000:100:0" + ); + + // Verify state was stored + const before = getPaginationState(commandKey, context); + expect(before).toBeDefined(); + expect(before!.stack).toEqual(["", "1735689600000:100:0"]); + + // Clear auth + await clearAuth(); + + // Verify pagination state was also cleared (this is the invariant!) + const after = getPaginationState(commandKey, context); + expect(after).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: 50 } + ); + }); + + test("alias lookup is case-insensitive", async () => { + await fcAssert( + asyncProperty( + tuple(aliasArb, slugArb, slugArb), + async ([alias, org, project]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set alias (stored lowercase) + setProjectAliases({ + [alias]: { orgSlug: org, projectSlug: project }, + }); + + // Lookup with uppercase + const upper = getProjectByAlias(alias.toUpperCase()); + // Lookup with lowercase + const lower = getProjectByAlias(alias.toLowerCase()); + // Lookup with original + const original = getProjectByAlias(alias); + + // All should return the same result + expect(upper).toEqual(lower); + expect(lower).toEqual(original); + expect(upper?.orgSlug).toBe(org); + expect(upper?.projectSlug).toBe(project); + } finally { + cleanup(); + } + } + ), + { numRuns: 50 } + ); + }); + + test("expired tokens return undefined", () => { + fcAssert( + property(storedTokenArb, (token) => { + const cleanup = createIsolatedDbContext(); + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + resetAuthTokenCache(); + resetAuthRowCache(); + try { + // Set token that expires immediately (negative expiresIn) + setAuthToken(token, -1); + + // Token should be undefined because it's expired + const retrieved = getAuthToken(); + expect(retrieved).toBeUndefined(); + + // Config also returns undefined for expired tokens without refresh token + const config = getAuthConfig(); + expect(config).toBeUndefined(); + } finally { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + cleanup(); + } + }), + { numRuns: 50 } + ); + }); + + test("fingerprint mismatch rejects alias lookup", async () => { + // Combine all parameters into a single tuple to avoid parameter limit + const paramsArb = tuple( + aliasArb, + slugArb, + slugArb, + nat(1000), + nat(1000), + nat(1000), + nat(1000) + ); + + await fcAssert( + asyncProperty(paramsArb, async ([alias, org, project, a, b, c, d]) => { + // Ensure fingerprints are different + const fp1 = `${a}:${b}`; + const fp2 = `${c}:${d}`; + if (fp1 === fp2) return; // Skip if same (unlikely) + + const cleanup = createIsolatedDbContext(); + try { + // Set alias with fingerprint 1 + setProjectAliases( + { [alias]: { orgSlug: org, projectSlug: project } }, + fp1 + ); + + // Lookup with fingerprint 2 should fail + const result = getProjectByAlias(alias, fp2); + expect(result).toBeUndefined(); + + // Lookup with matching fingerprint should succeed + const matchingResult = getProjectByAlias(alias, fp1); + expect(matchingResult?.orgSlug).toBe(org); + } finally { + cleanup(); + } + }), + { numRuns: 50 } + ); + }); + + test("setProjectAliases replaces all existing aliases", async () => { + const aliasEntryArb = array(tuple(aliasArb, slugArb, slugArb), { + minLength: 1, + maxLength: 3, + }); + + await fcAssert( + asyncProperty( + tuple(aliasEntryArb, aliasEntryArb), + async ([first, second]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set first batch + const aliases1: Record< + string, + { orgSlug: string; projectSlug: string } + > = {}; + for (const [a, o, p] of first) { + aliases1[a] = { orgSlug: o, projectSlug: p }; + } + setProjectAliases(aliases1); + + // Set second batch (should replace all) + const aliases2: Record< + string, + { orgSlug: string; projectSlug: string } + > = {}; + for (const [a, o, p] of second) { + aliases2[a] = { orgSlug: o, projectSlug: p }; + } + setProjectAliases(aliases2); + + // Only second batch should exist + const result = getProjectAliases(); + expect(result).toBeDefined(); + expect(Object.keys(result!).length).toBe( + Object.keys(aliases2).length + ); + + // Check second batch aliases are present + for (const [alias, entry] of Object.entries(aliases2)) { + expect(result![alias.toLowerCase()]).toEqual(entry); + } + + // Check first batch aliases that aren't in second batch are gone + for (const alias of Object.keys(aliases1)) { + if (!(alias in aliases2)) { + expect(result![alias.toLowerCase()]).toBeUndefined(); + } + } + } finally { + cleanup(); + } + } + ), + { numRuns: 50 } + ); + }); +}); diff --git a/packages/cli/test/lib/db/pagination.model-based.test.ts b/packages/cli/test/lib/db/pagination.model-based.test.ts new file mode 100644 index 000000000..9269445fa --- /dev/null +++ b/packages/cli/test/lib/db/pagination.model-based.test.ts @@ -0,0 +1,609 @@ +/** + * Model-Based Tests for Pagination Cursor Stack + * + * Uses fast-check to generate random sequences of advance/clear/resolve + * operations and verifies behavior against a simplified model of the + * stack-based pagination system. + * + * Invariants tested: + * - Stack grows on "next", shrinks (truncates) on back-then-forward + * - Index tracks current page position + * - hasPreviousPage reflects index > 0 + * - resolveCursor resolves keywords to correct stack entries or throws + * - clearPaginationState removes all state + * - Expired state is treated as absent + */ + +// biome-ignore-all lint/suspicious/noMisplacedAssertion: Model-based testing uses expect() inside command classes, not directly in test() functions. This is the standard fast-check pattern for stateful testing. + +import { + type AsyncCommand, + asyncModelRun, + asyncProperty, + commands, + constantFrom, + assert as fcAssert, + property, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { + advancePaginationState, + clearPaginationState, + getPaginationState, + hasPreviousPage, + resolveCursor, +} from "../../../src/lib/db/pagination.js"; +import { + createIsolatedDbContext, + DEFAULT_NUM_RUNS, +} from "../../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +/** + * Model representing expected pagination state for a single (command, context) pair. + * + * We track only one pair in the model since the composite-key independence + * is tested separately with property-based tests below. + */ +type PaginationModel = { + /** Current cursor stack, or null if no state exists. */ + stack: string[] | null; + /** Current page index (0-based), or null if no state. */ + index: number | null; +}; + +type RealDb = Record; + +/** Fixed keys used throughout the model-based test. */ +const CMD_KEY = "test-list"; +const CTX_KEY = "org:test|sort:date"; + +function createEmptyModel(): PaginationModel { + return { stack: null, index: null }; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** + * Advance with direction "next" and an optional nextCursor. + * Models both initial setup (no prior state) and forward navigation. + */ +class AdvanceNextCommand implements AsyncCommand { + readonly nextCursor: string | undefined; + + constructor(nextCursor: string | undefined) { + this.nextCursor = nextCursor; + } + + /** + * When state already exists, only advance "next" if there's a stored next + * cursor at index+1. This matches real usage: commands call resolveCursor + * before advancing, which throws if no next page exists. + * + * When no state exists (first page), always allow — this initialises the stack. + */ + check(model: PaginationModel): boolean { + if (model.stack === null) { + return true; + } + // Can advance if the next slot in the stack is populated + return model.index !== null && model.index + 1 < model.stack.length; + } + + async run(model: PaginationModel, _real: RealDb): Promise { + advancePaginationState(CMD_KEY, CTX_KEY, "next", this.nextCursor); + + if (model.stack === null) { + // First page: initialise + model.stack = this.nextCursor ? ["", this.nextCursor] : [""]; + model.index = 0; + } else { + const newIndex = model.index! + 1; + // Truncate beyond new position (back-then-forward) + const stack = model.stack.slice(0, newIndex + 1); + if (this.nextCursor) { + stack[newIndex + 1] = this.nextCursor; + } + model.stack = stack; + model.index = newIndex; + } + + // Verify real state matches model + const real = getPaginationState(CMD_KEY, CTX_KEY); + expect(real).toBeDefined(); + expect(real!.stack).toEqual(model.stack); + expect(real!.index).toBe(model.index!); + } + + toString(): string { + return `advance("next", ${this.nextCursor ? `"${this.nextCursor}"` : "undefined"})`; + } +} + +/** + * Advance with direction "prev". + * Only valid when state exists and index > 0. + */ +class AdvancePrevCommand implements AsyncCommand { + readonly nextCursor: string | undefined; + + constructor(nextCursor: string | undefined) { + this.nextCursor = nextCursor; + } + + /** Only run when we can actually go back. */ + check(model: PaginationModel): boolean { + return model.stack !== null && model.index !== null && model.index > 0; + } + + async run(model: PaginationModel, _real: RealDb): Promise { + advancePaginationState(CMD_KEY, CTX_KEY, "prev", this.nextCursor); + + const newIndex = Math.max(0, model.index! - 1); + const stack = [...model.stack!]; + if (this.nextCursor) { + stack[newIndex + 1] = this.nextCursor; + } + // Truncate beyond newIndex + 2 (or newIndex + 1 if no nextCursor) + model.stack = stack.slice(0, this.nextCursor ? newIndex + 2 : newIndex + 1); + model.index = newIndex; + + const real = getPaginationState(CMD_KEY, CTX_KEY); + expect(real).toBeDefined(); + expect(real!.stack).toEqual(model.stack); + expect(real!.index).toBe(model.index); + } + + toString(): string { + return `advance("prev", ${this.nextCursor ? `"${this.nextCursor}"` : "undefined"})`; + } +} + +/** + * Advance with direction "first". + * Only valid when state exists. + */ +class AdvanceFirstCommand implements AsyncCommand { + readonly nextCursor: string | undefined; + + constructor(nextCursor: string | undefined) { + this.nextCursor = nextCursor; + } + + check(model: PaginationModel): boolean { + return model.stack !== null; + } + + async run(model: PaginationModel, _real: RealDb): Promise { + advancePaginationState(CMD_KEY, CTX_KEY, "first", this.nextCursor); + + model.stack = this.nextCursor ? ["", this.nextCursor] : [""]; + model.index = 0; + + const real = getPaginationState(CMD_KEY, CTX_KEY); + expect(real).toBeDefined(); + expect(real!.stack).toEqual(model.stack); + expect(real!.index).toBe(model.index); + } + + toString(): string { + return `advance("first", ${this.nextCursor ? `"${this.nextCursor}"` : "undefined"})`; + } +} + +/** Clear pagination state. */ +class ClearCommand implements AsyncCommand { + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + clearPaginationState(CMD_KEY, CTX_KEY); + + model.stack = null; + model.index = null; + + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeUndefined(); + } + + toString = () => "clear()"; +} + +/** Verify getPaginationState matches model. */ +class GetStateCommand implements AsyncCommand { + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + const real = getPaginationState(CMD_KEY, CTX_KEY); + + if (model.stack === null) { + expect(real).toBeUndefined(); + } else { + expect(real).toBeDefined(); + expect(real!.stack).toEqual(model.stack); + expect(real!.index).toBe(model.index!); + } + } + + toString = () => "getState()"; +} + +/** Verify hasPreviousPage matches model. */ +class HasPrevCommand implements AsyncCommand { + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + const real = hasPreviousPage(CMD_KEY, CTX_KEY); + const expected = + model.stack !== null && model.index !== null && model.index > 0; + expect(real).toBe(expected); + } + + toString = () => "hasPreviousPage()"; +} + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +const cursorArb = constantFrom( + "1735689600000:0:0", + "1735689600000:100:0", + "1735689600000:200:0", + "1735689600000:300:0", + "9999999999999:50:1" +); + +const optionalCursorArb = constantFrom( + "1735689600000:0:0", + "1735689600000:100:0", + "1735689600000:200:0", + undefined +); + +const advanceNextCmdArb = optionalCursorArb.map( + (cur) => new AdvanceNextCommand(cur) +); + +const advancePrevCmdArb = optionalCursorArb.map( + (cur) => new AdvancePrevCommand(cur) +); + +const advanceFirstCmdArb = optionalCursorArb.map( + (cur) => new AdvanceFirstCommand(cur) +); + +const clearCmdArb = constantFrom(new ClearCommand()); +const getStateCmdArb = constantFrom(new GetStateCommand()); +const hasPrevCmdArb = constantFrom(new HasPrevCommand()); + +const allCommands = [ + advanceNextCmdArb, + advancePrevCmdArb, + advanceFirstCmdArb, + clearCmdArb, + getStateCmdArb, + hasPrevCmdArb, +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("model-based: pagination cursor stack", () => { + test("random sequences of stack operations maintain consistency", async () => { + await fcAssert( + asyncProperty(commands(allCommands, { size: "+1" }), async (cmds) => { + const cleanup = createIsolatedDbContext(); + try { + const setup = () => ({ + model: createEmptyModel(), + real: {} as RealDb, + }); + await asyncModelRun(setup, cmds); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS, verbose: false } + ); + }); + + test("composite key: different contexts are independent", () => { + fcAssert( + property(tuple(cursorArb, cursorArb), ([cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + const ctx1 = "org:sentry"; + const ctx2 = "org:acme"; + + // Advance in two different contexts + advancePaginationState(CMD_KEY, ctx1, "next", cursor1); + advancePaginationState(CMD_KEY, ctx2, "next", cursor2); + + const state1 = getPaginationState(CMD_KEY, ctx1); + const state2 = getPaginationState(CMD_KEY, ctx2); + + expect(state1).toBeDefined(); + expect(state2).toBeDefined(); + expect(state1!.stack).toEqual(["", cursor1]); + expect(state2!.stack).toEqual(["", cursor2]); + + // Clear one, the other remains + clearPaginationState(CMD_KEY, ctx1); + expect(getPaginationState(CMD_KEY, ctx1)).toBeUndefined(); + expect(getPaginationState(CMD_KEY, ctx2)).toBeDefined(); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("composite key: different command keys are independent", () => { + fcAssert( + property(tuple(cursorArb, cursorArb), ([cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + const ctx = "org:sentry"; + const cmd1 = "project-list"; + const cmd2 = "issue-list"; + + advancePaginationState(cmd1, ctx, "next", cursor1); + advancePaginationState(cmd2, ctx, "next", cursor2); + + const state1 = getPaginationState(cmd1, ctx); + const state2 = getPaginationState(cmd2, ctx); + + expect(state1!.stack).toEqual(["", cursor1]); + expect(state2!.stack).toEqual(["", cursor2]); + + clearPaginationState(cmd1, ctx); + expect(getPaginationState(cmd1, ctx)).toBeUndefined(); + expect(getPaginationState(cmd2, ctx)).toBeDefined(); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("forward then backward navigation preserves stack correctly", () => { + const cleanup = createIsolatedDbContext(); + try { + // Page 1 (initial) + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p2"); + let state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state).toEqual({ stack: ["", "cursor-p2"], index: 0 }); + expect(hasPreviousPage(CMD_KEY, CTX_KEY)).toBe(false); + + // Page 2 (advance next) + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p3"); + state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state).toEqual({ + stack: ["", "cursor-p2", "cursor-p3"], + index: 1, + }); + expect(hasPreviousPage(CMD_KEY, CTX_KEY)).toBe(true); + + // Page 3 (advance next) + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p4"); + state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state).toEqual({ + stack: ["", "cursor-p2", "cursor-p3", "cursor-p4"], + index: 2, + }); + + // Go back to page 2 + advancePaginationState(CMD_KEY, CTX_KEY, "prev", "cursor-p3-refreshed"); + state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state!.index).toBe(1); + expect(hasPreviousPage(CMD_KEY, CTX_KEY)).toBe(true); + + // Go back to page 1 + advancePaginationState(CMD_KEY, CTX_KEY, "prev", "cursor-p2-refreshed"); + state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state!.index).toBe(0); + expect(hasPreviousPage(CMD_KEY, CTX_KEY)).toBe(false); + } finally { + cleanup(); + } + }); + + test("back-then-forward truncates stale entries", () => { + const cleanup = createIsolatedDbContext(); + try { + // Build up a 4-page stack + advancePaginationState(CMD_KEY, CTX_KEY, "next", "c2"); + advancePaginationState(CMD_KEY, CTX_KEY, "next", "c3"); + advancePaginationState(CMD_KEY, CTX_KEY, "next", "c4"); + // Now on page 3, stack: ["", "c2", "c3", "c4"] + + // Go back to page 2 + advancePaginationState(CMD_KEY, CTX_KEY, "prev", "c3-new"); + + // Go forward from page 2 with a new cursor — should truncate c4 + advancePaginationState(CMD_KEY, CTX_KEY, "next", "c4-new"); + const state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state!.index).toBe(2); + // Old c4 should be gone, replaced by c4-new + expect(state!.stack).toEqual(["", "c2", "c3-new", "c4-new"]); + } finally { + cleanup(); + } + }); + + test("resolveCursor: 'next' returns next stack entry", () => { + const cleanup = createIsolatedDbContext(); + try { + // Set up state with a next page available + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p2"); + // Now on page 0 with stack ["", "cursor-p2"] + + const resolved = resolveCursor("next", CMD_KEY, CTX_KEY); + expect(resolved.cursor).toBe("cursor-p2"); + expect(resolved.direction).toBe("next"); + } finally { + cleanup(); + } + }); + + test("resolveCursor: 'next' throws when no next page", () => { + const cleanup = createIsolatedDbContext(); + try { + // Set up state on last page (no next cursor) + advancePaginationState(CMD_KEY, CTX_KEY, "next", undefined); + + expect(() => resolveCursor("next", CMD_KEY, CTX_KEY)).toThrow( + /No next page/i + ); + } finally { + cleanup(); + } + }); + + test("resolveCursor: 'prev' returns previous stack entry", () => { + const cleanup = createIsolatedDbContext(); + try { + // Navigate to page 2 + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p2"); + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p3"); + // Now on page 1 + + const resolved = resolveCursor("prev", CMD_KEY, CTX_KEY); + // stack[0] is "" (first page) → cursor should be undefined + expect(resolved.cursor).toBeUndefined(); + expect(resolved.direction).toBe("prev"); + } finally { + cleanup(); + } + }); + + test("resolveCursor: 'prev' throws when on first page", () => { + const cleanup = createIsolatedDbContext(); + try { + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p2"); + // On page 0 + + expect(() => resolveCursor("prev", CMD_KEY, CTX_KEY)).toThrow( + /first page/i + ); + } finally { + cleanup(); + } + }); + + test("resolveCursor: 'first' always returns undefined cursor", () => { + const cleanup = createIsolatedDbContext(); + try { + // Navigate forward a few pages + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p2"); + advancePaginationState(CMD_KEY, CTX_KEY, "next", "cursor-p3"); + + const resolved = resolveCursor("first", CMD_KEY, CTX_KEY); + expect(resolved.cursor).toBeUndefined(); + expect(resolved.direction).toBe("first"); + } finally { + cleanup(); + } + }); + + test("resolveCursor: undefined flag returns first page with 'first' direction", () => { + const cleanup = createIsolatedDbContext(); + try { + const resolved = resolveCursor(undefined, CMD_KEY, CTX_KEY); + expect(resolved.cursor).toBeUndefined(); + expect(resolved.direction).toBe("first"); + } finally { + cleanup(); + } + }); + + test("resolveCursor: raw cursor string is passed through", () => { + const cleanup = createIsolatedDbContext(); + try { + const resolved = resolveCursor("1735689600000:100:0", CMD_KEY, CTX_KEY); + expect(resolved.cursor).toBe("1735689600000:100:0"); + expect(resolved.direction).toBe("next"); + } finally { + cleanup(); + } + }); + + test("expired state returns undefined", () => { + fcAssert( + property(cursorArb, (cursor) => { + const cleanup = createIsolatedDbContext(); + try { + // Advance to create state, then manually expire it via direct DB write + advancePaginationState(CMD_KEY, CTX_KEY, "next", cursor); + + // Verify state exists + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeDefined(); + + // Expire it by writing directly to DB with past timestamp + const db = getDatabase(); + db.query( + "UPDATE pagination_cursors SET expires_at = ? WHERE command_key = ? AND context = ?" + ).run(Date.now() - 1000, CMD_KEY, CTX_KEY); + + // Should return undefined + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeUndefined(); + + // Row should be deleted after the expired read + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("get on empty table returns undefined", () => { + const cleanup = createIsolatedDbContext(); + try { + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeUndefined(); + } finally { + cleanup(); + } + }); + + test("clear on non-existent key is a no-op", () => { + const cleanup = createIsolatedDbContext(); + try { + // Should not throw + clearPaginationState(CMD_KEY, CTX_KEY); + expect(getPaginationState(CMD_KEY, CTX_KEY)).toBeUndefined(); + } finally { + cleanup(); + } + }); + + test("advance 'first' resets index to 0", () => { + const cleanup = createIsolatedDbContext(); + try { + // Navigate forward + advancePaginationState(CMD_KEY, CTX_KEY, "next", "c2"); + advancePaginationState(CMD_KEY, CTX_KEY, "next", "c3"); + advancePaginationState(CMD_KEY, CTX_KEY, "next", "c4"); + expect(getPaginationState(CMD_KEY, CTX_KEY)!.index).toBe(2); + + // Jump to first + advancePaginationState(CMD_KEY, CTX_KEY, "first", "c2-new"); + const state = getPaginationState(CMD_KEY, CTX_KEY); + expect(state!.index).toBe(0); + expect(state!.stack).toEqual(["", "c2-new"]); + expect(hasPreviousPage(CMD_KEY, CTX_KEY)).toBe(false); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/cli/test/lib/db/pagination.test.ts b/packages/cli/test/lib/db/pagination.test.ts new file mode 100644 index 000000000..38d0d0b7e --- /dev/null +++ b/packages/cli/test/lib/db/pagination.test.ts @@ -0,0 +1,52 @@ +/** + * Unit tests for pagination context key builders. + */ + +import { describe, expect, test } from "vitest"; +import { + buildOrgContextKey, + buildPaginationContextKey, +} from "../../../src/lib/db/pagination.js"; + +describe("buildPaginationContextKey", () => { + test("builds simple org-scoped key", () => { + const key = buildPaginationContextKey("org", "my-org"); + expect(key).toContain("type:org:my-org"); + expect(key).toMatch(/^host:.+\|type:org:my-org$/); + }); + + test("includes optional params when defined", () => { + const key = buildPaginationContextKey("trace", "my-org/my-proj", { + sort: "date", + q: "GET /api", + }); + expect(key).toContain("type:trace:my-org/my-proj"); + expect(key).toContain("|sort:date"); + expect(key).toContain("|q:GET /api"); + }); + + test("omits undefined params", () => { + const key = buildPaginationContextKey("trace", "my-org/my-proj", { + sort: "date", + q: undefined, + }); + expect(key).toContain("|sort:date"); + expect(key).not.toContain("|q:"); + }); + + test("escapes pipe characters in param values", () => { + const key = buildPaginationContextKey("org", "my-org", { + q: "a|b", + }); + expect(key).toContain("|q:a%7Cb"); + expect(key).not.toContain("|q:a|b"); + }); +}); + +describe("buildOrgContextKey", () => { + test("delegates to buildPaginationContextKey", () => { + const orgKey = buildOrgContextKey("test-org"); + const directKey = buildPaginationContextKey("org", "test-org"); + expect(orgKey).toBe(directKey); + }); +}); diff --git a/packages/cli/test/lib/db/project-cache.test.ts b/packages/cli/test/lib/db/project-cache.test.ts new file mode 100644 index 000000000..c9f92fe35 --- /dev/null +++ b/packages/cli/test/lib/db/project-cache.test.ts @@ -0,0 +1,579 @@ +/** + * Project Cache Tests + * + * Tests for caching project information by orgId:projectId or DSN public key. + */ + +import { setTimeout as sleep } from "node:timers/promises"; +import { describe, expect, test } from "vitest"; +import { + cacheProjectsForOrg, + clearProjectCache, + getCachedProject, + getCachedProjectByDsnKey, + getCachedProjectBySlug, + getCachedProjectsForOrg, + setCachedProject, + setCachedProjectByDsnKey, +} from "../../../src/lib/db/project-cache.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-project-cache-"); + +describe("getCachedProject", () => { + test("returns undefined when no cache entry exists", async () => { + const result = getCachedProject("org-123", "project-456"); + expect(result).toBeUndefined(); + }); + + test("returns cached project when entry exists", async () => { + setCachedProject("org-123", "project-456", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const result = getCachedProject("org-123", "project-456"); + expect(result).toBeDefined(); + expect(result?.orgSlug).toBe("my-org"); + expect(result?.orgName).toBe("My Organization"); + expect(result?.projectSlug).toBe("my-project"); + expect(result?.projectName).toBe("My Project"); + expect(result?.cachedAt).toBeGreaterThan(0); + }); + + test("returns undefined for different orgId", async () => { + setCachedProject("org-123", "project-456", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const result = getCachedProject("org-different", "project-456"); + expect(result).toBeUndefined(); + }); + + test("returns undefined for different projectId", async () => { + setCachedProject("org-123", "project-456", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const result = getCachedProject("org-123", "project-different"); + expect(result).toBeUndefined(); + }); +}); + +describe("setCachedProject", () => { + test("creates new cache entry", async () => { + setCachedProject("org-123", "project-456", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const result = getCachedProject("org-123", "project-456"); + expect(result).toBeDefined(); + expect(result?.orgSlug).toBe("my-org"); + }); + + test("updates existing cache entry (upsert)", async () => { + // Set initial cache + setCachedProject("org-123", "project-456", { + orgSlug: "old-org", + orgName: "Old Organization", + projectSlug: "old-project", + projectName: "Old Project", + }); + + // Update cache + setCachedProject("org-123", "project-456", { + orgSlug: "new-org", + orgName: "New Organization", + projectSlug: "new-project", + projectName: "New Project", + }); + + const result = getCachedProject("org-123", "project-456"); + expect(result).toBeDefined(); + expect(result?.orgSlug).toBe("new-org"); + expect(result?.orgName).toBe("New Organization"); + expect(result?.projectSlug).toBe("new-project"); + expect(result?.projectName).toBe("New Project"); + }); + + test("stores cachedAt timestamp", async () => { + const before = Date.now(); + + setCachedProject("org-123", "project-456", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const after = Date.now(); + const result = getCachedProject("org-123", "project-456"); + + expect(result?.cachedAt).toBeGreaterThanOrEqual(before); + expect(result?.cachedAt).toBeLessThanOrEqual(after); + }); + + test("handles multiple distinct cache entries", async () => { + setCachedProject("org-1", "project-1", { + orgSlug: "org-one", + orgName: "Org One", + projectSlug: "project-one", + projectName: "Project One", + }); + + setCachedProject("org-2", "project-2", { + orgSlug: "org-two", + orgName: "Org Two", + projectSlug: "project-two", + projectName: "Project Two", + }); + + const result1 = getCachedProject("org-1", "project-1"); + const result2 = getCachedProject("org-2", "project-2"); + + expect(result1?.orgSlug).toBe("org-one"); + expect(result2?.orgSlug).toBe("org-two"); + }); +}); + +describe("getCachedProjectByDsnKey", () => { + test("returns undefined when no cache entry exists", async () => { + const result = getCachedProjectByDsnKey("abc123publickey"); + expect(result).toBeUndefined(); + }); + + test("returns cached project when entry exists", async () => { + setCachedProjectByDsnKey("abc123publickey", { + orgSlug: "dsn-org", + orgName: "DSN Organization", + projectSlug: "dsn-project", + projectName: "DSN Project", + }); + + const result = getCachedProjectByDsnKey("abc123publickey"); + expect(result).toBeDefined(); + expect(result?.orgSlug).toBe("dsn-org"); + expect(result?.orgName).toBe("DSN Organization"); + expect(result?.projectSlug).toBe("dsn-project"); + expect(result?.projectName).toBe("DSN Project"); + expect(result?.cachedAt).toBeGreaterThan(0); + }); + + test("returns undefined for different public key", async () => { + setCachedProjectByDsnKey("abc123publickey", { + orgSlug: "dsn-org", + orgName: "DSN Organization", + projectSlug: "dsn-project", + projectName: "DSN Project", + }); + + const result = getCachedProjectByDsnKey("different-key"); + expect(result).toBeUndefined(); + }); +}); + +describe("setCachedProjectByDsnKey", () => { + test("creates new cache entry", async () => { + setCachedProjectByDsnKey("mykey123", { + orgSlug: "key-org", + orgName: "Key Organization", + projectSlug: "key-project", + projectName: "Key Project", + }); + + const result = getCachedProjectByDsnKey("mykey123"); + expect(result).toBeDefined(); + expect(result?.orgSlug).toBe("key-org"); + }); + + test("updates existing cache entry (upsert)", async () => { + setCachedProjectByDsnKey("mykey123", { + orgSlug: "old-org", + orgName: "Old Organization", + projectSlug: "old-project", + projectName: "Old Project", + }); + + setCachedProjectByDsnKey("mykey123", { + orgSlug: "new-org", + orgName: "New Organization", + projectSlug: "new-project", + projectName: "New Project", + }); + + const result = getCachedProjectByDsnKey("mykey123"); + expect(result?.orgSlug).toBe("new-org"); + expect(result?.orgName).toBe("New Organization"); + }); + + test("dsn key cache is separate from orgId:projectId cache", async () => { + // Cache by orgId:projectId + setCachedProject("123", "456", { + orgSlug: "by-id-org", + orgName: "By ID Organization", + projectSlug: "by-id-project", + projectName: "By ID Project", + }); + + // Cache by DSN key + setCachedProjectByDsnKey("publickey", { + orgSlug: "by-dsn-org", + orgName: "By DSN Organization", + projectSlug: "by-dsn-project", + projectName: "By DSN Project", + }); + + // Both should exist independently + const byId = getCachedProject("123", "456"); + const byDsn = getCachedProjectByDsnKey("publickey"); + + expect(byId?.orgSlug).toBe("by-id-org"); + expect(byDsn?.orgSlug).toBe("by-dsn-org"); + + // Cross-lookup should fail - keys are in different formats + // orgId:projectId format -> "123:456" + // DSN key format -> "dsn:publickey" + const byIdAsDsn = getCachedProjectByDsnKey("123:456"); + expect(byIdAsDsn).toBeUndefined(); + + // Lookup with wrong key format + const wrongKey = getCachedProject("wrong", "key"); + expect(wrongKey).toBeUndefined(); + }); +}); + +describe("clearProjectCache", () => { + test("clears all cache entries", async () => { + // Add several entries + setCachedProject("org-1", "project-1", { + orgSlug: "org-one", + orgName: "Org One", + projectSlug: "project-one", + projectName: "Project One", + }); + + setCachedProject("org-2", "project-2", { + orgSlug: "org-two", + orgName: "Org Two", + projectSlug: "project-two", + projectName: "Project Two", + }); + + setCachedProjectByDsnKey("key1", { + orgSlug: "key-org", + orgName: "Key Organization", + projectSlug: "key-project", + projectName: "Key Project", + }); + + // Clear all + clearProjectCache(); + + // All should be undefined across every lookup shape. + expect(getCachedProject("org-1", "project-1")).toBeUndefined(); + expect(getCachedProject("org-2", "project-2")).toBeUndefined(); + expect(getCachedProjectByDsnKey("key1")).toBeUndefined(); + expect(getCachedProjectBySlug("org-one", "project-one")).toBeUndefined(); + expect(getCachedProjectBySlug("key-org", "key-project")).toBeUndefined(); + }); + + test("does not throw when cache is already empty", async () => { + // Should not throw + clearProjectCache(); + clearProjectCache(); + }); +}); + +describe("cache key uniqueness", () => { + test("different orgId:projectId combinations are stored separately", async () => { + setCachedProject("org-A", "project-1", { + orgSlug: "org-a", + orgName: "Org A", + projectSlug: "project-1", + projectName: "Project 1 in A", + }); + + setCachedProject("org-B", "project-1", { + orgSlug: "org-b", + orgName: "Org B", + projectSlug: "project-1", + projectName: "Project 1 in B", + }); + + setCachedProject("org-A", "project-2", { + orgSlug: "org-a", + orgName: "Org A", + projectSlug: "project-2", + projectName: "Project 2 in A", + }); + + const resultA1 = getCachedProject("org-A", "project-1"); + const resultB1 = getCachedProject("org-B", "project-1"); + const resultA2 = getCachedProject("org-A", "project-2"); + + expect(resultA1?.projectName).toBe("Project 1 in A"); + expect(resultB1?.projectName).toBe("Project 1 in B"); + expect(resultA2?.projectName).toBe("Project 2 in A"); + }); + + test("handles special characters in IDs", async () => { + setCachedProject("org:with:colons", "project/with/slashes", { + orgSlug: "special-org", + orgName: "Special Organization", + projectSlug: "special-project", + projectName: "Special Project", + }); + + const result = getCachedProject("org:with:colons", "project/with/slashes"); + expect(result?.orgSlug).toBe("special-org"); + }); + + test("handles numeric-like string IDs", async () => { + setCachedProject("123", "456", { + orgSlug: "numeric-org", + orgName: "Numeric Organization", + projectSlug: "numeric-project", + projectName: "Numeric Project", + }); + + const result = getCachedProject("123", "456"); + expect(result?.orgSlug).toBe("numeric-org"); + }); +}); + +describe("cacheProjectsForOrg", () => { + test("caches multiple projects in one call", async () => { + cacheProjectsForOrg("my-org", "My Org", [ + { id: "1", slug: "frontend", name: "Frontend" }, + { id: "2", slug: "backend", name: "Backend" }, + { id: "3", slug: "mobile", name: "Mobile App" }, + ]); + + const result = getCachedProjectsForOrg("my-org"); + expect(result).toHaveLength(3); + expect(result).toContainEqual({ + projectSlug: "frontend", + projectName: "Frontend", + }); + expect(result).toContainEqual({ + projectSlug: "backend", + projectName: "Backend", + }); + expect(result).toContainEqual({ + projectSlug: "mobile", + projectName: "Mobile App", + }); + }); + + test("is idempotent on repeated calls", async () => { + const projects = [ + { id: "1", slug: "frontend", name: "Frontend" }, + { id: "2", slug: "backend", name: "Backend" }, + ]; + + cacheProjectsForOrg("my-org", "My Org", projects); + cacheProjectsForOrg("my-org", "My Org", projects); + + const result = getCachedProjectsForOrg("my-org"); + expect(result).toHaveLength(2); + }); + + test("empty array is a no-op", async () => { + cacheProjectsForOrg("my-org", "My Org", []); + const result = getCachedProjectsForOrg("my-org"); + expect(result).toEqual([]); + }); + + test("does not conflict with orgId:projectId cache entries", async () => { + setCachedProject("org-123", "proj-456", { + orgSlug: "my-org", + orgName: "My Org", + projectSlug: "frontend", + projectName: "Frontend (by DSN)", + }); + + cacheProjectsForOrg("my-org", "My Org", [ + { id: "456", slug: "frontend", name: "Frontend (by list)" }, + ]); + + // Both entries exist — getCachedProjectsForOrg deduplicates by slug + const result = getCachedProjectsForOrg("my-org"); + expect(result).toHaveLength(1); + expect(result[0]?.projectSlug).toBe("frontend"); + }); +}); + +describe("getCachedProjectsForOrg", () => { + test("returns empty array when no projects for org", async () => { + const result = getCachedProjectsForOrg("nonexistent-org"); + expect(result).toEqual([]); + }); + + test("returns projects only for the specified org", async () => { + setCachedProject("org-1", "project-1", { + orgSlug: "alpha-org", + orgName: "Alpha", + projectSlug: "alpha-project", + projectName: "Alpha Project", + }); + setCachedProject("org-2", "project-2", { + orgSlug: "beta-org", + orgName: "Beta", + projectSlug: "beta-project", + projectName: "Beta Project", + }); + setCachedProject("org-3", "project-3", { + orgSlug: "alpha-org", + orgName: "Alpha", + projectSlug: "alpha-second", + projectName: "Alpha Second", + }); + + const result = getCachedProjectsForOrg("alpha-org"); + expect(result).toHaveLength(2); + expect(result).toContainEqual({ + projectSlug: "alpha-project", + projectName: "Alpha Project", + }); + expect(result).toContainEqual({ + projectSlug: "alpha-second", + projectName: "Alpha Second", + }); + }); + + test("returns empty for wrong org", async () => { + setCachedProject("org-1", "project-1", { + orgSlug: "alpha-org", + orgName: "Alpha", + projectSlug: "alpha-project", + projectName: "Alpha Project", + }); + + const result = getCachedProjectsForOrg("beta-org"); + expect(result).toEqual([]); + }); +}); + +describe("getCachedProjectBySlug", () => { + test("returns undefined when no cache entry exists", () => { + const result = getCachedProjectBySlug("my-org", "my-project"); + expect(result).toBeUndefined(); + }); + + test("returns entry populated via setCachedProject (orgId:projectId key)", () => { + setCachedProject("123", "456", { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + projectId: "456", + }); + + const result = getCachedProjectBySlug("my-org", "my-project"); + expect(result).toBeDefined(); + expect(result?.orgSlug).toBe("my-org"); + expect(result?.projectSlug).toBe("my-project"); + expect(result?.projectId).toBe("456"); + }); + + test("returns entry populated via setCachedProjectByDsnKey (dsn: key)", () => { + setCachedProjectByDsnKey("abcdef1234567890", { + orgSlug: "dsn-org", + orgName: "DSN Org", + projectSlug: "dsn-project", + projectName: "DSN Project", + projectId: "789", + }); + + const result = getCachedProjectBySlug("dsn-org", "dsn-project"); + expect(result).toBeDefined(); + expect(result?.projectId).toBe("789"); + }); + + test("returns entry populated via cacheProjectsForOrg (list: key)", () => { + cacheProjectsForOrg("my-org", "My Org", [ + { id: "101", slug: "listed-project", name: "Listed Project" }, + ]); + + const result = getCachedProjectBySlug("my-org", "listed-project"); + expect(result).toBeDefined(); + expect(result?.projectId).toBe("101"); + expect(result?.projectName).toBe("Listed Project"); + }); + + test("returns the most recently cached entry when multiple key shapes match", async () => { + // Older entry via dsn key + setCachedProjectByDsnKey("key-1", { + orgSlug: "my-org", + orgName: "My Org", + projectSlug: "dup", + projectName: "Old Name", + projectId: "111", + }); + // Small delay so `cached_at` differs; setCachedProject uses Date.now() + await sleep(5); + // Newer entry via orgId:projectId key + setCachedProject("org-id", "proj-id", { + orgSlug: "my-org", + orgName: "My Org", + projectSlug: "dup", + projectName: "New Name", + projectId: "222", + }); + + const result = getCachedProjectBySlug("my-org", "dup"); + expect(result).toBeDefined(); + expect(result?.projectName).toBe("New Name"); + expect(result?.projectId).toBe("222"); + }); + + test("does not match a different org", () => { + setCachedProject("123", "456", { + orgSlug: "org-a", + orgName: "A", + projectSlug: "shared-slug", + projectName: "A", + projectId: "456", + }); + + expect(getCachedProjectBySlug("org-b", "shared-slug")).toBeUndefined(); + }); + + test("does not match a different project", () => { + setCachedProject("123", "456", { + orgSlug: "org-a", + orgName: "A", + projectSlug: "proj-a", + projectName: "A", + projectId: "456", + }); + + expect(getCachedProjectBySlug("org-a", "proj-b")).toBeUndefined(); + }); + + test("returns entry even when projectId is missing (older rows)", () => { + setCachedProject("123", "456", { + orgSlug: "legacy-org", + orgName: "Legacy", + projectSlug: "legacy-project", + projectName: "Legacy", + }); + + const result = getCachedProjectBySlug("legacy-org", "legacy-project"); + expect(result).toBeDefined(); + // SQLite stores missing project IDs as NULL; the row mapper passes + // the value through unchanged, so callers see `null` (not undefined). + expect(result?.projectId).toBeFalsy(); + }); +}); diff --git a/packages/cli/test/lib/db/project-root-cache.test.ts b/packages/cli/test/lib/db/project-root-cache.test.ts new file mode 100644 index 000000000..2f067fc11 --- /dev/null +++ b/packages/cli/test/lib/db/project-root-cache.test.ts @@ -0,0 +1,193 @@ +/** + * Project Root Cache Tests + * + * Tests for cwd -> projectRoot caching with mtime-based invalidation. + */ + +import { mkdirSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, test } from "vitest"; +import { + clearProjectRootCache, + clearProjectRootCacheFor, + getCachedProjectRoot, + setCachedProjectRoot, +} from "../../../src/lib/db/project-root-cache.js"; +import { cleanupTestDir, useTestConfigDir } from "../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-project-root-cache-"); +let testProjectDir: string; + +beforeEach(() => { + // Create a test project directory with a file (to have a stable mtime) + testProjectDir = join(getConfigDir(), "project"); + mkdirSync(testProjectDir, { recursive: true }); + writeFileSync(join(testProjectDir, "package.json"), "{}"); +}); + +describe("getCachedProjectRoot", () => { + test("returns undefined when no cache entry exists", async () => { + const result = await getCachedProjectRoot("/nonexistent/path"); + expect(result).toBeUndefined(); + }); + + test("returns cached entry when valid", async () => { + await setCachedProjectRoot(testProjectDir, { + projectRoot: testProjectDir, + reason: "language", + }); + + const result = await getCachedProjectRoot(testProjectDir); + expect(result).toEqual({ + projectRoot: testProjectDir, + reason: "language", + }); + }); + + test("invalidates cache when directory mtime changes", async () => { + await setCachedProjectRoot(testProjectDir, { + projectRoot: testProjectDir, + reason: "language", + }); + + // Verify cache exists + const before = await getCachedProjectRoot(testProjectDir); + expect(before).toBeDefined(); + + // Add a new file and set dir mtime to cachedMtime + 5s + writeFileSync(join(testProjectDir, "new-file.txt"), "test"); + const cachedMtime = statSync(testProjectDir).mtimeMs; + const futureTime = new Date(cachedMtime + 5000); + utimesSync(testProjectDir, futureTime, futureTime); + + // Cache should be invalidated + const after = await getCachedProjectRoot(testProjectDir); + expect(after).toBeUndefined(); + }); + + test("invalidates cache when directory is deleted", async () => { + const tempDir = join(getConfigDir(), "temp-dir"); + mkdirSync(tempDir); + writeFileSync(join(tempDir, "file.txt"), "test"); + + await setCachedProjectRoot(tempDir, { + projectRoot: tempDir, + reason: "vcs", + }); + + // Verify cache exists + const before = await getCachedProjectRoot(tempDir); + expect(before).toBeDefined(); + + // Delete the directory + await cleanupTestDir(tempDir); + + // Cache should be invalidated + const after = await getCachedProjectRoot(tempDir); + expect(after).toBeUndefined(); + }); +}); + +describe("setCachedProjectRoot", () => { + test("stores project root with reason", async () => { + await setCachedProjectRoot(testProjectDir, { + projectRoot: "/some/root", + reason: "vcs", + }); + + const result = await getCachedProjectRoot(testProjectDir); + expect(result?.projectRoot).toBe("/some/root"); + expect(result?.reason).toBe("vcs"); + }); + + test("overwrites existing cache entry", async () => { + await setCachedProjectRoot(testProjectDir, { + projectRoot: "/first/root", + reason: "language", + }); + + await setCachedProjectRoot(testProjectDir, { + projectRoot: "/second/root", + reason: "vcs", + }); + + const result = await getCachedProjectRoot(testProjectDir); + expect(result?.projectRoot).toBe("/second/root"); + expect(result?.reason).toBe("vcs"); + }); + + test("does not cache when directory cannot be stat", async () => { + await setCachedProjectRoot("/nonexistent/path", { + projectRoot: "/some/root", + reason: "vcs", + }); + + // Should not have cached anything (can't stat the directory) + const result = await getCachedProjectRoot("/nonexistent/path"); + expect(result).toBeUndefined(); + }); +}); + +describe("clearProjectRootCache", () => { + test("removes all cached entries", async () => { + const dir1 = join(getConfigDir(), "dir1"); + const dir2 = join(getConfigDir(), "dir2"); + mkdirSync(dir1); + mkdirSync(dir2); + + await setCachedProjectRoot(dir1, { + projectRoot: dir1, + reason: "vcs", + }); + await setCachedProjectRoot(dir2, { + projectRoot: dir2, + reason: "language", + }); + + // Verify both exist + expect(await getCachedProjectRoot(dir1)).toBeDefined(); + expect(await getCachedProjectRoot(dir2)).toBeDefined(); + + // Clear all + await clearProjectRootCache(); + + // Both should be gone + expect(await getCachedProjectRoot(dir1)).toBeUndefined(); + expect(await getCachedProjectRoot(dir2)).toBeUndefined(); + }); + + test("is safe to call on empty cache", async () => { + // Should not throw + await clearProjectRootCache(); + }); +}); + +describe("clearProjectRootCacheFor", () => { + test("removes only the specified entry", async () => { + const dir1 = join(getConfigDir(), "dir1"); + const dir2 = join(getConfigDir(), "dir2"); + mkdirSync(dir1); + mkdirSync(dir2); + + await setCachedProjectRoot(dir1, { + projectRoot: dir1, + reason: "vcs", + }); + await setCachedProjectRoot(dir2, { + projectRoot: dir2, + reason: "language", + }); + + // Clear only dir1 + await clearProjectRootCacheFor(dir1); + + // dir1 should be gone, dir2 should remain + expect(await getCachedProjectRoot(dir1)).toBeUndefined(); + expect(await getCachedProjectRoot(dir2)).toBeDefined(); + }); + + test("is safe to call for nonexistent entry", async () => { + // Should not throw + await clearProjectRootCacheFor("/nonexistent/path"); + }); +}); diff --git a/packages/cli/test/lib/db/release-channel.test.ts b/packages/cli/test/lib/db/release-channel.test.ts new file mode 100644 index 000000000..60c6efe7e --- /dev/null +++ b/packages/cli/test/lib/db/release-channel.test.ts @@ -0,0 +1,130 @@ +/** + * Release Channel Storage Tests + */ + +import { describe, expect, test } from "vitest"; +import { + getReleaseChannel, + parseReleaseChannel, + setReleaseChannel, +} from "../../../src/lib/db/release-channel.js"; +import { + clearVersionCheckCache, + getVersionCheckInfo, + setVersionCheckInfo, +} from "../../../src/lib/db/version-check.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-release-channel-"); + +describe("getReleaseChannel", () => { + test("returns 'stable' when nothing is stored", () => { + expect(getReleaseChannel()).toBe("stable"); + }); + + test("returns stored channel after set", () => { + setReleaseChannel("nightly"); + expect(getReleaseChannel()).toBe("nightly"); + }); + + test("returns 'stable' after explicitly setting stable", () => { + setReleaseChannel("nightly"); + setReleaseChannel("stable"); + expect(getReleaseChannel()).toBe("stable"); + }); +}); + +describe("setReleaseChannel", () => { + test("persists 'nightly'", () => { + setReleaseChannel("nightly"); + expect(getReleaseChannel()).toBe("nightly"); + }); + + test("persists 'stable'", () => { + setReleaseChannel("stable"); + expect(getReleaseChannel()).toBe("stable"); + }); + + test("overwrites previous channel", () => { + setReleaseChannel("nightly"); + setReleaseChannel("stable"); + expect(getReleaseChannel()).toBe("stable"); + + setReleaseChannel("nightly"); + expect(getReleaseChannel()).toBe("nightly"); + }); +}); + +describe("parseReleaseChannel", () => { + test("accepts 'stable'", () => { + expect(parseReleaseChannel("stable")).toBe("stable"); + }); + + test("accepts 'nightly'", () => { + expect(parseReleaseChannel("nightly")).toBe("nightly"); + }); + + test("is case-insensitive", () => { + expect(parseReleaseChannel("STABLE")).toBe("stable"); + expect(parseReleaseChannel("Nightly")).toBe("nightly"); + expect(parseReleaseChannel("NIGHTLY")).toBe("nightly"); + }); + + test("throws on unrecognized value", () => { + expect(() => parseReleaseChannel("beta")).toThrow( + "Invalid channel: beta. Must be one of: stable, nightly" + ); + }); + + test("throws on empty string", () => { + expect(() => parseReleaseChannel("")).toThrow(); + }); +}); + +describe("clearVersionCheckCache", () => { + test("clears cached lastChecked and latestVersion", () => { + setVersionCheckInfo("1.0.0"); + const before = getVersionCheckInfo(); + expect(before.lastChecked).not.toBeNull(); + expect(before.latestVersion).toBe("1.0.0"); + + clearVersionCheckCache(); + + const after = getVersionCheckInfo(); + expect(after.lastChecked).toBeNull(); + expect(after.latestVersion).toBeNull(); + }); + + test("is idempotent (no error on double-clear)", () => { + clearVersionCheckCache(); + expect(() => clearVersionCheckCache()).not.toThrow(); + const info = getVersionCheckInfo(); + expect(info.lastChecked).toBeNull(); + expect(info.latestVersion).toBeNull(); + }); +}); + +describe("setReleaseChannel — cache-clearing side effect", () => { + test("clears version check cache when channel changes", () => { + setVersionCheckInfo("1.0.0"); + expect(getVersionCheckInfo().latestVersion).toBe("1.0.0"); + + // Switching from default "stable" to "nightly" should clear the cache + setReleaseChannel("nightly"); + + const info = getVersionCheckInfo(); + expect(info.lastChecked).toBeNull(); + expect(info.latestVersion).toBeNull(); + }); + + test("does not clear cache when channel is unchanged", () => { + setVersionCheckInfo("2.0.0"); + expect(getVersionCheckInfo().latestVersion).toBe("2.0.0"); + + // Setting same channel again — cache should remain intact + setReleaseChannel("stable"); // default is stable, so this is unchanged + setReleaseChannel("stable"); // second set: now stored=stable, setting=stable → no change + + expect(getVersionCheckInfo().latestVersion).toBe("2.0.0"); + }); +}); diff --git a/packages/cli/test/lib/db/repo-cache.test.ts b/packages/cli/test/lib/db/repo-cache.test.ts new file mode 100644 index 000000000..6d065a527 --- /dev/null +++ b/packages/cli/test/lib/db/repo-cache.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for the Sentry repository offline cache. + * + * The cache is a per-org JSON blob with a TTL — covers cache hit, cache + * miss (no row), staleness (older than TTL), and corruption resilience. + */ + +import { afterEach, describe, expect, test } from "vitest"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { + clearCachedRepos, + getCachedRepos, + REPO_CACHE_TTL_MS, + setCachedRepos, +} from "../../../src/lib/db/repo-cache.js"; +import type { SentryRepository } from "../../../src/types/sentry.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-repo-cache-"); + +function makeRepo(name: string): SentryRepository { + return { + id: "1", + name, + url: `https://github.com/${name}`, + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + externalSlug: name, + } as SentryRepository; +} + +afterEach(() => { + // Each test starts fresh — useTestConfigDir resets the DB. +}); + +describe("getCachedRepos", () => { + test("returns null when no row exists", () => { + expect(getCachedRepos("no-such-org")).toBeNull(); + }); + + test("returns the stored list on cache hit", () => { + const repos = [makeRepo("getsentry/cli"), makeRepo("getsentry/sentry")]; + setCachedRepos("my-org", repos); + const result = getCachedRepos("my-org"); + expect(result).toHaveLength(2); + expect(result?.[0]?.name).toBe("getsentry/cli"); + expect(result?.[1]?.name).toBe("getsentry/sentry"); + }); + + test("returns null when the row is older than TTL", () => { + setCachedRepos("aged-org", [makeRepo("foo/bar")]); + // Manually age the row past the TTL + const db = getDatabase(); + db.query("UPDATE repo_cache SET cached_at = ? WHERE org_slug = ?").run( + Date.now() - (REPO_CACHE_TTL_MS + 1000), + "aged-org" + ); + expect(getCachedRepos("aged-org")).toBeNull(); + }); + + test("returns null when repos_json is corrupted (treats as miss)", () => { + const db = getDatabase(); + db.query( + "INSERT INTO repo_cache (org_slug, repos_json, cached_at) VALUES (?, ?, ?)" + ).run("broken-org", "{not-json", Date.now()); + expect(getCachedRepos("broken-org")).toBeNull(); + }); + + test("returns null when repos_json is valid JSON but not an array", () => { + const db = getDatabase(); + db.query( + "INSERT INTO repo_cache (org_slug, repos_json, cached_at) VALUES (?, ?, ?)" + ).run("wrong-shape-org", JSON.stringify({ not: "array" }), Date.now()); + expect(getCachedRepos("wrong-shape-org")).toBeNull(); + }); +}); + +describe("setCachedRepos", () => { + test("overwrites the existing entry on repeated writes", () => { + setCachedRepos("my-org", [makeRepo("foo/bar")]); + setCachedRepos("my-org", [makeRepo("baz/qux"), makeRepo("quux/corge")]); + const result = getCachedRepos("my-org"); + expect(result).toHaveLength(2); + expect(result?.[0]?.name).toBe("baz/qux"); + }); + + test("stores the full repo payload (round-trip)", () => { + const original = makeRepo("getsentry/cli"); + setCachedRepos("my-org", [original]); + const [roundTripped] = getCachedRepos("my-org") ?? []; + expect(roundTripped).toEqual(original); + }); +}); + +describe("clearCachedRepos", () => { + test("removes a single org's entry without affecting others", () => { + setCachedRepos("org-a", [makeRepo("a/1")]); + setCachedRepos("org-b", [makeRepo("b/1")]); + clearCachedRepos("org-a"); + expect(getCachedRepos("org-a")).toBeNull(); + expect(getCachedRepos("org-b")).not.toBeNull(); + }); + + test("is a no-op when the entry doesn't exist", () => { + // Should not throw + clearCachedRepos("never-existed"); + expect(getCachedRepos("never-existed")).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/db/schema.test.ts b/packages/cli/test/lib/db/schema.test.ts new file mode 100644 index 000000000..29619bb8d --- /dev/null +++ b/packages/cli/test/lib/db/schema.test.ts @@ -0,0 +1,548 @@ +/** + * Tests for database schema repair functions. + */ + +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + CURRENT_SCHEMA_VERSION, + EXPECTED_COLUMNS, + EXPECTED_TABLES, + generatePreMigrationTableDDL, + getSchemaIssues, + hasColumn, + initSchema, + isReadonlyError, + repairSchema, + runMigrations, + tableExists, +} from "../../../src/lib/db/schema.js"; +import { Database } from "../../../src/lib/db/sqlite.js"; +import { getMetadata } from "../../../src/lib/db/utils.js"; +import { useTestConfigDir } from "../../helpers.js"; + +/** + * Create a database with all tables but some missing (for testing repair). + */ +function createDatabaseWithMissingTables( + db: Database, + missingTables: string[] +): void { + const statements: string[] = []; + for (const tableName of Object.keys(EXPECTED_TABLES)) { + if (missingTables.includes(tableName)) continue; + statements.push(EXPECTED_TABLES[tableName] as string); + } + db.exec(statements.join(";\n")); + db.query("INSERT INTO schema_version (version) VALUES (?)").run( + CURRENT_SCHEMA_VERSION + ); +} + +/** + * Create a database with pre-migration versions of specified tables. + * Tables with migrated columns will be created without those columns. + */ +function createPreMigrationDatabase( + db: Database, + preMigrationTables: string[] +): void { + const statements: string[] = []; + for (const tableName of Object.keys(EXPECTED_TABLES)) { + if (preMigrationTables.includes(tableName)) { + statements.push(generatePreMigrationTableDDL(tableName)); + } else { + statements.push(EXPECTED_TABLES[tableName] as string); + } + } + db.exec(statements.join(";\n")); + db.query("INSERT INTO schema_version (version) VALUES (?)").run( + CURRENT_SCHEMA_VERSION + ); +} + +const getTestDir = useTestConfigDir("schema-test-"); + +describe("tableExists", () => { + test("returns true for existing table", () => { + const db = new Database(join(getTestDir(), "test.db")); + db.exec("CREATE TABLE test_table (id INTEGER PRIMARY KEY)"); + + expect(tableExists(db, "test_table")).toBe(true); + db.close(); + }); + + test("returns false for non-existent table", () => { + const db = new Database(join(getTestDir(), "test.db")); + + expect(tableExists(db, "nonexistent")).toBe(false); + db.close(); + }); +}); + +describe("hasColumn", () => { + test("returns true for existing column", () => { + const db = new Database(join(getTestDir(), "test.db")); + db.exec("CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)"); + + expect(hasColumn(db, "test_table", "id")).toBe(true); + expect(hasColumn(db, "test_table", "name")).toBe(true); + db.close(); + }); + + test("returns false for non-existent column", () => { + const db = new Database(join(getTestDir(), "test.db")); + db.exec("CREATE TABLE test_table (id INTEGER PRIMARY KEY)"); + + expect(hasColumn(db, "test_table", "missing_column")).toBe(false); + db.close(); + }); +}); + +describe("getSchemaIssues", () => { + test("returns empty array for healthy database", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + const issues = getSchemaIssues(db); + expect(issues).toEqual([]); + db.close(); + }); + + test("detects missing table", () => { + const db = new Database(join(getTestDir(), "test.db")); + // Create schema without dsn_cache using the helper + createDatabaseWithMissingTables(db, ["dsn_cache"]); + + const issues = getSchemaIssues(db); + const missingTables = issues.filter((i) => i.type === "missing_table"); + + expect(missingTables).toContainEqual({ + type: "missing_table", + table: "dsn_cache", + }); + db.close(); + }); + + test("detects missing column", () => { + const db = new Database(join(getTestDir(), "test.db")); + // Create dsn_cache without v4 columns (pre-migration state) + createPreMigrationDatabase(db, ["dsn_cache"]); + + const issues = getSchemaIssues(db); + const missingColumns = issues.filter((i) => i.type === "missing_column"); + + // Should detect all v4 columns are missing from dsn_cache + expect(missingColumns).toContainEqual({ + type: "missing_column", + table: "dsn_cache", + column: "fingerprint", + }); + expect(missingColumns).toContainEqual({ + type: "missing_column", + table: "dsn_cache", + column: "dir_mtimes_json", + }); + db.close(); + }); +}); + +describe("repairSchema", () => { + test("creates missing tables", () => { + const db = new Database(join(getTestDir(), "test.db")); + db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY)"); + db.query("INSERT INTO schema_version (version) VALUES (?)").run(1); + + // Verify table is missing + expect(tableExists(db, "dsn_cache")).toBe(false); + + const result = repairSchema(db); + + // Should have created the table + expect(tableExists(db, "dsn_cache")).toBe(true); + expect( + result.fixed.some((f) => f.includes("Created table dsn_cache")) + ).toBe(true); + expect(result.failed).toEqual([]); + db.close(); + }); + + test("adds missing columns", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + // Remove migrated columns by recreating the table in pre-migration state + db.exec("DROP TABLE dsn_cache"); + db.exec(generatePreMigrationTableDDL("dsn_cache")); + + // Verify column is missing + expect(hasColumn(db, "dsn_cache", "fingerprint")).toBe(false); + expect(hasColumn(db, "dsn_cache", "dir_mtimes_json")).toBe(false); + + const result = repairSchema(db); + + // Should have added the columns + expect(hasColumn(db, "dsn_cache", "fingerprint")).toBe(true); + expect(hasColumn(db, "dsn_cache", "dir_mtimes_json")).toBe(true); + expect(result.fixed.some((f) => f.includes("dsn_cache.fingerprint"))).toBe( + true + ); + expect(result.failed).toEqual([]); + db.close(); + }); + + test("returns empty result for healthy database", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + const result = repairSchema(db); + + expect(result.fixed).toEqual([]); + expect(result.failed).toEqual([]); + db.close(); + }); + + test("updates schema version after repair", () => { + const db = new Database(join(getTestDir(), "test.db")); + db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY)"); + db.query("INSERT INTO schema_version (version) VALUES (?)").run(1); + + repairSchema(db); + + const version = ( + db.query("SELECT version FROM schema_version").get() as { + version: number; + } + ).version; + expect(version).toBe(CURRENT_SCHEMA_VERSION); + db.close(); + }); +}); + +describe("EXPECTED_TABLES", () => { + test("contains all required tables", () => { + const expectedTableNames = [ + "schema_version", + "auth", + "project_cache", + "dsn_cache", + "project_aliases", + "metadata", + "org_regions", + "user_info", + "instance_info", + "project_root_cache", + "pagination_cursors", + ]; + + for (const table of expectedTableNames) { + expect(EXPECTED_TABLES).toHaveProperty(table); + } + }); +}); + +describe("EXPECTED_COLUMNS", () => { + test("dsn_cache includes v4 columns", () => { + const dsnCacheColumns = EXPECTED_COLUMNS.dsn_cache; + const columnNames = dsnCacheColumns?.map((c) => c.name) ?? []; + + expect(columnNames).toContain("fingerprint"); + expect(columnNames).toContain("all_dsns_json"); + expect(columnNames).toContain("source_mtimes_json"); + expect(columnNames).toContain("dir_mtimes_json"); + expect(columnNames).toContain("root_dir_mtime"); + expect(columnNames).toContain("ttl_expires_at"); + }); + + test("user_info includes v3 column", () => { + const userInfoColumns = EXPECTED_COLUMNS.user_info; + const columnNames = userInfoColumns?.map((c) => c.name) ?? []; + + expect(columnNames).toContain("name"); + }); +}); + +describe("isReadonlyError", () => { + test("returns true for SQLiteError with readonly message", () => { + const error = new Error("attempt to write a readonly database"); + error.name = "SQLiteError"; + expect(isReadonlyError(error)).toBe(true); + }); + + test("returns true for mixed-case readonly message", () => { + const error = new Error("Attempt to Write a Readonly Database"); + error.name = "SQLiteError"; + expect(isReadonlyError(error)).toBe(true); + }); + + test("returns false for schema errors", () => { + const error = new Error("no such table: foo"); + error.name = "SQLiteError"; + expect(isReadonlyError(error)).toBe(false); + }); + + test("returns true for plain Error with readonly message", () => { + // node:sqlite throws plain Error (not SQLiteError) — the check + // must match by message content to work across runtimes. + const error = new Error("attempt to write a readonly database"); + expect(isReadonlyError(error)).toBe(true); + }); + + test("returns false for non-Error values", () => { + expect(isReadonlyError("attempt to write a readonly database")).toBe(false); + expect(isReadonlyError(null)).toBe(false); + expect(isReadonlyError(undefined)).toBe(false); + }); +}); + +describe("runMigrations", () => { + test("no-op when already at current version", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + // Should not throw and version stays at current + runMigrations(db); + + const version = ( + db.query("SELECT version FROM schema_version").get() as { + version: number; + } + ).version; + expect(version).toBe(CURRENT_SCHEMA_VERSION); + db.close(); + }); + + test("repairs pagination_cursors with wrong single-column PK (CLI-72)", () => { + const db = new Database(join(getTestDir(), "test.db")); + // Build a full schema but with the bugged pagination_cursors table + initSchema(db); + db.exec("DROP TABLE pagination_cursors"); + db.exec( + "CREATE TABLE pagination_cursors (command_key TEXT PRIMARY KEY, context TEXT NOT NULL, cursor TEXT NOT NULL, expires_at INTEGER NOT NULL)" + ); + // Set version to 5 so migration 5→6 fires + db.query("UPDATE schema_version SET version = 5").run(); + + runMigrations(db); + + // Table should now have the correct composite PK + const row = db + .query( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='pagination_cursors'" + ) + .get() as { sql: string }; + expect(row.sql).toContain("PRIMARY KEY (command_key, context)"); + + // Version should be bumped to 6 + const version = ( + db.query("SELECT version FROM schema_version").get() as { + version: number; + } + ).version; + expect(version).toBe(CURRENT_SCHEMA_VERSION); + db.close(); + }); + + test("skips pagination_cursors repair when PK is already correct", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + db.query("UPDATE schema_version SET version = 5").run(); + + // pagination_cursors was created by initSchema with the correct PK + runMigrations(db); + + const row = db + .query( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='pagination_cursors'" + ) + .get() as { sql: string }; + expect(row.sql).toContain("PRIMARY KEY (command_key, context)"); + db.close(); + }); + + test("creates pagination_cursors when missing during migration 4→5", () => { + const db = new Database(join(getTestDir(), "test.db")); + // Set up schema at version 4 without pagination_cursors + const statementsWithoutPagination = Object.entries(EXPECTED_TABLES) + .filter(([name]) => name !== "pagination_cursors") + .map(([, ddl]) => ddl); + db.exec(statementsWithoutPagination.join(";\n")); + db.query("INSERT INTO schema_version (version) VALUES (4)").run(); + + runMigrations(db); + + expect(tableExists(db, "pagination_cursors")).toBe(true); + db.close(); + }); + + test("migration 12→13 moves defaults data to metadata and drops table", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + // Recreate the old defaults table that was removed from TABLE_SCHEMAS + db.exec(`CREATE TABLE IF NOT EXISTS defaults ( + id INTEGER PRIMARY KEY CHECK (id = 1), + organization TEXT, + project TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + )`); + db.query( + "INSERT INTO defaults (id, organization, project) VALUES (1, ?, ?)" + ).run("migrated-org", "migrated-project"); + + // Set version to 12 so migration 12→13 fires + db.query("UPDATE schema_version SET version = 12").run(); + + runMigrations(db); + + // defaults table should be dropped + expect(tableExists(db, "defaults")).toBe(false); + + // Data should be in metadata + const m = getMetadata(db, ["defaults.org", "defaults.project"]); + expect(m.get("defaults.org")).toBe("migrated-org"); + expect(m.get("defaults.project")).toBe("migrated-project"); + + db.close(); + }); + + test("migration 12→13 handles empty defaults table", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + // Create old defaults table with no data + db.exec(`CREATE TABLE IF NOT EXISTS defaults ( + id INTEGER PRIMARY KEY CHECK (id = 1), + organization TEXT, + project TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + )`); + db.query("UPDATE schema_version SET version = 12").run(); + + runMigrations(db); + + // Table should still be dropped even without data + expect(tableExists(db, "defaults")).toBe(false); + + // No metadata entries created for empty values + const m = getMetadata(db, ["defaults.org", "defaults.project"]); + expect(m.get("defaults.org")).toBeUndefined(); + + db.close(); + }); + + test("migration 12→13 handles partial defaults (org only)", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + db.exec(`CREATE TABLE IF NOT EXISTS defaults ( + id INTEGER PRIMARY KEY CHECK (id = 1), + organization TEXT, + project TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + )`); + db.query( + "INSERT INTO defaults (id, organization, project) VALUES (1, ?, NULL)" + ).run("only-org"); + db.query("UPDATE schema_version SET version = 12").run(); + + runMigrations(db); + + expect(tableExists(db, "defaults")).toBe(false); + + const m = getMetadata(db, ["defaults.org", "defaults.project"]); + expect(m.get("defaults.org")).toBe("only-org"); + expect(m.get("defaults.project")).toBeUndefined(); + + db.close(); + }); + + test("migration 12→13 skipped when defaults table does not exist", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + db.query("UPDATE schema_version SET version = 12").run(); + + // No defaults table exists (fresh install on schema ≥13) + runMigrations(db); + + // Should complete without error, version updated + const version = ( + db.query("SELECT version FROM schema_version").get() as { + version: number; + } + ).version; + expect(version).toBe(CURRENT_SCHEMA_VERSION); + db.close(); + }); +}); + +describe("repairSchema: wrong primary key", () => { + test("detects and repairs pagination_cursors with wrong single-column PK", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + // Simulate the bug: drop and recreate with wrong PK + db.exec("DROP TABLE pagination_cursors"); + db.exec( + "CREATE TABLE pagination_cursors (command_key TEXT PRIMARY KEY, context TEXT NOT NULL, cursor TEXT NOT NULL, expires_at INTEGER NOT NULL)" + ); + + const result = repairSchema(db); + + expect(result.fixed.some((f) => f.includes("pagination_cursors"))).toBe( + true + ); + expect(result.failed).toEqual([]); + + const row = db + .query( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='pagination_cursors'" + ) + .get() as { sql: string }; + expect(row.sql).toContain("PRIMARY KEY (command_key, context)"); + db.close(); + }); + + test("no-op when pagination_cursors already has correct composite PK", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + const result = repairSchema(db); + + // Should not report pagination_cursors as fixed + expect(result.fixed.some((f) => f.includes("pagination_cursors"))).toBe( + false + ); + db.close(); + }); +}); + +describe("getSchemaIssues: wrong primary key", () => { + test("detects wrong_primary_key when pagination_cursors has single-column PK", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + db.exec("DROP TABLE pagination_cursors"); + db.exec( + "CREATE TABLE pagination_cursors (command_key TEXT PRIMARY KEY, context TEXT NOT NULL, cursor TEXT NOT NULL, expires_at INTEGER NOT NULL)" + ); + + const issues = getSchemaIssues(db); + const pkIssues = issues.filter((i) => i.type === "wrong_primary_key"); + + expect(pkIssues).toContainEqual({ + type: "wrong_primary_key", + table: "pagination_cursors", + }); + db.close(); + }); + + test("no wrong_primary_key issues for healthy database", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + + const issues = getSchemaIssues(db); + const pkIssues = issues.filter((i) => i.type === "wrong_primary_key"); + + expect(pkIssues).toEqual([]); + db.close(); + }); +}); diff --git a/packages/cli/test/lib/db/sqlite.test.ts b/packages/cli/test/lib/db/sqlite.test.ts new file mode 100644 index 000000000..56e2973ef --- /dev/null +++ b/packages/cli/test/lib/db/sqlite.test.ts @@ -0,0 +1,370 @@ +/** + * Tests for the dual-driver SQLite adapter. + * + * The adapter selects `node:sqlite` (Node.js 22.15+) or `node-sqlite3-wasm` + * (older Node.js) at runtime. The two drivers have opposite parameter + * conventions — `node:sqlite` takes spread args, `node-sqlite3-wasm` takes a + * single array — so these tests run the *same* adapter-level operations + * against *both* backing drivers to prove the normalisation in + * `wrapStatement`/`toWasmParams` keeps callers driver-agnostic. + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { __setDriverForTests, Database } from "../../../src/lib/db/sqlite.js"; + +type DriverKind = "node" | "wasm"; + +const DRIVERS: DriverKind[] = ["node", "wasm"]; + +/** + * Find a PID with no running process, searching downward from a high value. + * Used to simulate a lock left by a since-exited (dead) process. + */ +function findDeadPid(): number { + for (let pid = 0x7f_ff_ff_ff; pid > 1; pid -= 7919) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") { + return pid; + } + } + } + // Fallback: an unlikely-but-not-guaranteed PID. + return 0x7f_ff_ff_ff; +} + +afterEach(() => { + // Always return to automatic detection so we don't leak forced state. + __setDriverForTests(null); +}); + +describe.each(DRIVERS)("sqlite adapter [%s driver]", (kind) => { + function open(): Database { + __setDriverForTests(kind); + return new Database(":memory:"); + } + + test("reports its own driver kind", () => { + const db = open(); + expect(db.driverKind).toBe(kind); + db.close(); + }); + + test("run + get roundtrips positional params", () => { + const db = open(); + db.exec("CREATE TABLE t (a TEXT, b INTEGER)"); + db.query("INSERT INTO t (a, b) VALUES (?, ?)").run("hello", 42); + + const row = db.query("SELECT a, b FROM t WHERE b = ?").get(42); + expect(row).toEqual({ a: "hello", b: 42 }); + db.close(); + }); + + test("get returns null (not undefined) for no rows", () => { + const db = open(); + db.exec("CREATE TABLE t (a TEXT)"); + const row = db.query("SELECT a FROM t WHERE a = ?").get("missing"); + expect(row).toBeNull(); + db.close(); + }); + + test("all returns every matching row", () => { + const db = open(); + db.exec("CREATE TABLE t (a TEXT, b INTEGER)"); + const insert = db.query("INSERT INTO t (a, b) VALUES (?, ?)"); + insert.run("x", 1); + insert.run("y", 2); + + const rows = db.query("SELECT a, b FROM t ORDER BY b").all(); + expect(rows).toEqual([ + { a: "x", b: 1 }, + { a: "y", b: 2 }, + ]); + db.close(); + }); + + test("multiple positional params bind in order (no silent misbinding)", () => { + // Regression guard: node-sqlite3-wasm treats a 2nd spread arg as an + // options object, silently dropping the bind. The adapter must convert + // spread args to an array for the WASM driver. + const db = open(); + db.exec("CREATE TABLE t (a TEXT, b INTEGER, c TEXT)"); + db.query("INSERT INTO t (a, b, c) VALUES (?, ?, ?)").run( + "first", + 99, + "last" + ); + + const row = db.query("SELECT a, b, c FROM t").get(); + expect(row).toEqual({ a: "first", b: 99, c: "last" }); + db.close(); + }); + + test("undefined bind is treated as null", () => { + const db = open(); + db.exec("CREATE TABLE t (a TEXT, b INTEGER)"); + // node:sqlite rejects undefined; the adapter maps undefined→null for the + // WASM driver. For the node driver, passing null explicitly is the + // equivalent caller contract. + if (kind === "wasm") { + db.query("INSERT INTO t (a, b) VALUES (?, ?)").run("u", undefined); + const row = db.query("SELECT a, b FROM t").get(); + expect(row).toEqual({ a: "u", b: null }); + } else { + db.query("INSERT INTO t (a, b) VALUES (?, ?)").run("u", null); + const row = db.query("SELECT a, b FROM t").get(); + expect(row).toEqual({ a: "u", b: null }); + } + db.close(); + }); + + test("transaction commits on success", () => { + const db = open(); + db.exec("CREATE TABLE t (a INTEGER)"); + const insertTwo = db.transaction(() => { + db.query("INSERT INTO t (a) VALUES (?)").run(1); + db.query("INSERT INTO t (a) VALUES (?)").run(2); + }); + insertTwo(); + + const rows = db.query("SELECT a FROM t ORDER BY a").all(); + expect(rows).toEqual([{ a: 1 }, { a: 2 }]); + db.close(); + }); + + test("transaction rolls back on error", () => { + const db = open(); + db.exec("CREATE TABLE t (a INTEGER)"); + db.query("INSERT INTO t (a) VALUES (?)").run(1); + + const boom = db.transaction(() => { + db.query("INSERT INTO t (a) VALUES (?)").run(2); + throw new Error("boom"); + }); + expect(boom).toThrow("boom"); + + // The row inserted before the throw must be rolled back. + const rows = db.query("SELECT a FROM t").all(); + expect(rows).toEqual([{ a: 1 }]); + db.close(); + }); + + test("blob (Uint8Array) roundtrips", () => { + const db = open(); + db.exec("CREATE TABLE t (data BLOB)"); + const blob = new Uint8Array([1, 2, 3, 255]); + db.query("INSERT INTO t (data) VALUES (?)").run(blob); + + const row = db.query("SELECT data FROM t").get() as { + data: Uint8Array; + }; + expect(Array.from(row.data)).toEqual([1, 2, 3, 255]); + db.close(); + }); + + test("boolean binds are coerced to 0/1 on both drivers", () => { + // node:sqlite rejects raw booleans; the adapter coerces to integers so the + // SQLQueryBindings `boolean` option behaves identically across runtimes. + const db = open(); + db.exec("CREATE TABLE t (flag INTEGER)"); + db.query("INSERT INTO t (flag) VALUES (?)").run(true); + db.query("INSERT INTO t (flag) VALUES (?)").run(false); + + const rows = db.query("SELECT flag FROM t ORDER BY rowid").all(); + expect(rows).toEqual([{ flag: 1 }, { flag: 0 }]); + db.close(); + }); + + test("bigint binds within safe-integer range roundtrip", () => { + // The CLI only ever stores integers well within Number.MAX_SAFE_INTEGER + // (e.g. millisecond timestamps ~1.7e12). Values beyond that diverge + // between drivers (node:sqlite throws unless BigInt mode is enabled), so + // we deliberately stay in the safe range that both drivers agree on. + const db = open(); + db.exec("CREATE TABLE t (n INTEGER)"); + const big = 1_700_000_000_000n; // realistic ms timestamp, < MAX_SAFE_INTEGER + db.query("INSERT INTO t (n) VALUES (?)").run(big); + + const row = db.query("SELECT n FROM t").get() as { n: number | bigint }; + expect(Number(row.n)).toBe(1_700_000_000_000); + db.close(); + }); +}); + +/** + * WASM driver (node-sqlite3-wasm) file-locking behaviour. + * + * The driver locks the DB by creating an empty `.lock` directory (an + * atomic mkdir mutex) and removes it when SQLite drops to lock level NONE. + * Two failure modes are guarded: + * + * 1. A `.get()` that reads one row leaves the statement cursor open, holding + * the lock past `close()` and leaking the dir → the `.get()` wrapper + * finalizes the statement so the driver releases the lock on close. + * 2. A process killed mid-write leaks the dir → the next open consults the + * PID sentinel: a lock past a short safety floor whose owner is dead is + * cleared immediately, a lock whose owner is alive is kept, and a lock + * younger than the floor is always kept (so a stale sentinel can't steal a + * freshly-acquired live lock). With no sentinel it falls back to an age + * heuristic. + */ +describe("wasm driver lock handling", () => { + let dir: string; + + /** + * A PID that is almost certainly not a running process. `process.kill(pid, 0)` + * throws ESRCH for it, so the adapter treats its lock as orphaned. + */ + const DEAD_PID = findDeadPid(); + + afterEach(() => { + __setDriverForTests(null); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a .get() does not leak a lock directory across close", () => { + // Root-cause guard for the cross-invocation "database is locked" failure: + // after a single-row read + close, no lock dir may survive. + __setDriverForTests("wasm"); + dir = mkdtempSync(join(tmpdir(), "sqlite-get-")); + const dbPath = join(dir, "cli.db"); + + const db = new Database(dbPath); + db.exec("CREATE TABLE t (a INTEGER)"); + db.query("INSERT INTO t (a) VALUES (?)").run(1); + db.query("SELECT a FROM t").get(); + db.close(); + + expect(existsSync(`${dbPath}.lock`)).toBe(false); + }); + + test("a second process can open after the first finishes", () => { + // Simulates the CI smoke sequence (`--help` then `auth status`): two + // sequential connections to the same DB must both succeed. + __setDriverForTests("wasm"); + dir = mkdtempSync(join(tmpdir(), "sqlite-seq-")); + const dbPath = join(dir, "cli.db"); + + const first = new Database(dbPath); + first.exec("CREATE TABLE t (a INTEGER)"); + first.query("INSERT INTO t (a) VALUES (?)").run(1); + first.query("SELECT a FROM t").get(); + first.close(); + + const second = new Database(dbPath); + expect(second.query("SELECT a FROM t").get()).toEqual({ a: 1 }); + second.close(); + }); + + test("clears a stale (old) leftover lock before opening", () => { + // Simulates a lock leaked by a crashed process, backdated beyond the + // staleness window so it's treated as orphaned and cleared. + __setDriverForTests("wasm"); + dir = mkdtempSync(join(tmpdir(), "sqlite-stale-")); + const dbPath = join(dir, "cli.db"); + + const lockDir = `${dbPath}.lock`; + mkdirSync(lockDir); + const old = new Date(Date.now() - 5 * 60_000); + utimesSync(lockDir, old, old); + + const db = new Database(dbPath); + db.exec("CREATE TABLE t (a INTEGER)"); + db.query("INSERT INTO t (a) VALUES (?)").run(1); + expect(db.query("SELECT a FROM t").get()).toEqual({ a: 1 }); + db.close(); + }); + + test("does not clear a recent (possibly live) lock when opening", () => { + // A freshly-created lock may belong to a concurrent process. The open-time + // cleanup must leave it intact — deleting it could let two writers hold the + // same DB at once (corruption). Live contention is left to busy_timeout. + __setDriverForTests("wasm"); + dir = mkdtempSync(join(tmpdir(), "sqlite-live-")); + const dbPath = join(dir, "cli.db"); + + const lockDir = `${dbPath}.lock`; + mkdirSync(lockDir); + + const db = new Database(dbPath); + // The constructor's clearStaleWasmLock must not have removed the recent dir. + expect(existsSync(lockDir)).toBe(true); + db.close(); + // close() must not remove a foreign lock either (it's a shared mutex). + expect(existsSync(lockDir)).toBe(true); + }); + + test("clears a dead-owner lock past the safety floor without the age wait", () => { + // A process killed mid-write leaves the lock dir plus an owner sentinel. + // Once the lock is older than the short safety floor, a dead owner PID + // means it's provably orphaned — cleared without waiting the full 60s. + __setDriverForTests("wasm"); + dir = mkdtempSync(join(tmpdir(), "sqlite-dead-")); + const dbPath = join(dir, "cli.db"); + + const lockDir = `${dbPath}.lock`; + mkdirSync(lockDir); + // Older than LOCK_SAFETY_FLOOR_MS (3s) but far younger than the 60s age + // window — so only the dead-owner sentinel can justify clearing it. + const pastFloor = new Date(Date.now() - 10_000); + utimesSync(lockDir, pastFloor, pastFloor); + writeFileSync(`${dbPath}.lock.owner`, String(DEAD_PID)); + + const db = new Database(dbPath); + db.exec("CREATE TABLE t (a INTEGER)"); + db.query("INSERT INTO t (a) VALUES (?)").run(1); + expect(db.query("SELECT a FROM t").get()).toEqual({ a: 1 }); + db.close(); + }); + + test("keeps a fresh lock even when the sentinel names a dead PID", () => { + // Guards against a stale sentinel stealing a live lock: a lock younger than + // the safety floor may have just been acquired by another (older) CLI that + // doesn't refresh the sentinel, so a leftover dead-PID sentinel must NOT + // cause it to be deleted. + __setDriverForTests("wasm"); + dir = mkdtempSync(join(tmpdir(), "sqlite-fresh-dead-")); + const dbPath = join(dir, "cli.db"); + + const lockDir = `${dbPath}.lock`; + mkdirSync(lockDir); // fresh mtime, under the safety floor + writeFileSync(`${dbPath}.lock.owner`, String(DEAD_PID)); + + const db = new Database(dbPath); + // Under the floor: the fresh lock is preserved despite the dead sentinel. + expect(existsSync(lockDir)).toBe(true); + db.close(); + }); + + test("keeps a lock owned by a live process, even if old", () => { + // If the owner PID is still alive the lock is genuinely held; the age of + // the directory is irrelevant and it must not be stolen. + __setDriverForTests("wasm"); + dir = mkdtempSync(join(tmpdir(), "sqlite-alive-")); + const dbPath = join(dir, "cli.db"); + + const lockDir = `${dbPath}.lock`; + mkdirSync(lockDir); + const old = new Date(Date.now() - 5 * 60_000); + utimesSync(lockDir, old, old); + // Our own PID is definitely alive. + writeFileSync(`${dbPath}.lock.owner`, String(process.pid)); + + const db = new Database(dbPath); + expect(existsSync(lockDir)).toBe(true); + db.close(); + }); +}); diff --git a/packages/cli/test/lib/db/user.test.ts b/packages/cli/test/lib/db/user.test.ts new file mode 100644 index 000000000..4a9effa1e --- /dev/null +++ b/packages/cli/test/lib/db/user.test.ts @@ -0,0 +1,112 @@ +/** + * User Info Storage Tests + */ + +import { describe, expect, test } from "vitest"; +import { getUserInfo, setUserInfo } from "../../../src/lib/db/user.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-user-"); + +describe("getUserInfo", () => { + test("returns undefined when no user info stored", () => { + const result = getUserInfo(); + expect(result).toBeUndefined(); + }); + + test("returns stored user info", () => { + setUserInfo({ + userId: "12345", + email: "test@example.com", + username: "testuser", + }); + + const result = getUserInfo(); + expect(result).toEqual({ + userId: "12345", + email: "test@example.com", + username: "testuser", + name: undefined, + }); + }); + + test("returns stored user info with name", () => { + setUserInfo({ + userId: "12345", + email: "test@example.com", + username: "testuser", + name: "Test User", + }); + + const result = getUserInfo(); + expect(result).toEqual({ + userId: "12345", + email: "test@example.com", + username: "testuser", + name: "Test User", + }); + }); + + test("handles missing email, username, and name", () => { + setUserInfo({ userId: "12345" }); + + const result = getUserInfo(); + expect(result).toEqual({ + userId: "12345", + email: undefined, + username: undefined, + name: undefined, + }); + }); +}); + +describe("setUserInfo", () => { + test("stores user info with all fields", () => { + setUserInfo({ + userId: "user123", + email: "user@test.com", + username: "myuser", + name: "My User", + }); + + const result = getUserInfo(); + expect(result?.userId).toBe("user123"); + expect(result?.email).toBe("user@test.com"); + expect(result?.username).toBe("myuser"); + expect(result?.name).toBe("My User"); + }); + + test("overwrites existing user info", () => { + setUserInfo({ userId: "first", email: "first@test.com", name: "First" }); + setUserInfo({ userId: "second", email: "second@test.com", name: "Second" }); + + const result = getUserInfo(); + expect(result?.userId).toBe("second"); + expect(result?.email).toBe("second@test.com"); + expect(result?.name).toBe("Second"); + }); + + test("stores user info with only userId", () => { + setUserInfo({ userId: "minimal" }); + + const result = getUserInfo(); + expect(result?.userId).toBe("minimal"); + expect(result?.email).toBeUndefined(); + expect(result?.username).toBeUndefined(); + expect(result?.name).toBeUndefined(); + }); + + test("stores user info with name but no username", () => { + setUserInfo({ + userId: "user456", + email: "user@test.com", + name: "Display Name", + }); + + const result = getUserInfo(); + expect(result?.userId).toBe("user456"); + expect(result?.email).toBe("user@test.com"); + expect(result?.username).toBeUndefined(); + expect(result?.name).toBe("Display Name"); + }); +}); diff --git a/packages/cli/test/lib/db/utils.test.ts b/packages/cli/test/lib/db/utils.test.ts new file mode 100644 index 000000000..f5d949c22 --- /dev/null +++ b/packages/cli/test/lib/db/utils.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "vitest"; +import { upsert } from "../../../src/lib/db/utils.js"; + +describe("upsert", () => { + test("generates basic UPSERT statement", () => { + const result = upsert("auth", { id: 1, token: "abc123" }, ["id"]); + + expect(result.sql).toBe( + "INSERT INTO auth (id, token) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET token = excluded.token" + ); + expect(result.values).toEqual([1, "abc123"]); + }); + + test("handles multiple columns", () => { + const result = upsert( + "users", + { id: 1, name: "Bob", age: 30, updated_at: 12_345 }, + ["id"] + ); + + expect(result.sql).toBe( + "INSERT INTO users (id, name, age, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, age = excluded.age, updated_at = excluded.updated_at" + ); + expect(result.values).toEqual([1, "Bob", 30, 12_345]); + }); + + test("handles multiple conflict columns", () => { + const result = upsert( + "cache", + { org_id: "org1", project_id: "proj1", data: "cached" }, + ["org_id", "project_id"] + ); + + expect(result.sql).toBe( + "INSERT INTO cache (org_id, project_id, data) VALUES (?, ?, ?) ON CONFLICT(org_id, project_id) DO UPDATE SET data = excluded.data" + ); + expect(result.values).toEqual(["org1", "proj1", "cached"]); + }); + + test("excludes columns from update", () => { + const result = upsert( + "users", + { id: 1, name: "Bob", created_at: 1000, updated_at: 2000 }, + ["id"], + { excludeFromUpdate: ["created_at"] } + ); + + expect(result.sql).toBe( + "INSERT INTO users (id, name, created_at, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at" + ); + expect(result.values).toEqual([1, "Bob", 1000, 2000]); + }); + + test("generates DO NOTHING when all non-conflict columns are excluded", () => { + const result = upsert("settings", { id: 1, value: "test" }, ["id"], { + excludeFromUpdate: ["value"], + }); + + expect(result.sql).toBe( + "INSERT INTO settings (id, value) VALUES (?, ?) ON CONFLICT(id) DO NOTHING" + ); + }); + + test("handles null values", () => { + const result = upsert( + "auth", + { id: 1, token: "abc", refresh_token: null }, + ["id"] + ); + + expect(result.sql).toBe( + "INSERT INTO auth (id, token, refresh_token) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET token = excluded.token, refresh_token = excluded.refresh_token" + ); + expect(result.values).toEqual([1, "abc", null]); + }); + + test("handles undefined converted to null", () => { + const result = upsert( + "auth", + { id: 1, token: "abc", refresh_token: undefined }, + ["id"] + ); + + expect(result.values).toEqual([1, "abc", undefined]); + }); + + test("throws error for empty data object", () => { + expect(() => upsert("auth", {}, ["id"])).toThrow( + "upsert: data object must have at least one column" + ); + }); + + test("throws error for empty conflict columns", () => { + expect(() => upsert("auth", { id: 1 }, [])).toThrow( + "upsert: must specify at least one conflict column" + ); + }); + + test("preserves column order from data object", () => { + const result = upsert("test", { z_col: 1, a_col: 2, m_col: 3 }, ["z_col"]); + + expect(result.sql).toContain("(z_col, a_col, m_col)"); + expect(result.values).toEqual([1, 2, 3]); + }); +}); diff --git a/packages/cli/test/lib/delta-upgrade.mocked.test.ts b/packages/cli/test/lib/delta-upgrade.mocked.test.ts new file mode 100644 index 000000000..f6186ab59 --- /dev/null +++ b/packages/cli/test/lib/delta-upgrade.mocked.test.ts @@ -0,0 +1,500 @@ +/** + * Integration tests for delta upgrade orchestration with a non-dev CLI_VERSION. + * + * These tests use `vi.mock()` to override `CLI_VERSION` from constants.js + * so that `canAttemptDelta()` passes its dev-build guard (the real `CLI_VERSION` + * is "0.0.0-dev" in test mode, which short-circuits the orchestrator). + * + * Kept as a sibling file to `delta-upgrade.test.ts` because its + * `vi.mock()` would invert the assumptions of the dev-mode null-return + * tests in that file. Under `bun test --isolate` each file gets a fresh + * module graph, so the mocks here don't leak. + */ + +import { copyFileSync, existsSync, unlinkSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("delta-upgrade-mocked-"); + +// ============================================================================ +// Mock Setup +// ============================================================================ + +/** + * Mock constants.js to pretend we're running a real stable version. + * This satisfies canAttemptDelta()'s CLI_VERSION !== "0.0.0-dev" check. + */ +vi.mock("../../src/lib/constants.js", async (importOriginal) => { + const orig = + await importOriginal(); + return { + ...orig, + CLI_VERSION: "0.13.0", + }; +}); + +// Import AFTER mock setup so the mocked constants are used +import { getPlatformBinaryName } from "../../src/lib/binary.js"; +import { + attemptDeltaUpgrade, + resolveNightlyDelta, + resolveStableDelta, +} from "../../src/lib/delta-upgrade.js"; + +// ============================================================================ +// Fetch mock infrastructure +// ============================================================================ + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function mockFetch( + fn: (url: string | URL | Request, init?: RequestInit) => Promise +): void { + globalThis.fetch = fn as typeof globalThis.fetch; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +const BINARY_NAME = getPlatformBinaryName(); + +function versionHex(version: string): string { + return Array.from(version) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); +} + +function tempFile(name: string): string { + return join( + tmpdir(), + `delta-iso-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}` + ); +} + +// ============================================================================ +// resolveStableDelta +// ============================================================================ + +describe("resolveStableDelta", () => { + test("resolves and applies a stable delta patch", async () => { + // Create a "current binary" to patch from + const oldBinaryPath = tempFile("old-binary.bin"); + const destPath = tempFile("patched-binary.bin"); + writeFileSync(oldBinaryPath, Buffer.from("old binary content for testing")); + + // Set up fetch mocks — releases API + patch download + // Since applyPatch will fail (we don't have a real TRDIFF10 matching this binary), + // we expect resolveStableDelta to throw, but the chain resolution should succeed + const patchUrl = `https://github.com/getsentry/toolkit/releases/download/cli@0.14.0/${BINARY_NAME}.patch`; + const releases = [ + { + tag_name: "cli@0.14.0", + assets: [ + { + name: BINARY_NAME, + size: 100_000, + digest: `sha256:${versionHex("0.14.0")}`, + browser_download_url: `https://example.com/${BINARY_NAME}`, + }, + { + name: `${BINARY_NAME}.patch`, + size: 500, + browser_download_url: patchUrl, + }, + { + name: `${BINARY_NAME}.gz`, + size: 100_000, + browser_download_url: `https://example.com/${BINARY_NAME}.gz`, + }, + ], + }, + { + tag_name: "cli@0.13.0", + assets: [ + { + name: BINARY_NAME, + size: 100_000, + browser_download_url: `https://example.com/${BINARY_NAME}`, + }, + ], + }, + ]; + + // A fake patch that is valid TRDIFF10 header but will fail during application + // (header says 0 control/diff/new size, which produces an empty file) + const emptyTrdiff10 = new Uint8Array(32); + // Set magic: "TRDIFF10" + const magic = new TextEncoder().encode("TRDIFF10"); + emptyTrdiff10.set(magic, 0); + // All sizes stay 0 — this will produce an empty output + + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.startsWith("https://api.github.com/")) { + return new Response(JSON.stringify(releases), { status: 200 }); + } + if (urlStr === patchUrl) { + return new Response(emptyTrdiff10.buffer as ArrayBuffer, { + status: 200, + }); + } + return new Response("Not Found", { status: 404 }); + }); + + try { + // resolveStableDelta will: + // 1. Fetch releases (success) + // 2. Extract chain (success — single hop 0.13→0.14) + // 3. Download patch (success) + // 4. Apply patch (produces empty file) + // 5. SHA-256 check fails → throws + await expect( + resolveStableDelta("0.14.0", oldBinaryPath, destPath) + ).rejects.toThrow("SHA-256 mismatch"); + } finally { + if (existsSync(oldBinaryPath)) unlinkSync(oldBinaryPath); + if (existsSync(destPath)) unlinkSync(destPath); + } + }); + + test("returns null when no chain is available", async () => { + const oldBinaryPath = tempFile("old-binary.bin"); + const destPath = tempFile("patched.bin"); + writeFileSync(oldBinaryPath, Buffer.from("old content")); + + mockFetch(async () => new Response("Error", { status: 500 })); + + try { + const result = await resolveStableDelta( + "0.14.0", + oldBinaryPath, + destPath + ); + expect(result).toBeNull(); + } finally { + if (existsSync(oldBinaryPath)) unlinkSync(oldBinaryPath); + } + }); +}); + +// ============================================================================ +// resolveNightlyDelta +// ============================================================================ + +describe("resolveNightlyDelta", () => { + test("returns null when nightly manifest has no .gz layer", async () => { + const oldBinaryPath = tempFile("old-nightly.bin"); + const destPath = tempFile("patched-nightly.bin"); + writeFileSync(oldBinaryPath, Buffer.from("old nightly")); + + // Mock GHCR: token exchange succeeds, nightly manifest has no .gz layer + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "test-token" }), { + status: 200, + }); + } + if (urlStr.includes("/manifests/nightly-")) { + return new Response( + JSON.stringify({ + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [ + { + digest: "sha256:aabb", + mediaType: "application/octet-stream", + size: 1000, + annotations: { + "org.opencontainers.image.title": `${BINARY_NAME}`, + }, + }, + ], + }), + { status: 200 } + ); + } + // listTags runs in parallel with fetchManifest — must not 404 + if (urlStr.includes("/tags/list")) { + return new Response(JSON.stringify({ tags: [] }), { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }); + + try { + const result = await resolveNightlyDelta( + "0.0.0-dev.200", + oldBinaryPath, + destPath + ); + expect(result).toBeNull(); + } finally { + if (existsSync(oldBinaryPath)) unlinkSync(oldBinaryPath); + } + }); + + test("returns null when no patch chain exists", async () => { + const oldBinaryPath = tempFile("old-nightly.bin"); + const destPath = tempFile("patched-nightly.bin"); + writeFileSync(oldBinaryPath, Buffer.from("old nightly")); + + // Mock GHCR: nightly manifest has .gz layer, but no patch tags + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "test-token" }), { + status: 200, + }); + } + if (urlStr.includes("/manifests/nightly-")) { + return new Response( + JSON.stringify({ + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [ + { + digest: "sha256:aabb", + mediaType: "application/octet-stream", + size: 30_000_000, + annotations: { + "org.opencontainers.image.title": `${BINARY_NAME}.gz`, + }, + }, + ], + }), + { status: 200 } + ); + } + if (urlStr.includes("/tags/list")) { + return new Response(JSON.stringify({ tags: [] }), { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }); + + try { + const result = await resolveNightlyDelta( + "0.0.0-dev.200", + oldBinaryPath, + destPath + ); + expect(result).toBeNull(); + } finally { + if (existsSync(oldBinaryPath)) unlinkSync(oldBinaryPath); + } + }); +}); + +// ============================================================================ +// attemptDeltaUpgrade +// ============================================================================ + +describe("attemptDeltaUpgrade", () => { + test("returns null for cross-channel upgrade (stable → nightly)", async () => { + // CLI_VERSION is mocked as "0.13.0" (stable), target is nightly + const result = await attemptDeltaUpgrade( + "0.0.0-dev.100", + "/tmp/fake-binary", + "/tmp/fake-dest" + ); + expect(result).toBeNull(); + }); + + test("returns null when stable chain resolution fails", async () => { + const oldBinaryPath = tempFile("old.bin"); + const destPath = tempFile("dest.bin"); + writeFileSync(oldBinaryPath, Buffer.from("binary")); + + // All fetches fail + mockFetch(async () => new Response("Error", { status: 500 })); + + try { + const result = await attemptDeltaUpgrade( + "0.14.0", + oldBinaryPath, + destPath + ); + expect(result).toBeNull(); + } finally { + if (existsSync(oldBinaryPath)) unlinkSync(oldBinaryPath); + } + }); + + test("catches errors from patch application and returns null", async () => { + const oldBinaryPath = tempFile("old.bin"); + const destPath = tempFile("dest.bin"); + writeFileSync(oldBinaryPath, Buffer.from("binary")); + + // Return releases + a broken patch + const patchUrl = "https://example.com/patch"; + const releases = [ + { + tag_name: "cli@0.14.0", + assets: [ + { + name: BINARY_NAME, + size: 100_000, + digest: `sha256:${versionHex("0.14.0")}`, + browser_download_url: `https://example.com/${BINARY_NAME}`, + }, + { + name: `${BINARY_NAME}.patch`, + size: 500, + browser_download_url: patchUrl, + }, + { + name: `${BINARY_NAME}.gz`, + size: 100_000, + browser_download_url: `https://example.com/${BINARY_NAME}.gz`, + }, + ], + }, + { + tag_name: "cli@0.13.0", + assets: [ + { + name: BINARY_NAME, + size: 100_000, + browser_download_url: `https://example.com/${BINARY_NAME}`, + }, + ], + }, + ]; + + // Return garbage patch data + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.startsWith("https://api.github.com/")) { + return new Response(JSON.stringify(releases), { status: 200 }); + } + if (urlStr === patchUrl) { + return new Response( + new Uint8Array([0, 1, 2, 3]).buffer as ArrayBuffer, + { + status: 200, + } + ); + } + return new Response("Not Found", { status: 404 }); + }); + + try { + // Should catch the bspatch error and return null + const result = await attemptDeltaUpgrade( + "0.14.0", + oldBinaryPath, + destPath + ); + expect(result).toBeNull(); + } finally { + if (existsSync(oldBinaryPath)) unlinkSync(oldBinaryPath); + if (existsSync(destPath)) unlinkSync(destPath); + } + }); + + test("returns DeltaResult with telemetry on successful stable patch", async () => { + // Use real TRDIFF10 fixture for end-to-end success + const fixturesDir = join(import.meta.dirname, "../fixtures/patches"); + const oldBinaryPath = tempFile("old-success.bin"); + const destPath = tempFile("dest-success.bin"); + + // Copy the real fixture "old" binary — loadOldBinary will copy+mmap this + copyFileSync(join(fixturesDir, "small-old.bin"), oldBinaryPath); + + // SHA-256 of the expected output (small-new.bin) + const expectedSha256 = + "54d0dcd74478bc154b5b24393fdc6129518271baa36f446384d60e84021bb724"; + + // Read the real TRDIFF10 patch + const patchData = await readFile(join(fixturesDir, "small.trdiff10")); + + const patchUrl = "https://example.com/small.patch"; + const releases = [ + { + tag_name: "cli@0.14.0", + assets: [ + { + name: BINARY_NAME, + size: 54, + digest: `sha256:${expectedSha256}`, + browser_download_url: `https://example.com/${BINARY_NAME}`, + }, + { + name: `${BINARY_NAME}.patch`, + size: 89, + browser_download_url: patchUrl, + }, + { + name: `${BINARY_NAME}.gz`, + size: 100_000, + browser_download_url: `https://example.com/${BINARY_NAME}.gz`, + }, + ], + }, + { + tag_name: "cli@0.13.0", + assets: [ + { + name: BINARY_NAME, + size: 54, + browser_download_url: `https://example.com/${BINARY_NAME}`, + }, + ], + }, + ]; + + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.startsWith("https://api.github.com/")) { + return new Response(JSON.stringify(releases), { status: 200 }); + } + if (urlStr === patchUrl) { + return new Response(patchData, { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }); + + try { + const result = await attemptDeltaUpgrade( + "0.14.0", + oldBinaryPath, + destPath + ); + expect(result).not.toBeNull(); + expect(result!.sha256).toBe(expectedSha256); + expect(result!.patchBytes).toBe(89); + expect(result!.chainLength).toBe(1); + + // Verify patched output matches expected file + const actual = await readFile(destPath); + const expected = await readFile(join(fixturesDir, "small-new.bin")); + expect(new Uint8Array(actual)).toEqual(new Uint8Array(expected)); + } finally { + if (existsSync(oldBinaryPath)) unlinkSync(oldBinaryPath); + if (existsSync(destPath)) unlinkSync(destPath); + } + }); +}); diff --git a/packages/cli/test/lib/delta-upgrade.test.ts b/packages/cli/test/lib/delta-upgrade.test.ts new file mode 100644 index 000000000..68d1cfbdf --- /dev/null +++ b/packages/cli/test/lib/delta-upgrade.test.ts @@ -0,0 +1,2034 @@ +/** + * Unit Tests for Delta Upgrade Module + * + * Tests the exported pure-computation functions that drive chain resolution + * for both stable (GitHub Releases) and nightly (GHCR) channels, plus + * async orchestration functions tested via fetch mocking. + */ + +import { createHash } from "node:crypto"; +import { existsSync, unlinkSync } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + getPlatformBinaryName, + UPGRADE_SOURCES, +} from "../../src/lib/binary.js"; +import { + applyPatchChain, + attemptDeltaUpgrade, + canAttemptDelta, + downloadStablePatch, + type ExtractStableChainOpts, + extractSha256, + extractStableChain, + fetchRecentReleases, + filterAndSortChainTags, + type GitHubAsset, + type GitHubRelease, + getPatchFromVersion, + getPatchTargetSha256, + getStableTargetSha256, + type PatchChain, + prefetchNightlyPatches, + prefetchStablePatches, + resolveNightlyChain, + resolveNightlyDelta, + resolveStableChain, + resolveStableDelta, + validateChainStep, +} from "../../src/lib/delta-upgrade.js"; +import type { OciManifest } from "../../src/lib/ghcr.js"; +import { useTestConfigDir } from "../helpers.js"; + +const LEGACY_UPGRADE_SOURCE = UPGRADE_SOURCES[1]; +if (!LEGACY_UPGRADE_SOURCE) { + throw new Error("Legacy upgrade source is not configured"); +} + +// --------------------------------------------------------------------------- +// Test helpers (file-scoped) +// --------------------------------------------------------------------------- + +/** Create a GitHub asset with optional overrides */ +function makeAsset(overrides: Partial = {}): GitHubAsset { + return { + name: "sentry-linux-x64", + size: 100_000, + browser_download_url: "https://example.com/download", + ...overrides, + }; +} + +/** Create a GitHub release with optional overrides */ +function makeRelease(tag: string, assets: GitHubAsset[] = []): GitHubRelease { + return { tag_name: tag, assets }; +} + +/** Create an OCI manifest with patch annotations */ +function makePatchManifest( + fromVersion: string, + sha256Map: Record = {}, + layers: OciManifest["layers"] = [] +): OciManifest { + const annotations: Record = { + "from-version": fromVersion, + }; + for (const [key, value] of Object.entries(sha256Map)) { + annotations[`sha256-${key}`] = value; + } + return { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers, + annotations, + }; +} + +// =================================================================== +// Pure computation tests +// =================================================================== + +// getPlatformBinaryName + +describe("getPlatformBinaryName", () => { + test("returns a string starting with 'sentry-'", () => { + const name = getPlatformBinaryName(); + expect(name.startsWith("sentry-")).toBe(true); + }); + + test("contains platform and arch components", () => { + const name = getPlatformBinaryName(); + const parts = name.replace(".exe", "").split("-"); + expect(parts.length).toBe(3); + expect(parts[0]).toBe("sentry"); + expect(["linux", "darwin", "windows"]).toContain(parts[1]); + expect(["x64", "arm64"]).toContain(parts[2]); + }); + + test("has .exe suffix on windows platform name", () => { + const name = getPlatformBinaryName(); + if (process.platform === "win32") { + expect(name.endsWith(".exe")).toBe(true); + } else { + expect(name.endsWith(".exe")).toBe(false); + } + }); +}); + +// canAttemptDelta + +describe("canAttemptDelta", () => { + test("returns false for cross-channel upgrade (stable → nightly)", () => { + const result = canAttemptDelta("0.14.0-dev.123"); + expect(result).toBe(false); + }); + + test("returns false for dev build", () => { + const result = canAttemptDelta("0.14.0"); + expect(result).toBe(false); + }); + + test("returns false for nightly target from dev build", () => { + const result = canAttemptDelta("0.14.0-dev.abc123"); + expect(result).toBe(false); + }); +}); + +// extractSha256 + +describe("extractSha256", () => { + test("extracts hex from sha256: prefixed digest", () => { + const asset = makeAsset({ digest: "sha256:abcdef0123456789" }); + expect(extractSha256(asset)).toBe("abcdef0123456789"); + }); + + test("returns null when no digest field", () => { + const asset = makeAsset({}); + expect(extractSha256(asset)).toBeNull(); + }); + + test("returns null for empty digest", () => { + const asset = makeAsset({ digest: "" }); + expect(extractSha256(asset)).toBeNull(); + }); + + test("returns null for non-sha256 digest format", () => { + const asset = makeAsset({ digest: "md5:abcdef" }); + expect(extractSha256(asset)).toBeNull(); + }); + + test("normalizes uppercase hex to lowercase", () => { + const asset = makeAsset({ digest: "sha256:ABCDEF0123456789" }); + expect(extractSha256(asset)).toBe("abcdef0123456789"); + }); + + test("handles mixed case prefix", () => { + const asset = makeAsset({ digest: "SHA256:abc123" }); + expect(extractSha256(asset)).toBe("abc123"); + }); +}); + +// getStableTargetSha256 + +describe("getStableTargetSha256", () => { + test("returns hex from matching binary asset", () => { + const release = makeRelease("0.14.0", [ + makeAsset({ + name: "sentry-linux-x64", + digest: "sha256:deadbeef", + }), + ]); + expect(getStableTargetSha256(release, "sentry-linux-x64")).toBe("deadbeef"); + }); + + test("returns null when binary asset not found", () => { + const release = makeRelease("0.14.0", [ + makeAsset({ name: "sentry-darwin-arm64" }), + ]); + expect(getStableTargetSha256(release, "sentry-linux-x64")).toBeNull(); + }); + + test("returns null when binary asset has no digest", () => { + const release = makeRelease("0.14.0", [ + makeAsset({ name: "sentry-linux-x64" }), + ]); + expect(getStableTargetSha256(release, "sentry-linux-x64")).toBeNull(); + }); + + test("returns null for empty assets array", () => { + const release = makeRelease("0.14.0", []); + expect(getStableTargetSha256(release, "sentry-linux-x64")).toBeNull(); + }); +}); + +// extractStableChain + +describe("extractStableChain", () => { + /** + * Create a deterministic hex digest from a version string. + * + * Converts each char to its hex code to produce valid [0-9a-f]+ output. + */ + function versionToHex(version: string): string { + return Array.from(version) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); + } + + /** Build a standard chain of releases (newest first) with valid patch assets */ + function buildReleases( + versions: string[], + binaryName: string, + patchSize = 1000, + gzSize = 100_000 + ): GitHubRelease[] { + return versions.map((v) => + makeRelease(v, [ + makeAsset({ + name: binaryName, + digest: `sha256:${versionToHex(v)}`, + }), + makeAsset({ + name: `${binaryName}.patch`, + size: patchSize, + browser_download_url: `https://example.com/${v}.patch`, + }), + makeAsset({ + name: `${binaryName}.gz`, + size: gzSize, + }), + ]) + ); + } + + function makeOpts( + overrides: Partial = {} + ): ExtractStableChainOpts { + return { + releases: [], + currentVersion: "0.12.0", + targetVersion: "0.14.0", + binaryName: "sentry-linux-x64", + fullGzSize: 100_000, + ...overrides, + }; + } + + test("resolves single-hop chain (0.12→0.13)", () => { + const releases = buildReleases(["0.13.0", "0.12.0"], "sentry-linux-x64"); + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.12.0", + targetVersion: "0.13.0", + fullGzSize: 100_000, + }) + ); + expect(result).not.toBeNull(); + expect(result?.patchUrls).toHaveLength(1); + expect(result?.patchUrls[0]).toBe("https://example.com/0.13.0.patch"); + expect(result?.expectedSha256).toBe(versionToHex("0.13.0")); + expect(result?.steps).toEqual([ + { fromVersion: "0.12.0", toVersion: "0.13.0" }, + ]); + }); + + test("resolves multi-hop chain (0.12→0.13→0.14)", () => { + const releases = buildReleases( + ["0.14.0", "0.13.0", "0.12.0"], + "sentry-linux-x64" + ); + const result = extractStableChain( + makeOpts({ releases, fullGzSize: 100_000 }) + ); + expect(result).not.toBeNull(); + expect(result?.patchUrls).toHaveLength(2); + expect(result?.patchUrls[0]).toBe("https://example.com/0.13.0.patch"); + expect(result?.patchUrls[1]).toBe("https://example.com/0.14.0.patch"); + expect(result?.expectedSha256).toBe(versionToHex("0.14.0")); + expect(result?.steps).toEqual([ + { fromVersion: "0.12.0", toVersion: "0.13.0" }, + { fromVersion: "0.13.0", toVersion: "0.14.0" }, + ]); + }); + + test("returns null when target version not in release list", () => { + const releases = buildReleases(["0.13.0", "0.12.0"], "sentry-linux-x64"); + const result = extractStableChain( + makeOpts({ + releases, + targetVersion: "0.15.0", + fullGzSize: 100_000, + }) + ); + expect(result).toBeNull(); + }); + + test("returns null when target is older than current (downgrade)", () => { + const releases = buildReleases( + ["0.14.0", "0.13.0", "0.12.0"], + "sentry-linux-x64" + ); + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.14.0", + targetVersion: "0.12.0", + fullGzSize: 100_000, + }) + ); + expect(result).toBeNull(); + }); + + test("returns null when target equals current", () => { + const releases = buildReleases(["0.13.0", "0.12.0"], "sentry-linux-x64"); + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.13.0", + targetVersion: "0.13.0", + fullGzSize: 100_000, + }) + ); + expect(result).toBeNull(); + }); + + test("returns null when chain exceeds size threshold", () => { + const releases = buildReleases( + ["0.14.0", "0.13.0", "0.12.0"], + "sentry-linux-x64", + 70_000 + ); + const result = extractStableChain( + makeOpts({ releases, fullGzSize: 100_000 }) + ); + expect(result).toBeNull(); + }); + + test("returns null when patch asset missing from a release", () => { + const releases = [ + makeRelease("0.14.0", [ + makeAsset({ + name: "sentry-linux-x64", + digest: `sha256:${versionToHex("0.14.0")}`, + }), + makeAsset({ name: "sentry-linux-x64.gz", size: 100_000 }), + ]), + makeRelease("0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), + ]; + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.13.0", + targetVersion: "0.14.0", + fullGzSize: 100_000, + }) + ); + expect(result).toBeNull(); + }); + + test("returns null when target binary has no digest (no SHA-256)", () => { + const releases = [ + makeRelease("0.14.0", [ + makeAsset({ name: "sentry-linux-x64" }), + makeAsset({ + name: "sentry-linux-x64.patch", + size: 1000, + browser_download_url: "https://example.com/0.14.0.patch", + }), + makeAsset({ name: "sentry-linux-x64.gz", size: 100_000 }), + ]), + makeRelease("0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), + ]; + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.13.0", + targetVersion: "0.14.0", + fullGzSize: 100_000, + }) + ); + expect(result).toBeNull(); + }); + + test("returns null for chain depth exceeding MAX_STABLE_CHAIN_DEPTH (10)", () => { + const versions = Array.from({ length: 12 }, (_, i) => `0.${i + 1}.0`); + versions.reverse(); + const releases = buildReleases(versions, "sentry-linux-x64", 100); + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.1.0", + targetVersion: "0.12.0", + fullGzSize: 100_000, + }) + ); + expect(result).toBeNull(); + }); + + test("handles exactly MAX_STABLE_CHAIN_DEPTH (10) hops", () => { + const versions = Array.from({ length: 11 }, (_, i) => `0.${i + 1}.0`); + versions.reverse(); + const releases = buildReleases(versions, "sentry-linux-x64", 100); + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.1.0", + targetVersion: "0.11.0", + fullGzSize: 100_000, + }) + ); + expect(result).not.toBeNull(); + expect(result?.patchUrls).toHaveLength(10); + }); + + test("patch URLs are returned in apply order (oldest first)", () => { + const releases = buildReleases( + ["0.15.0", "0.14.0", "0.13.0", "0.12.0"], + "sentry-linux-x64" + ); + const result = extractStableChain( + makeOpts({ + releases, + currentVersion: "0.12.0", + targetVersion: "0.15.0", + fullGzSize: 100_000, + }) + ); + expect(result).not.toBeNull(); + expect(result?.patchUrls).toEqual([ + "https://example.com/0.13.0.patch", + "https://example.com/0.14.0.patch", + "https://example.com/0.15.0.patch", + ]); + }); + + test("cumulative size threshold is checked progressively", () => { + const releases = [ + makeRelease("0.14.0", [ + makeAsset({ + name: "sentry-linux-x64", + digest: `sha256:${versionToHex("0.14.0")}`, + }), + makeAsset({ + name: "sentry-linux-x64.patch", + size: 50_000, + browser_download_url: "https://example.com/0.14.0.patch", + }), + makeAsset({ name: "sentry-linux-x64.gz", size: 100_000 }), + ]), + makeRelease("0.13.0", [ + makeAsset({ + name: "sentry-linux-x64", + digest: `sha256:${versionToHex("0.13.0")}`, + }), + makeAsset({ + name: "sentry-linux-x64.patch", + size: 15_000, + browser_download_url: "https://example.com/0.13.0.patch", + }), + ]), + makeRelease("0.12.0", [makeAsset({ name: "sentry-linux-x64" })]), + ]; + const result = extractStableChain( + makeOpts({ releases, fullGzSize: 100_000 }) + ); + expect(result).toBeNull(); + }); +}); + +// getPatchFromVersion & getPatchTargetSha256 + +describe("getPatchFromVersion", () => { + test("extracts from-version annotation", () => { + const manifest = makePatchManifest("0.12.0"); + expect(getPatchFromVersion(manifest)).toBe("0.12.0"); + }); + + test("returns null when annotation missing", () => { + const manifest: OciManifest = { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [], + annotations: {}, + }; + expect(getPatchFromVersion(manifest)).toBeNull(); + }); + + test("returns null when annotations object is undefined", () => { + const manifest: OciManifest = { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [], + }; + expect(getPatchFromVersion(manifest)).toBeNull(); + }); +}); + +describe("getPatchTargetSha256", () => { + test("extracts sha256 annotation for the given platform", () => { + const manifest = makePatchManifest("0.12.0", { + "sentry-linux-x64": "abc123", + "sentry-darwin-arm64": "def456", + }); + expect(getPatchTargetSha256(manifest, "sentry-linux-x64")).toBe("abc123"); + expect(getPatchTargetSha256(manifest, "sentry-darwin-arm64")).toBe( + "def456" + ); + }); + + test("returns null when platform not found", () => { + const manifest = makePatchManifest("0.12.0", { + "sentry-linux-x64": "abc123", + }); + expect(getPatchTargetSha256(manifest, "sentry-freebsd-x64")).toBeNull(); + }); + + test("returns null when annotations are undefined", () => { + const manifest: OciManifest = { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [], + }; + expect(getPatchTargetSha256(manifest, "sentry-linux-x64")).toBeNull(); + }); +}); + +// filterAndSortChainTags + +describe("filterAndSortChainTags", () => { + test("returns empty array when no tags match the range", () => { + const tags = ["patch-0.0.0-dev.90", "patch-0.0.0-dev.95"]; + const result = filterAndSortChainTags( + tags, + "0.0.0-dev.100", + "0.0.0-dev.105" + ); + expect(result).toEqual([]); + }); + + test("returns empty array when tags list is empty", () => { + const result = filterAndSortChainTags([], "0.0.0-dev.100", "0.0.0-dev.105"); + expect(result).toEqual([]); + }); + + test("filters to tags strictly between current and target (inclusive of target)", () => { + const tags = [ + "patch-0.0.0-dev.100", + "patch-0.0.0-dev.101", + "patch-0.0.0-dev.102", + "patch-0.0.0-dev.103", + ]; + const result = filterAndSortChainTags( + tags, + "0.0.0-dev.100", + "0.0.0-dev.102" + ); + // currentVersion (100) excluded, target (102) included + expect(result).toEqual(["patch-0.0.0-dev.101", "patch-0.0.0-dev.102"]); + }); + + test("excludes tags outside the range", () => { + const tags = [ + "patch-0.0.0-dev.98", + "patch-0.0.0-dev.101", + "patch-0.0.0-dev.105", + ]; + const result = filterAndSortChainTags( + tags, + "0.0.0-dev.100", + "0.0.0-dev.103" + ); + expect(result).toEqual(["patch-0.0.0-dev.101"]); + }); + + test("sorts tags by version in ascending order", () => { + // Tags arrive in arbitrary order from registry + const tags = [ + "patch-0.0.0-dev.103", + "patch-0.0.0-dev.101", + "patch-0.0.0-dev.102", + ]; + const result = filterAndSortChainTags( + tags, + "0.0.0-dev.100", + "0.0.0-dev.103" + ); + expect(result).toEqual([ + "patch-0.0.0-dev.101", + "patch-0.0.0-dev.102", + "patch-0.0.0-dev.103", + ]); + }); + + test("includes target version tag", () => { + const tags = ["patch-0.0.0-dev.101"]; + const result = filterAndSortChainTags( + tags, + "0.0.0-dev.100", + "0.0.0-dev.101" + ); + expect(result).toEqual(["patch-0.0.0-dev.101"]); + }); + + test("excludes current version tag", () => { + const tags = ["patch-0.0.0-dev.100", "patch-0.0.0-dev.101"]; + const result = filterAndSortChainTags( + tags, + "0.0.0-dev.100", + "0.0.0-dev.101" + ); + expect(result).toEqual(["patch-0.0.0-dev.101"]); + }); + + test("handles real-world version strings with timestamps", () => { + const tags = [ + "patch-0.14.0-dev.1772661724", + "patch-0.14.0-dev.1772732047", + "patch-0.14.0-dev.1772800000", + ]; + const result = filterAndSortChainTags( + tags, + "0.14.0-dev.1772661724", + "0.14.0-dev.1772800000" + ); + expect(result).toEqual([ + "patch-0.14.0-dev.1772732047", + "patch-0.14.0-dev.1772800000", + ]); + }); + + test("returns >10 tags for nightly chains (higher depth limit than stable)", () => { + // Simulate 25 nightly builds — should all be returned since nightly + // chains allow up to MAX_NIGHTLY_CHAIN_DEPTH (30) hops + const tags = Array.from( + { length: 25 }, + (_, i) => `patch-0.14.0-dev.${1000 + i + 1}` + ); + const result = filterAndSortChainTags( + tags, + "0.14.0-dev.1000", + "0.14.0-dev.1025" + ); + expect(result).toHaveLength(25); + expect(result[0]).toBe("patch-0.14.0-dev.1001"); + expect(result[24]).toBe("patch-0.14.0-dev.1025"); + }); + + test("handles cross-minor version nightly chains", () => { + // Versions crossing 0.16.x → 0.17.x boundary + const tags = [ + "patch-0.16.0-dev.200", + "patch-0.16.0-dev.300", + "patch-0.17.0-dev.400", + "patch-0.17.0-dev.500", + ]; + const result = filterAndSortChainTags( + tags, + "0.16.0-dev.100", + "0.17.0-dev.500" + ); + expect(result).toEqual([ + "patch-0.16.0-dev.200", + "patch-0.16.0-dev.300", + "patch-0.17.0-dev.400", + "patch-0.17.0-dev.500", + ]); + }); + + test("returns single tag for single-hop upgrade", () => { + const tags = [ + "patch-0.0.0-dev.100", + "patch-0.0.0-dev.101", + "patch-0.0.0-dev.102", + ]; + const result = filterAndSortChainTags( + tags, + "0.0.0-dev.101", + "0.0.0-dev.102" + ); + expect(result).toEqual(["patch-0.0.0-dev.102"]); + }); +}); + +// validateChainStep + +describe("validateChainStep", () => { + const PATCH_LAYER_NAME = `${getPlatformBinaryName()}.patch`; + + function makeLayer( + title: string, + size: number + ): OciManifest["layers"][number] { + return { + digest: `sha256:${title.replace(/\W/g, "")}`, + mediaType: "application/octet-stream", + size, + annotations: { "org.opencontainers.image.title": title }, + }; + } + + test("returns version-mismatch when from-version differs", () => { + const manifest = makePatchManifest("0.1.0", {}, [ + makeLayer(PATCH_LAYER_NAME, 500), + ]); + const result = validateChainStep(manifest, { + expectedFrom: "0.0.9", + patchLayerName: PATCH_LAYER_NAME, + sizeLimit: 100_000, + }); + expect(result).toEqual({ + ok: false, + failure: { + reason: "version-mismatch", + expected: "0.0.9", + actual: "0.1.0", + }, + }); + }); + + test("returns missing-layer when platform layer is absent", () => { + const manifest = makePatchManifest("0.0.9", {}, [ + makeLayer("sentry-other-platform.patch", 500), + ]); + const result = validateChainStep(manifest, { + expectedFrom: "0.0.9", + patchLayerName: PATCH_LAYER_NAME, + sizeLimit: 100_000, + }); + expect(result).toEqual({ + ok: false, + failure: { reason: "missing-layer", layerName: PATCH_LAYER_NAME }, + }); + }); + + test("returns size-exceeded when layer exceeds budget", () => { + const manifest = makePatchManifest("0.0.9", {}, [ + makeLayer(PATCH_LAYER_NAME, 200_000), + ]); + const result = validateChainStep(manifest, { + expectedFrom: "0.0.9", + patchLayerName: PATCH_LAYER_NAME, + sizeLimit: 100_000, + }); + expect(result).toEqual({ + ok: false, + failure: { + reason: "size-exceeded", + layerSize: 200_000, + budget: 100_000, + }, + }); + }); + + test("returns ok with digest and size on success", () => { + const manifest = makePatchManifest("0.0.9", {}, [ + makeLayer(PATCH_LAYER_NAME, 500), + ]); + const result = validateChainStep(manifest, { + expectedFrom: "0.0.9", + patchLayerName: PATCH_LAYER_NAME, + sizeLimit: 100_000, + }); + expect(result).toEqual({ + ok: true, + digest: `sha256:${PATCH_LAYER_NAME.replace(/\W/g, "")}`, + size: 500, + }); + }); +}); + +// =================================================================== +// Async functions (fetch-mocked) +// =================================================================== + +/** Helper to mock globalThis.fetch */ +function mockFetch( + fn: (url: string | URL | Request, init?: RequestInit) => Promise +): void { + globalThis.fetch = fn as typeof globalThis.fetch; +} + +/** Store original fetch for restoration */ +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// fetchRecentReleases + +describe("fetchRecentReleases", () => { + test("returns releases from GitHub API", async () => { + const releases: GitHubRelease[] = [ + makeRelease("cli@0.14.0", [makeAsset({ name: "sentry-linux-x64" })]), + makeRelease("cli@0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), + makeRelease("mcp@9.0.0", [makeAsset({ name: "sentry-linux-x64" })]), + ]; + + mockFetch(async (url) => { + expect(String(url)).toContain( + "api.github.com/repos/getsentry/toolkit/releases" + ); + expect(String(url)).toContain("per_page="); + return new Response(JSON.stringify(releases), { status: 200 }); + }); + + const result = await fetchRecentReleases(); + expect(result).toHaveLength(2); + expect(result[0]?.tag_name).toBe("0.14.0"); + }); + + test.each([ + ["Toolkit", undefined, "cli@0.14.0-dev.1", "cli@0.14.0"], + ["legacy", LEGACY_UPGRADE_SOURCE, "0.14.0-dev.1", "0.14.0"], + ])("excludes semantic prereleases from the %s stable source", async (_name, source, prereleaseTag, stableTag) => { + mockFetch( + async () => + new Response( + JSON.stringify([ + { ...makeRelease(prereleaseTag, []), prerelease: false }, + makeRelease(stableTag, []), + ]), + { status: 200 } + ) + ); + + const result = await fetchRecentReleases(undefined, source); + expect(result.map((release) => release.tag_name)).toEqual(["0.14.0"]); + }); + + test("uses the selected legacy GitHub repository", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response(JSON.stringify([]), { status: 200 }); + }); + + await fetchRecentReleases(undefined, LEGACY_UPGRADE_SOURCE); + + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + }); + + test("returns empty array on HTTP error", async () => { + mockFetch(async () => new Response("Server Error", { status: 500 })); + + const result = await fetchRecentReleases(); + expect(result).toEqual([]); + }); + + test("returns empty array on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + const result = await fetchRecentReleases(); + expect(result).toEqual([]); + }); +}); + +// downloadStablePatch + +describe("downloadStablePatch", () => { + test("returns Uint8Array on success", async () => { + const patchData = new Uint8Array([1, 2, 3, 4, 5]); + + mockFetch(async (url) => { + expect(String(url)).toBe("https://example.com/patch.bin"); + return new Response(patchData.buffer as ArrayBuffer, { + status: 200, + }); + }); + + const result = await downloadStablePatch("https://example.com/patch.bin"); + expect(result).not.toBeNull(); + expect(result).toEqual(patchData); + }); + + test("returns null on HTTP 404", async () => { + mockFetch(async () => new Response("Not Found", { status: 404 })); + + const result = await downloadStablePatch("https://example.com/missing.bin"); + expect(result).toBeNull(); + }); + + test("returns null on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + const result = await downloadStablePatch("https://example.com/fail.bin"); + expect(result).toBeNull(); + }); +}); + +// resolveStableChain (async orchestrator) + +describe("resolveStableChain", () => { + /** + * Create a deterministic hex digest from a version string. + * Reuses the same approach as the extractStableChain tests above. + */ + function versionHex(version: string): string { + return Array.from(version) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); + } + + /** Build a mock that serves both releases API and patch downloads */ + function setupStableMocks( + releases: GitHubRelease[], + patches: Map + ): void { + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.startsWith("https://api.github.com/")) { + return new Response(JSON.stringify(releases), { status: 200 }); + } + const patchData = patches.get(urlStr); + if (patchData) { + return new Response(patchData.buffer as ArrayBuffer, { + status: 200, + }); + } + return new Response("Not Found", { status: 404 }); + }); + } + + test("resolves prefixed Toolkit CLI releases and ignores other products", async () => { + const binaryName = getPlatformBinaryName(); + const patchBytes = new Uint8Array([10, 20, 30]); + const patchUrl = `https://github.com/getsentry/toolkit/releases/download/cli@0.14.0/${binaryName}.patch`; + + const releases: GitHubRelease[] = [ + makeRelease("mcp@9.0.0", [makeAsset({ name: binaryName })]), + makeRelease("cli@0.14.0", [ + makeAsset({ + name: binaryName, + digest: `sha256:${versionHex("0.14.0")}`, + }), + makeAsset({ + name: `${binaryName}.patch`, + size: 100, + browser_download_url: patchUrl, + }), + makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), + ]), + makeRelease("cli@0.13.0", [makeAsset({ name: binaryName })]), + ]; + + setupStableMocks(releases, new Map([[patchUrl, patchBytes]])); + + const chain = await resolveStableChain("0.13.0", "0.14.0"); + expect(chain).not.toBeNull(); + expect(chain?.patches).toHaveLength(1); + expect(chain?.patches[0]?.data).toEqual(patchBytes); + expect(chain?.expectedSha256).toBe(versionHex("0.14.0")); + expect(chain?.steps).toEqual([ + { fromVersion: "0.13.0", toVersion: "0.14.0" }, + ]); + }); + + test("keeps stable resolution on the selected legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Not Found", { status: 404 }); + }); + + await expect( + resolveStableChain("0.13.0", "0.14.0", undefined, LEGACY_UPGRADE_SOURCE) + ).resolves.toBeNull(); + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("resolves multi-hop chain with parallel downloads", async () => { + const binaryName = getPlatformBinaryName(); + const patchA = new Uint8Array([1, 2]); + const patchB = new Uint8Array([3, 4]); + const urlA = "https://example.com/0.14.0.patch"; + const urlB = "https://example.com/0.15.0.patch"; + + const releases: GitHubRelease[] = [ + makeRelease("cli@0.15.0", [ + makeAsset({ + name: binaryName, + digest: `sha256:${versionHex("0.15.0")}`, + }), + makeAsset({ + name: `${binaryName}.patch`, + size: 50, + browser_download_url: urlB, + }), + makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), + ]), + makeRelease("cli@0.14.0", [ + makeAsset({ + name: binaryName, + digest: `sha256:${versionHex("0.14.0")}`, + }), + makeAsset({ + name: `${binaryName}.patch`, + size: 50, + browser_download_url: urlA, + }), + makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), + ]), + makeRelease("cli@0.13.0", [makeAsset({ name: binaryName })]), + ]; + + setupStableMocks( + releases, + new Map([ + [urlA, patchA], + [urlB, patchB], + ]) + ); + + const chain = await resolveStableChain("0.13.0", "0.15.0"); + expect(chain).not.toBeNull(); + expect(chain?.patches).toHaveLength(2); + // Oldest patch first (apply order) + expect(chain?.patches[0]?.data).toEqual(patchA); + expect(chain?.patches[1]?.data).toEqual(patchB); + expect(chain?.steps).toEqual([ + { fromVersion: "0.13.0", toVersion: "0.14.0" }, + { fromVersion: "0.14.0", toVersion: "0.15.0" }, + ]); + }); + + test("returns null when target not in releases", async () => { + const releases: GitHubRelease[] = [ + makeRelease("cli@0.13.0", [makeAsset({ name: "sentry-linux-x64" })]), + ]; + setupStableMocks(releases, new Map()); + + const chain = await resolveStableChain("0.12.0", "0.14.0"); + expect(chain).toBeNull(); + }); + + test("returns null when releases API fails", async () => { + mockFetch(async () => new Response("Error", { status: 500 })); + + const chain = await resolveStableChain("0.12.0", "0.13.0"); + expect(chain).toBeNull(); + }); + + test("returns null when a patch download fails", async () => { + const binaryName = getPlatformBinaryName(); + const releases: GitHubRelease[] = [ + makeRelease("cli@0.14.0", [ + makeAsset({ + name: binaryName, + digest: `sha256:${versionHex("0.14.0")}`, + }), + makeAsset({ + name: `${binaryName}.patch`, + size: 100, + browser_download_url: "https://example.com/missing.patch", + }), + makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), + ]), + makeRelease("cli@0.13.0", [makeAsset({ name: binaryName })]), + ]; + + // Only mock releases API, no patch data available + setupStableMocks(releases, new Map()); + + const chain = await resolveStableChain("0.13.0", "0.14.0"); + expect(chain).toBeNull(); + }); + + test("returns null when chain depth exceeds stable limit", async () => { + const binaryName = getPlatformBinaryName(); + // 15 releases = 14 hops, exceeds MAX_STABLE_CHAIN_DEPTH (10) + const versions = Array.from({ length: 15 }, (_, i) => `0.${i + 1}.0`); + versions.reverse(); // newest first + const releases = versions.map((v) => + makeRelease(`cli@${v}`, [ + makeAsset({ name: binaryName, digest: `sha256:${versionHex(v)}` }), + makeAsset({ + name: `${binaryName}.patch`, + size: 100, + browser_download_url: `https://example.com/${v}.patch`, + }), + makeAsset({ name: `${binaryName}.gz`, size: 100_000 }), + ]) + ); + + setupStableMocks(releases, new Map()); + + const chain = await resolveStableChain("0.1.0", "0.15.0"); + expect(chain).toBeNull(); + }); +}); + +// resolveNightlyChain (async orchestrator) + +describe("resolveNightlyChain", () => { + const BINARY_NAME = getPlatformBinaryName(); + const PATCH_NAME = `${BINARY_NAME}.patch`; + + /** Set up GHCR mocks for tag listing, manifest fetches, and blob downloads */ + function setupNightlyMocks( + tags: string[], + manifests: Map, + blobs: Map + ): void { + mockFetch(async (url) => { + const urlStr = String(url); + + if (urlStr.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "test-token" }), { + status: 200, + }); + } + + if (urlStr.includes("/tags/list")) { + return new Response(JSON.stringify({ tags }), { + status: 200, + }); + } + + const manifestMatch = urlStr.match(/\/manifests\/(.+)$/); + if (manifestMatch) { + const tag = manifestMatch[1]; + const manifest = manifests.get(tag ?? ""); + if (manifest) { + return new Response(JSON.stringify(manifest), { + status: 200, + }); + } + return new Response("Not Found", { status: 404 }); + } + + // Blob download — redirect then serve + const blobMatch = urlStr.match(/\/blobs\/(sha256:[a-f0-9A-F]+)/); + if (blobMatch) { + const digest = blobMatch[1]; + const blobData = blobs.get(digest ?? ""); + if (blobData) { + // Manual redirect response (redirect: "manual" is used by downloadNightlyBlob) + return new Response(null, { + status: 307, + headers: { Location: `https://blob.test/${digest}` }, + }); + } + return new Response("Not Found", { status: 404 }); + } + + // Follow redirect — serve blob by digest from URL + if (urlStr.includes("blob.test/")) { + const digestFromUrl = urlStr.split("blob.test/")[1]; + const blobData = blobs.get(digestFromUrl ?? ""); + if (blobData) { + return new Response(blobData.buffer as ArrayBuffer, { status: 200 }); + } + } + + return new Response("Not Found", { status: 404 }); + }); + } + + test("resolves single-hop nightly chain", async () => { + const patchData = new Uint8Array([99, 88, 77]); + const patchDigest = "sha256:aabbccdd1122334455"; + const patchManifest = makePatchManifest( + "0.0.0-dev.100", + { [BINARY_NAME]: "aabb1122" }, + [ + { + digest: patchDigest, + mediaType: "application/octet-stream", + size: 100, + annotations: { + "org.opencontainers.image.title": PATCH_NAME, + }, + }, + ] + ); + + setupNightlyMocks( + ["patch-0.0.0-dev.101"], + new Map([["patch-0.0.0-dev.101", patchManifest]]), + new Map([[patchDigest, patchData]]) + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + }); + + expect(chain).not.toBeNull(); + expect(chain?.patches).toHaveLength(1); + expect(chain?.expectedSha256).toBe("aabb1122"); + expect(chain?.steps).toEqual([ + { fromVersion: "0.0.0-dev.100", toVersion: "0.0.0-dev.101" }, + ]); + }); + + test("keeps nightly resolution on the selected legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response(JSON.stringify({ tags: [] }), { status: 200 }); + }); + + await expect( + resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + source: LEGACY_UPGRADE_SOURCE, + }) + ).resolves.toBeNull(); + expect(urls).toEqual(["https://ghcr.io/v2/getsentry/cli/tags/list?n=100"]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("returns null when no matching patches in graph", async () => { + setupNightlyMocks([], new Map(), new Map()); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.102", + fullGzSize: 100_000, + }); + + expect(chain).toBeNull(); + }); + + test("returns null when chain depth exceeds MAX_NIGHTLY_CHAIN_DEPTH (30)", async () => { + // 35 tags exceeds the nightly limit of 30 + const tags = Array.from( + { length: 35 }, + (_, i) => `patch-0.0.0-dev.${101 + i}` + ); + + setupNightlyMocks(tags, new Map(), new Map()); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.135", + fullGzSize: 100_000, + preloadedTags: tags, + }); + + expect(chain).toBeNull(); + }); + + test("resolves multi-hop nightly chain", async () => { + const patchA = new Uint8Array([10, 20]); + const patchB = new Uint8Array([30, 40]); + const digestA = "sha256:aaaa1111"; + const digestB = "sha256:bbbb2222"; + + const manifestA = makePatchManifest("0.0.0-dev.100", {}, [ + { + digest: digestA, + mediaType: "application/octet-stream", + size: 50, + annotations: { + "org.opencontainers.image.title": PATCH_NAME, + }, + }, + ]); + + const manifestB = makePatchManifest( + "0.0.0-dev.101", + { [BINARY_NAME]: "finalhash" }, + [ + { + digest: digestB, + mediaType: "application/octet-stream", + size: 60, + annotations: { + "org.opencontainers.image.title": PATCH_NAME, + }, + }, + ] + ); + + setupNightlyMocks( + ["patch-0.0.0-dev.101", "patch-0.0.0-dev.102"], + new Map([ + ["patch-0.0.0-dev.101", manifestA], + ["patch-0.0.0-dev.102", manifestB], + ]), + new Map([ + [digestA, patchA], + [digestB, patchB], + ]) + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.102", + fullGzSize: 100_000, + }); + + expect(chain).not.toBeNull(); + expect(chain?.patches).toHaveLength(2); + expect(chain?.patches[0]?.data).toEqual(patchA); + expect(chain?.patches[1]?.data).toEqual(patchB); + expect(chain?.expectedSha256).toBe("finalhash"); + expect(chain?.steps).toEqual([ + { fromVersion: "0.0.0-dev.100", toVersion: "0.0.0-dev.101" }, + { fromVersion: "0.0.0-dev.101", toVersion: "0.0.0-dev.102" }, + ]); + }); + + test("returns null when from-version does not match chain linkage", async () => { + // Manifest claims from-version is dev.99 but chain expects dev.100 + const patchManifest = makePatchManifest( + "0.0.0-dev.99", // wrong — should be dev.100 + { [BINARY_NAME]: "aabb1122" }, + [ + { + digest: "sha256:dd1122", + mediaType: "application/octet-stream", + size: 50, + annotations: { + "org.opencontainers.image.title": PATCH_NAME, + }, + }, + ] + ); + + setupNightlyMocks( + ["patch-0.0.0-dev.101"], + new Map([["patch-0.0.0-dev.101", patchManifest]]), + new Map() + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + }); + + expect(chain).toBeNull(); + }); + + test("returns null when patch layer exceeds size budget", async () => { + // Patch layer size (90_000) > fullGzSize * 0.6 (60_000) + const patchManifest = makePatchManifest( + "0.0.0-dev.100", + { [BINARY_NAME]: "aabb1122" }, + [ + { + digest: "sha256:bigpatch", + mediaType: "application/octet-stream", + size: 90_000, + annotations: { + "org.opencontainers.image.title": PATCH_NAME, + }, + }, + ] + ); + + setupNightlyMocks( + ["patch-0.0.0-dev.101"], + new Map([["patch-0.0.0-dev.101", patchManifest]]), + new Map() + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + }); + + expect(chain).toBeNull(); + }); + + test("returns null when manifest fetch fails for a chain tag", async () => { + // Tag exists but manifest 404s — fetchChainManifests catches the error + setupNightlyMocks( + ["patch-0.0.0-dev.101"], + new Map(), // no manifests — will 404 + new Map() + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + }); + + expect(chain).toBeNull(); + }); + + test("returns null when last tag version does not match target", async () => { + // Chain has patch-dev.101 but target is dev.102 — chain stops short + const patchManifest = makePatchManifest( + "0.0.0-dev.100", + { [BINARY_NAME]: "aabb1122" }, + [ + { + digest: "sha256:dd1122", + mediaType: "application/octet-stream", + size: 50, + annotations: { + "org.opencontainers.image.title": PATCH_NAME, + }, + }, + ] + ); + + // Only patch-dev.101 is in the graph, but target is dev.102 + // filterAndSortChainTags will include dev.101 (it's in range) + // but the last tag (dev.101) != targetVersion (dev.102) + setupNightlyMocks( + ["patch-0.0.0-dev.101"], + new Map([["patch-0.0.0-dev.101", patchManifest]]), + new Map() + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.102", + fullGzSize: 100_000, + preloadedTags: ["patch-0.0.0-dev.101"], + }); + + expect(chain).toBeNull(); + }); + + test("returns null when target manifest lacks sha256 annotation", async () => { + // Manifest is valid but missing the sha256- annotation + const patchManifest = makePatchManifest( + "0.0.0-dev.100", + {}, // no sha256 annotations + [ + { + digest: "sha256:dd1122", + mediaType: "application/octet-stream", + size: 50, + annotations: { + "org.opencontainers.image.title": PATCH_NAME, + }, + }, + ] + ); + + setupNightlyMocks( + ["patch-0.0.0-dev.101"], + new Map([["patch-0.0.0-dev.101", patchManifest]]), + new Map() + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + }); + + expect(chain).toBeNull(); + }); + + test("returns null when patch layer name is missing from manifest", async () => { + // Manifest has a layer but with a different title + const patchManifest = makePatchManifest( + "0.0.0-dev.100", + { [BINARY_NAME]: "aabb1122" }, + [ + { + digest: "sha256:dd1122", + mediaType: "application/octet-stream", + size: 50, + annotations: { + "org.opencontainers.image.title": "wrong-name.patch", + }, + }, + ] + ); + + setupNightlyMocks( + ["patch-0.0.0-dev.101"], + new Map([["patch-0.0.0-dev.101", patchManifest]]), + new Map() + ); + + const chain = await resolveNightlyChain({ + token: "test-token", + currentVersion: "0.0.0-dev.100", + targetVersion: "0.0.0-dev.101", + fullGzSize: 100_000, + }); + + expect(chain).toBeNull(); + }); +}); + +// applyPatchChain (real filesystem + TRDIFF10 fixtures) + +describe("applyPatchChain", () => { + const fixturesDir = join(import.meta.dirname, "../fixtures/patches"); + + /** Generate a unique temp file path */ + function tempFile(name: string): string { + return join(tmpdir(), `delta-test-${Date.now()}-${name}`); + } + + test("applies single-patch chain and verifies SHA-256", async () => { + const oldPath = join(fixturesDir, "small-old.bin"); + const destPath = tempFile("single-chain-out.bin"); + const patchData = await readFile(join(fixturesDir, "small.trdiff10")); + const expectedNewData = await readFile(join(fixturesDir, "small-new.bin")); + + const expectedSha256 = createHash("sha256") + .update(expectedNewData) + .digest("hex"); + + const chain: PatchChain = { + patches: [ + { + data: new Uint8Array(patchData), + size: patchData.byteLength, + }, + ], + totalSize: patchData.byteLength, + expectedSha256, + }; + + try { + const sha256 = await applyPatchChain(chain, oldPath, destPath); + expect(sha256).toBe(expectedSha256); + + // Verify output matches expected + const outputData = await readFile(destPath); + expect(outputData).toEqual(expectedNewData); + } finally { + if (existsSync(destPath)) { + unlinkSync(destPath); + } + } + }); + + test("throws on SHA-256 mismatch", async () => { + const oldPath = join(fixturesDir, "small-old.bin"); + const destPath = tempFile("mismatch-out.bin"); + const patchData = await readFile(join(fixturesDir, "small.trdiff10")); + + const chain: PatchChain = { + patches: [ + { + data: new Uint8Array(patchData), + size: patchData.byteLength, + }, + ], + totalSize: patchData.byteLength, + expectedSha256: + "0000000000000000000000000000000000000000000000000000000000000000", + }; + + try { + await expect(applyPatchChain(chain, oldPath, destPath)).rejects.toThrow( + "SHA-256 mismatch" + ); + } finally { + if (existsSync(destPath)) { + unlinkSync(destPath); + } + } + }); + + test("applies multi-step chains in memory without intermediate files", async () => { + // Intermediate hops are kept in memory, so the legacy `.patching.a`/`.b` + // scratch files must never be written. We only have one-step fixtures, so + // reuse the same patch twice: the first hop produces valid bytes, the + // second applies to mismatched bytes and fails final-hash verification — + // but either way no intermediate file should touch disk. + const oldPath = join(fixturesDir, "small-old.bin"); + const destPath = tempFile("multi-chain-out.bin"); + const intermediateA = `${destPath}.patching.a`; + const intermediateB = `${destPath}.patching.b`; + const patchData = await readFile(join(fixturesDir, "small.trdiff10")); + + const chain: PatchChain = { + patches: [ + { + data: new Uint8Array(patchData), + size: patchData.byteLength, + }, + { + data: new Uint8Array(patchData), + size: patchData.byteLength, + }, + ], + totalSize: patchData.byteLength * 2, + expectedSha256: "anything", + }; + + try { + await applyPatchChain(chain, oldPath, destPath).catch(() => { + // Expected — second patch applied to mismatched bytes fails verification + }); + + // The in-memory chain never creates scratch files on disk. + expect(existsSync(intermediateA)).toBe(false); + expect(existsSync(intermediateB)).toBe(false); + } finally { + for (const p of [destPath, intermediateA, intermediateB]) { + if (existsSync(p)) { + unlinkSync(p); + } + } + } + }); + + test("creates output file that is readable", async () => { + const oldPath = join(fixturesDir, "small-old.bin"); + const destPath = tempFile("output-readable.bin"); + const patchData = await readFile(join(fixturesDir, "small.trdiff10")); + const expectedNewData = await readFile(join(fixturesDir, "small-new.bin")); + const expectedSha256 = createHash("sha256") + .update(expectedNewData) + .digest("hex"); + + const chain: PatchChain = { + patches: [ + { + data: new Uint8Array(patchData), + size: patchData.byteLength, + }, + ], + totalSize: patchData.byteLength, + expectedSha256, + }; + + try { + await applyPatchChain(chain, oldPath, destPath); + + expect( + await access(destPath).then( + () => true, + () => false + ) + ).toBe(true); + } finally { + if (existsSync(destPath)) { + unlinkSync(destPath); + } + } + }); +}); + +// resolveStableDelta (high-level orchestrator) +// CLI_VERSION is "0.0.0-dev" in test mode, so chain resolution returns null. +// This still exercises the function entry, chain check, and null-return path. + +describe("resolveStableDelta", () => { + test("returns null when current version is dev", async () => { + // Mock fetch to return releases (won't match "0.0.0-dev") + mockFetch( + async () => + new Response( + JSON.stringify([ + makeRelease("0.14.0", [ + makeAsset({ name: "sentry-linux-x64.patch" }), + ]), + ]), + { status: 200 } + ) + ); + + const result = await resolveStableDelta( + "0.14.0", + "/tmp/fake-old", + "/tmp/fake-out" + ); + expect(result).toBeNull(); + }); +}); + +// resolveNightlyDelta (high-level orchestrator) + +describe("resolveNightlyDelta", () => { + const BINARY_NAME_ND = getPlatformBinaryName(); + + /** Set up GHCR mocks for the full resolveNightlyDelta flow */ + function setupFullNightlyMocks(opts: { + targetVersion: string; + targetManifest: OciManifest; + patchTags: string[]; + patchManifests: Map; + blobs: Map; + }): void { + mockFetch(async (url) => { + const urlStr = String(url); + + // Token exchange + if (urlStr.includes("ghcr.io/token")) { + return new Response(JSON.stringify({ token: "test-token" }), { + status: 200, + }); + } + + // Tag listing + if (urlStr.includes("/tags/list")) { + return new Response(JSON.stringify({ tags: opts.patchTags }), { + status: 200, + }); + } + + // Manifest fetch — target nightly or patch manifests + const manifestMatch = urlStr.match(/\/manifests\/(.+)$/); + if (manifestMatch) { + const tag = manifestMatch[1] ?? ""; + if (tag === `nightly-${opts.targetVersion}`) { + return new Response(JSON.stringify(opts.targetManifest), { + status: 200, + }); + } + const patchManifest = opts.patchManifests.get(tag); + if (patchManifest) { + return new Response(JSON.stringify(patchManifest), { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + } + + // Blob redirect + const blobMatch = urlStr.match(/\/blobs\/(sha256:[a-f0-9A-F]+)/); + if (blobMatch) { + const digest = blobMatch[1] ?? ""; + if (opts.blobs.has(digest)) { + return new Response(null, { + status: 307, + headers: { Location: `https://blob.test/${digest}` }, + }); + } + return new Response("Not Found", { status: 404 }); + } + + // Follow redirect + if (urlStr.includes("blob.test/")) { + const digestFromUrl = urlStr.split("blob.test/")[1] ?? ""; + const blobData = opts.blobs.get(digestFromUrl); + if (blobData) { + return new Response(blobData.buffer as ArrayBuffer, { status: 200 }); + } + } + + return new Response("Not Found", { status: 404 }); + }); + } + + test("returns null when GHCR token fetch fails", async () => { + mockFetch(async () => new Response("Unauthorized", { status: 401 })); + + await expect( + resolveNightlyDelta("0.14.0-dev.123", "/tmp/fake-old", "/tmp/fake-out") + ).resolves.toBeNull(); + }); + + test("returns null when no patch tags exist for the version range", async () => { + // Target manifest exists with .gz layer, but no patch tags match + // Exercises: resolveNightlyChainWithContext → resolveAndApplyDelta null path + const targetManifest: OciManifest = { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [ + { + digest: "sha256:targetgz", + mediaType: "application/octet-stream", + size: 100_000, + annotations: { + "org.opencontainers.image.title": `${BINARY_NAME_ND}.gz`, + }, + }, + ], + annotations: {}, + }; + + setupFullNightlyMocks({ + targetVersion: "0.14.0-dev.200", + targetManifest, + patchTags: [], // no patches + patchManifests: new Map(), + blobs: new Map(), + }); + + const result = await resolveNightlyDelta( + "0.14.0-dev.200", + "/tmp/fake-old", + "/tmp/fake-out" + ); + expect(result).toBeNull(); + }); + + test("returns null when target manifest has no .gz layer", async () => { + // Target manifest exists but lacks the .gz layer needed for size threshold + // Exercises: resolveNightlyChainWithContext gzLayer null check + const targetManifest: OciManifest = { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: "sha256:config", + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [ + { + digest: "sha256:onlypatch", + mediaType: "application/octet-stream", + size: 500, + annotations: { + "org.opencontainers.image.title": `${BINARY_NAME_ND}.patch`, + }, + }, + ], + annotations: {}, + }; + + setupFullNightlyMocks({ + targetVersion: "0.14.0-dev.200", + targetManifest, + patchTags: ["patch-0.14.0-dev.200"], + patchManifests: new Map(), + blobs: new Map(), + }); + + const result = await resolveNightlyDelta( + "0.14.0-dev.200", + "/tmp/fake-old", + "/tmp/fake-out" + ); + expect(result).toBeNull(); + }); +}); + +// attemptDeltaUpgrade (top-level orchestrator) + +describe("attemptDeltaUpgrade", () => { + test("returns null when canAttemptDelta is false (dev version)", async () => { + const result = await attemptDeltaUpgrade( + "0.14.0", + "/tmp/fake-old", + "/tmp/fake-out" + ); + expect(result).toBeNull(); + }); +}); + +// prefetch functions (background version-check optimization) +// CLI_VERSION is "0.0.0-dev" in test, so canAttemptDelta bails early. +// This exercises the function entry and the guard in prefetchAndCache. + +describe("prefetchNightlyPatches", () => { + test("returns immediately when CLI_VERSION is dev", async () => { + // Should not make any fetch calls since canAttemptDelta returns false + mockFetch(async () => { + throw new Error("fetch should not be called"); + }); + + await prefetchNightlyPatches("0.14.0-dev.123"); + }); +}); + +describe("prefetchStablePatches", () => { + test("returns immediately when CLI_VERSION is dev", async () => { + mockFetch(async () => { + throw new Error("fetch should not be called"); + }); + + await prefetchStablePatches("0.14.0"); + }); +}); + +async function importDeltaUpgradeWithVersion(version: string) { + vi.resetModules(); + vi.doMock("../../src/lib/constants.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, CLI_VERSION: version }; + }); + return import("../../src/lib/delta-upgrade.js"); +} + +function restoreDeltaUpgradeModule(): void { + vi.doUnmock("../../src/lib/constants.js"); + vi.resetModules(); +} + +describe("selected source affinity", () => { + useTestConfigDir("delta-source-affinity-"); + afterEach(restoreDeltaUpgradeModule); + + test("attemptDeltaUpgrade keeps stable requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Not Found", { status: 404 }); + }); + const versionedDelta = await importDeltaUpgradeWithVersion("0.13.0"); + + await expect( + versionedDelta.attemptDeltaUpgrade( + "0.14.0", + "/tmp/fake-old", + "/tmp/fake-out", + false, + undefined, + LEGACY_UPGRADE_SOURCE + ) + ).resolves.toBeNull(); + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("attemptDeltaUpgrade keeps nightly requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Unauthorized", { status: 401 }); + }); + const versionedDelta = + await importDeltaUpgradeWithVersion("0.14.0-dev.100"); + + await expect( + versionedDelta.attemptDeltaUpgrade( + "0.14.0-dev.101", + "/tmp/fake-old", + "/tmp/fake-out", + false, + undefined, + LEGACY_UPGRADE_SOURCE + ) + ).resolves.toBeNull(); + expect(urls).toEqual([ + "https://ghcr.io/token?scope=repository:getsentry/cli:pull", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("prefetchStablePatches keeps requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Not Found", { status: 404 }); + }); + const versionedDelta = await importDeltaUpgradeWithVersion("0.13.0"); + + await versionedDelta.prefetchStablePatches( + "0.14.0", + undefined, + LEGACY_UPGRADE_SOURCE + ); + expect(urls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=12", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); + + test("prefetchNightlyPatches keeps requests on the legacy source", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(String(url)); + return new Response("Unauthorized", { status: 401 }); + }); + const versionedDelta = + await importDeltaUpgradeWithVersion("0.14.0-dev.100"); + + await versionedDelta.prefetchNightlyPatches( + "0.14.0-dev.101", + undefined, + LEGACY_UPGRADE_SOURCE + ); + expect(urls).toEqual([ + "https://ghcr.io/token?scope=repository:getsentry/cli:pull", + ]); + expect(urls.every((url) => !url.includes("getsentry/toolkit"))).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/detect-agent.property.test.ts b/packages/cli/test/lib/detect-agent.property.test.ts new file mode 100644 index 000000000..8efadd2cf --- /dev/null +++ b/packages/cli/test/lib/detect-agent.property.test.ts @@ -0,0 +1,205 @@ +/** + * Property-Based Tests for Agent Normalization + * + * Uses fast-check to verify invariants that should hold for any input + * to normalizeAgent(), regardless of content. + */ + +import { + array, + constantFrom, + assert as fcAssert, + oneof, + property, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { AGENT_ALIASES, normalizeAgent } from "../../src/lib/detect-agent.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** Characters valid in agent names (lowercase alphanum + hyphens). */ +const agentNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-"; + +/** + * Values treated as boolean-ish garbage by normalizeAgent. + * Excluded from the agent name arbitrary to prevent false failures + * in compound tests where version/role extraction is asserted. + */ +const GARBAGE_NAMES = new Set([ + "0", + "1", + "true", + "false", + "yes", + "no", + "on", + "off", +]); + +/** Generate valid-looking agent names (lowercase, no leading/trailing dash, not garbage). */ +const agentNameArb = array(constantFrom(...agentNameChars.split("")), { + minLength: 2, + maxLength: 20, +}) + .map((chars) => chars.join("")) + .filter( + (s) => + /^[a-z]/.test(s) && + !s.endsWith("-") && + !s.includes("--") && + !GARBAGE_NAMES.has(s) + ); + +/** Generate semver-ish version strings. */ +const versionArb = tuple( + constantFrom(...Array.from({ length: 20 }, (_, i) => String(i))), + constantFrom(...Array.from({ length: 20 }, (_, i) => String(i))), + constantFrom(...Array.from({ length: 100 }, (_, i) => String(i))) +).map(([major, minor, patch]) => `${major}.${minor}.${patch}`); + +/** Generate role strings. */ +const roleArb = constantFrom("agent", "assistant", "bot", "worker", "cli"); + +/** Truthy garbage — signals "agent present" but unnamed → { name: "unknown" }. */ +const truthyGarbageArb = constantFrom("1", "true", "yes", "on", "TRUE", "YES"); + +/** Falsy garbage — signals "no agent" / opt-out → undefined. */ +const falsyGarbageArb = constantFrom("0", "false", "no", "off", "False", "NO"); + +/** Generate compound agent strings: name/version/role. */ +const compoundArb = tuple(agentNameArb, versionArb, roleArb).map( + ([name, version, role]) => `${name}/${version}/${role}` +); + +describe("property: normalizeAgent", () => { + test("name is always lowercase when defined", () => { + fcAssert( + property(oneof(agentNameArb, compoundArb, truthyGarbageArb), (raw) => { + const result = normalizeAgent(raw); + expect(result).toBeDefined(); + expect(result!.name).toBe(result!.name.toLowerCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("name is always non-empty when defined", () => { + fcAssert( + property(oneof(agentNameArb, compoundArb, truthyGarbageArb), (raw) => { + const result = normalizeAgent(raw); + expect(result).toBeDefined(); + expect(result!.name.length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("truthy garbage always produces 'unknown'", () => { + fcAssert( + property(truthyGarbageArb, (raw) => { + const result = normalizeAgent(raw); + expect(result).toEqual({ name: "unknown" }); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("falsy garbage always returns undefined", () => { + fcAssert( + property(falsyGarbageArb, (raw) => { + expect(normalizeAgent(raw)).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("version is always digits-and-dots when present", () => { + fcAssert( + property(compoundArb, (raw) => { + const result = normalizeAgent(raw); + expect(result).toBeDefined(); + if (result?.version) { + expect(result.version).toMatch(/^\d+(\.\d+)*$/); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("version never has leading v", () => { + fcAssert( + property( + tuple(agentNameArb, versionArb).map(([name, ver]) => `${name}/v${ver}`), + (raw) => { + const result = normalizeAgent(raw); + expect(result).toBeDefined(); + if (result?.version) { + expect(result.version.startsWith("v")).toBe(false); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("alias keys always resolve to their mapped value", () => { + fcAssert( + property(constantFrom(...AGENT_ALIASES.keys()), (aliasKey) => { + const result = normalizeAgent(aliasKey); + expect(result).toBeDefined(); + expect(result!.name).toBe(AGENT_ALIASES.get(aliasKey)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent: normalizing the name again yields same name", () => { + fcAssert( + property(oneof(agentNameArb, compoundArb), (raw) => { + const first = normalizeAgent(raw); + expect(first).toBeDefined(); + const second = normalizeAgent(first!.name); + expect(second).toBeDefined(); + expect(second!.name).toBe(first!.name); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("compound: name/version always extracts version", () => { + fcAssert( + property(tuple(agentNameArb, versionArb), ([name, version]) => { + const result = normalizeAgent(`${name}/${version}`); + expect(result).toBeDefined(); + expect(result!.version).toBe(version); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("compound: name/version/role always extracts role", () => { + fcAssert( + property( + tuple(agentNameArb, versionArb, roleArb), + ([name, version, role]) => { + const result = normalizeAgent(`${name}/${version}/${role}`); + expect(result).toBeDefined(); + expect(result!.role).toBe(role); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("simple name never has version or role", () => { + fcAssert( + property(agentNameArb, (name) => { + const result = normalizeAgent(name); + expect(result).toBeDefined(); + expect(result!.version).toBeUndefined(); + expect(result!.role).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/detect-agent.test.ts b/packages/cli/test/lib/detect-agent.test.ts new file mode 100644 index 000000000..0d866f3d1 --- /dev/null +++ b/packages/cli/test/lib/detect-agent.test.ts @@ -0,0 +1,675 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { + AGENT_ALIASES, + type AgentInfo, + detectAgent, + detectAgentFromProcessTree, + ENV_VAR_AGENTS, + getProcessInfoFromOS, + normalizeAgent, + PROCESS_NAME_AGENTS, + setProcessInfoProvider, +} from "../../src/lib/detect-agent.js"; +import { setEnv } from "../../src/lib/env.js"; + +/** Shorthand for building an AgentInfo with only a name. */ +function named(name: string): AgentInfo { + return { name }; +} + +function withEnv(vars: Record) { + setEnv(vars as NodeJS.ProcessEnv); +} + +/** No-op async provider typed to satisfy ProcessInfoProvider. */ +async function noProcessInfo(_pid: number): Promise { + return; +} + +describe("detectAgent", () => { + afterEach(() => { + setEnv(process.env); + setProcessInfoProvider(getProcessInfoFromOS); + }); + + // ── AI_AGENT override ────────────────────────────────────────────── + + test("AI_AGENT takes highest priority", () => { + withEnv({ + AI_AGENT: "custom-agent", + CURSOR_EXTENSION_HOST_ROLE: "agent-exec", + GROK_PLUGIN_DATA: "/tmp/grok", + CLAUDE_CODE: "1", + CI: "true", + }); + expect(detectAgent()).toEqual(named("custom-agent")); + }); + + test("AI_AGENT empty string is ignored", () => { + withEnv({ AI_AGENT: "" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT whitespace-only is ignored", () => { + withEnv({ AI_AGENT: " " }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT is trimmed and lowercased", () => { + withEnv({ AI_AGENT: " My-Agent " }); + expect(detectAgent()).toEqual(named("my-agent")); + }); + + test("AI_AGENT compound value extracts name, version, and role", () => { + withEnv({ AI_AGENT: "claude-code/2.1.123/agent" }); + expect(detectAgent()).toEqual({ + name: "claude", + version: "2.1.123", + role: "agent", + }); + }); + + test("AI_AGENT with alias resolves to canonical name", () => { + withEnv({ AI_AGENT: "claude-code" }); + expect(detectAgent()).toEqual(named("claude")); + }); + + test("AI_AGENT garbage value '1' becomes unknown", () => { + withEnv({ AI_AGENT: "1" }); + expect(detectAgent()).toEqual(named("unknown")); + }); + + test("AI_AGENT garbage value 'true' becomes unknown", () => { + withEnv({ AI_AGENT: "true" }); + expect(detectAgent()).toEqual(named("unknown")); + }); + + test("AI_AGENT falsy value 'false' is treated as no agent", () => { + withEnv({ AI_AGENT: "false" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT falsy value '0' is treated as no agent", () => { + withEnv({ AI_AGENT: "0" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT falsy opt-out falls through to next detection level", () => { + withEnv({ AI_AGENT: "false", CLAUDE_CODE: "1" }); + expect(detectAgent()).toEqual(named("claude")); + }); + + test.each([ + ["CLINE_ACTIVE", "cline"], + ["OPENCLAW_SHELL", "openclaw"], + ["KIMI_PLUGIN_ROOT", "kimi"], + ["GROK_PLUGIN_ROOT", "grok"], + ["GROK_PLUGIN_DATA", "grok"], + ["JUNIE_DATA", "junie"], + ["JUNIE_SHIM_PATH", "junie"], + ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"], + ["ANTIGRAVITY_CLI_ALIAS", "antigravity"], + ["OPENCODE", "opencode"], + ])("%s detects %s before the AGENT fallback", (envVar, agent) => { + withEnv({ [envVar]: "1", AGENT: "other-agent" }); + expect(detectAgent()).toEqual(named(agent)); + }); + + test("empty agent-specific signals are ignored", () => { + withEnv({ + CLINE_ACTIVE: "", + OPENCLAW_SHELL: "", + KIMI_PLUGIN_ROOT: "", + GROK_PLUGIN_ROOT: "", + GROK_PLUGIN_DATA: "", + JUNIE_DATA: "", + JUNIE_SHIM_PATH: "", + CODEX_SANDBOX_NETWORK_DISABLED: "", + ANTIGRAVITY_CLI_ALIAS: "", + OPENCODE: "", + }); + expect(detectAgent()).toBeUndefined(); + }); + + // ── Cursor ───────────────────────────────────────────────────────── + + test("CURSOR_TRACE_ID → cursor", () => { + withEnv({ CURSOR_TRACE_ID: "abc123" }); + expect(detectAgent()).toEqual(named("cursor")); + }); + + test("CURSOR_AGENT → cursor", () => { + withEnv({ CURSOR_AGENT: "1" }); + expect(detectAgent()).toEqual(named("cursor")); + }); + + test("CURSOR_TRACE_ID takes priority over CURSOR_AGENT", () => { + withEnv({ CURSOR_TRACE_ID: "abc", CURSOR_AGENT: "1" }); + expect(detectAgent()).toEqual(named("cursor")); + }); + + test("CURSOR_EXTENSION_HOST_ROLE=agent-exec → cursor", () => { + withEnv({ CURSOR_EXTENSION_HOST_ROLE: "agent-exec" }); + expect(detectAgent()).toEqual(named("cursor")); + }); + + test("Cursor agent-exec takes priority over other agent markers", () => { + withEnv({ + CURSOR_EXTENSION_HOST_ROLE: "agent-exec", + GEMINI_CLI: "1", + GROK_PLUGIN_ROOT: "/tmp/grok", + CLAUDE_CODE: "1", + AGENT: "other-agent", + }); + expect(detectAgent()).toEqual(named("cursor")); + }); + + test.each([ + "", + "terminal", + "agent-exec-helper", + "AGENT-EXEC", + ])("CURSOR_EXTENSION_HOST_ROLE=%j does not trigger detection", (role) => { + withEnv({ CURSOR_EXTENSION_HOST_ROLE: role }); + expect(detectAgent()).toBeUndefined(); + }); + + test("a different Cursor role falls through to other agent markers", () => { + withEnv({ CURSOR_EXTENSION_HOST_ROLE: "terminal", CLINE_ACTIVE: "1" }); + expect(detectAgent()).toEqual(named("cline")); + }); + + // ── Gemini ───────────────────────────────────────────────────────── + + test("GEMINI_CLI → gemini", () => { + withEnv({ GEMINI_CLI: "1" }); + expect(detectAgent()).toEqual(named("gemini")); + }); + + // ── Codex ────────────────────────────────────────────────────────── + + test("CODEX_SANDBOX → codex", () => { + withEnv({ CODEX_SANDBOX: "1" }); + expect(detectAgent()).toEqual(named("codex")); + }); + + test("CODEX_CI → codex", () => { + withEnv({ CODEX_CI: "1" }); + expect(detectAgent()).toEqual(named("codex")); + }); + + test("CODEX_THREAD_ID → codex", () => { + withEnv({ CODEX_THREAD_ID: "thread-123" }); + expect(detectAgent()).toEqual(named("codex")); + }); + + // ── Antigravity ──────────────────────────────────────────────────── + + test("ANTIGRAVITY_AGENT → antigravity", () => { + withEnv({ ANTIGRAVITY_AGENT: "1" }); + expect(detectAgent()).toEqual(named("antigravity")); + }); + + // ── Augment ──────────────────────────────────────────────────────── + + test("AUGMENT_AGENT → augment", () => { + withEnv({ AUGMENT_AGENT: "1" }); + expect(detectAgent()).toEqual(named("augment")); + }); + + // ── OpenCode ─────────────────────────────────────────────────────── + + test("OPENCODE_CLIENT → opencode", () => { + withEnv({ OPENCODE_CLIENT: "1" }); + expect(detectAgent()).toEqual(named("opencode")); + }); + + // ── Claude Code ──────────────────────────────────────────────────── + + test("CLAUDE_CODE → claude", () => { + withEnv({ CLAUDE_CODE: "1" }); + expect(detectAgent()).toEqual(named("claude")); + }); + + test("CLAUDECODE → claude", () => { + withEnv({ CLAUDECODE: "1" }); + expect(detectAgent()).toEqual(named("claude")); + }); + + test("CLAUDE_CODE + CLAUDE_CODE_IS_COWORK → cowork", () => { + withEnv({ CLAUDE_CODE: "1", CLAUDE_CODE_IS_COWORK: "1" }); + expect(detectAgent()).toEqual(named("cowork")); + }); + + test("CLAUDECODE + CLAUDE_CODE_IS_COWORK → cowork", () => { + withEnv({ CLAUDECODE: "1", CLAUDE_CODE_IS_COWORK: "1" }); + expect(detectAgent()).toEqual(named("cowork")); + }); + + test("CLAUDE_CODE_IS_COWORK alone does not trigger detection", () => { + withEnv({ CLAUDE_CODE_IS_COWORK: "1" }); + expect(detectAgent()).toBeUndefined(); + }); + + test.each([ + ["GROK_PLUGIN_ROOT", "CLAUDE_CODE", ""], + ["GROK_PLUGIN_DATA", "CLAUDECODE", ""], + ["GROK_PLUGIN_ROOT", "CLAUDECODE", "1"], + ["GROK_PLUGIN_DATA", "CLAUDE_CODE", "1"], + ])("%s takes priority over %s with CLAUDE_CODE_IS_COWORK=%j", (grokVar, claudeVar, cowork) => { + withEnv({ + [grokVar]: "/tmp/grok", + [claudeVar]: "1", + CLAUDE_CODE_IS_COWORK: cowork, + }); + expect(detectAgent()).toEqual(named("grok")); + }); + + // ── Excluded env vars (false positive risks) ────────────────────── + + test("REPL_ID alone does not trigger detection (platform env, not agent signal)", () => { + withEnv({ REPL_ID: "abc123" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("COPILOT_GITHUB_TOKEN alone does not trigger detection (false positive risk)", () => { + withEnv({ COPILOT_GITHUB_TOKEN: "ghu_xxx" }); + expect(detectAgent()).toBeUndefined(); + }); + + test.each([ + ["GOOSE_PROVIDER", "openai"], + ["KIMI_CODE_HOME", "/tmp/kimi"], + ])("%s configuration alone does not trigger detection", (envVar, value) => { + withEnv({ [envVar]: value }); + expect(detectAgent()).toBeUndefined(); + }); + + // ── GitHub Copilot ───────────────────────────────────────────────── + + test("COPILOT_MODEL → github-copilot", () => { + withEnv({ COPILOT_MODEL: "gpt-4" }); + expect(detectAgent()).toEqual(named("github-copilot")); + }); + + test("COPILOT_ALLOW_ALL → github-copilot", () => { + withEnv({ COPILOT_ALLOW_ALL: "1" }); + expect(detectAgent()).toEqual(named("github-copilot")); + }); + + // ── Goose ────────────────────────────────────────────────────────── + + test("GOOSE_TERMINAL → goose", () => { + withEnv({ GOOSE_TERMINAL: "1" }); + expect(detectAgent()).toEqual(named("goose")); + }); + + // ── Amp ──────────────────────────────────────────────────────────── + + test("AMP_THREAD_ID → amp", () => { + withEnv({ AMP_THREAD_ID: "thread-456" }); + expect(detectAgent()).toEqual(named("amp")); + }); + + // ── AGENT generic fallback ───────────────────────────────────────── + + test("AGENT as generic fallback", () => { + withEnv({ AGENT: "some-new-agent" }); + expect(detectAgent()).toEqual(named("some-new-agent")); + }); + + test("AGENT is trimmed", () => { + withEnv({ AGENT: " goose " }); + expect(detectAgent()).toEqual(named("goose")); + }); + + test("AGENT empty string is ignored", () => { + withEnv({ AGENT: "" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("specific env vars take priority over AGENT", () => { + withEnv({ AGENT: "goose", CLAUDE_CODE: "1" }); + expect(detectAgent()).toEqual(named("claude")); + }); + + test("AGENT garbage value '1' becomes unknown", () => { + withEnv({ AGENT: "1" }); + expect(detectAgent()).toEqual(named("unknown")); + }); + + test("AGENT falsy value 'false' is treated as no agent", () => { + withEnv({ AGENT: "false" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AGENT compound value is normalized", () => { + withEnv({ AGENT: "my-agent/1.0.0" }); + expect(detectAgent()).toEqual({ name: "my-agent", version: "1.0.0" }); + }); + + test.each([ + ["claude_code", "claude"], + ["codex_cli", "codex"], + ["gemini_cli", "gemini"], + ["open_code", "opencode"], + ["cursor-cli", "cursor"], + ["augment-cli", "augment"], + ])("%s identifies %s through AI_AGENT and AGENT", (alias, name) => { + for (const envVar of ["AI_AGENT", "AGENT"]) { + withEnv({ [envVar]: `${alias}/1.2.3/agent` }); + expect(detectAgent()).toEqual({ name, version: "1.2.3", role: "agent" }); + } + }); + + // ── No agent ─────────────────────────────────────────────────────── + + test("no env vars → undefined", () => { + withEnv({}); + expect(detectAgent()).toBeUndefined(); + }); +}); + +describe("ENV_VAR_AGENTS map structure", () => { + test("is a Map instance", () => { + expect(ENV_VAR_AGENTS).toBeInstanceOf(Map); + }); + + test("all values are non-empty strings", () => { + for (const [envVar, agent] of ENV_VAR_AGENTS) { + expect(envVar.length).toBeGreaterThan(0); + expect(agent.length).toBeGreaterThan(0); + } + }); + + test("env var keys are UPPER_SNAKE_CASE", () => { + for (const envVar of ENV_VAR_AGENTS.keys()) { + expect(envVar).toMatch(/^[A-Z][A-Z0-9_]*$/); + } + }); + + test("agent names are lowercase with optional hyphens", () => { + for (const agent of ENV_VAR_AGENTS.values()) { + expect(agent).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); +}); + +describe("PROCESS_NAME_AGENTS map structure", () => { + test("is a Map instance", () => { + expect(PROCESS_NAME_AGENTS).toBeInstanceOf(Map); + }); + + test("all keys are lowercase", () => { + for (const name of PROCESS_NAME_AGENTS.keys()) { + expect(name).toBe(name.toLowerCase()); + } + }); + + test("agent names are lowercase with optional hyphens", () => { + for (const agent of PROCESS_NAME_AGENTS.values()) { + expect(agent).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); +}); + +describe("detectAgentFromProcessTree", () => { + afterEach(() => { + setProcessInfoProvider(getProcessInfoFromOS); + }); + + test("returns agent when parent matches", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "cursor", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toEqual(named("cursor")); + }); + + test("walks up to grandparent", async () => { + const shellPid = 100; + setProcessInfoProvider(async (pid) => { + // parent is bash, grandparent is cursor + if (pid === process.ppid) { + return { name: "bash", ppid: shellPid }; + } + if (pid === shellPid) { + return { name: "Cursor", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toEqual(named("cursor")); + }); + + test("case-insensitive matching", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "Claude", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toEqual(named("claude")); + }); + + test("returns undefined when no agent in tree", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "bash", ppid: 1 }; + } + }); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("stops at PID 1 (init/launchd)", async () => { + setProcessInfoProvider(async (pid) => { + if (pid === process.ppid) { + return { name: "bash", ppid: 1 }; + } + // PID 1 should not be checked + if (pid === 1) { + return { name: "cursor", ppid: 0 }; + } + }); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("stops when getProcessInfo returns undefined", async () => { + setProcessInfoProvider(noProcessInfo); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("respects max depth", async () => { + // Create a chain deeper than MAX_ANCESTOR_DEPTH (5) + let nextPid = process.ppid; + const chain = new Map(); + for (let i = 0; i < 10; i++) { + const ppid = nextPid + 1; + chain.set(nextPid, { name: "bash", ppid }); + nextPid = ppid; + } + // Put cursor at depth 8 (beyond the limit) + chain.set(nextPid, { name: "cursor", ppid: 1 }); + + setProcessInfoProvider(async (pid) => chain.get(pid)); + expect(await detectAgentFromProcessTree()).toBeUndefined(); + }); +}); + +describe("normalizeAgent", () => { + test("simple name passes through lowercase", () => { + expect(normalizeAgent("cursor")).toEqual(named("cursor")); + }); + + test("uppercased name is lowercased", () => { + expect(normalizeAgent("Cursor")).toEqual(named("cursor")); + }); + + test("whitespace is trimmed", () => { + expect(normalizeAgent(" cursor ")).toEqual(named("cursor")); + }); + + test("empty string returns undefined", () => { + expect(normalizeAgent("")).toBeUndefined(); + }); + + test("whitespace-only returns undefined", () => { + expect(normalizeAgent(" ")).toBeUndefined(); + }); + + test("claude-code resolves to claude", () => { + expect(normalizeAgent("claude-code")).toEqual(named("claude")); + }); + + test("claudecode resolves to claude", () => { + expect(normalizeAgent("claudecode")).toEqual(named("claude")); + }); + + test("Claude-Code resolves to claude (case-insensitive)", () => { + expect(normalizeAgent("Claude-Code")).toEqual(named("claude")); + }); + + test("name/version extracts both", () => { + expect(normalizeAgent("cursor/1.2.3")).toEqual({ + name: "cursor", + version: "1.2.3", + }); + }); + + test("name/version/role extracts all three", () => { + expect(normalizeAgent("claude-code/2.1.123/agent")).toEqual({ + name: "claude", + version: "2.1.123", + role: "agent", + }); + }); + + test("version with v prefix strips the v", () => { + expect(normalizeAgent("my-agent/v3.0.0")).toEqual({ + name: "my-agent", + version: "3.0.0", + }); + }); + + test("non-semver second segment is ignored", () => { + expect(normalizeAgent("my-agent/not-a-version")).toEqual(named("my-agent")); + }); + + test("single-number version is accepted", () => { + expect(normalizeAgent("agent/42")).toEqual({ + name: "agent", + version: "42", + }); + }); + + test("four-segment version is accepted", () => { + expect(normalizeAgent("agent/1.2.3.4")).toEqual({ + name: "agent", + version: "1.2.3.4", + }); + }); + + test("garbage role is dropped", () => { + expect(normalizeAgent("agent/1.0.0/true")).toEqual({ + name: "agent", + version: "1.0.0", + }); + }); + + test("extra slash segments beyond role are ignored", () => { + expect(normalizeAgent("agent/1.0.0/role/extra")).toEqual({ + name: "agent", + version: "1.0.0", + role: "role", + }); + }); + + test("'1' becomes unknown", () => { + expect(normalizeAgent("1")).toEqual(named("unknown")); + }); + + test("'true' becomes unknown", () => { + expect(normalizeAgent("true")).toEqual(named("unknown")); + }); + + test("'yes' becomes unknown", () => { + expect(normalizeAgent("yes")).toEqual(named("unknown")); + }); + + test("'on' becomes unknown", () => { + expect(normalizeAgent("on")).toEqual(named("unknown")); + }); + + test("'TRUE' (uppercase) becomes unknown", () => { + expect(normalizeAgent("TRUE")).toEqual(named("unknown")); + }); + + test("'0' returns undefined", () => { + expect(normalizeAgent("0")).toBeUndefined(); + }); + + test("'false' returns undefined", () => { + expect(normalizeAgent("false")).toBeUndefined(); + }); + + test("'no' returns undefined", () => { + expect(normalizeAgent("no")).toBeUndefined(); + }); + + test("'off' returns undefined", () => { + expect(normalizeAgent("off")).toBeUndefined(); + }); + + test("'FALSE' (uppercase) returns undefined", () => { + expect(normalizeAgent("FALSE")).toBeUndefined(); + }); +}); + +describe("AGENT_ALIASES map structure", () => { + test("all keys are lowercase", () => { + for (const key of AGENT_ALIASES.keys()) { + expect(key).toBe(key.toLowerCase()); + } + }); + + test("all values are lowercase with optional hyphens", () => { + for (const value of AGENT_ALIASES.values()) { + expect(value).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); + + test("no alias maps to itself", () => { + for (const [key, value] of AGENT_ALIASES) { + expect(key).not.toBe(value); + } + }); +}); + +describe("getProcessInfoFromOS", () => { + test("returns info for own process", async () => { + const info = await getProcessInfoFromOS(process.pid); + expect(info).toBeDefined(); + if (info) { + expect(info.name.length).toBeGreaterThan(0); + expect(info.ppid).toBeGreaterThan(0); + } + }); + + test("returns info for parent process", async () => { + const info = await getProcessInfoFromOS(process.ppid); + expect(info).toBeDefined(); + if (info) { + expect(info.name.length).toBeGreaterThan(0); + expect(info.ppid).toBeGreaterThanOrEqual(0); + } + }); + + test("returns undefined for non-existent PID", async () => { + const info = await getProcessInfoFromOS(99_999_999); + expect(info).toBeUndefined(); + }); + + test("returns undefined for PID 0", async () => { + const info = await getProcessInfoFromOS(0); + expect(info).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/dev-script.property.test.ts b/packages/cli/test/lib/dev-script.property.test.ts new file mode 100644 index 000000000..0e60740dd --- /dev/null +++ b/packages/cli/test/lib/dev-script.property.test.ts @@ -0,0 +1,61 @@ +/** + * Property-based tests for detectDevCommand. + * + * Verifies that any script name in the priority set, when placed in a + * package.json scripts object, is detected by detectDevCommand. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + asyncProperty, + constantFrom, + assert as fcAssert, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { detectDevCommand } from "../../src/lib/dev-script.js"; +import { TEST_TMP_DIR } from "../constants.js"; + +const SCRIPT_NAMES = ["dev", "develop", "serve", "start"] as const; + +/** + * Arbitrary for a non-empty script value containing only safe chars + * (letters, digits, spaces, dashes, dots). Avoids unicode/control chars + * that would break the split assertion or filesystem. + */ +const scriptValueArb = string({ + unit: constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789 -.".split("")), + minLength: 1, + maxLength: 30, +}).filter((s) => s.trim().length > 0); + +describe("property: detectDevCommand", () => { + test("any recognized script name in package.json is detected", async () => { + await fcAssert( + asyncProperty( + constantFrom(...SCRIPT_NAMES), + scriptValueArb, + async (name, value) => { + // Each iteration gets its own directory to avoid cross-contamination + const dir = await mkdtemp(join(TEST_TMP_DIR, "dev-prop-")); + try { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ scripts: { [name]: value } }) + ); + const result = await detectDevCommand(dir); + expect(result).not.toBeNull(); + expect(result!.source).toBe(`package.json scripts.${name}`); + } finally { + // Best-effort cleanup — suppress errors + rm(dir, { recursive: true, force: true }).catch(() => { + /* intentionally empty */ + }); + } + } + ), + { numRuns: 20 } + ); + }); +}); diff --git a/packages/cli/test/lib/dev-script.test.ts b/packages/cli/test/lib/dev-script.test.ts new file mode 100644 index 000000000..0182cf6e6 --- /dev/null +++ b/packages/cli/test/lib/dev-script.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for detectDevCommand. + * + * Note: Core invariants (script priority detection for arbitrary script names) + * are tested via property-based tests in dev-script.property.test.ts. These + * tests focus on filesystem integration, fallback chains, and priority ordering. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { detectDevCommand } from "../../src/lib/dev-script.js"; +import { TEST_TMP_DIR } from "../constants.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(TEST_TMP_DIR, "dev-script-test-")); +}); + +afterEach(async () => { + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +}); + +describe("detectDevCommand", () => { + test("detects package.json scripts.dev", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "next dev" } }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["next", "dev"]); + expect(result!.source).toBe("package.json scripts.dev"); + }); + + test("detects package.json scripts.start when dev is absent", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { start: "node server.js" } }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["node", "server.js"]); + expect(result!.source).toBe("package.json scripts.start"); + }); + + test("falls through package.json with no scripts", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ name: "test", version: "1.0.0" }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).toBeNull(); + }); + + test("detects manage.py (Django)", async () => { + await writeFile(join(tmpDir, "manage.py"), "#!/usr/bin/env python"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["python", "manage.py", "runserver"]); + expect(result!.source).toBe("manage.py"); + }); + + test("detects app.py", async () => { + await writeFile(join(tmpDir, "app.py"), "from flask import Flask"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["python", "app.py"]); + expect(result!.source).toBe("app.py"); + }); + + test("detects main.py", async () => { + await writeFile(join(tmpDir, "main.py"), "print('hello')"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["python", "main.py"]); + expect(result!.source).toBe("main.py"); + }); + + test("detects go.mod", async () => { + await writeFile(join(tmpDir, "go.mod"), "module example.com/myapp"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["go", "run", "."]); + expect(result!.source).toBe("go.mod"); + }); + + test("detects docker-compose.yml", async () => { + await writeFile(join(tmpDir, "docker-compose.yml"), "version: '3'"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["docker", "compose", "up"]); + expect(result!.source).toBe("docker-compose.yml"); + }); + + test("detects compose.yml", async () => { + await writeFile(join(tmpDir, "compose.yml"), "version: '3'"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["docker", "compose", "up"]); + expect(result!.source).toBe("compose.yml"); + }); + + test("returns null for empty directory", async () => { + const result = await detectDevCommand(tmpDir); + expect(result).toBeNull(); + }); + + test("package.json takes priority over manage.py", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "vite" } }) + ); + await writeFile(join(tmpDir, "manage.py"), "#!/usr/bin/env python"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.source).toBe("package.json scripts.dev"); + }); + + test("prefers dev over start in package.json", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { start: "node index.js", dev: "vite" } }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.source).toBe("package.json scripts.dev"); + expect(result!.args).toEqual(["vite"]); + }); + + test("prefers develop over serve", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ + scripts: { serve: "serve dist", develop: "gatsby develop" }, + }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.source).toBe("package.json scripts.develop"); + }); +}); diff --git a/packages/cli/test/lib/dif/embedded-ppdb.test.ts b/packages/cli/test/lib/dif/embedded-ppdb.test.ts new file mode 100644 index 000000000..984693333 --- /dev/null +++ b/packages/cli/test/lib/dif/embedded-ppdb.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for embedded Portable PDB extraction from managed PE assemblies. + * + * Uses small committed .NET PE fixtures (from the Sentry sample console app): + * one that embeds a Portable PDB and one that does not. Extraction runs through + * the `@sentry/symbolic` WASM module, so this also exercises the `asPe()` → + * `embeddedPpdb()` binding end-to-end. + */ + +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import { + extractEmbeddedPpdb, + parseDebugFile, +} from "../../../src/lib/dif/index.js"; + +/** Read a committed binary DIF fixture as raw bytes. */ +function readFixture(name: string): Uint8Array { + return new Uint8Array( + readFileSync(new URL(`../../fixtures/dif/${name}`, import.meta.url)) + ); +} + +/** Debug id of the managed PE fixture (shared by its embedded Portable PDB). */ +const EMBEDDED_PE_DEBUG_ID = "d8eb7dca-4883-4b10-a1f7-048ea1ea388b-cfb0fc89"; + +describe("extractEmbeddedPpdb", () => { + test("extracts an embedded Portable PDB from a managed PE", () => { + const result = extractEmbeddedPpdb(readFixture("embedded-ppdb.dll")); + expect(result).not.toBeNull(); + expect(result?.debugId).toBe(EMBEDDED_PE_DEBUG_ID); + expect(result?.ppdb.byteLength).toBeGreaterThan(0); + }); + + test("the extracted bytes are themselves a standalone Portable PDB", () => { + const result = extractEmbeddedPpdb(readFixture("embedded-ppdb.dll")); + expect(result).not.toBeNull(); + if (!result) { + return; + } + const parsed = parseDebugFile(result.ppdb); + expect(parsed.fileFormat).toBe("portablepdb"); + // The embedded PPDB carries the same debug id as its parent PE, which is + // why using the PE's debug id as the extracted DIF's advisory id is correct. + expect(parsed.objects[0]?.debugId).toBe(EMBEDDED_PE_DEBUG_ID); + }); + + test("returns null for a PE without an embedded Portable PDB", () => { + expect(extractEmbeddedPpdb(readFixture("pe-no-ppdb.dll"))).toBeNull(); + }); + + test("returns null for a non-PE object", () => { + const breakpad = new TextEncoder().encode( + [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "FUNC 1000 10 0 main", + ].join("\n") + ); + expect(extractEmbeddedPpdb(breakpad)).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/dif/find.test.ts b/packages/cli/test/lib/dif/find.test.ts new file mode 100644 index 000000000..41f9c9727 --- /dev/null +++ b/packages/cli/test/lib/dif/find.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for `debug-files find` search logic. + * + * Uses the committed PE fixture (a real debug id) and a generated ProGuard + * mapping (deterministic UUID) — no network, no mocks of the parser. + */ + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { findDebugFiles, idHint } from "../../../src/lib/dif/find.js"; +import { computeProguardUuid } from "../../../src/lib/proguard.js"; + +const FIXTURES = join(process.cwd(), "test/fixtures/dif"); +// The committed PE fixture's embedded debug id (see check output). +const PE_ID = "d8eb7dca-4883-4b10-a1f7-048ea1ea388b-cfb0fc89"; + +const dirs: string[] = []; +afterEach(() => { + while (dirs.length > 0) { + const d = dirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } +}); + +describe("idHint", () => { + test("classifies ids by shape", () => { + expect(idHint("00000000-0000-6000-0000-000000000000-1")).toBe("likely PDB"); + expect(idHint("00000000-0000-5000-0000-000000000000")).toBe( + "likely Proguard" + ); + expect(idHint("00000000-0000-3000-0000-000000000000")).toBe("likely dSYM"); + expect(idHint("00000000-0000-4000-0000-000000000000")).toBe("unknown"); + expect(idHint("00000000-0000-0000-0000-000000000000")).toBe( + "likely ELF Debug" + ); + }); +}); + +describe("findDebugFiles", () => { + test("locates a PE by its exact debug id", async () => { + const result = await findDebugFiles({ ids: [PE_ID], paths: [FIXTURES] }); + expect(result.missing).toEqual([]); + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ type: "pe", id: PE_ID }); + expect(result.matches[0].path).toContain("embedded-ppdb.dll"); + }); + + test("reports an unmatched id as missing with a hint", async () => { + const missingId = "ffffffff-0000-0000-0000-000000000000"; + const result = await findDebugFiles({ + ids: [missingId], + paths: [FIXTURES], + }); + expect(result.matches).toEqual([]); + expect(result.missing).toEqual([ + { id: missingId, hint: "likely ELF Debug" }, + ]); + }); + + test("a type filter that excludes PE finds nothing", async () => { + const result = await findDebugFiles({ + ids: [PE_ID], + types: ["elf"], + paths: [FIXTURES], + }); + expect(result.matches).toEqual([]); + expect(result.missing.map((m) => m.id)).toEqual([PE_ID]); + }); + + test("locates a ProGuard mapping by its computed UUID", async () => { + const dir = mkdtempSync(join(tmpdir(), "find-pg-")); + dirs.push(dir); + const mapping = + "com.example.Foo -> a.a:\n" + + " int field -> a\n" + + " void method() -> a\n"; + const buf = Buffer.from(mapping); + writeFileSync(join(dir, "mapping.txt"), buf); + const uuid = computeProguardUuid(buf); + + const result = await findDebugFiles({ + ids: [uuid], + types: ["proguard"], + paths: [dir], + }); + expect(result.missing).toEqual([]); + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ type: "proguard", id: uuid }); + }); + + test("de-duplicates repeated search paths", async () => { + const result = await findDebugFiles({ + ids: [PE_ID], + paths: [FIXTURES, FIXTURES], + }); + expect(result.matches).toHaveLength(1); + }); + + test("a real match after a breakpad clears the id from missing", async () => { + // Construct a breakpad whose debug id equals a ProGuard mapping's UUID, so + // the same id is matched by both a breakpad (does not satisfy) and a real + // ProGuard file (satisfies). The id must not remain "missing". + const dir = mkdtempSync(join(tmpdir(), "find-bp-")); + dirs.push(dir); + const mapping = "com.example.Foo -> a.a:\n int field -> a\n"; + const uuid = computeProguardUuid(Buffer.from(mapping)); + const hex = uuid.replaceAll("-", "").toUpperCase(); + // `a.sym` sorts before `z.txt`, so the breakpad is seen first. + writeFileSync( + join(dir, "a.sym"), + `MODULE Linux x86_64 ${hex}0 example\nFUNC 1000 10 0 main\n` + ); + writeFileSync(join(dir, "z.txt"), mapping); + + const result = await findDebugFiles({ + ids: [uuid], + types: ["breakpad", "proguard"], + paths: [dir], + }); + expect(result.missing).toEqual([]); + expect(result.matches.map((m) => m.type).sort()).toEqual([ + "breakpad", + "proguard", + ]); + }); + + test("labels a jvm-only search as jvm, not sourcebundle", async () => { + // sourcebundle + jvm share a format; the requested type drives the label. + // (No sourcebundle fixture is needed to verify the mapping choice.) + const noneForElf = await findDebugFiles({ + ids: [PE_ID], + types: ["jvm"], + paths: [FIXTURES], + }); + // PE isn't a sourcebundle, so nothing matches — but the search must not throw + // and the jvm type must be accepted. + expect(noneForElf.missing.map((m) => m.id)).toEqual([PE_ID]); + }); +}); diff --git a/packages/cli/test/lib/dif/il2cpp.test.ts b/packages/cli/test/lib/dif/il2cpp.test.ts new file mode 100644 index 000000000..01ac8bb78 --- /dev/null +++ b/packages/cli/test/lib/dif/il2cpp.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for Unity IL2CPP line-mapping extraction and IL2CPP source collection. + * + * Uses text Breakpad fixtures (which reference source files via `FILE` records) + * plus synthetic C++/C# provider content, so no binary fixtures are needed. The + * extraction runs through the `@sentry/symbolic` WASM module. + */ + +import { unzipSync } from "fflate"; +import { describe, expect, test } from "vitest"; +import { + createIl2cppLineMapping, + createSourceBundle, +} from "../../../src/lib/dif/index.js"; + +const CPP_PATH = "/src/Game.cpp"; +const CS_PATH = "/src/Game.cs"; +const KNOWN_DEBUG_ID = "0f13a5da-412a-fbf7-c866-2048f3294f3d"; + +/** Breakpad object referencing one C++ source file via a FILE record. */ +const BREAKPAD_WITH_CPP = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + `FILE 0 ${CPP_PATH}`, + "FUNC 1000 10 0 main", + "1000 10 42 0", +].join("\n"); + +/** Generated C++ carrying an IL2CPP source_info marker mapping to a C# file. */ +const CPP_WITH_SOURCE_INFO = `//\nint generated = 0;\n`; + +function bytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +describe("createIl2cppLineMapping", () => { + test("computes a mapping from source_info markers via the provider", () => { + const calls: string[] = []; + const result = createIl2cppLineMapping(bytes(BREAKPAD_WITH_CPP), (path) => { + calls.push(path); + return bytes(CPP_WITH_SOURCE_INFO); + }); + expect(calls).toContain(CPP_PATH); + expect(result).not.toBeNull(); + if (!result) { + return; + } + expect(result.debugId).toBe(KNOWN_DEBUG_ID); + const doc = JSON.parse(new TextDecoder().decode(result.mapping)) as Record< + string, + Record + >; + expect(doc[CPP_PATH]?.[CS_PATH]).toBeDefined(); + }); + + test("returns the mapped object's own debug id when targeting by id", () => { + // The returned debug id must always describe the object the mapping came + // from, so the uploaded DIF can be stamped with a matching id. + const result = createIl2cppLineMapping( + bytes(BREAKPAD_WITH_CPP), + () => bytes(CPP_WITH_SOURCE_INFO), + KNOWN_DEBUG_ID + ); + expect(result?.debugId).toBe(KNOWN_DEBUG_ID); + }); + + test("falls back to the primary object when targetDebugId is not found", () => { + const result = createIl2cppLineMapping( + bytes(BREAKPAD_WITH_CPP), + () => bytes(CPP_WITH_SOURCE_INFO), + "ffffffff-ffff-ffff-ffff-ffffffffffff" + ); + expect(result?.debugId).toBe(KNOWN_DEBUG_ID); + }); + + test("returns null when no referenced source is available", () => { + expect( + createIl2cppLineMapping(bytes(BREAKPAD_WITH_CPP), () => null) + ).toBeNull(); + }); + + test("returns null when the C++ has no source_info markers", () => { + expect( + createIl2cppLineMapping(bytes(BREAKPAD_WITH_CPP), () => + bytes("int plain = 0;\n") + ) + ).toBeNull(); + }); +}); + +describe("createSourceBundle collectIl2cppSources", () => { + const sources: Record = { + [CPP_PATH]: bytes(CPP_WITH_SOURCE_INFO), + [CS_PATH]: bytes("class Game {}\n"), + }; + const read = (p: string): Uint8Array | null => sources[p] ?? null; + + function bundleFiles(bundle: Uint8Array | null): string[] { + if (!bundle) { + return []; + } + return Object.keys(unzipSync(bundle)).filter((f) => f.startsWith("files/")); + } + + test("includes referenced C# sources when enabled", () => { + const result = createSourceBundle( + bytes(BREAKPAD_WITH_CPP), + "example", + read, + { + collectIl2cppSources: true, + } + ); + const files = bundleFiles(result.bundle); + expect(files).toContain(`files${CPP_PATH}`); + expect(files).toContain(`files${CS_PATH}`); + }); + + test("omits C# sources when disabled", () => { + const result = createSourceBundle( + bytes(BREAKPAD_WITH_CPP), + "example", + read + ); + const files = bundleFiles(result.bundle); + expect(files).toContain(`files${CPP_PATH}`); + expect(files).not.toContain(`files${CS_PATH}`); + }); +}); diff --git a/packages/cli/test/lib/dif/index.test.ts b/packages/cli/test/lib/dif/index.test.ts new file mode 100644 index 000000000..c493be2c1 --- /dev/null +++ b/packages/cli/test/lib/dif/index.test.ts @@ -0,0 +1,208 @@ +/** + * Tests for the DIF (debug information file) parser. + * + * Uses Breakpad symbol files as fixtures because they are a deterministic, + * portable TEXT format — no committed binary fixtures or platform-specific + * system binaries required. The same WASM code path parses Mach-O/ELF/PE/PDB. + */ + +import { describe, expect, test } from "vitest"; +import { + createSourceBundle, + listSources, + parseDebugFile, + peekFormat, + selectBundledObject, +} from "../../../src/lib/dif/index.js"; + +/** A minimal, valid Breakpad symbol file with a known debug id + code id. */ +const BREAKPAD_FIXTURE = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + "FUNC 1000 10 0 main", + "1000 10 42 1", + "PUBLIC 2000 0 some_symbol", +].join("\n"); + +/** Breakpad file that references one source file (FILE 0) via a line record. */ +const BREAKPAD_WITH_SOURCE = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + "FILE 0 /src/example.c", + "FUNC 1000 10 0 main", + "1000 10 42 0", +].join("\n"); + +function toBytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +describe("peekFormat", () => { + test("detects breakpad", () => { + expect(peekFormat(toBytes(BREAKPAD_FIXTURE))).toBe("breakpad"); + }); + + test("returns unknown for unrecognized data", () => { + expect(peekFormat(toBytes("not an object file"))).toBe("unknown"); + }); + + test("returns unknown for empty input", () => { + expect(peekFormat(new Uint8Array())).toBe("unknown"); + }); +}); + +describe("parseDebugFile", () => { + test("extracts metadata from a Breakpad file", () => { + const archive = parseDebugFile(toBytes(BREAKPAD_FIXTURE)); + expect(archive.fileFormat).toBe("breakpad"); + expect(archive.objects).toHaveLength(1); + + const obj = archive.objects[0]; + expect(obj).toBeDefined(); + expect(obj?.debugId).toBe("0f13a5da-412a-fbf7-c866-2048f3294f3d"); + expect(obj?.codeId).toBe("daa5130f2a41f7fbc8662048f3294f3d439ca7ff"); + expect(obj?.arch).toBe("x86_64"); + expect(obj?.fileFormat).toBe("breakpad"); + expect(obj?.hasSymbols).toBe(true); + }); + + test("normalizes the debug id to lowercase UUID form", () => { + const archive = parseDebugFile(toBytes(BREAKPAD_FIXTURE)); + expect(archive.objects[0]?.debugId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + }); + + test("is deterministic for the same input", () => { + const a = parseDebugFile(toBytes(BREAKPAD_FIXTURE)); + const b = parseDebugFile(toBytes(BREAKPAD_FIXTURE)); + expect(a).toEqual(b); + }); + + test("throws on unrecognized data", () => { + expect(() => parseDebugFile(toBytes("not an object file"))).toThrow(); + }); + + test("throws on empty input", () => { + expect(() => parseDebugFile(new Uint8Array())).toThrow(); + }); + + test("renders a nil debug id as the hyphenated nil UUID", () => { + // Guards the check command's nil-id sentinel against format drift. + const archive = parseDebugFile( + toBytes("MODULE Linux x86_64 000000000000000000000000000000000 x") + ); + expect(archive.objects[0]?.debugId).toBe( + "00000000-0000-0000-0000-000000000000" + ); + }); +}); + +describe("createSourceBundle", () => { + test("bundles a referenced source file supplied by the provider", () => { + const requested: string[] = []; + const result = createSourceBundle( + toBytes(BREAKPAD_WITH_SOURCE), + "example", + (path) => { + requested.push(path); + return toBytes(`// ${path}`); + } + ); + + expect(requested).toContain("/src/example.c"); + expect(result.fileCount).toBe(1); + expect(result.debugId).toBe("0f13a5da-412a-fbf7-c866-2048f3294f3d"); + expect(result.bundle).toBeInstanceOf(Uint8Array); + expect((result.bundle as Uint8Array).length).toBeGreaterThan(0); + }); + + test("produces no bundle when the provider supplies nothing", () => { + const result = createSourceBundle( + toBytes(BREAKPAD_WITH_SOURCE), + "example", + () => null + ); + expect(result.fileCount).toBe(0); + expect(result.bundle).toBeNull(); + }); + + test("produces no bundle when the object references no sources", () => { + const result = createSourceBundle( + toBytes(BREAKPAD_FIXTURE), + "example", + () => toBytes("unused") + ); + expect(result.fileCount).toBe(0); + expect(result.bundle).toBeNull(); + }); + + test("surfaces a provider error as a throw (not a silent partial bundle)", () => { + // The error crosses the wasm boundary, so the original message isn't + // preserved; what matters is that it throws rather than skipping silently. + expect(() => + createSourceBundle(toBytes(BREAKPAD_WITH_SOURCE), "example", () => { + throw new Error("read failed"); + }) + ).toThrow(); + }); + + test("throws on unrecognized data", () => { + expect(() => + createSourceBundle(toBytes("not an object file"), "x", () => null) + ).toThrow(); + }); +}); + +describe("listSources", () => { + test("lists the source files an object references", () => { + const info = listSources(toBytes(BREAKPAD_WITH_SOURCE)); + expect(info.objects).toHaveLength(1); + + const object = info.objects[0]; + expect(object?.debugId).toBe("0f13a5da-412a-fbf7-c866-2048f3294f3d"); + expect(object?.fileFormat).toBe("breakpad"); + expect(object?.hasDebugInfo).toBe(true); + expect(object?.enumerationError).toBeNull(); + expect(object?.files).toHaveLength(1); + + const file = object?.files[0]; + expect(file?.path).toBe("/src/example.c"); + // Breakpad references files but embeds no source content. + expect(file?.resolved).toBe(false); + expect(file?.type).toBeNull(); + }); + + test("returns an empty file list for an object with no referenced sources", () => { + const info = listSources(toBytes(BREAKPAD_FIXTURE)); + expect(info.objects).toHaveLength(1); + expect(info.objects[0]?.files).toHaveLength(0); + }); + + test("throws on unrecognized data", () => { + expect(() => listSources(toBytes("not an object file"))).toThrow(); + }); +}); + +describe("selectBundledObject", () => { + test("prefers the first object that carries debug info", () => { + const objects = [ + { hasDebugInfo: false, id: "a" }, + { hasDebugInfo: true, id: "b" }, + { hasDebugInfo: true, id: "c" }, + ]; + expect(selectBundledObject(objects)?.id).toBe("b"); + }); + + test("falls back to the first object when none carry debug info", () => { + const objects = [ + { hasDebugInfo: false, id: "a" }, + { hasDebugInfo: false, id: "b" }, + ]; + expect(selectBundledObject(objects)?.id).toBe("a"); + }); + + test("returns undefined for an empty archive", () => { + expect(selectBundledObject([])).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/dif/scan.property.test.ts b/packages/cli/test/lib/dif/scan.property.test.ts new file mode 100644 index 000000000..6bc8b385e --- /dev/null +++ b/packages/cli/test/lib/dif/scan.property.test.ts @@ -0,0 +1,247 @@ +/** + * Property-based tests for `debug-files upload` scanning filters. + * + * Verifies the pure filter predicates in `src/lib/dif/scan.ts` + * ({@link objectPassesFilters}, {@link normalizeDebugId}, {@link buildDifFilters}) + * hold for arbitrary parsed-object inputs, without touching the filesystem. + */ + +import { + type Arbitrary, + boolean, + constant, + constantFrom, + assert as fcAssert, + oneof, + property, + record, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import type { DifObjectInfo } from "../../../src/lib/dif/index.js"; +import { + buildDifFilters, + type DifFilters, + debugIdMatches, + normalizeDebugId, + objectPassesFilters, + VALID_DIF_TYPES, +} from "../../../src/lib/dif/scan.js"; +import { ValidationError } from "../../../src/lib/errors.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +const NIL_DEBUG_ID = "00000000-0000-0000-0000-000000000000"; + +const hexGroup = (length: number) => + tuple( + ...Array.from({ length }, () => constantFrom(..."0123456789abcdef")) + ).map((chars) => chars.join("")); + +/** A non-nil UUID (8-4-4-4-12). May coincide with nil with negligible odds. */ +const uuidArb = tuple( + hexGroup(8), + hexGroup(4), + hexGroup(4), + hexGroup(4), + hexGroup(12) +).map((parts) => parts.join("-")); + +/** A debug id: valid UUID, UUID+age suffix, or the nil id. */ +const debugIdArb = oneof( + uuidArb, + tuple(uuidArb, hexGroup(8)).map(([id, age]) => `${id}-${age}`), + constant(NIL_DEBUG_ID) +); + +const formatArb = constantFrom( + "elf", + "macho", + "pe", + "pdb", + "portablepdb", + "wasm", + "breakpad", + "sourcebundle", + "unknown" +); + +const difObjectArb: Arbitrary = record({ + debugId: debugIdArb, + codeId: oneof(constant(null), hexGroup(16)), + arch: constant("x86_64"), + fileFormat: formatArb, + kind: constant("dbg"), + hasSymbols: boolean(), + hasDebugInfo: boolean(), + hasUnwindInfo: boolean(), + hasSources: boolean(), +}); + +/** The default filter set: every feature wanted, no type/id restriction. */ +const defaultFilters = (): DifFilters => buildDifFilters({}); + +describe("property: normalizeDebugId", () => { + test("is idempotent", () => { + fcAssert( + property(debugIdArb, (id) => { + const once = normalizeDebugId(id); + expect(normalizeDebugId(once)).toBe(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is lowercase and brace-free", () => { + fcAssert( + property(debugIdArb, (id) => { + const wrapped = `{${id.toUpperCase()}}`; + const normalized = normalizeDebugId(wrapped); + expect(normalized).toBe(normalized.toLowerCase()); + expect(normalized).not.toContain("{"); + expect(normalized).not.toContain("}"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: objectPassesFilters", () => { + test("a nil debug id never passes, regardless of filters", () => { + fcAssert( + property(difObjectArb, (obj) => { + const nilObj = { ...obj, debugId: NIL_DEBUG_ID }; + expect(objectPassesFilters(nilObj, defaultFilters())).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("an object with no features never passes", () => { + fcAssert( + property(difObjectArb, (obj) => { + const featureless = { + ...obj, + hasSymbols: false, + hasDebugInfo: false, + hasUnwindInfo: false, + hasSources: false, + }; + expect(objectPassesFilters(featureless, defaultFilters())).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("dropping every feature filter rejects everything", () => { + const noFeatures = buildDifFilters({ + noDebug: true, + noUnwind: true, + noSources: true, + }); + fcAssert( + property(difObjectArb, (obj) => { + expect(objectPassesFilters(obj, noFeatures)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("a type filter excludes objects of other formats", () => { + fcAssert( + property(difObjectArb, (obj) => { + // Filter for ELF only; any non-ELF, non-nil object must be excluded. + const elfOnly = buildDifFilters({ types: ["elf"] }); + if (obj.fileFormat !== "elf") { + expect(objectPassesFilters(obj, elfOnly)).toBe(false); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("a valid id + at least one feature passes with default filters", () => { + fcAssert( + property(uuidArb, constantFrom(0, 1, 2, 3), (id, featureIdx) => { + const obj: DifObjectInfo = { + debugId: id, + codeId: null, + arch: "x86_64", + fileFormat: "elf", + kind: "dbg", + hasSymbols: featureIdx === 0, + hasDebugInfo: featureIdx === 1, + hasUnwindInfo: featureIdx === 2, + hasSources: featureIdx === 3, + }; + expect(objectPassesFilters(obj, defaultFilters())).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("an id filter matches regardless of age suffix", () => { + fcAssert( + property(uuidArb, hexGroup(8), (id, age) => { + const obj: DifObjectInfo = { + debugId: `${id}-${age}`, + codeId: null, + arch: "x86_64", + fileFormat: "pe", + kind: "dbg", + hasSymbols: true, + hasDebugInfo: false, + hasUnwindInfo: false, + hasSources: false, + }; + // Requesting the base UUID (no age) still matches the aged object. + const idFilter = buildDifFilters({ ids: [id] }); + expect(objectPassesFilters(obj, idFilter)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: debugIdMatches", () => { + test("is reflexive and case/brace insensitive", () => { + fcAssert( + property(debugIdArb, (id) => { + expect(debugIdMatches(id, id)).toBe(true); + expect(debugIdMatches(id, `{${id.toUpperCase()}}`)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("a base UUID matches its aged form (PE/PDB)", () => { + fcAssert( + property(uuidArb, hexGroup(8), (id, age) => { + expect(debugIdMatches(id, `${id}-${age}`)).toBe(true); + expect(debugIdMatches(`${id}-${age}`, id)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildDifFilters", () => { + test("accepts every documented type", () => { + for (const type of VALID_DIF_TYPES) { + expect(() => buildDifFilters({ types: [type] })).not.toThrow(); + } + }); + + test("throws ValidationError on an unknown type", () => { + expect(() => buildDifFilters({ types: ["bogus"] })).toThrow( + ValidationError + ); + }); + + test("--no-debug drops both debug and symtab features", () => { + const filters = buildDifFilters({ noDebug: true }); + expect(filters.debug).toBe(false); + expect(filters.symtab).toBe(false); + expect(filters.unwind).toBe(true); + expect(filters.sources).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/dif/scan.test.ts b/packages/cli/test/lib/dif/scan.test.ts new file mode 100644 index 000000000..2626c19a5 --- /dev/null +++ b/packages/cli/test/lib/dif/scan.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for the DIF scanner's I/O paths. + * + * Core filter invariants (format/id/feature matching, debug-id normalization) + * are covered by the property-based tests in scan.property.test.ts. These tests + * focus on filesystem behavior the property generators cannot express: the + * `prepareDifs` size gate. + */ + +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + buildDifFilters, + prepareDifs, + scanPaths, +} from "../../../src/lib/dif/scan.js"; + +/** A minimal, valid Breakpad symbol file with a known debug id. */ +const BREAKPAD_FIXTURE = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + "FUNC 1000 10 0 main", + "1000 10 42 1", + "PUBLIC 2000 0 some_symbol", +].join("\n"); + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "df-scan-test-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("prepareDifs size gate", () => { + test("keeps a file within the size limit", async () => { + const path = join(tempDir, "ok.sym"); + await writeFile(path, BREAKPAD_FIXTURE); + const files = await scanPaths([path]); + const { prepared, oversizedCount } = await prepareDifs( + files, + buildDifFilters({}), + { maxFileSize: 10 * 1024 } + ); + expect(prepared).toHaveLength(1); + expect(oversizedCount).toBe(0); + }); + + test("skips a file larger than the size limit and counts it", async () => { + const path = join(tempDir, "big.sym"); + await writeFile(path, BREAKPAD_FIXTURE); + const files = await scanPaths([path]); + // The fixture is well over 1 byte, so a 1-byte cap drops it. + const { prepared, oversizedCount } = await prepareDifs( + files, + buildDifFilters({}), + { maxFileSize: 1 } + ); + expect(prepared).toHaveLength(0); + expect(oversizedCount).toBe(1); + }); + + test("no size limit (0/omitted) keeps the file", async () => { + const path = join(tempDir, "nolimit.sym"); + await writeFile(path, BREAKPAD_FIXTURE); + const files = await scanPaths([path]); + const { prepared, oversizedCount } = await prepareDifs( + files, + buildDifFilters({}) + ); + expect(prepared).toHaveLength(1); + expect(oversizedCount).toBe(0); + }); + + test("does not count an oversized file of an unrequested --type", async () => { + const path = join(tempDir, "big.sym"); + await writeFile(path, BREAKPAD_FIXTURE); + const files = await scanPaths([path]); + // The fixture is a Breakpad file; with --type elf it is filtered out by the + // header format check before the size gate, so it must not be counted as + // oversized (which would otherwise misreport "all matched files too large"). + const { prepared, oversizedCount } = await prepareDifs( + files, + buildDifFilters({ types: ["elf"] }), + { maxFileSize: 1 } + ); + expect(prepared).toHaveLength(0); + expect(oversizedCount).toBe(0); + }); + + test("counts an oversized file that matches the requested --type", async () => { + const path = join(tempDir, "big.sym"); + await writeFile(path, BREAKPAD_FIXTURE); + const files = await scanPaths([path]); + const { prepared, oversizedCount } = await prepareDifs( + files, + buildDifFilters({ types: ["breakpad"] }), + { maxFileSize: 1 } + ); + expect(prepared).toHaveLength(0); + expect(oversizedCount).toBe(1); + }); +}); + +describe("scanPaths traversal", () => { + /** Map a list of file paths to their basenames for order-independent checks. */ + const names = (paths: string[]): string[] => paths.map((p) => basename(p)); + + test("walks a directory recursively and returns nested files", async () => { + await mkdir(join(tempDir, "sub"), { recursive: true }); + await writeFile(join(tempDir, "top.sym"), BREAKPAD_FIXTURE); + await writeFile(join(tempDir, "sub", "nested.sym"), BREAKPAD_FIXTURE); + + const files = await scanPaths([tempDir]); + + expect(names(files).sort()).toEqual(["nested.sym", "top.sym"]); + }); + + test("keeps an explicit file argument as-is", async () => { + const path = join(tempDir, "explicit.sym"); + await writeFile(path, BREAKPAD_FIXTURE); + + const files = await scanPaths([path]); + + expect(files).toEqual([path]); + }); + + test("discovers files under .gitignore'd and node_modules directories", async () => { + // The walker's DSN-tuned defaults (respectGitignore + build-output + // skip-dirs) would hide these; the DIF scanner must disable both because + // debug files routinely live in gitignored build output. + await writeFile(join(tempDir, ".gitignore"), "build/\nnode_modules/\n"); + await mkdir(join(tempDir, "build"), { recursive: true }); + await mkdir(join(tempDir, "node_modules"), { recursive: true }); + await writeFile(join(tempDir, "build", "ignored.sym"), BREAKPAD_FIXTURE); + await writeFile(join(tempDir, "node_modules", "dep.sym"), BREAKPAD_FIXTURE); + + const found = names(await scanPaths([tempDir])); + + expect(found).toContain("ignored.sym"); + expect(found).toContain("dep.sym"); + }); + + test("dedupes files reachable via overlapping arguments", async () => { + await mkdir(join(tempDir, "sub"), { recursive: true }); + await writeFile(join(tempDir, "sub", "dup.sym"), BREAKPAD_FIXTURE); + + // The same file is reachable both via the parent dir walk and the explicit + // nested-dir walk; it must appear exactly once. + const files = await scanPaths([tempDir, join(tempDir, "sub")]); + + expect(names(files).filter((n) => n === "dup.sym")).toHaveLength(1); + }); + + test("is cycle-safe with a directory symlink loop", async () => { + await mkdir(join(tempDir, "a"), { recursive: true }); + await writeFile(join(tempDir, "a", "real.sym"), BREAKPAD_FIXTURE); + // A self-referential symlink would loop forever without cycle detection. + await symlink(join(tempDir, "a"), join(tempDir, "a", "loop")); + + const found = names(await scanPaths([tempDir])); + + expect(found).toContain("real.sym"); + }); + + test("throws ValidationError for a non-existent path", async () => { + await expect(scanPaths([join(tempDir, "does-not-exist")])).rejects.toThrow( + /does not exist/ + ); + }); +}); diff --git a/packages/cli/test/lib/dif/zip.test.ts b/packages/cli/test/lib/dif/zip.test.ts new file mode 100644 index 000000000..d21860607 --- /dev/null +++ b/packages/cli/test/lib/dif/zip.test.ts @@ -0,0 +1,288 @@ +/** + * Unit tests for ZIP archive scanning in the DIF scanner. + * + * Covers {@link readZipDifEntries} directly (magic/extension detection, the + * per-entry / cumulative / container size gates, unsupported-compression + * handling, malformed input) and the end-to-end `prepareDifs` integration + * (entries found, no nested-archive recursion, `scanZips` toggle, advisory + * oversized accounting). Archives are built in memory with `fflate.zipSync` + * (with a header-patching helper for unsupported compression) so no binary + * fixtures are needed. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { zipSync } from "fflate"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + buildDifFilters, + prepareDifs, + scanPaths, +} from "../../../src/lib/dif/scan.js"; +import { readZipDifEntries } from "../../../src/lib/dif/zip.js"; + +/** A minimal, valid Breakpad symbol file with a known debug id. */ +const BREAKPAD_FIXTURE = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + "FUNC 1000 10 0 main", + "1000 10 42 1", + "PUBLIC 2000 0 some_symbol", +].join("\n"); + +const BREAKPAD_BYTES = new TextEncoder().encode(BREAKPAD_FIXTURE); + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "df-zip-test-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +/** Write a ZIP built from `entries` to `name` under the temp dir, return path. */ +async function writeZip( + name: string, + entries: Record +): Promise { + const path = join(tempDir, name); + await writeFile(path, zipSync(entries)); + return path; +} + +/** + * Overwrite the 2-byte compression-method field of `targetName` (in both its + * local file header and central directory record) with `method`, producing an + * archive fflate cannot inflate for that one entry. Offsets follow the ZIP + * spec: LFH method @+8 / fnLen @+26 / name @+30; CDH method @+10 / fnLen @+28 / + * name @+46. + */ +function corruptCompressionMethod( + zip: Uint8Array, + targetName: string, + method: number +): Uint8Array { + const out = new Uint8Array(zip); + const target = new TextEncoder().encode(targetName); + const nameMatchesAt = (start: number): boolean => { + if (start + target.length > out.length) { + return false; + } + for (let k = 0; k < target.length; k++) { + if (out[start + k] !== target[k]) { + return false; + } + } + return true; + }; + // Little-endian 16-bit read/write via arithmetic (Biome bans bitwise ops). + const readU16 = (offset: number): number => + (out[offset] ?? 0) + (out[offset + 1] ?? 0) * 256; + const setMethod = (offset: number) => { + out[offset] = method % 256; + out[offset + 1] = Math.floor(method / 256) % 256; + }; + for (let i = 0; i + 4 <= out.length; i++) { + if (out[i] !== 0x50 || out[i + 1] !== 0x4b) { + continue; + } + if (out[i + 2] === 0x03 && out[i + 3] === 0x04) { + if (readU16(i + 26) === target.length && nameMatchesAt(i + 30)) { + setMethod(i + 8); + } + } else if ( + out[i + 2] === 0x01 && + out[i + 3] === 0x02 && + readU16(i + 28) === target.length && + nameMatchesAt(i + 46) + ) { + setMethod(i + 10); + } + } + return out; +} + +describe("readZipDifEntries", () => { + test("returns null for a non-.zip extension", async () => { + const path = join(tempDir, "data.bin"); + await writeFile(path, zipSync({ "example.sym": BREAKPAD_BYTES })); + expect(await readZipDifEntries(path)).toBeNull(); + }); + + test("returns null for a .zip extension without PK magic", async () => { + const path = join(tempDir, "notzip.zip"); + await writeFile(path, BREAKPAD_BYTES); + expect(await readZipDifEntries(path)).toBeNull(); + }); + + test("does not treat a source bundle (SYSB header) as a container", async () => { + // symbolic source bundles (incl. JVM bundles, .src.zip) are a ZIP archive + // preceded by an 8-byte `SYSB`+version header, so they start with `SYSB`, + // not `PK`. They must fall through to the DIF parser and upload as-is, never + // get expanded into their inner source files. + const path = join(tempDir, "bundle.src.zip"); + const header = new Uint8Array([0x53, 0x59, 0x53, 0x42, 0x02, 0, 0, 0]); // "SYSB" + v2 + const inner = zipSync({ "example.sym": BREAKPAD_BYTES }); + await writeFile( + path, + Buffer.concat([Buffer.from(header), Buffer.from(inner)]) + ); + expect(await readZipDifEntries(path)).toBeNull(); + }); + + test("returns null for a malformed (truncated) zip", async () => { + const path = join(tempDir, "broken.zip"); + // Valid local-header magic, then garbage — fflate throws, we skip. + await writeFile(path, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0xff, 0xff])); + expect(await readZipDifEntries(path)).toBeNull(); + }); + + test("extracts entries, dropping directory placeholders", async () => { + const path = await writeZip("syms.zip", { + "dir/": new Uint8Array(0), + "example.sym": BREAKPAD_BYTES, + }); + const result = await readZipDifEntries(path); + expect(result).not.toBeNull(); + expect(result?.entries).toHaveLength(1); + expect(result?.entries[0]?.path).toBe(`${path}/example.sym`); + expect(result?.oversizedCount).toBe(0); + }); + + test("size-gates oversized entries before decompression and counts them", async () => { + const path = await writeZip("syms.zip", { + "example.sym": BREAKPAD_BYTES, + }); + const result = await readZipDifEntries(path, { maxFileSize: 1 }); + expect(result?.entries).toHaveLength(0); + expect(result?.oversizedCount).toBe(1); + }); + + test("skips an unsupported-compression entry without discarding the archive", async () => { + // fflate throws on any method other than store/deflate; returning it from + // the filter would discard the whole archive and its valid siblings. The + // unsupported entry must be dropped while its sibling DIF still extracts. + const raw = zipSync( + { "good.sym": BREAKPAD_BYTES, "bad.bin": new Uint8Array([1, 2, 3, 4]) }, + { level: 0 } + ); + const path = join(tempDir, "mixed.zip"); + await writeFile(path, corruptCompressionMethod(raw, "bad.bin", 99)); + const result = await readZipDifEntries(path); + expect(result).not.toBeNull(); + expect(result?.entries.map((e) => e.path)).toEqual([`${path}/good.sym`]); + }); + + test("skips a container whose compressed size exceeds the total budget", async () => { + // The whole archive is larger than 1 byte, so it is skipped wholesale + // rather than buffered into memory. + const path = await writeZip("syms.zip", { "example.sym": BREAKPAD_BYTES }); + expect(await readZipDifEntries(path, { maxTotalSize: 1 })).toBeNull(); + }); + + test("caps cumulative extraction via maxTotalSize", async () => { + // Two highly-compressible entries: the container (compressed) stays under + // the budget so it is read, but the second entry would push cumulative + // *uncompressed* extraction past it and is skipped. + const big = new Uint8Array(10_000); + const path = await writeZip("syms.zip", { "a.bin": big, "b.bin": big }); + const result = await readZipDifEntries(path, { maxTotalSize: 15_000 }); + expect(result?.entries).toHaveLength(1); + expect(result?.entries[0]?.path).toBe(`${path}/a.bin`); + }); +}); + +describe("prepareDifs ZIP scanning", () => { + test("finds a debug file inside a .zip", async () => { + const path = await writeZip("syms.zip", { + "example.sym": BREAKPAD_BYTES, + }); + const files = await scanPaths([path]); + const { prepared, oversizedCount } = await prepareDifs( + files, + buildDifFilters({}) + ); + expect(prepared).toHaveLength(1); + expect(prepared[0]?.path).toBe(`${path}/example.sym`); + expect(oversizedCount).toBe(0); + }); + + test("does not double-count the .zip container itself", async () => { + const path = await writeZip("syms.zip", { + "example.sym": BREAKPAD_BYTES, + }); + const files = await scanPaths([path]); + const { prepared } = await prepareDifs(files, buildDifFilters({})); + // Exactly the one entry — the container is never also parsed as a DIF. + expect(prepared).toHaveLength(1); + }); + + test("does not recurse into nested .zip archives", async () => { + const inner = zipSync({ "example.sym": BREAKPAD_BYTES }); + const path = await writeZip("outer.zip", { "inner.zip": inner }); + const files = await scanPaths([path]); + const { prepared } = await prepareDifs(files, buildDifFilters({})); + // The inner archive is an opaque, non-object entry — its DIF is not found. + expect(prepared).toHaveLength(0); + }); + + test("scanZips: false ignores entries inside archives", async () => { + const path = await writeZip("syms.zip", { + "example.sym": BREAKPAD_BYTES, + }); + const files = await scanPaths([path]); + const { prepared } = await prepareDifs(files, buildDifFilters({}), { + scanZips: false, + }); + expect(prepared).toHaveLength(0); + }); + + test("oversized zip entries are advisory and do not drive the exit count", async () => { + // A compressed entry's format is unknown until it is inflated, so an + // oversized zip entry cannot be attributed to the requested --type the way + // an on-disk file can. It is skipped (and warned per entry) but must not + // inflate `oversizedCount`, which gates the command's exit code — otherwise + // an unrelated large asset inside a .zip would cause a false + // "all matched files too large" failure. + const path = await writeZip("syms.zip", { + "example.sym": BREAKPAD_BYTES, + }); + const files = await scanPaths([path]); + const { prepared, oversizedCount } = await prepareDifs( + files, + buildDifFilters({}), + { maxFileSize: 1 } + ); + expect(prepared).toHaveLength(0); + expect(oversizedCount).toBe(0); + }); + + test("falls back to normal parsing for a .zip-named non-archive", async () => { + // A Breakpad file misnamed `.zip` lacks PK magic, so it is parsed directly. + const path = join(tempDir, "misnamed.zip"); + await writeFile(path, BREAKPAD_FIXTURE); + const files = await scanPaths([path]); + const { prepared } = await prepareDifs(files, buildDifFilters({})); + expect(prepared).toHaveLength(1); + expect(prepared[0]?.path).toBe(path); + }); + + test("applies --type filter to zip entries", async () => { + const path = await writeZip("syms.zip", { + "example.sym": BREAKPAD_BYTES, + }); + const files = await scanPaths([path]); + // Breakpad entry filtered out by --type elf. + const elf = await prepareDifs(files, buildDifFilters({ types: ["elf"] })); + expect(elf.prepared).toHaveLength(0); + // ...and kept by --type breakpad. + const bp = await prepareDifs( + files, + buildDifFilters({ types: ["breakpad"] }) + ); + expect(bp.prepared).toHaveLength(1); + }); +}); diff --git a/packages/cli/test/lib/docs-context.test.ts b/packages/cli/test/lib/docs-context.test.ts new file mode 100644 index 000000000..563228669 --- /dev/null +++ b/packages/cli/test/lib/docs-context.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { + DOCS_CONTEXT_MANIFESTS, + detectDocsContextFromReader, +} from "../../src/lib/docs-context.js"; + +describe("detectDocsContextFromReader", () => { + test("returns only normalized framework and language signals", async () => { + const requested: string[] = []; + const context = await detectDocsContextFromReader({ + async readManifest(name) { + requested.push(name); + if (name === "package.json") { + return JSON.stringify({ + dependencies: { "@sentry/nextjs": "9.0.0", next: "15.0.0" }, + scripts: { secret: "not sent" }, + }); + } + if (name === "pyproject.toml") { + return "[project]\ndependencies = ['django']"; + } + return; + }, + async hasConfig(name) { + return name === "sentry.client.config.ts"; + }, + }); + + expect(context).toEqual({ + frameworks: ["django", "nextjs"], + languages: ["javascript", "python"], + sentryConfigured: true, + }); + expect(requested).toEqual(DOCS_CONTEXT_MANIFESTS); + expect(requested).not.toContain(".env"); + expect(requested).not.toContain("package-lock.json"); + }); + + test("degrades to an empty context when manifests cannot be read", async () => { + const context = await detectDocsContextFromReader({ + async readManifest() { + throw new Error("permission denied"); + }, + async hasConfig() { + return false; + }, + }); + + expect(context).toEqual({ + frameworks: [], + languages: [], + sentryConfigured: false, + }); + }); + + test("does not infer Next.js from an unrelated substring", async () => { + const context = await detectDocsContextFromReader({ + async readManifest(name) { + return name === "package.json" + ? '{"description":"context"}' + : undefined; + }, + async hasConfig() { + return false; + }, + }); + + expect(context.frameworks).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/docs-service.test.ts b/packages/cli/test/lib/docs-service.test.ts new file mode 100644 index 000000000..6aefae2ba --- /dev/null +++ b/packages/cli/test/lib/docs-service.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi } from "vitest"; + +const { assertHostedInitServiceAcceptsTokenHost, customFetch, refreshToken } = + vi.hoisted(() => ({ + assertHostedInitServiceAcceptsTokenHost: vi.fn(), + customFetch: vi.fn(), + refreshToken: vi.fn(), + })); + +vi.mock("../../src/lib/custom-ca.js", () => ({ customFetch })); +vi.mock("../../src/lib/db/auth.js", () => ({ refreshToken })); +vi.mock("../../src/lib/init/init-service-auth.js", () => ({ + assertHostedInitServiceAcceptsTokenHost, +})); + +import { queryDocs } from "../../src/lib/docs-service.js"; +import { EXIT, isUserError } from "../../src/lib/errors.js"; + +describe("queryDocs", () => { + test("rejects a malformed bearer before calling the docs service", async () => { + customFetch.mockClear(); + refreshToken.mockResolvedValue({ + token: "synthetic-prefix\nsynthetic-secret-tail", + }); + + const error = await queryDocs("How do I configure tracing?", { + frameworks: [], + languages: [], + sentryConfigured: false, + }).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(String(error)).not.toContain("synthetic-secret-tail"); + expect(customFetch).not.toHaveBeenCalled(); + }); + + test("explains when Docs AI is unavailable in the service region", async () => { + refreshToken.mockResolvedValue({ token: "test-token" }); + customFetch.mockResolvedValue({ + ok: false, + status: 502, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + code: "DOCS_MODEL_UNAVAILABLE", + error: "Sentry Docs AI is temporarily unavailable in this region.", + }) + ), + }); + + await expect( + queryDocs("How do I configure tracing?", { + frameworks: [], + languages: [], + sentryConfigured: false, + }) + ).rejects.toMatchObject({ + exitCode: EXIT.API, + message: + "Sentry Docs AI is temporarily unavailable in this region. Please try again later.", + }); + }); + + test("explains when the service cannot verify a cited answer", async () => { + refreshToken.mockResolvedValue({ token: "test-token" }); + customFetch.mockResolvedValue({ + ok: false, + status: 502, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + code: "DOCS_UNGROUNDED", + error: "Sentry documentation answer could not be verified", + }) + ), + }); + + await expect( + queryDocs("How do I configure tracing?", { + frameworks: [], + languages: [], + sentryConfigured: false, + }) + ).rejects.toMatchObject({ + exitCode: EXIT.API, + message: expect.stringContaining( + "Could not produce a verified documentation answer." + ), + }); + }); + + test("treats an ungrounded answer as a user-facing failure", async () => { + refreshToken.mockResolvedValue({ token: "test-token" }); + customFetch.mockResolvedValue({ + ok: false, + status: 502, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + code: "DOCS_UNGROUNDED", + error: "Sentry documentation answer could not be verified", + }) + ), + }); + + const error = await queryDocs("How do I configure tracing?", { + frameworks: [], + languages: [], + sentryConfigured: false, + }).catch((caught: unknown) => caught); + + expect(isUserError(error)).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/dsn.property.test.ts b/packages/cli/test/lib/dsn.property.test.ts new file mode 100644 index 000000000..5cccd8296 --- /dev/null +++ b/packages/cli/test/lib/dsn.property.test.ts @@ -0,0 +1,444 @@ +/** + * Property-Based Tests for DSN Parsing + * + * Uses fast-check to verify properties that should always hold true + * for the DSN parsing functions, regardless of input. + */ + +import { + array, + constantFrom, + assert as fcAssert, + nat, + property, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + createDetectedDsn, + createDsnFingerprint, + extractOrgIdFromHost, + inferPackagePath, + isValidDsn, + parseDsn, +} from "../../src/lib/dsn/parser.js"; +import type { DetectedDsn } from "../../src/lib/dsn/types.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Generate valid public keys (hex-like strings) */ +const publicKeyArb = array(constantFrom(..."0123456789abcdef".split("")), { + minLength: 8, + maxLength: 32, +}).map((chars) => chars.join("")); + +/** Generate valid positive project IDs (numeric strings) */ +const projectIdArb = nat(9_999_999_998).map((id) => String(id + 1)); + +/** Generate valid positive org IDs (numeric) */ +const orgIdArb = nat(9_999_998).map((id) => id + 1); + +/** Generate region codes */ +const regionArb = constantFrom("us", "de", "eu", ""); + +/** Generate valid SaaS ingest hosts with org ID */ +const saasHostArb = tuple(orgIdArb, regionArb).map(([orgId, region]) => + region ? `o${orgId}.ingest.${region}.sentry.io` : `o${orgId}.ingest.sentry.io` +); + +/** Generate self-hosted hosts (no org ID pattern) */ +const selfHostedHostArb = constantFrom( + "sentry.mycompany.com", + "errors.internal.corp", + "sentry.localhost", + "my-sentry.example.org" +); + +/** Generate complete valid DSN strings */ +const validDsnArb = tuple(publicKeyArb, saasHostArb, projectIdArb).map( + ([key, host, projectId]) => `https://${key}@${host}/${projectId}` +); + +/** Generate valid self-hosted DSN strings */ +const selfHostedDsnArb = tuple( + publicKeyArb, + selfHostedHostArb, + projectIdArb +).map(([key, host, projectId]) => `https://${key}@${host}/${projectId}`); + +/** Generate invalid DSN-like strings */ +const invalidDsnArb = constantFrom( + "", + "not-a-dsn", + "https://missing-key@sentry.io/", + "https://@sentry.io/123", // empty key + "ftp://key@sentry.io/123", // still valid URL, but will parse + "https://key@/123", // no host + "://key@sentry.io/123" // invalid protocol +); + +/** Generate monorepo-style paths */ +const monorepoPathArb = tuple( + constantFrom("packages", "apps", "services", "libs"), + array(constantFrom(..."abcdefghijklmnopqrstuvwxyz".split("")), { + minLength: 1, + maxLength: 15, + }), + constantFrom("src/index.ts", ".env", "config.js", "main.py") +).map(([root, pkgChars, file]) => `${root}/${pkgChars.join("")}/${file}`); + +/** Generate non-monorepo paths */ +const rootPathArb = constantFrom( + "src/index.ts", + ".env", + "config.js", + "main.py", + "index.js", + "app/main.ts" +); + +// Properties for parseDsn + +describe("property: parseDsn", () => { + test("successfully parses all valid DSN formats", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const result = parseDsn(dsn); + expect(result).not.toBeNull(); + expect(result?.protocol).toBe("https"); + expect(result?.publicKey).toBeDefined(); + expect(result?.host).toBeDefined(); + expect(result?.projectId).toBeDefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("extracts orgId from SaaS hosts", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const result = parseDsn(dsn); + // SaaS DSNs should have orgId extracted + expect(result?.orgId).toBeDefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("orgId is undefined for self-hosted DSNs", () => { + fcAssert( + property(selfHostedDsnArb, (dsn) => { + const result = parseDsn(dsn); + expect(result).not.toBeNull(); + expect(result?.orgId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns null for invalid DSNs", () => { + fcAssert( + property(invalidDsnArb, (dsn) => { + const result = parseDsn(dsn); + // Most invalid DSNs should return null + // Some edge cases like ftp:// may still parse as valid URLs + if (dsn === "" || dsn === "not-a-dsn" || dsn.includes("://key@/")) { + expect(result).toBeNull(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("round-trip: parsed components reconstruct to equivalent DSN", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const parsed = parseDsn(dsn); + if (parsed) { + const reconstructed = `${parsed.protocol}://${parsed.publicKey}@${parsed.host}/${parsed.projectId}`; + expect(reconstructed).toBe(dsn); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for extractOrgIdFromHost + +describe("property: extractOrgIdFromHost", () => { + test("extracts org ID from valid SaaS ingest hosts", () => { + fcAssert( + property(tuple(orgIdArb, regionArb), ([orgId, region]) => { + const host = region + ? `o${orgId}.ingest.${region}.sentry.io` + : `o${orgId}.ingest.sentry.io`; + + const result = extractOrgIdFromHost(host); + expect(result).toBe(String(orgId)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns null for self-hosted hosts", () => { + fcAssert( + property(selfHostedHostArb, (host) => { + const result = extractOrgIdFromHost(host); + expect(result).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns null for malformed hosts", () => { + const malformedHosts = constantFrom( + "sentry.io", + "ingest.sentry.io", + "o.ingest.sentry.io", // no number + "oabc.ingest.sentry.io", // letters instead of number + "o123.sentry.io" // missing "ingest" + ); + + fcAssert( + property(malformedHosts, (host) => { + const result = extractOrgIdFromHost(host); + expect(result).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for isValidDsn + +describe("property: isValidDsn", () => { + test("returns true for all valid DSNs", () => { + fcAssert( + property(validDsnArb, (dsn) => { + expect(isValidDsn(dsn)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns true for self-hosted DSNs", () => { + fcAssert( + property(selfHostedDsnArb, (dsn) => { + expect(isValidDsn(dsn)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("consistent with parseDsn", () => { + fcAssert( + property(string({ maxLength: 100 }), (input) => { + const isValid = isValidDsn(input); + const parsed = parseDsn(input); + expect(isValid).toBe(parsed !== null); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for createDetectedDsn + +describe("property: createDetectedDsn", () => { + test("returns null for invalid DSNs", () => { + fcAssert( + property(invalidDsnArb, (dsn) => { + const result = createDetectedDsn(dsn, "env"); + // Should return null for truly invalid DSNs + if (parseDsn(dsn) === null) { + expect(result).toBeNull(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("preserves raw DSN string", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const result = createDetectedDsn(dsn, "code", "src/index.ts"); + expect(result?.raw).toBe(dsn); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("preserves source metadata", () => { + fcAssert( + property( + tuple( + validDsnArb, + constantFrom( + "env" as const, + "env_file" as const, + "code" as const, + "config" as const + ) + ), + ([dsn, source]) => { + const result = createDetectedDsn(dsn, source, "test/path.ts"); + expect(result?.source).toBe(source); + expect(result?.sourcePath).toBe("test/path.ts"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for createDsnFingerprint + +describe("property: createDsnFingerprint", () => { + test("fingerprint is deterministic", () => { + fcAssert( + property(array(validDsnArb, { minLength: 1, maxLength: 5 }), (dsns) => { + const detected = dsns + .map((d) => createDetectedDsn(d, "code")) + .filter((d): d is DetectedDsn => d !== null); + + const fp1 = createDsnFingerprint(detected); + const fp2 = createDsnFingerprint(detected); + expect(fp1).toBe(fp2); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("fingerprint is order-independent (sorted)", () => { + fcAssert( + property(array(validDsnArb, { minLength: 2, maxLength: 5 }), (dsns) => { + const detected = dsns + .map((d) => createDetectedDsn(d, "code")) + .filter((d): d is DetectedDsn => d !== null); + + if (detected.length < 2) return; + + const fp1 = createDsnFingerprint(detected); + const fp2 = createDsnFingerprint([...detected].reverse()); + expect(fp1).toBe(fp2); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("fingerprint deduplicates same DSN from multiple sources", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const detected1 = createDetectedDsn(dsn, "code", "src/a.ts"); + const detected2 = createDetectedDsn(dsn, "env_file", ".env"); + + if (detected1 && detected2) { + const fpSingle = createDsnFingerprint([detected1]); + const fpDuplicate = createDsnFingerprint([detected1, detected2]); + expect(fpSingle).toBe(fpDuplicate); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("different DSNs produce different fingerprints", () => { + fcAssert( + property(tuple(validDsnArb, validDsnArb), ([dsn1, dsn2]) => { + if (dsn1 === dsn2) return; // Skip if same + + const detected1 = createDetectedDsn(dsn1, "code"); + const detected2 = createDetectedDsn(dsn2, "code"); + + if (detected1 && detected2) { + const fp1 = createDsnFingerprint([detected1]); + const fp2 = createDsnFingerprint([detected2]); + + // Different DSNs should (usually) have different fingerprints + // Unless they happen to have same orgId:projectId (unlikely with random data) + if ( + detected1.projectId !== detected2.projectId || + detected1.orgId !== detected2.orgId + ) { + expect(fp1).not.toBe(fp2); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty array returns empty fingerprint", () => { + const fp = createDsnFingerprint([]); + expect(fp).toBe(""); + }); + + test("fingerprint format is comma-separated key pairs", () => { + fcAssert( + property(array(validDsnArb, { minLength: 1, maxLength: 3 }), (dsns) => { + const detected = dsns + .map((d) => createDetectedDsn(d, "code")) + .filter((d): d is DetectedDsn => d !== null); + + const fp = createDsnFingerprint(detected); + + // Should be comma-separated pairs or single pair + if (fp.length > 0) { + const parts = fp.split(","); + for (const part of parts) { + // Each part should be "prefix:projectId" format + expect(part).toMatch(/^.+:.+$/); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for inferPackagePath + +describe("property: inferPackagePath", () => { + test("extracts package path from monorepo-style paths", () => { + fcAssert( + property(monorepoPathArb, (path) => { + const result = inferPackagePath(path); + expect(result).toBeDefined(); + + // Should be "root/package" format + const parts = result!.split("/"); + expect(parts.length).toBe(2); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns undefined for root-level paths", () => { + fcAssert( + property(rootPathArb, (path) => { + const result = inferPackagePath(path); + expect(result).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("handles edge cases gracefully", () => { + const edgeCases = constantFrom("", "/", "a", "a/", "/a", "a/b"); + + fcAssert( + property(edgeCases, (path) => { + // Should not throw + const result = inferPackagePath(path); + // Result should be either undefined or a valid path + if (result !== undefined) { + expect(result.length).toBeGreaterThan(0); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/dsn.test.ts b/packages/cli/test/lib/dsn.test.ts new file mode 100644 index 000000000..80dfee23d --- /dev/null +++ b/packages/cli/test/lib/dsn.test.ts @@ -0,0 +1,190 @@ +/** + * DSN Parsing Tests + * + * Note: Core invariants (parsing, validation, round-trips, fingerprint ordering/dedup) + * are tested via property-based tests in dsn.property.test.ts. These tests focus on + * edge cases and self-hosted DSN behavior that property generators don't cover. + */ + +import { describe, expect, test } from "vitest"; +import type { DetectedDsn } from "../../src/lib/dsn/index.js"; +import { + createDetectedDsn, + createDsnFingerprint, + inferPackagePath, + isPlaceholderNumericId, + isPlaceholderPublicKey, + parseDsn, +} from "../../src/lib/dsn/index.js"; + +describe("parseDsn edge cases", () => { + test("returns null for DSN without project ID (trailing slash)", () => { + const dsn = "https://key@o123.ingest.sentry.io/"; + const result = parseDsn(dsn); + expect(result).toBeNull(); + }); + + test("handles DSN with path segments", () => { + const dsn = "https://key@o123.ingest.sentry.io/api/456"; + const result = parseDsn(dsn); + expect(result?.projectId).toBe("456"); + }); + + test.each([ + // Canonical sentry-docs codeContext default + "https://examplePublicKey@o0.ingest.sentry.io/0", + // Docs example with bare `public` key + o0 host (rejected via org id) + "https://public@o0.ingest.sentry.io/1", + // All-zero padded org host used in OTLP docs examples + "https://abc123@o00000.ingest.sentry.io/1111111", + // Zero project ID only + "https://key@o123.ingest.sentry.io/0", + // Zero SaaS org ID only + "https://key@o0.ingest.sentry.io/456", + // Angle-bracket template keys (URL-encoded by the parser) + "https://%3Ckey%3E@o123.ingest.sentry.io/456", + "https://YOUR_DSN_HERE@o123.ingest.sentry.io/456", + "https://___PUBLIC_DSN___@o123.ingest.sentry.io/456", + "https://__DSN__@o123.ingest.sentry.io/456", + ])("returns null for docs placeholder DSN %s", (dsn) => { + expect(parseDsn(dsn)).toBeNull(); + }); + + test("still accepts a real-looking SaaS DSN", () => { + const dsn = + "https://abc123def456@o1169445.ingest.us.sentry.io/4505229541441536"; + const result = parseDsn(dsn); + expect(result).toEqual({ + protocol: "https", + publicKey: "abc123def456", + host: "o1169445.ingest.us.sentry.io", + projectId: "4505229541441536", + orgId: "1169445", + }); + }); + + test("still accepts legacy public:secret DSNs with real org/project ids", () => { + // Bare "public" is a valid public key in legacy DSNs; do not reject it alone. + const dsn = "https://public:secret@o123.ingest.sentry.io/456"; + const result = parseDsn(dsn); + expect(result).toEqual({ + protocol: "https", + publicKey: "public", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + }); + }); +}); + +describe("placeholder helpers", () => { + test.each(["0", "00", "00000"])("isPlaceholderNumericId accepts %s", (id) => { + expect(isPlaceholderNumericId(id)).toBe(true); + }); + + test.each([ + "1", + "10", + "1169445", + ])("isPlaceholderNumericId rejects %s", (id) => { + expect(isPlaceholderNumericId(id)).toBe(false); + }); + + test.each([ + "examplePublicKey", + "exampleKey", + "YOUR_DSN_HERE", + "___PUBLIC_DSN___", + "__DSN__", + "", + ])("isPlaceholderPublicKey accepts %s", (key) => { + expect(isPlaceholderPublicKey(key)).toBe(true); + }); + + test.each([ + "abc123def456", + "public", + "publickey", + "public-app-key", + ])("isPlaceholderPublicKey rejects %s", (key) => { + expect(isPlaceholderPublicKey(key)).toBe(false); + }); +}); + +describe("createDsnFingerprint: self-hosted DSNs", () => { + test("includes DSNs without orgId using host as prefix", () => { + const saas: DetectedDsn = { + raw: "https://key@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "key", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + }; + const selfHosted: DetectedDsn = { + raw: "https://key@sentry.mycompany.com/1", + protocol: "https", + publicKey: "key", + host: "sentry.mycompany.com", + projectId: "1", + orgId: undefined, + source: "env", + }; + + const result = createDsnFingerprint([saas, selfHosted]); + expect(result).toBe("123:456,sentry.mycompany.com:1"); + }); + + test("returns host-based fingerprint for self-hosted DSNs", () => { + const selfHosted: DetectedDsn = { + raw: "https://key@sentry.mycompany.com/1", + protocol: "https", + publicKey: "key", + host: "sentry.mycompany.com", + projectId: "1", + orgId: undefined, + source: "env", + }; + + const result = createDsnFingerprint([selfHosted]); + expect(result).toBe("sentry.mycompany.com:1"); + }); +}); + +describe("createDetectedDsn edge cases", () => { + test("includes packagePath when provided", () => { + const result = createDetectedDsn( + "https://abc123@o123.ingest.sentry.io/456", + "code", + "packages/web/src/config.ts", + "packages/web" + ); + expect(result?.packagePath).toBe("packages/web"); + }); +}); + +describe("inferPackagePath", () => { + test("infers package path from packages/ directory", () => { + expect(inferPackagePath("packages/frontend/src/index.ts")).toBe( + "packages/frontend" + ); + }); + + test("infers package path from apps/ directory", () => { + expect(inferPackagePath("apps/web/.env")).toBe("apps/web"); + }); + + test("infers package path from services/ directory", () => { + expect(inferPackagePath("services/api/server.ts")).toBe("services/api"); + }); + + test("infers package path from modules/ directory", () => { + // Property generator only uses packages/apps/services/libs — modules/ is not covered + expect(inferPackagePath("modules/auth/index.ts")).toBe("modules/auth"); + }); + + test("returns undefined for non-monorepo directories", () => { + expect(inferPackagePath("other/path/file.ts")).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/dsn/cache.test.ts b/packages/cli/test/lib/dsn/cache.test.ts new file mode 100644 index 000000000..53bc8e103 --- /dev/null +++ b/packages/cli/test/lib/dsn/cache.test.ts @@ -0,0 +1,179 @@ +/** + * DSN Cache Tests + * + * Tests for DSN detection caching functionality. + */ + +import { describe, expect, test } from "vitest"; +import { + clearDsnCache, + getCachedDsn, + setCachedDsn, + updateCachedResolution, +} from "../../../src/lib/db/dsn-cache.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-dsn-cache-"); + +describe("DSN Cache", () => { + describe("getCachedDsn", () => { + test("returns undefined when no cache exists", async () => { + const result = getCachedDsn("/some/path"); + expect(result).toBeUndefined(); + }); + + test("returns cached entry when it exists", async () => { + const testDir = "/test/directory"; + setCachedDsn(testDir, { + dsn: "https://key@o123.ingest.sentry.io/456", + projectId: "456", + orgId: "123", + source: "env_file", + sourcePath: ".env.local", + }); + + const result = getCachedDsn(testDir); + + expect(result).toBeDefined(); + expect(result?.dsn).toBe("https://key@o123.ingest.sentry.io/456"); + expect(result?.projectId).toBe("456"); + expect(result?.source).toBe("env_file"); + expect(result?.cachedAt).toBeDefined(); + }); + }); + + describe("setCachedDsn", () => { + test("creates new cache entry", async () => { + const testDir = "/new/directory"; + + setCachedDsn(testDir, { + dsn: "https://abc@o789.ingest.sentry.io/111", + projectId: "111", + orgId: "789", + source: "code", + sourcePath: "src/config.ts", + }); + + const cached = getCachedDsn(testDir); + expect(cached?.dsn).toBe("https://abc@o789.ingest.sentry.io/111"); + expect(cached?.sourcePath).toBe("src/config.ts"); + }); + + test("updates existing cache entry", async () => { + const testDir = "/update/test"; + + setCachedDsn(testDir, { + dsn: "https://old@o1.ingest.sentry.io/1", + projectId: "1", + orgId: "1", + source: "env_file", + }); + + setCachedDsn(testDir, { + dsn: "https://new@o2.ingest.sentry.io/2", + projectId: "2", + orgId: "2", + source: "code", + }); + + const cached = getCachedDsn(testDir); + expect(cached?.dsn).toBe("https://new@o2.ingest.sentry.io/2"); + expect(cached?.projectId).toBe("2"); + }); + + test("adds cachedAt timestamp", async () => { + const testDir = "/timestamp/test"; + const before = Date.now(); + + setCachedDsn(testDir, { + dsn: "https://key@o1.ingest.sentry.io/1", + projectId: "1", + source: "env", + }); + + const after = Date.now(); + const cached = getCachedDsn(testDir); + + expect(cached?.cachedAt).toBeGreaterThanOrEqual(before); + expect(cached?.cachedAt).toBeLessThanOrEqual(after); + }); + }); + + describe("updateCachedResolution", () => { + test("adds resolved info to existing cache entry", async () => { + const testDir = "/resolve/test"; + + setCachedDsn(testDir, { + dsn: "https://key@o123.ingest.sentry.io/456", + projectId: "456", + orgId: "123", + source: "env_file", + }); + + updateCachedResolution(testDir, { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "my-project", + projectName: "My Project", + }); + + const cached = getCachedDsn(testDir); + expect(cached?.resolved).toBeDefined(); + expect(cached?.resolved?.orgSlug).toBe("my-org"); + expect(cached?.resolved?.projectName).toBe("My Project"); + }); + + test("does nothing when no cache entry exists", async () => { + updateCachedResolution("/nonexistent", { + orgSlug: "test", + orgName: "Test", + projectSlug: "test", + projectName: "Test", + }); + + const cached = getCachedDsn("/nonexistent"); + expect(cached).toBeUndefined(); + }); + }); + + describe("clearDsnCache", () => { + test("clears specific directory cache", async () => { + const dir1 = "/dir1"; + const dir2 = "/dir2"; + + setCachedDsn(dir1, { + dsn: "https://a@o1.ingest.sentry.io/1", + projectId: "1", + source: "env", + }); + setCachedDsn(dir2, { + dsn: "https://b@o2.ingest.sentry.io/2", + projectId: "2", + source: "env", + }); + + clearDsnCache(dir1); + + expect(getCachedDsn(dir1)).toBeUndefined(); + expect(getCachedDsn(dir2)).toBeDefined(); + }); + + test("clears all cache when no directory specified", async () => { + setCachedDsn("/dir1", { + dsn: "https://a@o1.ingest.sentry.io/1", + projectId: "1", + source: "env", + }); + setCachedDsn("/dir2", { + dsn: "https://b@o2.ingest.sentry.io/2", + projectId: "2", + source: "env", + }); + + clearDsnCache(); + + expect(getCachedDsn("/dir1")).toBeUndefined(); + expect(getCachedDsn("/dir2")).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/code-scanner.property.test.ts b/packages/cli/test/lib/dsn/code-scanner.property.test.ts new file mode 100644 index 000000000..218505799 --- /dev/null +++ b/packages/cli/test/lib/dsn/code-scanner.property.test.ts @@ -0,0 +1,86 @@ +/** + * Property tests for `extractDsnsFromContent`'s literal-prefix fast + * path. + * + * The fast-path in `code-scanner.ts` short-circuits `matchAll` when + * the file contains no case-insensitive `http` substring. The tests + * here pin down two properties: + * + * 1. **Short-circuit correctness**: when we strip ALL casings of + * `http` from random content, `extractDsnsFromContent` must + * return `[]` (the fast path is safe to take). + * + * 2. **Set equality against the raw regex**: the fast-path output, + * filtered through host validation, must equal the host-valid + * subset of the raw `DSN_PATTERN` matches. This is bidirectional + * — a subset-only check wouldn't catch the fast path silently + * dropping valid DSNs (see the mixed-case regression a prior + * reviewer found). + * + * The content arbitrary uses a MIXED-CASE ASCII alphabet deliberately. + * A lowercase-only alphabet would hide the mixed-case bug — if you + * narrow it back, the fast path's correctness is no longer tested. + */ + +import { + array, + constantFrom, + assert as fcAssert, + property, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { extractDsnsFromContent } from "../../../src/lib/dsn/code-scanner.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// Mixed-case charset on purpose: the fast-path probe must match +// ANY casing of the scheme (including `Https://`, `hTtP://`, etc.). +// A lowercase-only arbitrary silently hides that regression. +const BENIGN_CHARS = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789\n".split(""); + +/** Generate file contents — benign ASCII, may or may not contain http. */ +const contentArb = array(constantFrom(...BENIGN_CHARS), { + minLength: 0, + maxLength: 500, +}).map((chars) => chars.join("")); + +/** Raw regex matcher (no fast-path) — our reference implementation. */ +const DSN_RE = + /https?:\/\/[a-z0-9]+(?::[a-z0-9]+)?@[a-z0-9.-]+(?:\.[a-z]+|:[0-9]+)\/\d+/gi; +function referenceMatches(content: string): string[] { + return Array.from(new Set(Array.from(content.matchAll(DSN_RE), (m) => m[0]))); +} + +describe("property: extractDsnsFromContent fast-path invariance", () => { + test("content without `http`/`HTTP` returns []", () => { + fcAssert( + property(contentArb, (content) => { + // Strip any http/HTTP substring from the random content to + // guarantee the fast-path triggers. Then assert zero DSNs. + const clean = content + .replace(/http/gi, "xxxx") + .replace(/HTTP/g, "XXXX"); + expect(extractDsnsFromContent(clean)).toEqual([]); + }), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 100) } + ); + }); + + test("fast-path output is a subset of the raw regex matches", () => { + // The fast-path may filter out DSNs whose host isn't valid; so + // extract's output is a SUBSET of the regex matches, not equal. + // This property just ensures we never fabricate DSNs that aren't + // in the raw regex pass. + fcAssert( + property(string({ minLength: 0, maxLength: 500 }), (content) => { + const extracted = new Set(extractDsnsFromContent(content)); + const reference = new Set(referenceMatches(content)); + for (const dsn of extracted) { + expect(reference.has(dsn)).toBe(true); + } + }), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 100) } + ); + }); +}); diff --git a/packages/cli/test/lib/dsn/code-scanner.test.ts b/packages/cli/test/lib/dsn/code-scanner.test.ts new file mode 100644 index 000000000..276dfa12f --- /dev/null +++ b/packages/cli/test/lib/dsn/code-scanner.test.ts @@ -0,0 +1,664 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + extractDsnsFromContent, + extractFirstDsnFromContent, + scanCodeForDsns, + scanCodeForFirstDsn, +} from "../../../src/lib/dsn/code-scanner.js"; + +describe("Code Scanner", () => { + const testDir = join(import.meta.dirname, ".test-code-scanner"); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + }); + + describe("extractDsnsFromContent", () => { + test("extracts DSN from JavaScript code", () => { + const content = ` + Sentry.init({ + dsn: "https://abc123@o123.ingest.sentry.io/456" + }); + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc123@o123.ingest.sentry.io/456"]); + }); + + test("extracts DSN from Python code", () => { + const content = ` + sentry_sdk.init( + dsn="https://abc123@o123.ingest.sentry.io/456" + ) + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc123@o123.ingest.sentry.io/456"]); + }); + + test("extracts DSN from Go code", () => { + const content = ` + sentry.Init(sentry.ClientOptions{ + Dsn: "https://abc123@o123.ingest.sentry.io/456", + }) + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc123@o123.ingest.sentry.io/456"]); + }); + + test("extracts DSN from constant assignment", () => { + const content = ` + const SENTRY_DSN = "https://abc123@o123.ingest.sentry.io/456"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc123@o123.ingest.sentry.io/456"]); + }); + + test.each([ + ["all lower", "https://abc@o1.ingest.sentry.io/456"], + ["all upper", "HTTPS://abc@o1.ingest.sentry.io/456"], + ["title", "Https://abc@o1.ingest.sentry.io/456"], + ["mixed", "hTtPs://abc@o1.ingest.sentry.io/456"], + ["http no s", "HTTP://abc@o1.ingest.sentry.io/456"], + ])("detects DSN with %s scheme casing (regression: literal-prefix fast path)", (_label, url) => { + // An earlier version of the literal-prefix probe used two + // case-sensitive `indexOf` calls (covering only all-lower and + // all-upper), which silently dropped mixed-case schemes like + // `Https://`. The probe is now `/http/i`. This test pins the + // correctness contract. + const content = `const DSN = "${url}";`; + const dsns = extractDsnsFromContent(content); + expect(dsns).toHaveLength(1); + expect(dsns[0]).toBe(url); + }); + + test("extracts multiple DSNs", () => { + const content = ` + const PROD_DSN = "https://prod@o123.ingest.sentry.io/111"; + const DEV_DSN = "https://dev@o456.ingest.sentry.io/222"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toHaveLength(2); + expect(dsns).toContain("https://prod@o123.ingest.sentry.io/111"); + expect(dsns).toContain("https://dev@o456.ingest.sentry.io/222"); + }); + + test("deduplicates DSNs", () => { + const content = ` + const DSN1 = "https://abc@o123.ingest.sentry.io/456"; + const DSN2 = "https://abc@o123.ingest.sentry.io/456"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toHaveLength(1); + }); + + test("ignores single-line comments with //", () => { + const content = ` + // const DSN = "https://abc123@o123.ingest.sentry.io/456"; + const REAL_DSN = "https://real@o456.ingest.sentry.io/789"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores Python/shell comments with #", () => { + const content = ` + # DSN = "https://abc123@o123.ingest.sentry.io/456" + REAL_DSN = "https://real@o456.ingest.sentry.io/789" + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores HTML comments with + + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores C-style block comment lines starting with /*", () => { + const content = ` + /* const DSN = "https://abc123@o123.ingest.sentry.io/456"; */ + const REAL_DSN = "https://real@o456.ingest.sentry.io/789"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores JSDoc/multi-line comment continuation lines starting with *", () => { + const content = ` + /** + * DSN: "https://abc123@o123.ingest.sentry.io/456" + */ + const REAL_DSN = "https://real@o456.ingest.sentry.io/789"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores SQL comments with --", () => { + const content = ` + -- INSERT INTO config VALUES ('dsn', 'https://abc123@o123.ingest.sentry.io/456'); + INSERT INTO config VALUES ('dsn', 'https://real@o456.ingest.sentry.io/789'); + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores Python triple-quote docstrings", () => { + const content = ` + '''https://abc123@o123.ingest.sentry.io/456''' + DSN = "https://real@o456.ingest.sentry.io/789" + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("returns empty array for content without DSNs", () => { + const content = ` + const config = { debug: true }; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual([]); + }); + + test("only accepts *.sentry.io hosts for SaaS", () => { + const content = ` + const REAL = "https://abc@o123.ingest.sentry.io/456"; + const FAKE = "https://abc@fake.example.com/456"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc@o123.ingest.sentry.io/456"]); + }); + + test("extracts DSN with secret key (legacy format)", () => { + // Some older Sentry installations or SDKs use public:secret format + const content = ` + const DSN = "https://publickey:secretkey@o123.ingest.sentry.io/456"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual([ + "https://publickey:secretkey@o123.ingest.sentry.io/456", + ]); + }); + + test("extracts both regular and secret-key DSNs", () => { + const content = ` + const DSN1 = "https://public@o123.ingest.sentry.io/111"; + const DSN2 = "https://public:secret@o456.ingest.sentry.io/222"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toHaveLength(2); + expect(dsns).toContain("https://public@o123.ingest.sentry.io/111"); + expect(dsns).toContain("https://public:secret@o456.ingest.sentry.io/222"); + }); + + test("accepts self-hosted DSNs when SENTRY_URL is set", () => { + process.env.SENTRY_URL = "https://sentry.mycompany.com:9000"; + const content = ` + const DSN = "https://abc@sentry.mycompany.com:9000/123"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc@sentry.mycompany.com:9000/123"]); + }); + + test("rejects SaaS DSNs when SENTRY_URL is set (self-hosted mode)", () => { + process.env.SENTRY_URL = "https://sentry.mycompany.com:9000"; + const content = ` + const SAAS_DSN = "https://abc@o123.ingest.sentry.io/456"; + const SELF_HOSTED_DSN = "https://def@sentry.mycompany.com:9000/789"; + `; + const dsns = extractDsnsFromContent(content); + // Only the self-hosted DSN should be accepted + expect(dsns).toEqual(["https://def@sentry.mycompany.com:9000/789"]); + }); + + test("throws ConfigError when SENTRY_URL is invalid", () => { + // normalizeUrl prepends https:// to bare strings, so use a value + // that remains unparseable even after normalization (https://://) + process.env.SENTRY_URL = "://"; + const content = ` + const SAAS_DSN = "https://abc@o123.ingest.sentry.io/456"; + `; + + // Invalid SENTRY_URL should throw immediately since nothing will work + expect(() => extractDsnsFromContent(content)).toThrow( + /SENTRY_HOST\/SENTRY_URL.*not a valid URL/ + ); + }); + + test("accepts self-hosted DSNs when SENTRY_HOST is set", () => { + process.env.SENTRY_HOST = "https://sentry.mycompany.com:9000"; + const content = ` + const DSN = "https://abc@sentry.mycompany.com:9000/123"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc@sentry.mycompany.com:9000/123"]); + }); + + test("SENTRY_HOST takes precedence over SENTRY_URL for DSN validation", () => { + process.env.SENTRY_HOST = "https://sentry.mycompany.com:9000"; + process.env.SENTRY_URL = "https://sentry.other.com"; + const content = ` + const DSN1 = "https://abc@sentry.mycompany.com:9000/123"; + const DSN2 = "https://def@sentry.other.com/456"; + `; + const dsns = extractDsnsFromContent(content); + // Only the SENTRY_HOST DSN should be accepted + expect(dsns).toEqual(["https://abc@sentry.mycompany.com:9000/123"]); + }); + }); + + describe("extractFirstDsnFromContent", () => { + test("returns first DSN", () => { + const content = ` + const DSN1 = "https://first@o123.ingest.sentry.io/111"; + const DSN2 = "https://second@o456.ingest.sentry.io/222"; + `; + const dsn = extractFirstDsnFromContent(content); + expect(dsn).toBe("https://first@o123.ingest.sentry.io/111"); + }); + + test("returns null when no DSN found", () => { + const dsn = extractFirstDsnFromContent("no dsn here"); + expect(dsn).toBeNull(); + }); + }); + + describe("scanCodeForFirstDsn", () => { + test("finds DSN in root file", async () => { + writeFileSync( + join(testDir, "config.ts"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://abc@o123.ingest.sentry.io/456"); + expect(result?.source).toBe("code"); + expect(result?.sourcePath).toBe("config.ts"); + }); + + test("finds DSN in subdirectory", async () => { + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/sentry.ts"), + 'Sentry.init({ dsn: "https://abc@o123.ingest.sentry.io/456" });' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://abc@o123.ingest.sentry.io/456"); + expect(result?.sourcePath).toBe("src/sentry.ts"); + }); + + test("returns null when no DSN found", async () => { + writeFileSync(join(testDir, "index.ts"), "console.log('hello');"); + + const result = await scanCodeForFirstDsn(testDir); + expect(result).toBeNull(); + }); + + test("skips node_modules directory", async () => { + mkdirSync(join(testDir, "node_modules/some-package"), { + recursive: true, + }); + writeFileSync( + join(testDir, "node_modules/some-package/index.js"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result).toBeNull(); + }); + + test("skips test directories", async () => { + // DSNs in test/ should be ignored (they contain test fixtures, not real config) + mkdirSync(join(testDir, "test/lib"), { recursive: true }); + writeFileSync( + join(testDir, "test/lib/scanner.test.ts"), + 'const DSN = "https://testkey@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result).toBeNull(); + + // Also test tests/ directory + mkdirSync(join(testDir, "tests/unit"), { recursive: true }); + writeFileSync( + join(testDir, "tests/unit/app.test.ts"), + 'const DSN = "https://testkey@o999.ingest.sentry.io/789";' + ); + + const allResult = await scanCodeForDsns(testDir); + expect(allResult.dsns).toHaveLength(0); + }); + + test("skips __mocks__ and fixtures directories", async () => { + mkdirSync(join(testDir, "__mocks__"), { recursive: true }); + writeFileSync( + join(testDir, "__mocks__/sentry.ts"), + 'export const DSN = "https://mock@o123.ingest.sentry.io/111";' + ); + mkdirSync(join(testDir, "fixtures"), { recursive: true }); + writeFileSync( + join(testDir, "fixtures/config.ts"), + 'export const DSN = "https://fixture@o123.ingest.sentry.io/222";' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(0); + }); + + test("still finds DSNs in src/ when test/ is skipped", async () => { + // Ensure test/ skipping doesn't affect real source directories + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/config.ts"), + 'const DSN = "https://realkey@o123.ingest.sentry.io/456";' + ); + mkdirSync(join(testDir, "test"), { recursive: true }); + writeFileSync( + join(testDir, "test/fixture.ts"), + 'const DSN = "https://fakekey@o999.ingest.sentry.io/999";' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(1); + expect(result.dsns[0].raw).toBe( + "https://realkey@o123.ingest.sentry.io/456" + ); + }); + + test("respects gitignore", async () => { + writeFileSync(join(testDir, ".gitignore"), "ignored/"); + mkdirSync(join(testDir, "ignored"), { recursive: true }); + writeFileSync( + join(testDir, "ignored/config.ts"), + 'const DSN = "https://ignored@o123.ingest.sentry.io/456";' + ); + writeFileSync( + join(testDir, "real.ts"), + 'const DSN = "https://real@o456.ingest.sentry.io/789";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://real@o456.ingest.sentry.io/789"); + }); + + test("infers packagePath for monorepo structure", async () => { + // Use depth 2 (packages/frontend/sentry.ts) to stay within MAX_SCAN_DEPTH + mkdirSync(join(testDir, "packages/frontend"), { recursive: true }); + writeFileSync( + join(testDir, "packages/frontend/sentry.ts"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.packagePath).toBe("packages/frontend"); + }); + + test("scans various file types", async () => { + // Test Python + writeFileSync( + join(testDir, "app.py"), + 'sentry_sdk.init(dsn="https://py@o123.ingest.sentry.io/1")' + ); + + let result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://py@o123.ingest.sentry.io/1"); + + // Clean and test Go + rmSync(join(testDir, "app.py")); + writeFileSync( + join(testDir, "main.go"), + 'sentry.Init(sentry.ClientOptions{Dsn: "https://go@o123.ingest.sentry.io/2"})' + ); + + result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://go@o123.ingest.sentry.io/2"); + + // Clean and test Ruby + rmSync(join(testDir, "main.go")); + writeFileSync( + join(testDir, "config.rb"), + 'Sentry.init do |config|\n config.dsn = "https://rb@o123.ingest.sentry.io/3"\nend' + ); + + result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://rb@o123.ingest.sentry.io/3"); + }); + }); + + describe("scanCodeForDsns", () => { + test("finds all DSNs across multiple files", async () => { + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/frontend.ts"), + 'const DSN = "https://frontend@o123.ingest.sentry.io/111";' + ); + writeFileSync( + join(testDir, "src/backend.ts"), + 'const DSN = "https://backend@o456.ingest.sentry.io/222";' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(2); + + const dsns = result.dsns.map((r) => r.raw); + expect(dsns).toContain("https://frontend@o123.ingest.sentry.io/111"); + expect(dsns).toContain("https://backend@o456.ingest.sentry.io/222"); + + // Verify mtimes are tracked for source files + expect(Object.keys(result.sourceMtimes)).toHaveLength(2); + expect(result.sourceMtimes["src/frontend.ts"]).toBeGreaterThan(0); + expect(result.sourceMtimes["src/backend.ts"]).toBeGreaterThan(0); + }); + + test("deduplicates same DSN from multiple files", async () => { + writeFileSync( + join(testDir, "a.ts"), + 'const DSN = "https://same@o123.ingest.sentry.io/456";' + ); + writeFileSync( + join(testDir, "b.ts"), + 'const DSN = "https://same@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(1); + // Both source files should be tracked even if DSN is deduplicated + expect(Object.keys(result.sourceMtimes)).toHaveLength(2); + }); + + test("returns empty result when no DSNs found", async () => { + writeFileSync(join(testDir, "index.ts"), "console.log('hello');"); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toEqual([]); + expect(result.sourceMtimes).toEqual({}); + }); + + test("skips files larger than 256 KB", async () => { + // Create a file that exceeds MAX_FILE_SIZE (256 * 1024 bytes) + const largePadding = "x".repeat(256 * 1024 + 1); + const content = `const DSN = "https://abc@o123.ingest.sentry.io/456";\n${largePadding}`; + writeFileSync(join(testDir, "large.ts"), content); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toEqual([]); + }); + + test("finds DSNs on long single-line files (>2000 chars, common in minified JS)", async () => { + // Regression: `grepFiles` defaults to `maxLineLength: 2000` which + // truncates `match.line` with a `…` suffix. The DSN scanner then + // re-runs `DSN_PATTERN` on that truncated line, so a DSN near the + // end of a long line would silently disappear (the pattern ends + // with `/\d+` — trailing digits get replaced by `…`). + // + // The scanner explicitly passes `maxLineLength: Infinity` to + // disable truncation, because memory is bounded by the walker's + // 256KB file cap. + const filler = "x".repeat(1990); + const dsn = "https://abc@o111222.ingest.us.sentry.io/7654321"; + const line = `${filler} const DSN = "${dsn}";`; + // Sanity: DSN starts past column 2000. + expect(line.indexOf(dsn)).toBeGreaterThan(2000); + writeFileSync(join(testDir, "minified.js"), line); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(1); + expect(result.dsns[0]?.raw).toBe(dsn); + }); + + test("finds DSNs in monorepo packages deeper than MAX_SCAN_DEPTH", async () => { + // packages/spotlight/src/instrument.ts is depth 3 from root, + // but with monorepo depth reset, packages/spotlight/ resets to 0 + // so src/instrument.ts is only depth 1 from the package root + mkdirSync(join(testDir, "packages/spotlight/src"), { recursive: true }); + writeFileSync( + join(testDir, "packages/spotlight/src/instrument.ts"), + 'Sentry.init({ dsn: "https://spotlight@o123.ingest.sentry.io/111" });' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(1); + expect(result.dsns[0]?.raw).toBe( + "https://spotlight@o123.ingest.sentry.io/111" + ); + expect(result.dsns[0]?.packagePath).toBe("packages/spotlight"); + }); + + test("finds DSNs from multiple monorepo packages", async () => { + mkdirSync(join(testDir, "packages/frontend/src"), { recursive: true }); + mkdirSync(join(testDir, "packages/backend/src"), { recursive: true }); + writeFileSync( + join(testDir, "packages/frontend/src/sentry.ts"), + 'const DSN = "https://fe@o123.ingest.sentry.io/111";' + ); + writeFileSync( + join(testDir, "packages/backend/src/sentry.ts"), + 'const DSN = "https://be@o456.ingest.sentry.io/222";' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(2); + + const dsns = result.dsns.map((d) => d.raw); + expect(dsns).toContain("https://fe@o123.ingest.sentry.io/111"); + expect(dsns).toContain("https://be@o456.ingest.sentry.io/222"); + + // Verify packagePath is set correctly for each + const feResult = result.dsns.find((d) => d.raw.includes("fe@")); + const beResult = result.dsns.find((d) => d.raw.includes("be@")); + expect(feResult?.packagePath).toBe("packages/frontend"); + expect(beResult?.packagePath).toBe("packages/backend"); + }); + + test("finds DSNs deeply nested in monorepo packages", async () => { + // packages/spotlight/src/electron/main/index.ts is depth 5 from root, + // but after monorepo reset at packages/spotlight/, it's depth 3 — + // exactly at MAX_SCAN_DEPTH. This was a specific failing case. + mkdirSync(join(testDir, "packages/spotlight/src/electron/main"), { + recursive: true, + }); + writeFileSync( + join(testDir, "packages/spotlight/src/electron/main/index.ts"), + 'Sentry.init({ dsn: "https://electron@o123.ingest.sentry.io/333" });' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toHaveLength(1); + expect(result.dsns[0]?.raw).toBe( + "https://electron@o123.ingest.sentry.io/333" + ); + expect(result.dsns[0]?.packagePath).toBe("packages/spotlight"); + }); + + test("respects depth limit for non-monorepo directories", async () => { + // src/very/deeply/nested/config.ts is depth 4 — beyond MAX_SCAN_DEPTH (3). + // Should NOT be found. This confirms the depth reset only applies to + // monorepo package directories, not arbitrary subdirectories. + mkdirSync(join(testDir, "src/very/deeply/nested"), { recursive: true }); + writeFileSync( + join(testDir, "src/very/deeply/nested/config.ts"), + 'const DSN = "https://deep@o123.ingest.sentry.io/999";' + ); + + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toEqual([]); + }); + + test("gracefully handles unreadable files", async () => { + const { chmodSync } = require("node:fs") as typeof import("node:fs"); + const filePath = join(testDir, "secret.ts"); + writeFileSync( + filePath, + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + chmodSync(filePath, 0o000); + + try { + const result = await scanCodeForDsns(testDir); + expect(result.dsns).toEqual([]); + } finally { + // Restore permissions so afterEach cleanup can delete + chmodSync(filePath, 0o644); + } + }); + + test("nonexistent root produces empty result, no partial state", async () => { + // Documents the contract that's hardened by the PR 791 review + // finding: any error path through `scanDirectory` returns all + // three result maps empty. The catch block used to leak a + // partial `dirMtimes` populated by `onDirectoryVisit` before + // the error — if cached, the verifier would only check visited + // dirs and miss new DSNs in unvisited ones. Fix: empty on error. + // + // (In practice the walker doesn't throw on a missing root — + // it simply yields nothing — so this is the happy-empty path. + // The mid-walk-throw case is hard to synthesize in a test but + // the fix covers it symmetrically via the catch block.) + const missingDir = join(testDir, "does-not-exist"); + const result = await scanCodeForDsns(missingDir); + expect(result.dsns).toEqual([]); + expect(result.sourceMtimes).toEqual({}); + expect(result.dirMtimes).toEqual({}); + }); + + test("dirMtimes is populated for every visited directory", async () => { + // PR 3 migrated the walker; dirMtimes comes from the + // `onDirectoryVisit` hook. This pins the invariant the cache + // verifier (`src/lib/db/dsn-cache.ts::validateDirMtime`) silently + // depends on — every directory entered by the walker must have + // a finite integer mtime in the result. Previously asserted by + // nothing; now explicit. + mkdirSync(join(testDir, "src", "lib", "deep"), { recursive: true }); + writeFileSync(join(testDir, "src/a.ts"), "// no dsn"); + writeFileSync(join(testDir, "src/lib/b.ts"), "// no dsn"); + writeFileSync(join(testDir, "src/lib/deep/c.ts"), "// no dsn"); + + const result = await scanCodeForDsns(testDir); + // Keys are POSIX-normalized relative paths; "." for cwd. + const keys = Object.keys(result.dirMtimes).sort(); + expect(keys).toContain("."); + expect(keys).toContain("src"); + expect(keys).toContain("src/lib"); + expect(keys).toContain("src/lib/deep"); + // Values are floored integers matching the cache verifier's math. + for (const [dir, mtime] of Object.entries(result.dirMtimes)) { + expect(Number.isInteger(mtime)).toBe(true); + expect(mtime).toBeGreaterThan(0); + // Sanity: key should never be an absolute path. + expect(dir.startsWith("/")).toBe(false); + } + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/detector.test.ts b/packages/cli/test/lib/dsn/detector.test.ts new file mode 100644 index 000000000..4676edca2 --- /dev/null +++ b/packages/cli/test/lib/dsn/detector.test.ts @@ -0,0 +1,451 @@ +/** + * DSN Detector Tests (New Module) + * + * Tests for the new cached DSN detection with conflict detection. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { clearDsnCache, getCachedDsn } from "../../../src/lib/db/dsn-cache.js"; +import { + detectAllDsns, + detectDsn, + getDsnSourceDescription, +} from "../../../src/lib/dsn/detector.js"; +import { useTestConfigDir } from "../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-dsn-detector-"); + +describe("DSN Detector (New Module)", () => { + let testDir: string; + + beforeEach(async () => { + // Create project dir inside the config dir managed by useTestConfigDir. + // Add .git to create a project root boundary so detectDsn doesn't + // walk up into the real project and find its DSNs. + testDir = join(getConfigDir(), "project"); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, ".git"), { recursive: true }); + // Clear any cached DSN for the test directory + clearDsnCache(testDir); + // Clear SENTRY_DSN env var + delete process.env.SENTRY_DSN; + }); + + afterEach(() => { + delete process.env.SENTRY_DSN; + }); + + describe("detectDsn with caching", () => { + test("caches DSN after first detection", async () => { + const dsn = "https://key@o123.ingest.sentry.io/456"; + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${dsn}`); + + // First detection + const result1 = await detectDsn(testDir); + expect(result1?.raw).toBe(dsn); + + // Check cache was created + const cached = getCachedDsn(testDir); + expect(cached).toBeDefined(); + expect(cached?.dsn).toBe(dsn); + expect(cached?.source).toBe("env_file"); + expect(cached?.sourcePath).toBe(".env"); + + // Second detection should use cache + const result2 = await detectDsn(testDir); + expect(result2?.raw).toBe(dsn); + }); + + test("updates cache when DSN changes", async () => { + const dsn1 = "https://key1@o111.ingest.sentry.io/111"; + const dsn2 = "https://key2@o222.ingest.sentry.io/222"; + + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${dsn1}`); + + // First detection + const result1 = await detectDsn(testDir); + expect(result1?.raw).toBe(dsn1); + + // Change DSN + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${dsn2}`); + + // Second detection should detect change + const result2 = await detectDsn(testDir); + expect(result2?.raw).toBe(dsn2); + + // Cache should be updated + const cached = getCachedDsn(testDir); + expect(cached?.dsn).toBe(dsn2); + }); + + test("code DSN takes priority over env file", async () => { + const envFileDsn = "https://file@o111.ingest.sentry.io/111"; + const codeDsn = "https://code@o222.ingest.sentry.io/222"; + + // Set up both env file and code file + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${envFileDsn}`); + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/config.ts"), + `Sentry.init({ dsn: "${codeDsn}" })` + ); + + // Code DSN takes priority over .env file DSN + // Priority order: code > env_file > env_var + const result = await detectDsn(testDir); + expect(result?.raw).toBe(codeDsn); + expect(result?.source).toBe("code"); + }); + + test("code DSN takes priority over env var", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + const codeDsn = "https://code@o222.ingest.sentry.io/222"; + + // Set env var + process.env.SENTRY_DSN = envVarDsn; + + // Set up code file + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/config.ts"), + `Sentry.init({ dsn: "${codeDsn}" })` + ); + + // Should return code DSN (highest priority) + const result = await detectDsn(testDir); + expect(result?.raw).toBe(codeDsn); + expect(result?.source).toBe("code"); + }); + + test("env file takes priority over env var", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + const envFileDsn = "https://file@o222.ingest.sentry.io/222"; + + // Set env var + process.env.SENTRY_DSN = envVarDsn; + + // Set up env file + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${envFileDsn}`); + + // Should return env file DSN (higher priority than env var) + const result = await detectDsn(testDir); + expect(result?.raw).toBe(envFileDsn); + expect(result?.source).toBe("env_file"); + }); + + test("env var is used when no code or env file exists", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + + // Only set env var + process.env.SENTRY_DSN = envVarDsn; + + // Should return env var DSN (lowest priority, but only one available) + const result = await detectDsn(testDir); + expect(result?.raw).toBe(envVarDsn); + expect(result?.source).toBe("env"); + }); + + test("env var DSN is cached and verified without full scan", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + const changedDsn = "https://changed@o222.ingest.sentry.io/222"; + + // Only set env var + process.env.SENTRY_DSN = envVarDsn; + + // First detection - should detect and cache + const result1 = await detectDsn(testDir); + expect(result1?.raw).toBe(envVarDsn); + expect(result1?.source).toBe("env"); + + // Verify it's cached + const cached = getCachedDsn(testDir); + expect(cached?.dsn).toBe(envVarDsn); + expect(cached?.source).toBe("env"); + + // Second detection - should use cache verification (not full scan) + const result2 = await detectDsn(testDir); + expect(result2?.raw).toBe(envVarDsn); + expect(result2?.source).toBe("env"); + + // Change env var - should detect the change + process.env.SENTRY_DSN = changedDsn; + const result3 = await detectDsn(testDir); + expect(result3?.raw).toBe(changedDsn); + expect(result3?.source).toBe("env"); + + // Cache should be updated + const updatedCache = getCachedDsn(testDir); + expect(updatedCache?.dsn).toBe(changedDsn); + }); + + test("cache verification respects priority when code DSN is added after env var", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + const codeDsn = "https://code@o222.ingest.sentry.io/222"; + + // First, detect with only env var + process.env.SENTRY_DSN = envVarDsn; + const result1 = await detectDsn(testDir); + expect(result1?.raw).toBe(envVarDsn); + expect(result1?.source).toBe("env"); + + // Verify it's cached + const cached = getCachedDsn(testDir); + expect(cached?.source).toBe("env"); + + // Now add a code DSN (higher priority) + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/config.ts"), + `Sentry.init({ dsn: "${codeDsn}" })` + ); + + // Next detection should find the code DSN (higher priority) + // even though env var is still cached + const result2 = await detectDsn(testDir); + expect(result2?.raw).toBe(codeDsn); + expect(result2?.source).toBe("code"); + + // Cache should be updated to code DSN + const updatedCache = getCachedDsn(testDir); + expect(updatedCache?.dsn).toBe(codeDsn); + expect(updatedCache?.source).toBe("code"); + }); + + test("skips node_modules and dist directories", async () => { + const nodeModulesDsn = "https://nm@o111.ingest.sentry.io/111"; + const distDsn = "https://dist@o222.ingest.sentry.io/222"; + + // Put DSNs in directories that should be skipped + mkdirSync(join(testDir, "node_modules/some-package"), { + recursive: true, + }); + writeFileSync( + join(testDir, "node_modules/some-package/index.js"), + `Sentry.init({ dsn: "${nodeModulesDsn}" })` + ); + + mkdirSync(join(testDir, "dist"), { recursive: true }); + writeFileSync( + join(testDir, "dist/bundle.js"), + `Sentry.init({ dsn: "${distDsn}" })` + ); + + // Should not find any DSN (skipped directories) + const result = await detectDsn(testDir); + expect(result).toBeNull(); + }); + }); + + describe("detectAllDsns (monorepo support)", () => { + test("detects single DSN", async () => { + const dsn = "https://key@o123.ingest.sentry.io/456"; + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${dsn}`); + + const result = await detectAllDsns(testDir); + + expect(result.hasMultiple).toBe(false); + expect(result.primary?.raw).toBe(dsn); + expect(result.all).toHaveLength(1); + }); + + test("detects multiple DSNs in different files", async () => { + const dsn1 = "https://a@o111.ingest.sentry.io/111"; + const dsn2 = "https://b@o222.ingest.sentry.io/222"; + + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${dsn1}`); + writeFileSync(join(testDir, ".env.local"), `SENTRY_DSN=${dsn2}`); + + const result = await detectAllDsns(testDir); + + expect(result.hasMultiple).toBe(true); + // Primary is now first found, not null + expect(result.primary?.raw).toBe(dsn2); // .env.local has higher priority + expect(result.all).toHaveLength(2); + }); + + test("deduplicates same DSN in multiple files", async () => { + const dsn = "https://key@o123.ingest.sentry.io/456"; + + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${dsn}`); + writeFileSync(join(testDir, ".env.local"), `SENTRY_DSN=${dsn}`); + + const result = await detectAllDsns(testDir); + + expect(result.hasMultiple).toBe(false); + expect(result.primary?.raw).toBe(dsn); + // Should dedupe + expect(result.all).toHaveLength(1); + }); + + test("detects multiple DSNs from env file and code", async () => { + const envDsn = "https://env@o111.ingest.sentry.io/111"; + const codeDsn = "https://code@o222.ingest.sentry.io/222"; + + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${envDsn}`); + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/config.ts"), + `Sentry.init({ dsn: "${codeDsn}" })` + ); + + const result = await detectAllDsns(testDir); + + expect(result.hasMultiple).toBe(true); + // Code DSNs have highest priority (code > env_file > env_var) + expect(result.primary?.raw).toBe(codeDsn); + expect(result.all).toHaveLength(2); + }); + + test("includes env var in detection", async () => { + const envVarDsn = "https://var@o111.ingest.sentry.io/111"; + const envFileDsn = "https://file@o222.ingest.sentry.io/222"; + + process.env.SENTRY_DSN = envVarDsn; + writeFileSync(join(testDir, ".env"), `SENTRY_DSN=${envFileDsn}`); + + const result = await detectAllDsns(testDir); + + expect(result.hasMultiple).toBe(true); + expect(result.all).toHaveLength(2); + expect(result.all.map((d) => d.raw)).toContain(envVarDsn); + expect(result.all.map((d) => d.raw)).toContain(envFileDsn); + }); + + test("detects DSNs in monorepo package directories", async () => { + const frontendDsn = "https://frontend@o111.ingest.sentry.io/111"; + const backendDsn = "https://backend@o222.ingest.sentry.io/222"; + + // Create monorepo structure + mkdirSync(join(testDir, "packages/frontend"), { recursive: true }); + mkdirSync(join(testDir, "packages/backend"), { recursive: true }); + + writeFileSync( + join(testDir, "packages/frontend/.env"), + `SENTRY_DSN=${frontendDsn}` + ); + writeFileSync( + join(testDir, "packages/backend/.env"), + `SENTRY_DSN=${backendDsn}` + ); + + const result = await detectAllDsns(testDir); + + expect(result.hasMultiple).toBe(true); + expect(result.all).toHaveLength(2); + expect(result.all.map((d) => d.raw)).toContain(frontendDsn); + expect(result.all.map((d) => d.raw)).toContain(backendDsn); + + // Check packagePath is set correctly + const frontend = result.all.find((d) => d.raw === frontendDsn); + const backend = result.all.find((d) => d.raw === backendDsn); + expect(frontend?.packagePath).toBe("packages/frontend"); + expect(backend?.packagePath).toBe("packages/backend"); + }); + + test("detects DSNs in apps directory", async () => { + const webDsn = "https://web@o111.ingest.sentry.io/111"; + const mobileDsn = "https://mobile@o222.ingest.sentry.io/222"; + + // Create apps structure (common in Turborepo) + mkdirSync(join(testDir, "apps/web"), { recursive: true }); + mkdirSync(join(testDir, "apps/mobile"), { recursive: true }); + + writeFileSync(join(testDir, "apps/web/.env"), `SENTRY_DSN=${webDsn}`); + writeFileSync( + join(testDir, "apps/mobile/.env"), + `SENTRY_DSN=${mobileDsn}` + ); + + const result = await detectAllDsns(testDir); + + expect(result.hasMultiple).toBe(true); + expect(result.all).toHaveLength(2); + + const web = result.all.find((d) => d.raw === webDsn); + const mobile = result.all.find((d) => d.raw === mobileDsn); + expect(web?.packagePath).toBe("apps/web"); + expect(mobile?.packagePath).toBe("apps/mobile"); + }); + }); + + describe("getDsnSourceDescription", () => { + test("describes env source", () => { + const dsn = { + raw: "https://key@o1.ingest.sentry.io/1", + source: "env" as const, + protocol: "https", + publicKey: "key", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + }; + + expect(getDsnSourceDescription(dsn)).toBe( + "SENTRY_DSN environment variable" + ); + }); + + test("describes env source with framework-prefixed var name", () => { + const dsn = { + raw: "https://key@o1.ingest.sentry.io/1", + source: "env" as const, + sourcePath: "NEXT_PUBLIC_SENTRY_DSN", + protocol: "https", + publicKey: "key", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + }; + + expect(getDsnSourceDescription(dsn)).toBe( + "NEXT_PUBLIC_SENTRY_DSN environment variable" + ); + }); + + test("describes env_file source with path", () => { + const dsn = { + raw: "https://key@o1.ingest.sentry.io/1", + source: "env_file" as const, + sourcePath: ".env.local", + protocol: "https", + publicKey: "key", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + }; + + expect(getDsnSourceDescription(dsn)).toBe(".env.local"); + }); + + test("describes code source with path", () => { + const dsn = { + raw: "https://key@o1.ingest.sentry.io/1", + source: "code" as const, + sourcePath: "src/instrumentation.ts", + protocol: "https", + publicKey: "key", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + }; + + expect(getDsnSourceDescription(dsn)).toBe("src/instrumentation.ts"); + }); + + test("describes inferred source", () => { + const dsn = { + raw: "https://key@o1.ingest.sentry.io/1", + source: "inferred" as const, + protocol: "https", + publicKey: "key", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + }; + + expect(getDsnSourceDescription(dsn)).toBe("directory name inference"); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/env-file.test.ts b/packages/cli/test/lib/dsn/env-file.test.ts new file mode 100644 index 000000000..1dc945694 --- /dev/null +++ b/packages/cli/test/lib/dsn/env-file.test.ts @@ -0,0 +1,470 @@ +/** + * Environment File Detection Tests + * + * Tests for .env file parsing and DSN extraction. + * Includes property-based tests for the pure parsing function + * and integration tests for file-system based detection. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + constantFrom, + assert as fcAssert, + oneof, + property, + string, +} from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + detectFromAllEnvFiles, + detectFromEnvFiles, + detectFromMonorepoEnvFiles, + ENV_FILES, + extractDsnFromEnvContent, +} from "../../../src/lib/dsn/env-file.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; + +// ============================================================================ +// Arbitraries for Property-Based Testing +// ============================================================================ + +/** Valid DSN format for testing */ +const validDsnArb = string({ minLength: 1, maxLength: 20 }).map( + (key) => `https://${key.replace(/[^a-z0-9]/gi, "a")}@sentry.io/123` +); + +/** Generate content without SENTRY_DSN */ +const envContentWithoutDsnArb = oneof( + constantFrom("", "# Just a comment", "OTHER_VAR=value", "SENTRY_OTHER=value") +); + +// ============================================================================ +// Property Tests for extractDsnFromEnvContent +// ============================================================================ + +describe("property: extractDsnFromEnvContent", () => { + test("extracts DSN from unquoted value", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const content = `SENTRY_DSN=${dsn}`; + expect(extractDsnFromEnvContent(content)).toBe(dsn); + }), + { numRuns: 50 } + ); + }); + + test("extracts DSN from double-quoted value", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const content = `SENTRY_DSN="${dsn}"`; + expect(extractDsnFromEnvContent(content)).toBe(dsn); + }), + { numRuns: 50 } + ); + }); + + test("extracts DSN from single-quoted value", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const content = `SENTRY_DSN='${dsn}'`; + expect(extractDsnFromEnvContent(content)).toBe(dsn); + }), + { numRuns: 50 } + ); + }); + + test("returns null when no SENTRY_DSN present", () => { + fcAssert( + property(envContentWithoutDsnArb, (content) => { + expect(extractDsnFromEnvContent(content)).toBeNull(); + }), + { numRuns: 50 } + ); + }); + + test("ignores lines after finding SENTRY_DSN", () => { + fcAssert( + property(validDsnArb, validDsnArb, (dsn1, dsn2) => { + const content = `SENTRY_DSN=${dsn1}\nSENTRY_DSN=${dsn2}`; + // Should return the first one + expect(extractDsnFromEnvContent(content)).toBe(dsn1); + }), + { numRuns: 30 } + ); + }); + + test("handles whitespace around equals sign", () => { + fcAssert( + property(validDsnArb, (dsn) => { + const content = `SENTRY_DSN = ${dsn}`; + expect(extractDsnFromEnvContent(content)).toBe(dsn); + }), + { numRuns: 30 } + ); + }); +}); + +// ============================================================================ +// Unit Tests for extractDsnFromEnvContent Edge Cases +// ============================================================================ + +describe("extractDsnFromEnvContent edge cases", () => { + test("returns null for empty string", () => { + expect(extractDsnFromEnvContent("")).toBeNull(); + }); + + test("returns null for only comments", () => { + const content = `# This is a comment +# Another comment +# SENTRY_DSN=commented-out`; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("returns null for only whitespace", () => { + expect(extractDsnFromEnvContent(" \n\t\n ")).toBeNull(); + }); + + test("ignores commented SENTRY_DSN", () => { + const content = `# SENTRY_DSN=commented-out +SENTRY_DSN=https://real@sentry.io/123`; + expect(extractDsnFromEnvContent(content)).toBe( + "https://real@sentry.io/123" + ); + }); + + test("handles trailing comment on same line", () => { + const content = "SENTRY_DSN=https://key@sentry.io/123 # This is a comment"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("handles mixed content with SENTRY_DSN in middle", () => { + const content = ` +OTHER_VAR=foo +# Comment +SENTRY_DSN=https://key@sentry.io/123 +ANOTHER_VAR=bar +`; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("handles Windows line endings (CRLF)", () => { + const content = "OTHER=foo\r\nSENTRY_DSN=https://key@sentry.io/123\r\n"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("handles value with equals signs", () => { + const content = "SENTRY_DSN=https://key@sentry.io/123?key=value"; + expect(extractDsnFromEnvContent(content)).toBe( + "https://key@sentry.io/123?key=value" + ); + }); + + test("handles empty value", () => { + const content = "SENTRY_DSN="; + // Empty string is falsy, so should return null + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("handles value with spaces in quotes", () => { + const content = `SENTRY_DSN="https://key@sentry.io/123"`; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("ignores SENTRY_DSN_OTHER variations", () => { + const content = `SENTRY_DSN_OTHER=https://wrong@sentry.io/123 +SENTRY_DSN=https://correct@sentry.io/456`; + expect(extractDsnFromEnvContent(content)).toBe( + "https://correct@sentry.io/456" + ); + }); + + test("handles leading whitespace on line", () => { + const content = " SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); +}); + +// ============================================================================ +// Framework-Prefixed Env Var Tests +// ============================================================================ + +describe("extractDsnFromEnvContent framework-prefixed vars", () => { + test("extracts DSN from NEXT_PUBLIC_SENTRY_DSN", () => { + const content = "NEXT_PUBLIC_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from REACT_APP_SENTRY_DSN", () => { + const content = 'REACT_APP_SENTRY_DSN="https://key@sentry.io/123"'; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from VITE_SENTRY_DSN", () => { + const content = "VITE_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from EXPO_PUBLIC_SENTRY_DSN", () => { + const content = "EXPO_PUBLIC_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from NUXT_PUBLIC_SENTRY_DSN", () => { + const content = "NUXT_PUBLIC_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("canonical SENTRY_DSN is returned when it appears first", () => { + const content = `SENTRY_DSN=https://canonical@sentry.io/1 +NEXT_PUBLIC_SENTRY_DSN=https://next@sentry.io/2`; + expect(extractDsnFromEnvContent(content)).toBe( + "https://canonical@sentry.io/1" + ); + }); + + test("framework-prefixed var returned when it appears before canonical", () => { + const content = `NEXT_PUBLIC_SENTRY_DSN=https://next@sentry.io/2 +SENTRY_DSN=https://canonical@sentry.io/1`; + expect(extractDsnFromEnvContent(content)).toBe("https://next@sentry.io/2"); + }); + + test("rejects unknown prefix MY_CUSTOM_SENTRY_DSN", () => { + const content = "MY_CUSTOM_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("rejects unknown prefix GATSBY_SENTRY_DSN", () => { + const content = "GATSBY_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("rejects bare underscore prefix _SENTRY_DSN", () => { + const content = "_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("rejects VUE_APP_SENTRY_DSN (not in allowlist)", () => { + const content = "VUE_APP_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); +}); + +// ============================================================================ +// Integration Tests for File-Based Detection +// ============================================================================ + +describe("integration: file-based detection", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestConfigDir("test-env-file-"); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + describe("detectFromEnvFiles", () => { + test("returns null when no .env files exist", async () => { + const result = await detectFromEnvFiles(testDir); + expect(result).toBeNull(); + }); + + test("detects DSN from .env file", async () => { + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://key@sentry.io/123" + ); + + const result = await detectFromEnvFiles(testDir); + expect(result).not.toBeNull(); + expect(result?.raw).toBe("https://key@sentry.io/123"); + expect(result?.source).toBe("env_file"); + }); + + test("detects DSN from .env.local with higher priority", async () => { + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://default@sentry.io/1" + ); + writeFileSync( + join(testDir, ".env.local"), + "SENTRY_DSN=https://local@sentry.io/2" + ); + + const result = await detectFromEnvFiles(testDir); + expect(result).not.toBeNull(); + // .env.local has higher priority than .env + expect(result?.raw).toBe("https://local@sentry.io/2"); + }); + + test("returns null when .env exists but has no DSN", async () => { + writeFileSync(join(testDir, ".env"), "OTHER_VAR=value"); + + const result = await detectFromEnvFiles(testDir); + expect(result).toBeNull(); + }); + + test("returns null when .env contains invalid DSN", async () => { + writeFileSync(join(testDir, ".env"), "SENTRY_DSN=not-a-valid-dsn"); + + const result = await detectFromEnvFiles(testDir); + // Invalid DSN should be parsed but createDetectedDsn may return null + // depending on DSN validation + // For this test, we just verify it doesn't crash + expect(result === null || result?.raw === "not-a-valid-dsn").toBe(true); + }); + + test("respects ENV_FILES priority order", async () => { + // Create multiple files + writeFileSync( + join(testDir, ".env.development"), + "SENTRY_DSN=https://dev@sentry.io/3" + ); + writeFileSync( + join(testDir, ".env.local"), + "SENTRY_DSN=https://local@sentry.io/2" + ); + + const result = await detectFromEnvFiles(testDir); + // .env.local should be checked before .env.development + expect(result?.raw).toBe("https://local@sentry.io/2"); + }); + }); + + describe("detectFromAllEnvFiles", () => { + test("returns empty result when no .env files exist", async () => { + const result = await detectFromAllEnvFiles(testDir); + expect(result.dsns).toHaveLength(0); + expect(Object.keys(result.sourceMtimes)).toHaveLength(0); + }); + + test("detects multiple DSNs from different .env files", async () => { + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://default@sentry.io/1" + ); + writeFileSync( + join(testDir, ".env.local"), + "SENTRY_DSN=https://local@sentry.io/2" + ); + + const result = await detectFromAllEnvFiles(testDir); + // Should find DSNs in both files (not stop at first) + expect(result.dsns).toHaveLength(2); + + const rawDsns = result.dsns.map((d) => d.raw).sort(); + expect(rawDsns).toContain("https://default@sentry.io/1"); + expect(rawDsns).toContain("https://local@sentry.io/2"); + }); + + test("includes source mtimes for caching", async () => { + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://key@sentry.io/123" + ); + + const result = await detectFromAllEnvFiles(testDir); + expect(result.dsns).toHaveLength(1); + expect(Object.keys(result.sourceMtimes)).toHaveLength(1); + expect(result.sourceMtimes[".env"]).toBeGreaterThan(0); + }); + }); + + describe("detectFromMonorepoEnvFiles", () => { + test("returns empty result when no monorepo dirs exist", async () => { + const result = await detectFromMonorepoEnvFiles(testDir); + expect(result.dsns).toHaveLength(0); + }); + + test("detects DSN in packages/ subdirectory", async () => { + const pkgDir = join(testDir, "packages", "frontend"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, ".env"), + "SENTRY_DSN=https://frontend@sentry.io/1" + ); + + const result = await detectFromMonorepoEnvFiles(testDir); + expect(result.dsns).toHaveLength(1); + expect(result.dsns[0].raw).toBe("https://frontend@sentry.io/1"); + expect(result.dsns[0].packagePath).toBe("packages/frontend"); + }); + + test("detects DSN in apps/ subdirectory", async () => { + const appDir = join(testDir, "apps", "web"); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(appDir, ".env"), "SENTRY_DSN=https://web@sentry.io/2"); + + const result = await detectFromMonorepoEnvFiles(testDir); + expect(result.dsns).toHaveLength(1); + expect(result.dsns[0].raw).toBe("https://web@sentry.io/2"); + expect(result.dsns[0].packagePath).toBe("apps/web"); + }); + + test("detects DSNs from multiple monorepo packages", async () => { + // Create packages/ + const pkg1 = join(testDir, "packages", "frontend"); + const pkg2 = join(testDir, "packages", "backend"); + mkdirSync(pkg1, { recursive: true }); + mkdirSync(pkg2, { recursive: true }); + writeFileSync( + join(pkg1, ".env"), + "SENTRY_DSN=https://frontend@sentry.io/1" + ); + writeFileSync( + join(pkg2, ".env"), + "SENTRY_DSN=https://backend@sentry.io/2" + ); + + const result = await detectFromMonorepoEnvFiles(testDir); + expect(result.dsns).toHaveLength(2); + + const rawDsns = result.dsns.map((d) => d.raw).sort(); + expect(rawDsns).toContain("https://frontend@sentry.io/1"); + expect(rawDsns).toContain("https://backend@sentry.io/2"); + }); + + test("ignores packages without .env files", async () => { + const pkg1 = join(testDir, "packages", "with-dsn"); + const pkg2 = join(testDir, "packages", "without-dsn"); + mkdirSync(pkg1, { recursive: true }); + mkdirSync(pkg2, { recursive: true }); + writeFileSync(join(pkg1, ".env"), "SENTRY_DSN=https://key@sentry.io/1"); + writeFileSync(join(pkg2, "package.json"), "{}"); // No .env file + + const result = await detectFromMonorepoEnvFiles(testDir); + expect(result.dsns).toHaveLength(1); + expect(result.dsns[0].packagePath).toBe("packages/with-dsn"); + }); + + test("ignores files in monorepo root (only scans subdirs)", async () => { + mkdirSync(join(testDir, "packages"), { recursive: true }); + // This file should be ignored (not in a package subdir) + writeFileSync( + join(testDir, "packages", ".env"), + "SENTRY_DSN=https://root@sentry.io/0" + ); + + const result = await detectFromMonorepoEnvFiles(testDir); + // Should not detect the .env in packages/ root + expect(result.dsns).toHaveLength(0); + }); + }); +}); // end integration: file-based detection + +describe("ENV_FILES constant", () => { + test("contains expected file names", () => { + expect(ENV_FILES).toContain(".env"); + expect(ENV_FILES).toContain(".env.local"); + expect(ENV_FILES).toContain(".env.development"); + expect(ENV_FILES).toContain(".env.production"); + }); + + test("has .env.local before .env (priority order)", () => { + const localIndex = ENV_FILES.indexOf(".env.local"); + const envIndex = ENV_FILES.indexOf(".env"); + expect(localIndex).toBeLessThan(envIndex); + }); +}); diff --git a/packages/cli/test/lib/dsn/env.test.ts b/packages/cli/test/lib/dsn/env.test.ts new file mode 100644 index 000000000..4256fd1f5 --- /dev/null +++ b/packages/cli/test/lib/dsn/env.test.ts @@ -0,0 +1,133 @@ +/** + * Runtime Environment Variable Detection Tests + * + * Tests for detecting Sentry DSN from process.env, including + * framework-prefixed variants (NEXT_PUBLIC_SENTRY_DSN, etc.). + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { detectFromEnv, SENTRY_DSN_ENV } from "../../../src/lib/dsn/env.js"; + +const VALID_DSN = "https://abc123@o1.ingest.us.sentry.io/456"; +const VALID_DSN_2 = "https://def456@o2.ingest.us.sentry.io/789"; +const INVALID_DSN = "not-a-valid-dsn"; + +/** All env var names this module may read */ +const ALL_DSN_VARS = [ + "SENTRY_DSN", + "NEXT_PUBLIC_SENTRY_DSN", + "REACT_APP_SENTRY_DSN", + "VITE_SENTRY_DSN", + "EXPO_PUBLIC_SENTRY_DSN", + "NUXT_PUBLIC_SENTRY_DSN", +] as const; + +/** Saved env values for cleanup */ +let savedEnv: Record; + +beforeEach(() => { + savedEnv = {}; + for (const key of ALL_DSN_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ALL_DSN_VARS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } +}); + +describe("detectFromEnv", () => { + test("returns null when no DSN env vars are set", () => { + expect(detectFromEnv()).toBeNull(); + }); + + test("detects canonical SENTRY_DSN", () => { + process.env.SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result).not.toBeNull(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.source).toBe("env"); + expect(result?.sourcePath).toBe(SENTRY_DSN_ENV); + }); + + test("detects NEXT_PUBLIC_SENTRY_DSN", () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result).not.toBeNull(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("NEXT_PUBLIC_SENTRY_DSN"); + }); + + test("detects REACT_APP_SENTRY_DSN", () => { + process.env.REACT_APP_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("REACT_APP_SENTRY_DSN"); + }); + + test("detects VITE_SENTRY_DSN", () => { + process.env.VITE_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("VITE_SENTRY_DSN"); + }); + + test("detects EXPO_PUBLIC_SENTRY_DSN", () => { + process.env.EXPO_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("EXPO_PUBLIC_SENTRY_DSN"); + }); + + test("detects NUXT_PUBLIC_SENTRY_DSN", () => { + process.env.NUXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("NUXT_PUBLIC_SENTRY_DSN"); + }); + + test("canonical SENTRY_DSN takes priority over framework-prefixed vars", () => { + process.env.SENTRY_DSN = VALID_DSN; + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN_2; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe(SENTRY_DSN_ENV); + }); + + test("skips invalid DSN in SENTRY_DSN and falls through to framework var", () => { + process.env.SENTRY_DSN = INVALID_DSN; + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result).not.toBeNull(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("NEXT_PUBLIC_SENTRY_DSN"); + }); + + test("skips invalid DSN in framework var and continues to next", () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = INVALID_DSN; + process.env.VITE_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("VITE_SENTRY_DSN"); + }); + + test("returns null when all set vars contain invalid DSNs", () => { + process.env.SENTRY_DSN = INVALID_DSN; + process.env.NEXT_PUBLIC_SENTRY_DSN = "also-not-valid"; + expect(detectFromEnv()).toBeNull(); + }); + + test("ignores empty string values", () => { + process.env.SENTRY_DSN = ""; + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + }); +}); diff --git a/packages/cli/test/lib/dsn/errors.test.ts b/packages/cli/test/lib/dsn/errors.test.ts new file mode 100644 index 000000000..8015d78b9 --- /dev/null +++ b/packages/cli/test/lib/dsn/errors.test.ts @@ -0,0 +1,374 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + formatConflictError, + formatMultipleProjectsFooter, + formatNoDsnError, + formatResolutionError, +} from "../../../src/lib/dsn/errors.js"; +import type { + DetectedDsn, + DsnDetectionResult, +} from "../../../src/lib/dsn/types.js"; + +// Mock api-client to prevent real API calls from getAccessibleProjects +const { mockListOrganizations, mockListProjects } = vi.hoisted(() => ({ + mockListOrganizations: vi.fn(() => Promise.resolve([])), + mockListProjects: vi.fn(() => Promise.resolve([])), +})); + +vi.mock("../../../src/lib/api-client.js", () => ({ + listOrganizations: mockListOrganizations, + listProjects: mockListProjects, + findProjectByDsnKey: vi.fn(() => Promise.resolve(null)), +})); + +describe("formatConflictError", () => { + test("formats error with two conflicting DSNs", () => { + const result: DsnDetectionResult = { + primary: null, + all: [ + { + raw: "https://abc123@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc123", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env_file", + sourcePath: ".env", + }, + { + raw: "https://def456@o789.ingest.sentry.io/101112", + protocol: "https", + publicKey: "def456", + host: "o789.ingest.sentry.io", + projectId: "101112", + orgId: "789", + source: "code", + sourcePath: "src/sentry.ts", + }, + ], + hasMultiple: true, + fingerprint: "", + }; + + const error = formatConflictError(result); + + expect(error).toContain("Error: Multiple Sentry DSNs detected"); + expect(error).toContain("DSN 1:"); + expect(error).toContain("abc123@o123.ingest.sentry.io/456"); + expect(error).toContain("DSN 2:"); + expect(error).toContain("def456@o789.ingest.sentry.io/101112"); + expect(error).toContain("Project ID: 456"); + expect(error).toContain("Project ID: 101112"); + expect(error).toContain("sentry /"); + expect(error).toContain("sentry config set defaults.org"); + }); + + test("formats error with DSN without projectId", () => { + const result: DsnDetectionResult = { + primary: null, + all: [ + { + raw: "https://abc123@sentry.io/456", + protocol: "https", + publicKey: "abc123", + host: "sentry.io", + projectId: "456", + source: "env", + }, + { + raw: "https://xyz789@sentry.io/789", + protocol: "https", + publicKey: "xyz789", + host: "sentry.io", + // No projectId on this one intentionally + source: "code", + sourcePath: "app.js", + } as DetectedDsn, + ], + hasMultiple: true, + fingerprint: "", + }; + + const error = formatConflictError(result); + + expect(error).toContain("DSN 1:"); + expect(error).toContain("DSN 2:"); + // First one has projectId, second doesn't + expect(error).toContain("Project ID: 456"); + }); + + test("formats error with single DSN (edge case)", () => { + const result: DsnDetectionResult = { + primary: null, + all: [ + { + raw: "https://single@o1.ingest.sentry.io/1", + protocol: "https", + publicKey: "single", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + source: "env", + }, + ], + hasMultiple: false, + fingerprint: "", + }; + + const error = formatConflictError(result); + + expect(error).toContain("DSN 1:"); + expect(error).not.toContain("DSN 2:"); + }); + + test("includes source description for each DSN", () => { + const result: DsnDetectionResult = { + primary: null, + all: [ + { + raw: "https://a@o1.ingest.sentry.io/1", + protocol: "https", + publicKey: "a", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + source: "env", + }, + { + raw: "https://b@o2.ingest.sentry.io/2", + protocol: "https", + publicKey: "b", + host: "o2.ingest.sentry.io", + projectId: "2", + orgId: "2", + source: "env_file", + sourcePath: ".env.local", + }, + { + raw: "https://c@o3.ingest.sentry.io/3", + protocol: "https", + publicKey: "c", + host: "o3.ingest.sentry.io", + projectId: "3", + orgId: "3", + source: "code", + sourcePath: "config/sentry.js", + }, + ], + hasMultiple: true, + fingerprint: "", + }; + + const error = formatConflictError(result); + + expect(error).toContain("Source:"); + // env source + expect(error).toMatch(/Source:.*SENTRY_DSN/i); + }); +}); + +describe("formatNoDsnError", () => { + beforeEach(() => { + mockListOrganizations.mockReset(); + mockListProjects.mockReset(); + }); + + test("formats basic error message with search locations", async () => { + mockListOrganizations.mockResolvedValue([]); + + const error = await formatNoDsnError("/path/to/project", false); + + expect(error).toContain("No Sentry DSN detected in /path/to/project"); + expect(error).toContain("SENTRY_DSN environment variable"); + expect(error).toContain(".env files"); + expect(error).toContain("JavaScript/TypeScript source code"); + expect(error).toContain("export SENTRY_DSN="); + expect(error).toContain("sentry /"); + expect(error).toContain("sentry config set defaults.org"); + }); + + test("includes accessible projects when showProjects is true", async () => { + mockListOrganizations.mockResolvedValue([ + { id: "1", slug: "my-org", name: "My Organization" }, + ]); + mockListProjects.mockResolvedValue([ + { id: "10", slug: "frontend", name: "Frontend App" }, + { id: "11", slug: "backend", name: "Backend API" }, + ]); + + const error = await formatNoDsnError("/path/to/project", true); + + expect(error).toContain("Your accessible projects:"); + expect(error).toContain("my-org/frontend"); + expect(error).toContain("my-org/backend"); + }); + + test("skips projects section when API fails", async () => { + mockListOrganizations.mockRejectedValue(new Error("Not authenticated")); + + const error = await formatNoDsnError("/path/to/project", true); + + expect(error).not.toContain("Your accessible projects:"); + expect(error).toContain("No Sentry DSN detected"); + }); + + test("skips projects section when no projects returned", async () => { + mockListOrganizations.mockResolvedValue([ + { id: "1", slug: "empty-org", name: "Empty Org" }, + ]); + mockListProjects.mockResolvedValue([]); + + const error = await formatNoDsnError("/path/to/project", true); + + expect(error).not.toContain("Your accessible projects:"); + expect(error).toContain("No Sentry DSN detected"); + }); + + test("does not call API when showProjects is false", async () => { + const error = await formatNoDsnError("/path/to/project", false); + + expect(mockListOrganizations).not.toHaveBeenCalled(); + expect(error).toContain("No Sentry DSN detected"); + }); +}); + +describe("formatResolutionError", () => { + test("formats error with DSN and error message", () => { + const error = new Error("Connection timeout"); + const dsn = "https://abc123@o123.ingest.sentry.io/456"; + + const formatted = formatResolutionError(error, dsn); + + expect(formatted).toContain("Error: Could not resolve project from DSN"); + expect(formatted).toContain(`DSN: ${dsn}`); + expect(formatted).toContain("Error: Connection timeout"); + expect(formatted).toContain("You don't have access to this project"); + expect(formatted).toContain("self-hosted Sentry instance"); + expect(formatted).toContain("invalid or expired"); + expect(formatted).toContain("sentry /"); + }); + + test("formats error with access denied message", () => { + const error = new Error("403 Forbidden"); + const dsn = "https://secret@o999.ingest.sentry.io/123"; + + const formatted = formatResolutionError(error, dsn); + + expect(formatted).toContain("Error: 403 Forbidden"); + expect(formatted).toContain(`DSN: ${dsn}`); + }); +}); + +describe("formatMultipleProjectsFooter", () => { + test("returns empty string for single project", () => { + const projects = [ + { + orgDisplay: "my-org", + projectDisplay: "frontend", + detectedFrom: ".env", + }, + ]; + + const footer = formatMultipleProjectsFooter(projects); + + expect(footer).toBe(""); + }); + + test("returns empty string for empty array", () => { + const footer = formatMultipleProjectsFooter([]); + + expect(footer).toBe(""); + }); + + test("formats footer with two projects", () => { + const projects = [ + { + orgDisplay: "my-org", + projectDisplay: "frontend", + detectedFrom: "packages/frontend/.env", + }, + { + orgDisplay: "my-org", + projectDisplay: "backend", + detectedFrom: "src/sentry.ts", + }, + ]; + + const footer = formatMultipleProjectsFooter(projects); + + expect(footer).toContain("Found 2 Sentry projects:"); + expect(footer).toContain( + "• my-org / frontend (from packages/frontend/.env)" + ); + expect(footer).toContain("• my-org / backend (from src/sentry.ts)"); + expect(footer).toContain( + "Use / to target a specific project." + ); + }); + + test("formats footer with projects without detectedFrom", () => { + const projects = [ + { + orgDisplay: "org-a", + projectDisplay: "project-1", + }, + { + orgDisplay: "org-b", + projectDisplay: "project-2", + }, + ]; + + const footer = formatMultipleProjectsFooter(projects); + + expect(footer).toContain("Found 2 Sentry projects:"); + expect(footer).toContain("• org-a / project-1"); + expect(footer).toContain("• org-b / project-2"); + expect(footer).not.toContain("(from"); + }); + + test("formats footer with mixed detectedFrom presence", () => { + const projects = [ + { + orgDisplay: "my-org", + projectDisplay: "frontend", + detectedFrom: ".env.local", + }, + { + orgDisplay: "my-org", + projectDisplay: "backend", + // No detectedFrom + }, + { + orgDisplay: "other-org", + projectDisplay: "shared", + detectedFrom: "libs/shared/sentry.config.js", + }, + ]; + + const footer = formatMultipleProjectsFooter(projects); + + expect(footer).toContain("Found 3 Sentry projects:"); + expect(footer).toContain("• my-org / frontend (from .env.local)"); + expect(footer).toContain("• my-org / backend"); + expect(footer).not.toContain("• my-org / backend (from"); + expect(footer).toContain( + "• other-org / shared (from libs/shared/sentry.config.js)" + ); + }); + + test("handles many projects", () => { + const projects = Array.from({ length: 10 }, (_, i) => ({ + orgDisplay: `org-${i}`, + projectDisplay: `project-${i}`, + detectedFrom: `path/to/project-${i}/.env`, + })); + + const footer = formatMultipleProjectsFooter(projects); + + expect(footer).toContain("Found 10 Sentry projects:"); + expect(footer).toContain("• org-0 / project-0"); + expect(footer).toContain("• org-9 / project-9"); + }); +}); diff --git a/packages/cli/test/lib/dsn/fifo-safety.test.ts b/packages/cli/test/lib/dsn/fifo-safety.test.ts new file mode 100644 index 000000000..a326573fa --- /dev/null +++ b/packages/cli/test/lib/dsn/fifo-safety.test.ts @@ -0,0 +1,266 @@ +/** + * FIFO / Named Pipe Safety Tests + * + * Verifies that DSN detection gracefully skips non-regular files + * (FIFOs, sockets, etc.) instead of blocking indefinitely. + * + * Motivation: 1Password streams secrets via symlinked named pipes for .env + * files. Bun.file().text() blocks indefinitely on FIFOs because open() + * waits for a writer. The isRegularFile() guard prevents this hang by + * checking file type with stat() before attempting to read. + */ + +import { execSync } from "node:child_process"; +import { + mkdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { isRegularFile } from "../../../src/lib/dsn/fs-utils.js"; +import { useTestConfigDir } from "../../helpers.js"; + +/** Create a FIFO (named pipe) at the given path using mkfifo(1). */ +function createFifo(path: string): void { + execSync(`mkfifo ${JSON.stringify(path)}`); +} + +const getConfigDir = useTestConfigDir("fifo-safety-"); + +describe("isRegularFile", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `sentry-fifo-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("returns true for regular files", async () => { + const filePath = join(testDir, ".env"); + writeFileSync(filePath, "SENTRY_DSN=https://key@sentry.io/123\n"); + expect(await isRegularFile(filePath)).toBe(true); + }); + + test("returns false for FIFOs (named pipes)", async () => { + const fifoPath = join(testDir, ".env"); + createFifo(fifoPath); + // Verify it's actually a FIFO + expect(statSync(fifoPath).isFIFO()).toBe(true); + expect(await isRegularFile(fifoPath)).toBe(false); + }); + + test("returns false for symlinks to FIFOs (1Password pattern)", async () => { + const fifoPath = join(testDir, ".env.fifo"); + createFifo(fifoPath); + const symlinkPath = join(testDir, ".env"); + symlinkSync(fifoPath, symlinkPath); + // stat() follows the symlink and sees the FIFO target + expect(await isRegularFile(symlinkPath)).toBe(false); + }); + + test("returns true for symlinks to regular files", async () => { + const realPath = join(testDir, ".env.real"); + writeFileSync(realPath, "SENTRY_DSN=https://key@sentry.io/123\n"); + const symlinkPath = join(testDir, ".env"); + symlinkSync(realPath, symlinkPath); + expect(await isRegularFile(symlinkPath)).toBe(true); + }); + + test("returns false for directories", async () => { + const dirPath = join(testDir, ".env"); + mkdirSync(dirPath); + expect(await isRegularFile(dirPath)).toBe(false); + }); + + test("returns false for non-existent paths", async () => { + expect(await isRegularFile(join(testDir, "no-such-file"))).toBe(false); + }); +}); + +describe("FIFO: env file detection", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `sentry-fifo-env-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + // Create .git so this is treated as project root (stops walk-up) + mkdirSync(join(testDir, ".git")); + }); + + afterEach(() => { + delete process.env.SENTRY_DSN; + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test("detectFromEnvFiles skips FIFO .env without hanging", async () => { + // Ensure config dir is set for DB access + getConfigDir(); + + const fifoPath = join(testDir, ".env"); + createFifo(fifoPath); + + const { detectFromEnvFiles } = await import( + "../../../src/lib/dsn/env-file.js" + ); + + // This would hang indefinitely before the fix. + // 2-second timeout ensures the test fails fast if the guard is broken. + const result = await Promise.race([ + detectFromEnvFiles(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).toBeNull(); + }); + + test("detectFromEnvFiles reads regular .env normally", async () => { + getConfigDir(); + + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://abc123@o456.ingest.sentry.io/789\n" + ); + + const { detectFromEnvFiles } = await import( + "../../../src/lib/dsn/env-file.js" + ); + const result = await detectFromEnvFiles(testDir); + + expect(result).not.toBeNull(); + expect(result!.raw).toBe("https://abc123@o456.ingest.sentry.io/789"); + }); + + test("detectFromEnvFiles skips FIFO .env but reads regular .env.local", async () => { + getConfigDir(); + + // .env is a FIFO (would hang) + createFifo(join(testDir, ".env")); + // .env.local is a regular file with a DSN + writeFileSync( + join(testDir, ".env.local"), + "SENTRY_DSN=https://key@o1.ingest.sentry.io/111\n" + ); + + const { detectFromEnvFiles } = await import( + "../../../src/lib/dsn/env-file.js" + ); + const result = await Promise.race([ + detectFromEnvFiles(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).not.toBeNull(); + expect(result!.raw).toBe("https://key@o1.ingest.sentry.io/111"); + }); + + test("findProjectRoot walk-up skips FIFO .env without hanging", async () => { + getConfigDir(); + + // Create a nested dir structure: testDir/.git + testDir/sub/.env (FIFO) + const subDir = join(testDir, "sub"); + mkdirSync(subDir); + createFifo(join(subDir, ".env")); + + const { findProjectRoot } = await import( + "../../../src/lib/dsn/project-root.js" + ); + + const result = await Promise.race([ + findProjectRoot(subDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + // Should still find the project root (.git in parent) + expect((result as { projectRoot: string }).projectRoot).toBe(testDir); + }); + + test("detectDsn skips symlinked FIFO .env.local and falls back to regular .env", async () => { + getConfigDir(); + + const fifoPath = join(testDir, ".env.local.fifo"); + createFifo(fifoPath); + symlinkSync(fifoPath, join(testDir, ".env.local")); + writeFileSync( + join(testDir, ".env"), + "SENTRY_DSN=https://fallback@o1.ingest.sentry.io/222\n" + ); + + const { detectDsn } = await import("../../../src/lib/dsn/detector.js"); + + const result = await Promise.race([ + detectDsn(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).not.toBeNull(); + expect(result!.raw).toBe("https://fallback@o1.ingest.sentry.io/222"); + expect(result!.source).toBe("env_file"); + }); + + test("detectDsn cache verification skips source file after it becomes a symlinked FIFO", async () => { + getConfigDir(); + + const cachedDsn = "https://cached@o1.ingest.sentry.io/111"; + const fallbackDsn = "https://fallback@o2.ingest.sentry.io/333"; + const envPath = join(testDir, ".env"); + writeFileSync(envPath, `SENTRY_DSN=${cachedDsn}\n`); + + const { detectDsn } = await import("../../../src/lib/dsn/detector.js"); + + const firstResult = await detectDsn(testDir); + expect(firstResult?.raw).toBe(cachedDsn); + expect(firstResult?.source).toBe("env_file"); + + rmSync(envPath); + const fifoPath = join(testDir, ".env.fifo"); + createFifo(fifoPath); + symlinkSync(fifoPath, envPath); + process.env.SENTRY_DSN = fallbackDsn; + + const result = await Promise.race([ + detectDsn(testDir), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 2000) + ), + ]); + + expect(result).not.toBe("timeout"); + expect(result).not.toBeNull(); + expect(result!.raw).toBe(fallbackDsn); + expect(result!.source).toBe("env"); + }); +}); diff --git a/packages/cli/test/lib/dsn/fs-utils.test.ts b/packages/cli/test/lib/dsn/fs-utils.test.ts new file mode 100644 index 000000000..3508812d6 --- /dev/null +++ b/packages/cli/test/lib/dsn/fs-utils.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for DSN file system utilities. + * + * Verifies that handleFileError correctly distinguishes expected filesystem + * errors (silently ignored) from unexpected ones (reported to Sentry). + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { captureException } = vi.hoisted(() => ({ + captureException: vi.fn(), +})); + +vi.mock("@sentry/node-core/light", () => ({ + captureException, + startSpan: (_opts: unknown, fn: () => unknown) => fn(), +})); + +const { handleFileError } = await import("../../../src/lib/dsn/fs-utils.js"); + +/** Create an Error with a `code` property, mimicking Node/Bun errno errors. */ +function errnoError(code: string, message?: string): Error { + const err = new Error(message ?? code) as NodeJS.ErrnoException; + err.code = code; + return err; +} + +describe("handleFileError", () => { + beforeEach(() => { + captureException.mockClear(); + }); + + afterEach(() => { + captureException.mockClear(); + }); + + describe("ignorable errors (should NOT report to Sentry)", () => { + test("ENOENT — file does not exist", () => { + handleFileError(errnoError("ENOENT"), { + operation: "test", + path: "/missing", + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + test("EACCES — permission denied", () => { + handleFileError(errnoError("EACCES"), { + operation: "test", + path: "/secret", + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + test("EPERM — operation not permitted", () => { + handleFileError(errnoError("EPERM"), { + operation: "test", + path: "/locked", + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + test("EISDIR — path is a directory, not a file", () => { + handleFileError( + errnoError("EISDIR", "Directories cannot be read like files"), + { + operation: "checkEnvForDsn", + path: "/project/.env", + } + ); + expect(captureException).not.toHaveBeenCalled(); + }); + + test("ENOTDIR — path component is not a directory", () => { + handleFileError(errnoError("ENOTDIR"), { + operation: "test", + path: "/file.txt/child", + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + test("ETIMEDOUT — connection timed out on a network mount", () => { + handleFileError(errnoError("ETIMEDOUT"), { + operation: "scandir", + path: "/mnt/cloud-storage", + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + test("UNKNOWN code — non-POSIX error with UNKNOWN code", () => { + handleFileError(errnoError("UNKNOWN", "Unknown system error -11"), { + operation: "scandir", + path: "/Users/austin/Documents/app-audit.trace", + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + test("Unknown system error — macOS non-POSIX errno with no standard code", () => { + const err = new Error( + "Unknown system error -11: Unknown system error -11, scandir '/Users/austin/Documents/app-audit.trace'" + ) as NodeJS.ErrnoException; + // No code property — Node.js couldn't map errno to a POSIX name + handleFileError(err, { + operation: "scandir", + path: "/Users/austin/Documents/app-audit.trace", + }); + expect(captureException).not.toHaveBeenCalled(); + }); + }); + + describe("unexpected errors (SHOULD report to Sentry)", () => { + test("EIO — I/O error", () => { + handleFileError(errnoError("EIO"), { + operation: "test", + path: "/disk-fail", + }); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test("ENOMEM — out of memory", () => { + handleFileError(errnoError("ENOMEM"), { operation: "test" }); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test("generic Error without code", () => { + handleFileError(new Error("something broke"), { operation: "test" }); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test("non-Error value", () => { + handleFileError("string error", { operation: "test" }); + expect(captureException).toHaveBeenCalledTimes(1); + }); + }); + + test("passes context tags and extras to Sentry", () => { + const error = errnoError("EIO"); + handleFileError(error, { + operation: "checkEnvForDsn", + path: "/project/.env", + }); + expect(captureException).toHaveBeenCalledWith(error, { + tags: { operation: "checkEnvForDsn" }, + extra: { path: "/project/.env" }, + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/home-fallback.test.ts b/packages/cli/test/lib/dsn/home-fallback.test.ts new file mode 100644 index 000000000..424c24e49 --- /dev/null +++ b/packages/cli/test/lib/dsn/home-fallback.test.ts @@ -0,0 +1,173 @@ +/** + * Home-directory fallback safety tests. + * + * When DSN auto-detection runs from `$HOME` (or any directory without a + * project marker), `findProjectRoot` falls back to `$HOME`. A downward + * scan from there would reach OS app-data and credential directories + * (`~/Library`, `~/.ssh`, `~/.aws`, …), tripping security monitoring and + * opening sensitive files for the content sniff. These tests lock in that + * the scan is skipped in that case. See getsentry/cli#1590. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { fakeHome } = vi.hoisted(() => ({ fakeHome: { path: "" } })); + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => fakeHome.path, + }; +}); + +import { clearDsnCache } from "../../../src/lib/db/dsn-cache.js"; +import { detectAllDsns, detectDsn } from "../../../src/lib/dsn/detector.js"; +import { useTestConfigDir } from "../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-dsn-home-fallback-"); + +describe("DSN detection from $HOME fallback", () => { + let home: string; + + beforeEach(() => { + home = join(getConfigDir(), "home"); + mkdirSync(home, { recursive: true }); + fakeHome.path = home; + clearDsnCache(home); + delete process.env.SENTRY_DSN; + }); + + afterEach(() => { + fakeHome.path = ""; + delete process.env.SENTRY_DSN; + }); + + test("does not scan into sensitive dirs under $HOME", async () => { + // A DSN-shaped string planted in a credential-like file under $HOME. + // If the downward scan ran, detection would surface it. + const sensitiveDsn = "https://leaked@o999.ingest.sentry.io/999"; + mkdirSync(join(home, "Library", "Group Containers", "x.1password"), { + recursive: true, + }); + writeFileSync( + join(home, "Library", "Group Containers", "x.1password", "config.json"), + `{ "dsn": "${sensitiveDsn}" }` + ); + mkdirSync(join(home, ".aws"), { recursive: true }); + writeFileSync( + join(home, ".aws", "credentials"), + `sentry_dsn=${sensitiveDsn}` + ); + + const result = await detectDsn(home); + expect(result).toBeNull(); + + const all = await detectAllDsns(home); + expect(all.all).toHaveLength(0); + expect(all.primary).toBeNull(); + }); + + test("skips the scan even when $HOME carries a trailing slash", async () => { + // getStopBoundary() returns homedir() raw; a trailing slash must not + // make the home-fallback skip fail open. See getsentry/cli#1590. + fakeHome.path = `${home}/`; + // Plant the DSN in a plain code file at the top of $HOME — not inside + // a DSN_ADDITIONAL_SKIP_DIRS directory — so that if isHomeOrAncestor + // regressed to failing open, the downward scan WOULD reach and return + // it. That makes this test actually exercise the resolve() fix rather + // than the skip list. + const scannedDsn = "https://leaked@o999.ingest.sentry.io/999"; + writeFileSync( + join(home, "config.ts"), + `Sentry.init({ dsn: "${scannedDsn}" });` + ); + + const result = await detectDsn(home); + expect(result).toBeNull(); + }); + + test("still returns SENTRY_DSN env var when set from $HOME", async () => { + const envDsn = "https://var@o111.ingest.sentry.io/111"; + process.env.SENTRY_DSN = envDsn; + + const result = await detectDsn(home); + expect(result?.raw).toBe(envDsn); + expect(result?.source).toBe("env"); + + const all = await detectAllDsns(home); + expect(all.primary?.raw).toBe(envDsn); + expect(all.all).toHaveLength(1); + }); + + test("scans normally when a project marker exists under $HOME", async () => { + const projectDsn = "https://code@o222.ingest.sentry.io/222"; + const project = join(home, "projects", "app"); + mkdirSync(join(project, ".git"), { recursive: true }); + mkdirSync(join(project, "src"), { recursive: true }); + writeFileSync( + join(project, "src", "config.ts"), + `Sentry.init({ dsn: "${projectDsn}" })` + ); + + const result = await detectDsn(project); + expect(result?.raw).toBe(projectDsn); + expect(result?.source).toBe("code"); + }); + + describe("from an ancestor of $HOME", () => { + let ancestor: string; + + beforeEach(() => { + // e.g. running from /Users when $HOME is /Users/alice. findProjectRoot + // finds no markers and falls back to the ancestor itself, which + // isHomeOrAncestor still treats as at/above home — the downward scan + // must be skipped there too, or it would walk straight into every + // user's home directory. + ancestor = join(getConfigDir(), "ancestor"); + home = join(ancestor, "alice"); + mkdirSync(home, { recursive: true }); + fakeHome.path = home; + clearDsnCache(ancestor); + }); + + test("does not scan into sibling home directories", async () => { + const scannedDsn = "https://leaked@o999.ingest.sentry.io/999"; + // A plain code file directly under the ancestor — reachable by the + // downward scan only if the home-fallback skip fails to trigger. + writeFileSync( + join(ancestor, "config.ts"), + `Sentry.init({ dsn: "${scannedDsn}" });` + ); + // And one inside a would-be sibling home, to mirror the real risk. + const sibling = join(ancestor, "bob"); + mkdirSync(sibling, { recursive: true }); + writeFileSync( + join(sibling, "config.ts"), + `Sentry.init({ dsn: "${scannedDsn}" });` + ); + + const result = await detectDsn(ancestor); + expect(result).toBeNull(); + + const all = await detectAllDsns(ancestor); + expect(all.all).toHaveLength(0); + expect(all.primary).toBeNull(); + }); + + test("still returns SENTRY_DSN env var when set from an ancestor", async () => { + const envDsn = "https://var@o333.ingest.sentry.io/333"; + process.env.SENTRY_DSN = envDsn; + + const result = await detectDsn(ancestor); + expect(result?.raw).toBe(envDsn); + expect(result?.source).toBe("env"); + + const all = await detectAllDsns(ancestor); + expect(all.primary?.raw).toBe(envDsn); + expect(all.all).toHaveLength(1); + }); + }); +}); diff --git a/packages/cli/test/lib/dsn/project-root.test.ts b/packages/cli/test/lib/dsn/project-root.test.ts new file mode 100644 index 000000000..09280f201 --- /dev/null +++ b/packages/cli/test/lib/dsn/project-root.test.ts @@ -0,0 +1,424 @@ +/** + * Project Root Detection Tests + * + * Tests for finding project root by walking up from a starting directory. + */ + +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock Sentry to avoid telemetry side effects. startSpan must pass a no-op +// span object to the callback — withTracingSpan calls span.setStatus() on it, +// and production code may call span.setAttribute()/setAttributes() as well. +const { noopSpan } = vi.hoisted(() => ({ + noopSpan: { + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + }, +})); + +vi.mock("@sentry/node-core/light", () => ({ + startSpan: (_opts: unknown, fn: (span: unknown) => unknown) => fn(noopSpan), + captureException: vi.fn(), +})); + +import { + findProjectRoot, + getStopBoundary, + hasBuildSystemMarker, + hasLanguageMarker, + hasRepoRootMarker, + isHomeOrAncestor, + STAT_CONCURRENCY, +} from "../../../src/lib/dsn/project-root.js"; + +// Test directory structure helper +function createDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +function createFile(path: string, content = ""): void { + writeFileSync(path, content); +} + +describe("project-root", () => { + let testDir: string; + + beforeEach(() => { + // Create a unique temp directory for each test + testDir = join( + tmpdir(), + `sentry-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + createDir(testDir); + }); + + afterEach(() => { + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("isHomeOrAncestor", () => { + test("returns true for the home directory itself", () => { + expect(isHomeOrAncestor(homedir())).toBe(true); + }); + + test("returns true for ancestors of home", () => { + expect(isHomeOrAncestor(join(homedir(), ".."))).toBe(true); + // Two levels up (e.g. /Users when home is /Users/alice) is still an + // ancestor and must be treated as at/above home. + expect(isHomeOrAncestor(join(homedir(), "..", ".."))).toBe(true); + expect(isHomeOrAncestor("/")).toBe(true); + }); + + test("returns true for a non-canonical ancestor path", () => { + // A trailing slash or "." segment must resolve to the same ancestor + // and not fail open the same way a raw string compare would. + expect(isHomeOrAncestor(`${join(homedir(), "..")}/`)).toBe(true); + expect(isHomeOrAncestor(join(homedir(), "..", "."))).toBe(true); + }); + + test("returns false for directories under home", () => { + expect(isHomeOrAncestor(join(homedir(), "projects", "app"))).toBe(false); + expect(isHomeOrAncestor(join(homedir(), "Library"))).toBe(false); + }); + }); + + describe("getStopBoundary", () => { + test("returns home directory", () => { + const boundary = getStopBoundary(); + expect(boundary).toBe(homedir()); + }); + }); + + describe("hasRepoRootMarker", () => { + test("detects .git directory", async () => { + createDir(join(testDir, ".git")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("vcs"); + }); + + test("detects .hg directory", async () => { + createDir(join(testDir, ".hg")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("vcs"); + }); + + test("detects .github directory", async () => { + createDir(join(testDir, ".github")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("ci"); + }); + + test("detects .gitlab-ci.yml file", async () => { + createFile(join(testDir, ".gitlab-ci.yml")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("ci"); + }); + + test("detects .editorconfig with root=true", async () => { + createFile( + join(testDir, ".editorconfig"), + "root = true\n[*]\nindent_style = space" + ); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("editorconfig"); + }); + + test("ignores .editorconfig without root=true", async () => { + createFile(join(testDir, ".editorconfig"), "[*]\nindent_style = space"); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(false); + }); + + test("returns found=false when no markers", async () => { + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(false); + }); + }); + + describe("hasLanguageMarker", () => { + test("detects package.json", async () => { + createFile(join(testDir, "package.json"), "{}"); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects pyproject.toml", async () => { + createFile(join(testDir, "pyproject.toml"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects go.mod", async () => { + createFile(join(testDir, "go.mod"), "module example.com/test"); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects Cargo.toml", async () => { + createFile(join(testDir, "Cargo.toml"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects .sln file (glob pattern)", async () => { + createFile(join(testDir, "MyProject.sln"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects .csproj file (glob pattern)", async () => { + createFile(join(testDir, "MyProject.csproj"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("returns false when no markers", async () => { + expect(await hasLanguageMarker(testDir)).toBe(false); + }); + }); + + describe("hasBuildSystemMarker", () => { + test("detects Makefile", async () => { + createFile(join(testDir, "Makefile"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("detects CMakeLists.txt", async () => { + createFile(join(testDir, "CMakeLists.txt"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("detects BUILD.bazel", async () => { + createFile(join(testDir, "BUILD.bazel"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("returns false when no markers", async () => { + expect(await hasBuildSystemMarker(testDir)).toBe(false); + }); + }); + + describe("findProjectRoot", () => { + describe("DSN detection in .env files", () => { + test("finds DSN in .env file and returns immediately", async () => { + const dsn = "https://abc123@o123.ingest.sentry.io/456"; + createFile(join(testDir, ".env"), `SENTRY_DSN=${dsn}`); + createDir(join(testDir, "src", "lib")); + + const result = await findProjectRoot(join(testDir, "src", "lib")); + + expect(result.foundDsn).toBeDefined(); + expect(result.foundDsn?.raw).toBe(dsn); + expect(result.reason).toBe("env_dsn"); + expect(result.projectRoot).toBe(testDir); + }); + + test("finds DSN in .env.local (higher priority)", async () => { + const dsnLocal = "https://local@o123.ingest.sentry.io/456"; + const dsnBase = "https://base@o123.ingest.sentry.io/789"; + createFile(join(testDir, ".env.local"), `SENTRY_DSN=${dsnLocal}`); + createFile(join(testDir, ".env"), `SENTRY_DSN=${dsnBase}`); + + const result = await findProjectRoot(testDir); + + expect(result.foundDsn?.raw).toBe(dsnLocal); + }); + + test("finds DSN at intermediate level during walk-up", async () => { + const dsn = "https://abc123@o123.ingest.sentry.io/456"; + // Create nested structure: testDir/packages/app/src + const packagesDir = join(testDir, "packages"); + const appDir = join(packagesDir, "app"); + const srcDir = join(appDir, "src"); + createDir(srcDir); + + // Put DSN in app directory + createFile(join(appDir, ".env"), `SENTRY_DSN=${dsn}`); + + // Put .git at root + createDir(join(testDir, ".git")); + + const result = await findProjectRoot(srcDir); + + // Should find DSN at app level (immediate return) + expect(result.foundDsn).toBeDefined(); + expect(result.foundDsn?.raw).toBe(dsn); + expect(result.reason).toBe("env_dsn"); + }); + }); + + describe("VCS marker detection", () => { + test("stops at .git directory", async () => { + createDir(join(testDir, ".git")); + createDir(join(testDir, "src", "lib", "utils")); + + const result = await findProjectRoot( + join(testDir, "src", "lib", "utils") + ); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("vcs"); + // Levels: utils(1) -> lib(2) -> src(3) -> testDir(4, found .git) + expect(result.levelsTraversed).toBe(4); + }); + + test("stops at .github directory", async () => { + createDir(join(testDir, ".github")); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("ci"); + }); + }); + + describe("language marker detection", () => { + test("uses closest language marker to cwd", async () => { + // Root has package.json + createFile(join(testDir, "package.json"), "{}"); + + // Nested package also has package.json + const nestedDir = join(testDir, "packages", "frontend"); + createDir(nestedDir); + createFile(join(nestedDir, "package.json"), "{}"); + + // Start from nested/src + const srcDir = join(nestedDir, "src"); + createDir(srcDir); + + const result = await findProjectRoot(srcDir); + + // Should use the closest package.json (in packages/frontend) + expect(result.projectRoot).toBe(nestedDir); + expect(result.reason).toBe("language"); + }); + + test("VCS marker takes precedence over language marker", async () => { + createDir(join(testDir, ".git")); + createFile(join(testDir, "package.json"), "{}"); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + // Should stop at .git even though package.json was also found + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("vcs"); + }); + }); + + describe("build system marker detection", () => { + test("uses build system marker as last resort", async () => { + createFile(join(testDir, "Makefile"), ""); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("build_system"); + }); + + test("language marker takes precedence over build system", async () => { + createFile(join(testDir, "Makefile"), ""); + createFile(join(testDir, "package.json"), "{}"); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.reason).toBe("language"); + }); + }); + + describe("fallback behavior", () => { + test("returns cwd when no markers found", async () => { + const deepDir = join(testDir, "a", "b", "c"); + createDir(deepDir); + + const result = await findProjectRoot(deepDir); + + // Should fall back to the starting directory + expect(result.projectRoot).toBe(deepDir); + expect(result.reason).toBe("fallback"); + }); + }); + + describe("levels traversed tracking", () => { + test("tracks correct number of levels", async () => { + createDir(join(testDir, ".git")); + createDir(join(testDir, "a", "b", "c", "d")); + + const result = await findProjectRoot(join(testDir, "a", "b", "c", "d")); + + // Levels: d(1) -> c(2) -> b(3) -> a(4) -> testDir(5, found .git) + expect(result.levelsTraversed).toBe(5); + }); + }); + }); +}); + +/** + * Tests for stat() concurrency limiting. + * + * Note: pathExists() in project-root.ts uses a statically-bound import of + * node:fs/promises stat, so vi.mock() cannot intercept it post-hoc. These + * tests instead verify the exported STAT_CONCURRENCY constant (which configures + * the pLimit instance) and confirm marker detection works end-to-end. The + * concurrency enforcement itself is delegated to pLimit, whose correctness is + * covered by its own test suite. + */ +describe("stat() concurrency limiting", () => { + function makeTempConcurrencyDir(prefix: string): string { + const dir = join( + tmpdir(), + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(dir, { recursive: true }); + return dir; + } + + test("STAT_CONCURRENCY is 32 — matches the pLimit budget in project-root.ts", () => { + // Guards against accidental changes to the budget value. The actual + // enforcement is delegated to pLimit; what we own is this constant. + expect(STAT_CONCURRENCY).toBe(32); + }); + + test("marker detection works correctly through the limiter (positive case)", async () => { + const testDir = makeTempConcurrencyDir("sentry-cli-marker"); + writeFileSync(join(testDir, "Makefile"), ""); + + const result = await hasBuildSystemMarker(testDir); + + expect(result).toBe(true); + }); + + test("marker detection returns false when no match (negative case)", async () => { + const testDir = makeTempConcurrencyDir("sentry-cli-no-marker"); + + const result = await hasBuildSystemMarker(testDir); + + expect(result).toBe(false); + }); + + test("multiple marker groups run correctly in parallel through shared limiter", async () => { + const testDir = makeTempConcurrencyDir("sentry-cli-parallel"); + writeFileSync(join(testDir, "package.json"), "{}"); + + // Fire both checks concurrently — both share statLimit. The language marker + // should be found; the build system marker should not. + const [hasBuild, hasLang] = await Promise.all([ + hasBuildSystemMarker(testDir), + hasLanguageMarker(testDir), + ]); + + expect(hasBuild).toBe(false); + expect(hasLang).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/dsn/resolver.test.ts b/packages/cli/test/lib/dsn/resolver.test.ts new file mode 100644 index 000000000..a9543f68d --- /dev/null +++ b/packages/cli/test/lib/dsn/resolver.test.ts @@ -0,0 +1,352 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createIsolatedDbContext } from "../../model-based/helpers.js"; + +// Mock api-client module to avoid real API calls +const { + mockListOrganizations, + mockListProjects, + mockFindProjectByDsnKey, + mockResolveOrgDisplayName, +} = vi.hoisted(() => ({ + mockListOrganizations: vi.fn(() => Promise.resolve([])), + mockListProjects: vi.fn(() => Promise.resolve([])), + mockFindProjectByDsnKey: vi.fn(() => Promise.resolve(null)), + mockResolveOrgDisplayName: vi.fn( + (slug: string, name?: string) => name ?? slug + ), +})); + +vi.mock("../../../src/lib/api-client.js", () => ({ + listOrganizations: mockListOrganizations, + listProjects: mockListProjects, + findProjectByDsnKey: mockFindProjectByDsnKey, + resolveOrgDisplayName: mockResolveOrgDisplayName, +})); + +// Now import the resolver after mocking +import { + getAccessibleProjects, + resolveProject, +} from "../../../src/lib/dsn/resolver.js"; +import type { DetectedDsn } from "../../../src/lib/dsn/types.js"; + +describe("resolveProject", () => { + let cleanup: () => void; + + beforeEach(() => { + cleanup = createIsolatedDbContext(); + mockListOrganizations.mockClear(); + mockListProjects.mockClear(); + mockFindProjectByDsnKey.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + test("returns resolved info if DSN already has resolved field", async () => { + const dsn: DetectedDsn = { + raw: "https://abc123@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc123", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + resolved: { + orgSlug: "my-org", + orgName: "My Organization", + projectSlug: "frontend", + projectName: "Frontend App", + }, + }; + + const result = await resolveProject("/some/path", dsn); + + expect(result.orgSlug).toBe("my-org"); + expect(result.orgName).toBe("My Organization"); + expect(result.projectSlug).toBe("frontend"); + expect(result.projectName).toBe("Frontend App"); + expect(result.dsn).toBe(dsn); + expect(result.sourceDescription).toContain("SENTRY_DSN"); + // Should not call API + expect(mockListOrganizations).not.toHaveBeenCalled(); + expect(mockFindProjectByDsnKey).not.toHaveBeenCalled(); + }); + + test("resolves DSN without orgId using findProjectByDsnKey", async () => { + const dsn: DetectedDsn = { + raw: "https://abc123@sentry.io/456", + protocol: "https", + publicKey: "abc123", + host: "sentry.io", + projectId: "456", + // No orgId - self-hosted or legacy DSN + source: "env_file", + sourcePath: ".env", + }; + + mockFindProjectByDsnKey.mockResolvedValue({ + id: "456", + slug: "my-project", + name: "My Project", + organization: { + id: "123", + slug: "my-org", + name: "My Organization", + }, + }); + + const result = await resolveProject("/test/path", dsn); + + expect(result.orgSlug).toBe("my-org"); + expect(result.orgName).toBe("My Organization"); + expect(result.projectSlug).toBe("my-project"); + expect(result.projectName).toBe("My Project"); + expect(mockFindProjectByDsnKey).toHaveBeenCalledWith("abc123"); + }); + + test("throws when DSN without orgId cannot be resolved", async () => { + const dsn: DetectedDsn = { + raw: "https://unknown@sentry.io/999", + protocol: "https", + publicKey: "unknown", + host: "sentry.io", + projectId: "999", + source: "code", + sourcePath: "app.js", + }; + + mockFindProjectByDsnKey.mockResolvedValue(null); + + await expect(resolveProject("/test/path", dsn)).rejects.toThrow( + /Cannot resolve project.*DSN could not be matched/ + ); + }); + + test("throws when project has no organization", async () => { + const dsn: DetectedDsn = { + raw: "https://abc123@sentry.io/456", + protocol: "https", + publicKey: "abc123", + host: "sentry.io", + projectId: "456", + source: "env", + }; + + mockFindProjectByDsnKey.mockResolvedValue({ + id: "456", + slug: "orphan-project", + name: "Orphan Project", + // No organization + }); + + await expect(resolveProject("/test/path", dsn)).rejects.toThrow( + /Cannot resolve project/ + ); + }); + + test("resolves DSN with orgId using listOrganizations and listProjects", async () => { + const dsn: DetectedDsn = { + raw: "https://abc123@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc123", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "code", + sourcePath: "src/sentry.ts", + }; + + mockListOrganizations.mockResolvedValue([ + { id: "999", slug: "other-org", name: "Other Org" }, + { id: "123", slug: "my-org", name: "My Organization" }, + ]); + + mockListProjects.mockResolvedValue([ + { id: "789", slug: "other-project", name: "Other Project" }, + { id: "456", slug: "frontend", name: "Frontend App" }, + ]); + + const result = await resolveProject("/test/path", dsn); + + expect(result.orgSlug).toBe("my-org"); + expect(result.orgName).toBe("My Organization"); + expect(result.projectSlug).toBe("frontend"); + expect(result.projectName).toBe("Frontend App"); + expect(mockListOrganizations).toHaveBeenCalled(); + expect(mockListProjects).toHaveBeenCalledWith("my-org"); + }); + + test("throws when organization not found by orgId", async () => { + const dsn: DetectedDsn = { + raw: "https://abc123@o999.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc123", + host: "o999.ingest.sentry.io", + projectId: "456", + orgId: "999", + source: "env", + }; + + mockListOrganizations.mockResolvedValue([ + { id: "123", slug: "my-org", name: "My Org" }, + ]); + + await expect(resolveProject("/test/path", dsn)).rejects.toThrow( + /Could not find organization with ID 999/ + ); + }); + + test("throws when project not found by projectId", async () => { + const dsn: DetectedDsn = { + raw: "https://abc123@o123.ingest.sentry.io/999", + protocol: "https", + publicKey: "abc123", + host: "o123.ingest.sentry.io", + projectId: "999", + orgId: "123", + source: "env", + }; + + mockListOrganizations.mockResolvedValue([ + { id: "123", slug: "my-org", name: "My Org" }, + ]); + + mockListProjects.mockResolvedValue([ + { id: "456", slug: "existing-project", name: "Existing" }, + ]); + + await expect(resolveProject("/test/path", dsn)).rejects.toThrow( + /Could not find project with ID 999 in organization my-org/ + ); + }); + + test("handles org ID as string vs number comparison", async () => { + const dsn: DetectedDsn = { + raw: "https://key@o42.ingest.sentry.io/100", + protocol: "https", + publicKey: "key", + host: "o42.ingest.sentry.io", + projectId: "100", + orgId: "42", + source: "env", + }; + + // API returns id as number, but DSN has it as string + mockListOrganizations.mockResolvedValue([ + { id: 42, slug: "number-org", name: "Number Org" }, + ]); + + mockListProjects.mockResolvedValue([ + { id: 100, slug: "number-project", name: "Number Project" }, + ]); + + const result = await resolveProject("/test/path", dsn); + + expect(result.orgSlug).toBe("number-org"); + expect(result.projectSlug).toBe("number-project"); + }); +}); + +describe("getAccessibleProjects", () => { + beforeEach(() => { + mockListOrganizations.mockReset(); + mockListProjects.mockReset(); + }); + + test("returns empty array when not authenticated", async () => { + mockListOrganizations.mockRejectedValue(new Error("Not authenticated")); + + const projects = await getAccessibleProjects(); + + expect(projects).toEqual([]); + }); + + test("returns projects from all accessible organizations", async () => { + mockListOrganizations.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Organization A" }, + { id: "2", slug: "org-b", name: "Organization B" }, + ]); + + // First call for org-a + mockListProjects.mockResolvedValueOnce([ + { id: "10", slug: "frontend", name: "Frontend" }, + { id: "11", slug: "backend", name: "Backend" }, + ]); + + // Second call for org-b + mockListProjects.mockResolvedValueOnce([ + { id: "20", slug: "api", name: "API Service" }, + ]); + + const projects = await getAccessibleProjects(); + + expect(projects).toHaveLength(3); + expect(projects).toContainEqual({ + org: "org-a", + project: "frontend", + orgName: "Organization A", + projectName: "Frontend", + }); + expect(projects).toContainEqual({ + org: "org-a", + project: "backend", + orgName: "Organization A", + projectName: "Backend", + }); + expect(projects).toContainEqual({ + org: "org-b", + project: "api", + orgName: "Organization B", + projectName: "API Service", + }); + }); + + test("skips organizations that fail to list projects", async () => { + mockListOrganizations.mockResolvedValue([ + { id: "1", slug: "accessible", name: "Accessible Org" }, + { id: "2", slug: "forbidden", name: "Forbidden Org" }, + ]); + + mockListProjects.mockResolvedValueOnce([ + { id: "10", slug: "my-project", name: "My Project" }, + ]); + + mockListProjects.mockRejectedValueOnce(new Error("403 Forbidden")); + + const projects = await getAccessibleProjects(); + + expect(projects).toHaveLength(1); + expect(projects[0]).toEqual({ + org: "accessible", + project: "my-project", + orgName: "Accessible Org", + projectName: "My Project", + }); + }); + + test("returns empty array when no projects exist", async () => { + mockListOrganizations.mockResolvedValue([ + { id: "1", slug: "empty-org", name: "Empty Organization" }, + ]); + + mockListProjects.mockResolvedValue([]); + + const projects = await getAccessibleProjects(); + + expect(projects).toEqual([]); + }); + + test("handles organization with no access to projects", async () => { + mockListOrganizations.mockResolvedValue([ + { id: "1", slug: "org-1", name: "Org 1" }, + ]); + + mockListProjects.mockRejectedValue(new Error("Access denied")); + + const projects = await getAccessibleProjects(); + + expect(projects).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/dsn/scan-options.test.ts b/packages/cli/test/lib/dsn/scan-options.test.ts new file mode 100644 index 000000000..e521f60a7 --- /dev/null +++ b/packages/cli/test/lib/dsn/scan-options.test.ts @@ -0,0 +1,85 @@ +/** + * Unit tests for `src/lib/dsn/scan-options.ts`. + * + * Ensures the preset we'll use in PR 3 to replace the DSN scanner's + * walk logic produces the expected `WalkOptions` shape. Isolated from + * the walker itself so we don't rely on fs side-effects. + */ + +import { describe, expect, test } from "vitest"; +import { + DSN_MAX_DEPTH, + dsnDescentHook, + dsnScanOptions, +} from "../../../src/lib/dsn/scan-options.js"; +import { + DEFAULT_SKIP_DIRS, + DSN_ADDITIONAL_SKIP_DIRS, + MONOREPO_ROOTS, + TEXT_EXTENSIONS, +} from "../../../src/lib/scan/constants.js"; + +describe("dsnScanOptions", () => { + test("returns the expected shape", () => { + const opts = dsnScanOptions(); + expect(opts.extensions).toBe(TEXT_EXTENSIONS); + expect(opts.maxDepth).toBe(DSN_MAX_DEPTH); + expect(opts.nestedGitignore).toBe(true); + expect(opts.respectGitignore).toBe(true); + expect(opts.hidden).toBe(true); + expect(opts.descentHook).toBe(dsnDescentHook); + }); + + test("skip list combines DEFAULT + DSN additions", () => { + const opts = dsnScanOptions(); + const skipSet = new Set(opts.alwaysSkipDirs); + for (const d of DEFAULT_SKIP_DIRS) { + expect(skipSet.has(d)).toBe(true); + } + for (const d of DSN_ADDITIONAL_SKIP_DIRS) { + expect(skipSet.has(d)).toBe(true); + } + // DSN-specific dirs are NOT in DEFAULT_SKIP_DIRS (sanity check + // that we're still getting them from the second list). + expect(DEFAULT_SKIP_DIRS.includes("test")).toBe(false); + expect(DSN_ADDITIONAL_SKIP_DIRS.includes("test")).toBe(true); + expect(skipSet.has("test")).toBe(true); + }); + + test("skips OS app-data and credential dirs (defence in depth)", () => { + const opts = dsnScanOptions(); + const skipSet = new Set(opts.alwaysSkipDirs); + for (const d of [ + "Library", + "AppData", + ".ssh", + ".aws", + ".gnupg", + ".config", + ]) { + expect(skipSet.has(d)).toBe(true); + } + }); +}); + +describe("dsnDescentHook", () => { + test("returns 0 for monorepo package dirs", () => { + for (const root of MONOREPO_ROOTS) { + expect(dsnDescentHook(`${root}/foo`, 1)).toBe(0); + expect(dsnDescentHook(`${root}/bar`, 5)).toBe(0); + } + }); + + test("returns currentDepth + 1 for non-package paths", () => { + expect(dsnDescentHook("src", 0)).toBe(1); + expect(dsnDescentHook("src/lib", 1)).toBe(2); + expect(dsnDescentHook("packages", 0)).toBe(1); // 1-segment, not a pkg dir + expect(dsnDescentHook("packages/foo/src", 1)).toBe(2); // 3-segment + }); + + test("returns 0 at deep monorepo pkg boundary too (multiple packages)", () => { + // Two-segment path with a monorepo root first segment always + // resets to 0, regardless of the walker's current depth. + expect(dsnDescentHook("apps/web", 100)).toBe(0); + }); +}); diff --git a/packages/cli/test/lib/env-token-host.test.ts b/packages/cli/test/lib/env-token-host.test.ts new file mode 100644 index 000000000..3d51d66e6 --- /dev/null +++ b/packages/cli/test/lib/env-token-host.test.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for env-token-host snapshot capture. + * + * Asserts: + * - Snapshot reads env-only (not .sentryclirc-injected values later). + * - Default is `DEFAULT_SENTRY_URL` when env is unset. + * - `captureEnvTokenHost` is idempotent. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { DEFAULT_SENTRY_URL } from "../../src/lib/constants.js"; +import { + captureEnvTokenHost, + getEnvTokenHost, + resetEnvTokenHostForTesting, +} from "../../src/lib/env-token-host.js"; +import { mintSntrysToken, useEnvSandbox } from "../helpers.js"; + +const ENV_KEYS = [ + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", +] as const; + +describe("env-token-host", () => { + useEnvSandbox(ENV_KEYS); + beforeEach(resetEnvTokenHostForTesting); + afterEach(resetEnvTokenHostForTesting); + + test("keeps the claim's host authoritative for a token wrapped in controls", () => { + const token = mintSntrysToken({ + iat: 1, + url: "https://self-hosted.example.com", + org: "synthetic-org", + }); + process.env.SENTRY_AUTH_TOKEN = `\x01\u00a0${token}\x7f`; + process.env.SENTRY_HOST = "https://different.example.com"; + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe("https://self-hosted.example.com"); + }); + + test("defaults to SaaS when neither SENTRY_HOST nor SENTRY_URL is set", () => { + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe(DEFAULT_SENTRY_URL); + }); + + test("captures SENTRY_HOST when set", () => { + process.env.SENTRY_HOST = "https://sentry.acme.com"; + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe("https://sentry.acme.com"); + }); + + test("captures SENTRY_URL when SENTRY_HOST is unset", () => { + process.env.SENTRY_URL = "https://sentry.acme.com"; + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe("https://sentry.acme.com"); + }); + + test("prefers SENTRY_HOST over SENTRY_URL when both are set", () => { + process.env.SENTRY_HOST = "https://host.example.com"; + process.env.SENTRY_URL = "https://url.example.com"; + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe("https://host.example.com"); + }); + + test("normalizes bare hostname to https://", () => { + process.env.SENTRY_HOST = "sentry.acme.com"; + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe("https://sentry.acme.com"); + }); + + test("is idempotent — second capture is a no-op", () => { + process.env.SENTRY_HOST = "https://first.example.com"; + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe("https://first.example.com"); + + // Change env AFTER initial capture — snapshot should NOT update + process.env.SENTRY_HOST = "https://second.example.com"; + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe("https://first.example.com"); + }); + + test("does NOT consult values added to env after capture (the .sentryclirc simulation)", () => { + // Simulates: captureEnvTokenHost() at boot, then + // applySentryCliRcEnvShim() writes SENTRY_URL. The env-token-host + // snapshot must NOT reflect the shim write. + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe(DEFAULT_SENTRY_URL); + + // Simulate shim write + process.env.SENTRY_URL = "https://injected-by-sentryclirc.com"; + // Second call is a no-op (idempotent) + captureEnvTokenHost(); + expect(getEnvTokenHost()).toBe(DEFAULT_SENTRY_URL); + }); + + test("auto-captures on first getEnvTokenHost() call without explicit capture", () => { + process.env.SENTRY_HOST = "https://auto.example.com"; + // Never call captureEnvTokenHost explicitly + expect(getEnvTokenHost()).toBe("https://auto.example.com"); + }); +}); diff --git a/packages/cli/test/lib/env.test.ts b/packages/cli/test/lib/env.test.ts new file mode 100644 index 000000000..a697b5996 --- /dev/null +++ b/packages/cli/test/lib/env.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { getEnv, setEnv } from "../../src/lib/env.js"; + +describe("env registry", () => { + afterEach(() => { + // Always restore to process.env + setEnv(process.env); + }); + + test("getEnv defaults to process.env", () => { + expect(getEnv()).toBe(process.env); + }); + + test("setEnv switches the active env", () => { + const custom = { CUSTOM_VAR: "test" } as NodeJS.ProcessEnv; + setEnv(custom); + expect(getEnv()).toBe(custom); + expect(getEnv().CUSTOM_VAR).toBe("test"); + }); + + test("setEnv(process.env) restores the default", () => { + const custom = { CUSTOM_VAR: "test" } as NodeJS.ProcessEnv; + setEnv(custom); + setEnv(process.env); + expect(getEnv()).toBe(process.env); + }); + + test("mutations to custom env don't affect process.env", () => { + const key = `__ENV_TEST_${Date.now()}`; + const custom: NodeJS.ProcessEnv = { ...process.env }; + setEnv(custom); + getEnv()[key] = "mutated"; + + expect(getEnv()[key]).toBe("mutated"); + expect(process.env[key]).toBeUndefined(); + }); + + test("mutations to process.env are visible when active", () => { + const key = `__ENV_TEST_${Date.now()}`; + // Default: getEnv() IS process.env + process.env[key] = "visible"; + expect(getEnv()[key]).toBe("visible"); + delete process.env[key]; + }); +}); diff --git a/packages/cli/test/lib/envelope/event-builder.test.ts b/packages/cli/test/lib/envelope/event-builder.test.ts new file mode 100644 index 000000000..4a6fb5aa1 --- /dev/null +++ b/packages/cli/test/lib/envelope/event-builder.test.ts @@ -0,0 +1,318 @@ +/** + * Tests for buildEventFromFlags — converts CLI flags to a Sentry Event. + * + * Note: Core invariants (tag/extra parsing, user field routing) are property- + * tested below. Unit tests here focus on specific edge cases and output shape. + */ + +import { describe, expect, test } from "vitest"; +import type { SendEventFlags } from "../../../src/lib/envelope/event-builder.js"; +import { + buildEventFromFlags, + parseKeyValue, + parseUserFields, +} from "../../../src/lib/envelope/event-builder.js"; +import { ValidationError } from "../../../src/lib/errors.js"; + +// ── parseKeyValue ────────────────────────────────────────────────── + +describe("parseKeyValue", () => { + test("splits on first colon", async () => { + expect(parseKeyValue("key:value")).toEqual(["key", "value"]); + }); + + test("value may contain colons", async () => { + expect(parseKeyValue("url:https://example.com")).toEqual([ + "url", + "https://example.com", + ]); + }); + + test("no colon → throws ValidationError", async () => { + expect(() => parseKeyValue("nocohere")).toThrow(ValidationError); + }); + + test("empty key → throws ValidationError", async () => { + expect(() => parseKeyValue(":value")).toThrow(ValidationError); + }); + + test("splits on first equals sign", async () => { + expect(parseKeyValue("key=value")).toEqual(["key", "value"]); + }); + + test("splits on whichever separator comes first", async () => { + expect(parseKeyValue("key=a:b")).toEqual(["key", "a:b"]); + expect(parseKeyValue("key:a=b")).toEqual(["key", "a=b"]); + }); + + test("leading equals sign → throws ValidationError", async () => { + expect(() => parseKeyValue("=value")).toThrow(ValidationError); + }); + + test("leading colon before a later equals → throws ValidationError", async () => { + expect(() => parseKeyValue(":key=value")).toThrow(ValidationError); + }); +}); + +// ── parseUserFields ─────────────────────────────────────────────── + +describe("parseUserFields", () => { + test("id maps to user.id", async () => { + expect(parseUserFields(["id:42"])).toMatchObject({ id: "42" }); + }); + + test("email maps to user.email", async () => { + expect(parseUserFields(["email:alice@example.com"])).toMatchObject({ + email: "alice@example.com", + }); + }); + + test("ip_address maps to user.ip_address", async () => { + expect(parseUserFields(["ip_address:1.2.3.4"])).toMatchObject({ + ip_address: "1.2.3.4", + }); + }); + + test("username maps to user.username", async () => { + expect(parseUserFields(["username:alice"])).toMatchObject({ + username: "alice", + }); + }); + + test("unknown keys go into user.data", async () => { + expect(parseUserFields(["role:admin"])).toMatchObject({ + data: { role: "admin" }, + }); + }); + + test("multiple pairs merged", async () => { + const result = parseUserFields(["id:1", "email:a@b.com", "role:admin"]); + expect(result).toMatchObject({ + id: "1", + email: "a@b.com", + data: { role: "admin" }, + }); + }); +}); + +// ── buildEventFromFlags ─────────────────────────────────────────── + +describe("buildEventFromFlags", () => { + function flags(overrides: Partial = {}): SendEventFlags { + return { "no-environ": true, ...overrides }; + } + + test("defaults: level=error, platform=other", async () => { + const event = await buildEventFromFlags(flags()); + expect(event.level).toBe("error"); + expect(event.platform).toBe("other"); + }); + + test("event_id is always a 32-char hex string", async () => { + const event = await buildEventFromFlags(flags()); + expect(event.event_id).toMatch(/^[0-9a-f]{32}$/); + }); + + test("timestamp is a Unix float", async () => { + const event = await buildEventFromFlags(flags()); + expect(typeof event.timestamp).toBe("number"); + expect(event.timestamp).toBeGreaterThan(0); + }); + + test("--timestamp with Unix epoch integer", async () => { + const event = await buildEventFromFlags(flags({ timestamp: "1700000000" })); + expect(event.timestamp).toBe(1_700_000_000); + }); + + test("--timestamp with Unix epoch float", async () => { + const event = await buildEventFromFlags( + flags({ timestamp: "1700000000.123" }) + ); + expect(event.timestamp).toBe(1_700_000_000.123); + }); + + test("--timestamp with ISO 8601 string", async () => { + const event = await buildEventFromFlags( + flags({ timestamp: "2024-01-15T12:00:00Z" }) + ); + // ISO 8601 → parsed via Date.parse, converted to seconds + expect(event.timestamp).toBe(Date.parse("2024-01-15T12:00:00Z") / 1000); + }); + + test("--timestamp with invalid string throws ValidationError", async () => { + await expect( + buildEventFromFlags(flags({ timestamp: "not-a-date" })) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("--timestamp with whitespace-only returns default (not epoch zero)", async () => { + const event = await buildEventFromFlags(flags({ timestamp: " " })); + // Should fall back to Date.now(), not epoch 0 + expect(event.timestamp).toBeGreaterThan(1_700_000_000); + }); + + test("--level sets level", async () => { + expect((await buildEventFromFlags(flags({ level: "warning" }))).level).toBe( + "warning" + ); + }); + + test("--message joined with newline", async () => { + const event = await buildEventFromFlags( + flags({ message: ["hello", "world"] }) + ); + expect(event.logentry?.message).toBe("hello\nworld"); + }); + + test("--message-arg sets params", async () => { + const event = await buildEventFromFlags( + flags({ message: ["hello %s"], "message-arg": ["world"] }) + ); + expect(event.logentry?.params).toEqual(["world"]); + }); + + test("--tag parses into tags object", async () => { + const event = await buildEventFromFlags( + flags({ tag: ["env:prod", "ver:1.0"] }) + ); + expect(event.tags).toEqual({ env: "prod", ver: "1.0" }); + }); + + test("--extra parses into extra object", async () => { + const event = await buildEventFromFlags(flags({ extra: ["foo:bar"] })); + expect((event.extra as Record).foo).toBe("bar"); + }); + + test("--no-environ omits process.env from extra", async () => { + const event = await buildEventFromFlags(flags({ "no-environ": true })); + expect((event.extra as Record)?.environ).toBeUndefined(); + }); + + test("environ included when --no-environ not set", async () => { + const event = await buildEventFromFlags(flags({ "no-environ": false })); + expect((event.extra as Record)?.environ).toBeDefined(); + }); + + test("--user routes known fields correctly", async () => { + const event = await buildEventFromFlags( + flags({ user: ["id:99", "email:a@b.com"] }) + ); + expect(event.user?.id).toBe("99"); + expect(event.user?.email).toBe("a@b.com"); + }); + + test("--fingerprint sets fingerprint array", async () => { + const event = await buildEventFromFlags( + flags({ fingerprint: ["my-error", "{{ default }}"] }) + ); + expect(event.fingerprint).toEqual(["my-error", "{{ default }}"]); + }); + + test("--release sets release", async () => { + expect( + (await buildEventFromFlags(flags({ release: "1.2.3" }))).release + ).toBe("1.2.3"); + }); + + test("--env sets environment", async () => { + expect( + (await buildEventFromFlags(flags({ env: "staging" }))).environment + ).toBe("staging"); + }); + + test("--platform sets platform", async () => { + expect( + (await buildEventFromFlags(flags({ platform: "python" }))).platform + ).toBe("python"); + }); + + test("--dist sets dist", async () => { + expect((await buildEventFromFlags(flags({ dist: "x86" }))).dist).toBe( + "x86" + ); + }); + + test("each call produces a unique event_id", async () => { + const a = await buildEventFromFlags(flags()); + const b = await buildEventFromFlags(flags()); + expect(a.event_id).not.toBe(b.event_id); + }); + + test("--logfile attaches breadcrumbs from file", async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "sentry-logfile-test-")); + const logPath = join(dir, "test.log"); + try { + writeFileSync(logPath, "line one\nline two\nline three\n"); + const event = await buildEventFromFlags(flags({ logfile: logPath })); + expect(event.breadcrumbs).toHaveLength(3); + expect(event.breadcrumbs?.[0]?.message).toBe("line one"); + expect(event.breadcrumbs?.[0]?.category).toBe("log"); + expect(event.breadcrumbs?.[2]?.message).toBe("line three"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("--logfile with --with-categories parses CATEGORY: message", async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "sentry-logfile-cat-")); + const logPath = join(dir, "test.log"); + try { + writeFileSync( + logPath, + "INFO: Server started\nERROR: Connection lost\nplain line\n" + ); + const event = await buildEventFromFlags( + flags({ logfile: logPath, "with-categories": true }) + ); + expect(event.breadcrumbs).toHaveLength(3); + expect(event.breadcrumbs?.[0]).toMatchObject({ + category: "INFO", + message: "Server started", + }); + expect(event.breadcrumbs?.[1]).toMatchObject({ + category: "ERROR", + message: "Connection lost", + }); + // Line without category prefix falls back to "log" + expect(event.breadcrumbs?.[2]).toMatchObject({ + category: "log", + message: "plain line", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("--logfile with nonexistent file throws ValidationError", async () => { + await expect( + buildEventFromFlags(flags({ logfile: "/nonexistent/logfile.log" })) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("--logfile caps at 100 breadcrumbs", async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const dir = mkdtempSync(join(tmpdir(), "sentry-logfile-cap-")); + const logPath = join(dir, "big.log"); + try { + const lines = Array.from({ length: 150 }, (_, i) => `line ${i}`).join( + "\n" + ); + writeFileSync(logPath, lines); + const event = await buildEventFromFlags(flags({ logfile: logPath })); + expect(event.breadcrumbs).toHaveLength(100); + // Should keep the LAST 100 lines (50-149) + expect(event.breadcrumbs?.[0]?.message).toBe("line 50"); + expect(event.breadcrumbs?.[99]?.message).toBe("line 149"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/test/lib/envelope/event-send-dsn.test.ts b/packages/cli/test/lib/envelope/event-send-dsn.test.ts new file mode 100644 index 000000000..fa0535050 --- /dev/null +++ b/packages/cli/test/lib/envelope/event-send-dsn.test.ts @@ -0,0 +1,302 @@ +/** + * Tests for DSN resolution used by `sentry event send`. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as projectsApi from "../../../src/lib/api/projects.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as auth from "../../../src/lib/db/auth.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as dsnIndex from "../../../src/lib/dsn/index.js"; +import { + EVENT_SEND_NO_DSN_MESSAGE, + peelEventSendTarget, + resolveEventSendDsn, +} from "../../../src/lib/envelope/event-send-dsn.js"; +import { ConfigError } from "../../../src/lib/errors.js"; +import type { ProjectKey } from "../../../src/types/sentry.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for mocked access +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +useTestConfigDir("event-send-dsn-"); +useEnvSandbox(["SENTRY_DSN"]); + +const SAAS_DSN = "https://abc123@o1.ingest.us.sentry.io/999"; +const OTHER_DSN = "https://def456@o2.ingest.us.sentry.io/111"; + +const ACTIVE_KEY = { + id: "key-1", + name: "Default", + isActive: true, + dsn: { public: SAAS_DSN, secret: "" }, +} as ProjectKey; + +const OTHER_ACTIVE_KEY = { + id: "key-2", + name: "Other", + isActive: true, + dsn: { public: OTHER_DSN, secret: "" }, +} as ProjectKey; + +const INACTIVE_KEY = { + id: "key-3", + name: "Inactive", + isActive: false, + dsn: { public: OTHER_DSN, secret: "" }, +} as ProjectKey; + +const OAUTH_SESSION = { + token: "sntrys_access", + source: "oauth" as const, + refreshToken: "refresh_xyz", + expiresAt: Date.now() + 3_600_000, +}; + +const REFRESHABLE_EXPIRED_OAUTH_SESSION = { + token: "sntrys_expired_access", + source: "oauth" as const, + refreshToken: "refresh_xyz", + expiresAt: Date.now() - 60_000, +}; + +function detectedDsn(raw: string) { + return { + raw, + protocol: "https", + publicKey: "abc123", + host: "o1.ingest.us.sentry.io", + projectId: "999", + source: "env_file" as const, + }; +} + +describe("peelEventSendTarget", () => { + test("peels a leading org/project target", () => { + const result = peelEventSendTarget(["example-org/javascript-react"]); + expect(result.target).toEqual({ + kind: "project", + target: "example-org/javascript-react", + }); + expect(result.files).toEqual([]); + }); + + test("peels a leading bare project target", () => { + const result = peelEventSendTarget(["javascript-react"]); + expect(result.target).toEqual({ + kind: "project", + target: "javascript-react", + }); + expect(result.files).toEqual([]); + }); + + test("peels a leading DSN target", () => { + const result = peelEventSendTarget([SAAS_DSN]); + expect(result.target).toEqual({ kind: "dsn", dsn: SAAS_DSN }); + expect(result.files).toEqual([]); + }); + + test("leaves remaining file args after the target", () => { + const result = peelEventSendTarget(["sentry/cli", "./event.json"]); + expect(result.target).toEqual({ + kind: "project", + target: "sentry/cli", + }); + expect(result.files).toEqual(["./event.json"]); + }); + + test("requires ./ for a target-shaped relative file", () => { + const result = peelEventSendTarget(["./acme/web"]); + expect(result.target).toBeUndefined(); + expect(result.files).toEqual(["./acme/web"]); + }); + + test("does not peel a JSON path with a slash", () => { + const result = peelEventSendTarget(["events/crash.json"]); + expect(result.target).toBeUndefined(); + expect(result.files).toEqual(["events/crash.json"]); + }); + + test("does not peel a bare JSON filename", () => { + const result = peelEventSendTarget(["event.json"]); + expect(result.target).toBeUndefined(); + expect(result.files).toEqual(["event.json"]); + }); + + test("does not peel relative or absolute paths", () => { + expect(peelEventSendTarget(["./sentry/cli"]).target).toBeUndefined(); + expect(peelEventSendTarget(["/tmp/sentry/cli"]).target).toBeUndefined(); + }); +}); + +describe("resolveEventSendDsn", () => { + let detectSpy: ReturnType; + let keysSpy: ReturnType; + let authSpy: ReturnType; + const resolveProjectMock = vi.mocked( + resolveTarget.resolveProjectBoundFromArg + ); + + beforeEach(() => { + detectSpy = vi.spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); + keysSpy = vi.spyOn(projectsApi, "getProjectKeys").mockResolvedValue([]); + authSpy = vi.spyOn(auth, "getAuthConfig").mockReturnValue(undefined); + resolveProjectMock.mockReset(); + resolveProjectMock.mockResolvedValue({ + org: "acme", + project: "web", + }); + }); + + afterEach(() => { + detectSpy.mockRestore(); + keysSpy.mockRestore(); + authSpy.mockRestore(); + resolveProjectMock.mockReset(); + }); + + test("positional DSN wins over SENTRY_DSN and project scan", async () => { + process.env.SENTRY_DSN = OTHER_DSN; + detectSpy.mockResolvedValue(detectedDsn(OTHER_DSN)); + + const dsn = await resolveEventSendDsn("/tmp", { + kind: "dsn", + dsn: SAAS_DSN, + }); + expect(dsn).toBe(SAAS_DSN); + expect(resolveProjectMock).not.toHaveBeenCalled(); + expect(keysSpy).not.toHaveBeenCalled(); + expect(detectSpy).not.toHaveBeenCalled(); + }); + + test("project target wins over SENTRY_DSN and project scan", async () => { + process.env.SENTRY_DSN = OTHER_DSN; + authSpy.mockReturnValue(OAUTH_SESSION); + keysSpy.mockResolvedValue([ACTIVE_KEY]); + + const dsn = await resolveEventSendDsn("/tmp", { + kind: "project", + target: "acme/web", + }); + expect(dsn).toBe(SAAS_DSN); + expect(resolveProjectMock).toHaveBeenCalledWith( + "acme/web", + "/tmp", + "event send" + ); + expect(keysSpy).toHaveBeenCalledWith("acme", "web", { + status: "active", + }); + expect(detectSpy).not.toHaveBeenCalled(); + }); + + test("bare project uses shared project resolution", async () => { + authSpy.mockReturnValue(OAUTH_SESSION); + keysSpy.mockResolvedValue([ACTIVE_KEY]); + + const dsn = await resolveEventSendDsn("/tmp", { + kind: "project", + target: "web", + }); + expect(dsn).toBe(SAAS_DSN); + expect(resolveProjectMock).toHaveBeenCalledWith( + "web", + "/tmp", + "event send" + ); + expect(keysSpy).toHaveBeenCalledWith("acme", "web", { + status: "active", + }); + }); + + test("project target without login throws ConfigError", async () => { + process.env.SENTRY_DSN = OTHER_DSN; + await expect( + resolveEventSendDsn("/tmp", { + kind: "project", + target: "acme/web", + }) + ).rejects.toBeInstanceOf(ConfigError); + expect(resolveProjectMock).not.toHaveBeenCalled(); + expect(keysSpy).not.toHaveBeenCalled(); + }); + + test("project target works with a refreshable expired session", async () => { + authSpy.mockReturnValue(REFRESHABLE_EXPIRED_OAUTH_SESSION); + keysSpy.mockResolvedValue([ACTIVE_KEY]); + + const dsn = await resolveEventSendDsn("/tmp", { + kind: "project", + target: "acme/web", + }); + expect(dsn).toBe(SAAS_DSN); + expect(keysSpy).toHaveBeenCalledWith("acme", "web", { + status: "active", + }); + }); + + test("project without an active client key throws ConfigError", async () => { + authSpy.mockReturnValue(OAUTH_SESSION); + keysSpy.mockResolvedValue([INACTIVE_KEY]); + + await expect( + resolveEventSendDsn("/tmp", { + kind: "project", + target: "acme/web", + }) + ).rejects.toMatchObject({ + name: "ConfigError", + message: + "No active DSN found for acme/web. Pass a DSN as the first argument.", + }); + }); + + test("project with multiple active DSNs requires an explicit DSN", async () => { + authSpy.mockReturnValue(OAUTH_SESSION); + keysSpy.mockResolvedValue([ACTIVE_KEY, OTHER_ACTIVE_KEY]); + + await expect( + resolveEventSendDsn("/tmp", { + kind: "project", + target: "acme/web", + }) + ).rejects.toMatchObject({ + name: "ConfigError", + message: + "Project acme/web has multiple active DSNs. Pass the desired DSN as the first argument.", + }); + }); + + test("uses SENTRY_DSN when no positional target is provided", async () => { + process.env.SENTRY_DSN = SAAS_DSN; + const dsn = await resolveEventSendDsn("/tmp", undefined); + expect(dsn).toBe(SAAS_DSN); + }); + + test("falls back to project scan without a positional target or env", async () => { + detectSpy.mockResolvedValue(detectedDsn(OTHER_DSN)); + const dsn = await resolveEventSendDsn("/tmp", undefined); + expect(dsn).toBe(OTHER_DSN); + }); + + test("throws ConfigError listing every source when nothing resolves", async () => { + const err = await resolveEventSendDsn("/tmp", undefined).catch( + (error) => error + ); + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).message).toBe(EVENT_SEND_NO_DSN_MESSAGE); + }); +}); diff --git a/packages/cli/test/lib/envelope/transport.test.ts b/packages/cli/test/lib/envelope/transport.test.ts new file mode 100644 index 000000000..7a5fd0bc2 --- /dev/null +++ b/packages/cli/test/lib/envelope/transport.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for the DSN-based envelope transport. + * + * Core invariants: + * - URL is built from DSN components (host + projectId) + * - Auth is injected as query params (sentry_key, sentry_version) + * - Content-Type is always application/x-sentry-envelope + * - Non-2xx responses throw ApiError + * - Both string and Uint8Array bodies are supported + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as dsnIndex from "../../../src/lib/dsn/index.js"; +import { + buildEnvelopeUrl, + resolveDsn, + resolveIngestDsn, + sendEnvelopeRequest, +} from "../../../src/lib/envelope/transport.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { useEnvSandbox } from "../../helpers.js"; + +const SAAS_DSN = "https://abc123@o1169445.ingest.us.sentry.io/4505229541441536"; +const SELF_HOSTED_DSN = "https://pubkey99@sentry.mycompany.com/7"; + +useEnvSandbox(["SENTRY_DSN"]); + +describe("buildEnvelopeUrl", () => { + test("SaaS DSN → correct ingest URL with auth params", () => { + const url = buildEnvelopeUrl(SAAS_DSN); + expect(url).toContain("/api/4505229541441536/envelope/"); + expect(url).toContain("sentry_key=abc123"); + expect(url).toContain("sentry_version=7"); + expect(url.startsWith("https://")).toBe(true); + }); + + test("self-hosted DSN → correct ingest URL", () => { + const url = buildEnvelopeUrl(SELF_HOSTED_DSN); + expect(url).toContain("sentry.mycompany.com"); + expect(url).toContain("/api/7/envelope/"); + expect(url).toContain("sentry_key=pubkey99"); + }); + + test("invalid DSN → throws ValidationError", () => { + expect(() => buildEnvelopeUrl("not-a-dsn")).toThrow(ValidationError); + }); + + test("sentry_client does not have doubled version suffix", () => { + // SENTRY_CLIENT must be the bare name ('sentry-cli'), not 'sentry-cli/dev', + // because getEnvelopeEndpointWithUrlEncodedAuth appends / internally. + const url = buildEnvelopeUrl(SAAS_DSN); + expect(url).not.toContain("sentry-cli%2Fdev%2Fdev"); + expect(decodeURIComponent(url)).toContain("sentry_client=sentry-cli/"); + }); +}); + +describe("resolveDsn", () => { + test("explicit --dsn flag takes priority over env", () => { + process.env.SENTRY_DSN = SELF_HOSTED_DSN; + const result = resolveDsn({ dsn: SAAS_DSN }); + expect(result).toBe(SAAS_DSN); + }); + + test("SENTRY_DSN env var used when no flag", () => { + process.env.SENTRY_DSN = SAAS_DSN; + const result = resolveDsn({ dsn: undefined }); + expect(result).toBe(SAAS_DSN); + }); + + test("returns undefined when neither flag nor env set", () => { + delete process.env.SENTRY_DSN; + const result = resolveDsn({ dsn: undefined }); + expect(result).toBeUndefined(); + }); + + test("trims whitespace from --dsn flag", () => { + const result = resolveDsn({ dsn: ` ${SAAS_DSN} ` }); + expect(result).toBe(SAAS_DSN); + }); + + test("trims whitespace from SENTRY_DSN env var", () => { + process.env.SENTRY_DSN = `\n${SAAS_DSN}\n`; + const result = resolveDsn({ dsn: undefined }); + expect(result).toBe(SAAS_DSN); + }); +}); + +describe("resolveIngestDsn", () => { + let detectSpy: ReturnType; + + beforeEach(() => { + detectSpy = vi.spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); + }); + + afterEach(() => { + detectSpy.mockRestore(); + }); + + test("returns flag/env DSN without scanning", async () => { + process.env.SENTRY_DSN = SAAS_DSN; + await expect(resolveIngestDsn({}, "/tmp")).resolves.toBe(SAAS_DSN); + expect(detectSpy).not.toHaveBeenCalled(); + }); + + test("returns a project-scanned DSN when explicit sources are absent", async () => { + detectSpy.mockResolvedValue({ + raw: SAAS_DSN, + protocol: "https", + publicKey: "abc123", + host: "o1169445.ingest.us.sentry.io", + projectId: "4505229541441536", + source: "env_file", + }); + + await expect(resolveIngestDsn({}, "/tmp/project")).resolves.toBe(SAAS_DSN); + expect(detectSpy).toHaveBeenCalledWith("/tmp/project"); + }); +}); + +describe("sendEnvelopeRequest", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs with correct Content-Type header", async () => { + let capturedRequest: Request | undefined; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedRequest = input as Request; + return new Response("{}", { status: 200 }); + }; + + await sendEnvelopeRequest( + SAAS_DSN, + '{"event_id":"abc"}\n{"type":"event","length":2}\n{}' + ); + + expect(capturedRequest).toBeDefined(); + expect(capturedRequest!.method).toBe("POST"); + expect(capturedRequest!.headers.get("Content-Type")).toBe( + "application/x-sentry-envelope" + ); + }); + + test("URL contains sentry_key and sentry_version", async () => { + let capturedUrl = ""; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedUrl = (input as Request).url; + return new Response("{}", { status: 200 }); + }; + + await sendEnvelopeRequest(SAAS_DSN, "body"); + + expect(capturedUrl).toContain("sentry_key=abc123"); + expect(capturedUrl).toContain("sentry_version=7"); + }); + + test("accepts Uint8Array body", async () => { + globalThis.fetch = async () => new Response("{}", { status: 200 }); + // should not throw + await expect( + sendEnvelopeRequest(SAAS_DSN, new TextEncoder().encode("bytes")) + ).resolves.toBeUndefined(); + }); + + test("non-2xx response throws ApiError", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "invalid DSN" }), { status: 403 }); + + await expect(sendEnvelopeRequest(SAAS_DSN, "body")).rejects.toBeInstanceOf( + ApiError + ); + }); + + test("400 response includes error detail in message", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "bad envelope" }), { + status: 400, + }); + + const err = await sendEnvelopeRequest(SAAS_DSN, "body").catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).message).toContain("bad envelope"); + }); +}); diff --git a/packages/cli/test/lib/error-reporting.property.test.ts b/packages/cli/test/lib/error-reporting.property.test.ts new file mode 100644 index 000000000..9b4a740c9 --- /dev/null +++ b/packages/cli/test/lib/error-reporting.property.test.ts @@ -0,0 +1,177 @@ +/** + * Property-Based Tests for Error Reporting Helpers + * + * Verifies that `extractResourceKind` produces stable grouping keys + * regardless of the user-supplied slug/id embedded in the resource string. + */ + +import { + array, + constantFrom, + assert as fcAssert, + integer, + property, + stringMatching, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { extractResourceKind } from "../../src/lib/error-reporting.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** Lowercase alphanumeric slug with optional hyphens. */ +const slugArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789-".split("")), + { minLength: 1, maxLength: 25 } +) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-")) && s.length > 0); + +/** Slug that contains at least one hyphen (matches the entity-name strip regex). */ +const hyphenatedSlugArb = slugArb.filter((s) => s.includes("-")); + +/** 32-character lowercase hex id (trace/event/log id). */ +const hexIdArb = stringMatching(/^[0-9a-f]{32}$/).filter( + (s) => s.length === 32 +); + +/** Arbitrary numeric id across the full range (small issue IDs to large). */ +const numericIdArb = integer({ min: 1, max: 9_999_999_999 }).map(String); + +describe("extractResourceKind — property tests", () => { + test("single-quoted slug produces same kind for any slug", () => { + fcAssert( + property(slugArb, slugArb, (a, b) => { + const ka = extractResourceKind(`Project '${a}'`); + const kb = extractResourceKind(`Project '${b}'`); + expect(ka).toBe(kb); + expect(ka).toBe("Project"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("double-quoted slug produces same kind as single-quoted", () => { + fcAssert( + property(slugArb, (slug) => { + expect(extractResourceKind(`Project '${slug}'`)).toBe( + extractResourceKind(`Project "${slug}"`) + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("numeric-id stripping is slug-invariant", () => { + fcAssert( + property(numericIdArb, numericIdArb, (id1, id2) => { + expect(extractResourceKind(`Issue ${id1} not found.`)).toBe( + extractResourceKind(`Issue ${id2} not found.`) + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("hex-id stripping is slug-invariant", () => { + fcAssert( + property(hexIdArb, hexIdArb, (h1, h2) => { + expect(extractResourceKind(`Trace ${h1} not found`)).toBe( + extractResourceKind(`Trace ${h2} not found`) + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent on output", () => { + fcAssert( + property(slugArb, (slug) => { + const once = extractResourceKind(`Project '${slug}' not found.`); + expect(extractResourceKind(once)).toBe(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("not-found template is slug-invariant for org/project pair", () => { + fcAssert( + property(slugArb, slugArb, slugArb, slugArb, (o1, p1, o2, p2) => { + expect( + extractResourceKind( + `Project '${p1}' not found in organization '${o1}'` + ) + ).toBe( + extractResourceKind( + `Project '${p2}' not found in organization '${o2}'` + ) + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/project path in headline is stripped for any org/project", () => { + fcAssert( + property(slugArb, slugArb, slugArb, slugArb, (o1, p1, o2, p2) => { + expect(extractResourceKind(`not found in ${o1}/${p1}`)).toBe( + extractResourceKind(`not found in ${o2}/${p2}`) + ); + expect(extractResourceKind(`not found in ${o1}/${p1}`)).toBe( + "not found" + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("small numeric IDs are stripped just like large ones", () => { + fcAssert( + property(integer({ min: 1, max: 99 }), numericIdArb, (small, large) => { + expect(extractResourceKind(`Issue ${small} not found.`)).toBe( + extractResourceKind(`Issue ${large} not found.`) + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("bare slug after 'in' (no slash) is stripped for any slug", () => { + fcAssert( + property(slugArb, slugArb, (a, b) => { + expect(extractResourceKind(`not found in ${a}`)).toBe( + extractResourceKind(`not found in ${b}`) + ); + expect(extractResourceKind(`not found in ${a}`)).toBe("not found"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("hyphenated slug after entity name is stripped for any slug", () => { + fcAssert( + property(hyphenatedSlugArb, hyphenatedSlugArb, (a, b) => { + expect(extractResourceKind(`Organization ${a}`)).toBe( + extractResourceKind(`Organization ${b}`) + ); + expect(extractResourceKind(`Organization ${a}`)).toBe("Organization"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("Dashboard with numeric ID and slug produces same kind", () => { + fcAssert( + property( + numericIdArb, + slugArb, + numericIdArb, + slugArb, + (n1, s1, n2, s2) => { + expect(extractResourceKind(`Dashboard ${n1} in ${s1}`)).toBe( + extractResourceKind(`Dashboard ${n2} in ${s2}`) + ); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/error-reporting.test.ts b/packages/cli/test/lib/error-reporting.test.ts new file mode 100644 index 000000000..b8b4da4a3 --- /dev/null +++ b/packages/cli/test/lib/error-reporting.test.ts @@ -0,0 +1,797 @@ +/** + * Unit tests for the central error reporting helper. + * + * Covers: + * - Silencing rules (OutputError / expected AuthError / 401–499 ApiError) + * - Grouping tag extraction (extractResourceKind) + * - Tag enrichment in beforeSend (enrichEventWithGroupingTags) + * - End-to-end behavior of reportCliError (metric emission + capture) + */ + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { formatAuthHeader } from "../../src/lib/auth-header.js"; +import { + classifySilenced, + enrichEventWithGroupingTags, + extractMessagePrefix, + extractResourceKind, + normalizeEndpoint, + reportCliError, +} from "../../src/lib/error-reporting.js"; +import { + ApiError, + AuthError, + CliError, + ConfigError, + ContextError, + HostScopeError, + MalformedAuthTokenError, + OutputError, + ResolutionError, + SeerError, + ValidationError, + WizardError, +} from "../../src/lib/errors.js"; + +// --------------------------------------------------------------------------- +// extractResourceKind +// --------------------------------------------------------------------------- + +describe("extractResourceKind", () => { + test("strips single-quoted user data", () => { + expect( + extractResourceKind("Project 'api-track' not found in organization 'foo'") + ).toBe("Project not found"); + }); + + test("strips double-quoted user data", () => { + expect(extractResourceKind('Event "abc123" not found in org "foo"')).toBe( + "Event not found" + ); + }); + + test("strips long hex IDs", () => { + expect( + extractResourceKind("Trace abcdef0123456789abcdef0123456789 not found") + ).toBe("Trace not found"); + }); + + test("strips long numeric IDs", () => { + expect(extractResourceKind("Issue 7420431306 not found.")).toBe( + "Issue not found." + ); + }); + + test("strips small numeric IDs", () => { + expect(extractResourceKind("Issue 19 not found.")).toBe("Issue not found."); + expect(extractResourceKind("Issue 11 not found.")).toBe("Issue not found."); + }); + + test("strips org/project paths after 'in'", () => { + expect(extractResourceKind("not found in neurio/installer-app")).toBe( + "not found" + ); + expect(extractResourceKind("not found in olli-inc/olli-app")).toBe( + "not found" + ); + expect(extractResourceKind("access denied in sentry-sdks/cli")).toBe( + "access denied" + ); + }); + + test("strips 'in ' without org/project slash", () => { + expect(extractResourceKind("not found in organization")).toBe("not found"); + expect(extractResourceKind("not found in my-org")).toBe("not found"); + }); + + test("strips bare slugs after known entity names", () => { + expect(extractResourceKind("Organization my-company")).toBe("Organization"); + expect(extractResourceKind("Dashboard my-dash-123")).toBe("Dashboard"); + expect(extractResourceKind("Dashboards in my-org")).toBe("Dashboards"); + expect(extractResourceKind("Team backend-team")).toBe("Team"); + }); + + test("strips 'in ' combined with entity names and numeric IDs", () => { + expect(extractResourceKind("Dashboard 42 in my-org")).toBe("Dashboard"); + expect( + extractResourceKind("Organization my-org not found or has no dashboards") + ).toBe("Organization not found or has no dashboards"); + }); + + test("handles empty input", () => { + expect(extractResourceKind("")).toBe(""); + }); + + test("collapses whitespace introduced by strips", () => { + expect(extractResourceKind("Issue suffix 'X'")).toBe("Issue suffix"); + }); +}); + +// --------------------------------------------------------------------------- +// extractMessagePrefix +// --------------------------------------------------------------------------- + +describe("extractMessagePrefix", () => { + test("returns first 3 words by default", () => { + expect( + extractMessagePrefix('Invalid trace ID "abc". Expected a 32-character.') + ).toBe("Invalid trace ID"); + }); + + test("strips quoted substrings before word-counting", () => { + // Quoted input doesn't push the real content past the word limit. + expect(extractMessagePrefix('Invalid event ID "anything"')).toBe( + "Invalid event ID" + ); + }); + + test("stops at first newline", () => { + expect( + extractMessagePrefix("Invalid slug.\n\nTry: sentry project create") + ).toBe("Invalid slug."); + }); + + test("returns '' for empty input", () => { + expect(extractMessagePrefix("")).toBe(""); + }); + + test("respects custom maxWords", () => { + expect(extractMessagePrefix("one two three four five", 2)).toBe("one two"); + }); + + test("same kind across different user-supplied values", () => { + // Invariant: slug variation should not change the kind + expect(extractMessagePrefix('Invalid trace ID "abc"')).toBe( + extractMessagePrefix('Invalid trace ID "def"') + ); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeEndpoint +// --------------------------------------------------------------------------- + +describe("normalizeEndpoint", () => { + test("parameterizes org slug in organizations path", () => { + expect(normalizeEndpoint("/api/0/organizations/my-org/issues/")).toBe( + "/api/0/organizations/{org}/issues/" + ); + }); + + test("parameterizes org and project in projects path", () => { + expect( + normalizeEndpoint("/api/0/projects/my-org/my-project/events/abc123/") + ).toBe("/api/0/projects/{org}/{project}/events/{id}/"); + }); + + test("parameterizes issue, event, group, release IDs", () => { + expect(normalizeEndpoint("/api/0/issues/12345/")).toBe( + "/api/0/issues/{id}/" + ); + expect(normalizeEndpoint("/api/0/groups/99/events/abc/")).toBe( + "/api/0/groups/{id}/events/{id}/" + ); + expect(normalizeEndpoint("/api/0/releases/1.0.0/")).toBe( + "/api/0/releases/{version}/" + ); + }); + + test("parameterizes teams path", () => { + expect(normalizeEndpoint("/api/0/teams/my-org/backend/")).toBe( + "/api/0/teams/{org}/{team}/" + ); + }); + + test("parameterizes dashboards path", () => { + expect(normalizeEndpoint("/api/0/dashboards/42/")).toBe( + "/api/0/dashboards/{id}/" + ); + }); + + test("parameterizes customers path", () => { + expect(normalizeEndpoint("/api/0/customers/my-org/")).toBe( + "/api/0/customers/{org}/" + ); + }); + + test("parameterizes bare numeric segments", () => { + expect(normalizeEndpoint("/api/0/some/123/thing/456")).toBe( + "/api/0/some/{id}/thing/{id}" + ); + }); + + test("leaves paths without variable segments unchanged", () => { + expect(normalizeEndpoint("/api/0/auth/")).toBe("/api/0/auth/"); + }); +}); + +// --------------------------------------------------------------------------- +// classifySilenced +// --------------------------------------------------------------------------- + +describe("classifySilenced", () => { + test("silences OutputError", () => { + expect(classifySilenced(new OutputError(null))).toBe("output_error"); + }); + + test("silences AuthError(not_authenticated)", () => { + expect(classifySilenced(new AuthError("not_authenticated"))).toBe( + "auth_expected" + ); + }); + + test("silences AuthError(expired)", () => { + expect(classifySilenced(new AuthError("expired"))).toBe("auth_expected"); + }); + + test("silences AuthError(invalid)", () => { + // Rejected credentials stay silenced; malformed tokens use a subclass. + expect(classifySilenced(new AuthError("invalid"))).toBe("auth_expected"); + }); + + test("does NOT silence MalformedAuthTokenError", () => { + expect(classifySilenced(new MalformedAuthTokenError())).toBeNull(); + }); + + test.each([ + 401, 403, 404, 429, 418, + ])("silences ApiError with status %i", (status) => { + expect(classifySilenced(new ApiError("x", status))).toBe("api_user_error"); + }); + + test("does NOT silence ApiError 400 (CLI bug)", () => { + expect(classifySilenced(new ApiError("bad", 400))).toBeNull(); + }); + + test("silences a raw 'fetch failed' TypeError (network failure)", () => { + expect(classifySilenced(new TypeError("fetch failed"))).toBe( + "network_error" + ); + }); + + test("does NOT silence a TLS cert error wrapped as ApiError(0)", () => { + // status 0 is shared by network failures and TLS cert errors; the latter + // are actionable (missing CA) and must stay captured. + expect( + classifySilenced(new ApiError("TLS certificate error", 0)) + ).toBeNull(); + }); + + test("does NOT silence an unrelated TypeError", () => { + expect(classifySilenced(new TypeError("x is not a function"))).toBeNull(); + }); + + test("does NOT silence a raw ApiError 400 with a search-query parse detail", () => { + // A user's unparseable --query is converted to a ValidationError at the + // command boundary (toSearchQueryError). A search-query 400 reaching the + // classifier therefore means the CLI built a bad query itself — a real bug + // that must stay reported (CLI-FA: stop papering over root causes). + expect( + classifySilenced( + new ApiError( + "Failed to list issues: 400 Bad Request", + 400, + "Error parsing search query: invalid status value of '403'" + ) + ) + ).toBeNull(); + }); + + test("does NOT silence any 400 regardless of detail", () => { + expect( + classifySilenced( + new ApiError("bad", 400, "Invalid dashboard widget configuration") + ) + ).toBeNull(); + expect(classifySilenced(new ApiError("bad", 400))).toBeNull(); + }); + + test.each([ + 500, 502, 503, + ])("does NOT silence ApiError with 5xx status %i", (status) => { + expect(classifySilenced(new ApiError("x", status))).toBeNull(); + }); + + test.each([ + ["auto-detect failure", new ContextError("Organization and project", "x")], + ["missing ID", new ContextError("Event ID", "sentry event view ", [])], + ])("does NOT silence ContextError (%s)", (_label, err) => { + // ContextError volume is the signal driving auto-detection/UX fixes + // (single-org auto-select, interactive picker), so it must stay captured. + expect(classifySilenced(err)).toBeNull(); + }); + + test("silences ValidationError with field 'project.ambiguous_org'", () => { + // A project slug that exists in multiple orgs is user-input ambiguity, not + // a CLI bug. Silence it so it doesn't pollute the issue tracker. + expect( + classifySilenced( + new ValidationError( + 'Project "webapp-backend" exists in multiple organizations.', + "project.ambiguous_org" + ) + ) + ).toBe("user_validation"); + }); + + test("silences ValidationError with field 'input' (--input file not found)", () => { + // A missing --input file is pure user-input noise, not a CLI bug (CLI-1JY). + expect( + classifySilenced( + new ValidationError("File not found: /tmp/does-not-exist.json", "input") + ) + ).toBe("user_input_error"); + }); + + test("silences ResolutionError (user provided a value that wasn't found)", () => { + expect( + classifySilenced( + new ResolutionError("Project 'x'", "not found", "sentry issue list") + ) + ).toBe("user_input_error"); + }); + test.each([ + ["ValidationError (no field)", new ValidationError("bad")], + [ + "ValidationError (other field)", + new ValidationError("Invalid trace ID", "trace_id"), + ], + ["SeerError", new SeerError("not_enabled")], + ["ConfigError", new ConfigError("bad")], + ["generic Error", new Error("boom")], + ])("does NOT silence %s", (_label, err) => { + expect(classifySilenced(err)).toBeNull(); + }); + + test.each([ + "not_enabled", + "no_budget", + "ai_disabled", + ] as const)("silences SeerError(%s) on self-hosted (CLI-1WP)", (reason) => { + withSentryUrl("https://sentry.example.com", () => { + expect(classifySilenced(new SeerError(reason, "my-org"))).toBe( + "seer_unavailable_self_hosted" + ); + }); + }); + + test("does NOT silence SeerError when SENTRY_URL points at SaaS", () => { + withSentryUrl("https://sentry.io", () => { + expect(classifySilenced(new SeerError("not_enabled"))).toBeNull(); + }); + }); +}); + +/** + * Run `fn` with `SENTRY_URL` set to `url`, restoring the previous value after. + * `SENTRY_HOST` is cleared for the duration since it takes precedence. + */ +function withSentryUrl(url: string, fn: () => void): void { + const savedUrl = process.env.SENTRY_URL; + const savedHost = process.env.SENTRY_HOST; + process.env.SENTRY_URL = url; + delete process.env.SENTRY_HOST; + try { + fn(); + } finally { + if (savedUrl === undefined) { + delete process.env.SENTRY_URL; + } else { + process.env.SENTRY_URL = savedUrl; + } + if (savedHost !== undefined) { + process.env.SENTRY_HOST = savedHost; + } + } +} + +// --------------------------------------------------------------------------- +// enrichEventWithGroupingTags +// --------------------------------------------------------------------------- + +describe("enrichEventWithGroupingTags", () => { + function makeEvent( + type: string, + tags?: Record + ): Sentry.ErrorEvent { + return { + exception: { values: [{ type, value: "msg" }] }, + ...(tags && { tags }), + } as Sentry.ErrorEvent; + } + + test("sets cli_error.class from exception type", () => { + const event = makeEvent("ContextError"); + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.class"]).toBe("ContextError"); + }); + + test("skips events that already have cli_error.class", () => { + const event = makeEvent("ContextError", { + "cli_error.class": "ContextError", + "cli_error.kind": "Organization", + }); + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.kind"]).toBe("Organization"); + }); + + test("skips events without exception", () => { + const event = {} as Sentry.ErrorEvent; + const result = enrichEventWithGroupingTags(event); + expect(result.tags).toBeUndefined(); + }); + + test("skips events without exception type", () => { + const event = { + exception: { values: [{ value: "msg" }] }, + } as Sentry.ErrorEvent; + const result = enrichEventWithGroupingTags(event); + expect(result.tags).toBeUndefined(); + }); + + test("sets cli_error.kind from exception value", () => { + const event = makeEvent("TypeError"); + event.exception!.values![0]!.value = + "Cannot read properties of undefined (reading 'replaceAll')"; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.kind"]).toBe("Cannot read properties of"); + }); + + test("sets cli_error.kind for fetch failed TypeError", () => { + const event = makeEvent("TypeError"); + event.exception!.values![0]!.value = "fetch failed"; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.kind"]).toBe("fetch failed"); + }); + + test("sets cli_error.kind for Error with variable project count", () => { + const event1 = makeEvent("Error"); + event1.exception!.values![0]!.value = + "Failed to fetch issues from 3 project(s): Failed to list issues: 400 Bad Request"; + const event2 = makeEvent("Error"); + event2.exception!.values![0]!.value = + "Failed to fetch issues from 1 project(s): Failed to list issues: 400 Bad Request"; + const result1 = enrichEventWithGroupingTags(event1); + const result2 = enrichEventWithGroupingTags(event2); + expect(result1.tags?.["cli_error.kind"]).toBe( + result2.tags?.["cli_error.kind"] + ); + }); + + test("does not set cli_error.kind when value is missing", () => { + const event = { + exception: { values: [{ type: "Error" }] }, + } as Sentry.ErrorEvent; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.class"]).toBe("Error"); + expect(result.tags?.["cli_error.kind"]).toBeUndefined(); + }); + + test("does not override existing tags from reportCliError", () => { + const event = makeEvent("ContextError", { + "cli_error.class": "ContextError", + "cli_error.kind": "Organization", + }); + event.exception!.values![0]!.value = "some different message"; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.kind"]).toBe("Organization"); + }); +}); + +// --------------------------------------------------------------------------- +// reportCliError integration +// --------------------------------------------------------------------------- + +describe("reportCliError integration", () => { + let captureSpy: ReturnType; + let metricSpy: ReturnType; + let withScopeSpy: ReturnType; + + beforeEach(() => { + captureSpy = vi.spyOn(Sentry, "captureException"); + metricSpy = vi.spyOn(Sentry.metrics, "distribution"); + withScopeSpy = vi.spyOn(Sentry, "withScope"); + }); + + afterEach(() => { + captureSpy.mockRestore(); + metricSpy.mockRestore(); + withScopeSpy.mockRestore(); + }); + + /** + * Capture the tags that `reportCliError` would set on the scope. + * Intercepts `Sentry.withScope` and runs the callback with a fake scope + * that records `setTag`/`setContext` calls. + */ + function capturedScopeTags(error: unknown): { + tags: Record; + contexts: Record; + } { + const tags: Record = {}; + const contexts: Record = {}; + const fakeScope = { + setTag(k: string, v: string) { + tags[k] = v; + }, + setContext(k: string, v: unknown) { + contexts[k] = v; + }, + setFingerprint() { + /* noop */ + }, + }; + withScopeSpy.mockImplementation((fn: (s: unknown) => void) => { + fn(fakeScope); + }); + reportCliError(error); + return { tags, contexts }; + } + + test("captures ContextError (no longer silenced) so its volume stays visible", () => { + reportCliError( + new ContextError("Organization and project", "sentry org view ") + ); + // CLI-3B: ContextError is the signal for auto-detection/UX improvements, so + // it is captured rather than dropped to a silenced-metric. + expect(captureSpy).toHaveBeenCalled(); + expect(metricSpy).not.toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ error_class: "ContextError" }), + }) + ); + }); + + test("ValidationError with field uses field as kind", () => { + const { tags } = capturedScopeTags(new ValidationError("Bad", "trace_id")); + expect(tags["cli_error.class"]).toBe("ValidationError"); + expect(tags["cli_error.kind"]).toBe("trace_id"); + }); + + test("ValidationError without field falls back to message prefix", () => { + // Without a stable fallback, every unfielded ValidationError would get + // kind="" and collapse into one huge mixed group. + const err = new ValidationError( + 'Invalid trace ID "d2ad4a2d947b5983". Expected 32-char hex.' + ); + const { tags } = capturedScopeTags(err); + expect(tags["cli_error.class"]).toBe("ValidationError"); + expect(tags["cli_error.kind"]).toBe("Invalid trace ID"); + }); + + test("ValidationError kind is stable across different user inputs", () => { + const a = capturedScopeTags( + new ValidationError('Invalid trace ID "abc"') + ).tags; + const b = capturedScopeTags( + new ValidationError('Invalid trace ID "xyz-different"') + ).tags; + expect(a["cli_error.kind"]).toBe(b["cli_error.kind"]); + }); + + test("ValidationError kind differentiates by validator", () => { + const traceErr = capturedScopeTags( + new ValidationError('Invalid trace ID "abc"') + ).tags; + const eventErr = capturedScopeTags( + new ValidationError('Invalid event ID "abc"') + ).tags; + expect(traceErr["cli_error.kind"]).not.toBe(eventErr["cli_error.kind"]); + }); + + test("silences ResolutionError and emits metric (CLI-RP)", () => { + // ResolutionError is user-input noise (value provided but not found), + // not a CLI bug — silence it so it doesn't pollute the issue tracker. + const err = new ResolutionError( + "Project 'x'", + "not found", + "sentry issue list /x" + ); + reportCliError(err); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ + error_class: "ResolutionError", + reason: "user_input_error", + }), + }) + ); + }); + + test("captures SeerError (marketing dashboard)", () => { + reportCliError(new SeerError("not_enabled", "my-org")); + expect(captureSpy).toHaveBeenCalled(); + expect(metricSpy).not.toHaveBeenCalled(); + }); + + test("silences SeerError on self-hosted and emits metric (CLI-1WP)", () => { + withSentryUrl("https://sentry.example.com", () => { + reportCliError(new SeerError("not_enabled", "my-org")); + }); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ + error_class: "SeerError", + reason: "seer_unavailable_self_hosted", + seer_reason: "not_enabled", + }), + }) + ); + }); + + test("silences AuthError(invalid) and emits metric", () => { + reportCliError(new AuthError("invalid")); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ + reason: "auth_expected", + auth_reason: "invalid", + }), + }) + ); + }); + + test("captures malformed-token failures once without retaining credentials", () => { + const firstPart = "sntrys_reporting-secret-first"; + const secondPart = "reporting-secret-second"; + let error: unknown; + try { + formatAuthHeader(`${firstPart}\n${secondPart}`); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(MalformedAuthTokenError); + + const { tags, contexts } = capturedScopeTags(error); + expect(captureSpy).toHaveBeenCalledExactlyOnceWith(error); + expect(metricSpy).not.toHaveBeenCalled(); + expect(tags).toMatchObject({ + "cli_error.class": "MalformedAuthTokenError", + "cli_error.kind": "invalid", + }); + + const captured = captureSpy.mock.calls[0]?.[0] as Error; + expect(captured.cause).toBeUndefined(); + const diagnostics = JSON.stringify({ + properties: Object.getOwnPropertyDescriptors(captured), + tags, + contexts, + }); + expect(diagnostics).not.toContain(firstPart); + expect(diagnostics).not.toContain(secondPart); + }); + + test("captures ApiError(400) with normalized endpoint tag", () => { + const err = new ApiError( + "failed", + 400, + undefined, + "/api/0/organizations/my-org/issues/" + ); + const { tags } = capturedScopeTags(err); + expect(tags["cli_error.api_status"]).toBe("400"); + expect(tags["cli_error.kind"]).toBe("400"); + expect(tags["cli_error.api_endpoint"]).toBe( + "/api/0/organizations/{org}/issues/" + ); + }); + + test("ApiError without endpoint does not set api_endpoint tag", () => { + const { tags } = capturedScopeTags(new ApiError("failed", 500)); + expect(tags["cli_error.api_status"]).toBe("500"); + expect(tags["cli_error.api_endpoint"]).toBeUndefined(); + }); + + test("HostScopeError gets kind=host_scope", () => { + const { tags } = capturedScopeTags( + new HostScopeError("URL argument", "https://other.sentry.io", "sentry.io") + ); + expect(tags["cli_error.class"]).toBe("HostScopeError"); + expect(tags["cli_error.kind"]).toBe("host_scope"); + }); + + test("WizardError gets kind=wizard", () => { + const { tags } = capturedScopeTags( + new WizardError("Workflow returned an error") + ); + expect(tags["cli_error.class"]).toBe("WizardError"); + expect(tags["cli_error.kind"]).toBe("wizard"); + }); + + test("bare CliError gets kind from message prefix", () => { + const { tags } = capturedScopeTags( + new CliError("Failed to create project 'my-app' in my-org.") + ); + expect(tags["cli_error.class"]).toBe("CliError"); + expect(tags["cli_error.kind"]).toBe("Failed to create project"); + }); + + test("bare CliError kind is stable across different user inputs", () => { + const a = capturedScopeTags( + new CliError("Failed to create project 'app-a' in org-a.") + ).tags; + const b = capturedScopeTags( + new CliError("Failed to create project 'app-b' in org-b.") + ).tags; + expect(a["cli_error.kind"]).toBe(b["cli_error.kind"]); + }); + + test.each([ + 401, 403, 404, 429, + ])("SILENCES ApiError(%i) and emits metric", (status) => { + reportCliError(new ApiError("user err", status, "detail", "/api/0/foo/")); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ + error_class: "ApiError", + reason: "api_user_error", + api_status: status, + }), + }) + ); + }); + + test("silences OutputError and emits metric", () => { + reportCliError(new OutputError(null)); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ reason: "output_error" }), + }) + ); + }); + + test("silences a network error and emits metric", () => { + reportCliError(new TypeError("fetch failed")); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ reason: "network_error" }), + }) + ); + }); + + test.each([ + "not_authenticated", + "expired", + ] as const)("silences AuthError(%s) and emits metric", (reason) => { + reportCliError(new AuthError(reason)); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ + reason: "auth_expected", + auth_reason: reason, + }), + }) + ); + }); + + test("captures ApiError(500) without silencing metric", () => { + reportCliError(new ApiError("server fail", 500)); + expect(captureSpy).toHaveBeenCalled(); + expect(metricSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/errors.test.ts b/packages/cli/test/lib/errors.test.ts new file mode 100644 index 000000000..ea91399cd --- /dev/null +++ b/packages/cli/test/lib/errors.test.ts @@ -0,0 +1,833 @@ +import { describe, expect, test } from "vitest"; +import { + AbortError, + ApiError, + AuthError, + buildValidationMessage, + CliError, + ConfigError, + ContextError, + DeviceFlowError, + EXIT, + formatError, + getExitCode, + HostScopeError, + isNetworkError, + isSearchQueryParseError, + isUserError, + OutputError, + ResolutionError, + SeerError, + stringifyUnknown, + TimeoutError, + toSearchQueryError, + UpgradeError, + ValidationError, + validationError, + WizardError, + withAuthGuard, +} from "../../src/lib/errors.js"; + +describe("CliError", () => { + test("has default exit code of EXIT.GENERAL", () => { + const err = new CliError("Something went wrong"); + expect(err.exitCode).toBe(EXIT.GENERAL); + expect(err.message).toBe("Something went wrong"); + }); + + test("accepts custom exit code", () => { + const err = new CliError("Custom exit", 42); + expect(err.exitCode).toBe(42); + }); + + test("format() returns message", () => { + const err = new CliError("Test message"); + expect(err.format()).toBe("Test message"); + }); +}); + +describe("AuthError", () => { + test("not_authenticated has default message", () => { + const err = new AuthError("not_authenticated"); + expect(err.message).toBe( + "Not authenticated. Run 'sentry auth login' first." + ); + expect(err.reason).toBe("not_authenticated"); + }); + + test("expired has default message", () => { + const err = new AuthError("expired"); + expect(err.message).toBe( + "Authentication expired. Run 'sentry auth login' to re-authenticate." + ); + expect(err.reason).toBe("expired"); + }); + + test("invalid has default message", () => { + const err = new AuthError("invalid"); + expect(err.message).toBe("Invalid authentication token."); + }); + + test("accepts custom message", () => { + const err = new AuthError("not_authenticated", "Custom auth error"); + expect(err.message).toBe("Custom auth error"); + expect(err.reason).toBe("not_authenticated"); + }); +}); + +describe("ApiError", () => { + test("stores status and detail", () => { + const err = new ApiError("Request failed", 404, "Not found", "/api/issues"); + expect(err.status).toBe(404); + expect(err.detail).toBe("Not found"); + expect(err.endpoint).toBe("/api/issues"); + }); + + test("format() includes detail on separate line", () => { + const err = new ApiError("Request failed", 500, "Internal server error"); + expect(err.format()).toBe("Request failed\n Internal server error"); + }); + + test("format() excludes detail if same as message", () => { + const err = new ApiError("Same message", 500, "Same message"); + expect(err.format()).toBe("Same message"); + }); + + test("format() works without detail", () => { + const err = new ApiError("Request failed", 503); + expect(err.format()).toBe("Request failed"); + }); +}); + +describe("ConfigError", () => { + test("format() includes suggestion", () => { + const err = new ConfigError("Invalid config", "Check the config file"); + expect(err.format()).toBe( + "Invalid config\n\nSuggestion: Check the config file" + ); + }); + + test("format() works without suggestion", () => { + const err = new ConfigError("Invalid config"); + expect(err.format()).toBe("Invalid config"); + }); +}); + +describe("ContextError", () => { + test("format() uses auto-detect headline when alternatives omitted", () => { + const err = new ContextError("Organization", "sentry org list"); + const formatted = err.format(); + expect(formatted).toContain("Could not auto-detect organization."); + expect(formatted).toContain("Provide it explicitly:"); + expect(formatted).toContain("sentry org list"); + expect(formatted).toContain( + "Run from a directory with a Sentry DSN in source code or .env files" + ); + expect(formatted).toContain( + "Set SENTRY_ORG and SENTRY_PROJECT (or SENTRY_DSN) environment variables" + ); + }); + + test("format() includes custom alternatives", () => { + const err = new ContextError("Project", "sentry project list", [ + "Specify project in / format", + ]); + const formatted = err.format(); + expect(formatted).toContain("Project is required."); + expect(formatted).toContain("Specify project in / format"); + expect(formatted).not.toContain("SENTRY_DSN"); + }); + + test("format() works with empty alternatives", () => { + const err = new ContextError("Resource", "sentry resource get", []); + const formatted = err.format(); + expect(formatted).toContain("Resource is required."); + expect(formatted).not.toContain("Or:"); + }); + + test("format() includes note section after alternatives", () => { + const err = new ContextError( + "Organization", + "sentry org list", + undefined, + "Found 2 DSN(s) that could not be resolved" + ); + const formatted = err.format(); + expect(formatted).toContain("Could not auto-detect organization."); + // Default alternatives are present + expect(formatted).toContain("Or:"); + expect(formatted).toContain( + "Run from a directory with a Sentry DSN in source code or .env files" + ); + // Note appears as a separate section + expect(formatted).toContain( + "Note: Found 2 DSN(s) that could not be resolved" + ); + // Note appears after alternatives + const orIndex = formatted.indexOf("Or:"); + const noteIndex = formatted.indexOf("Note:"); + expect(noteIndex).toBeGreaterThan(orIndex); + }); + + test("format() includes note without alternatives", () => { + const err = new ContextError( + "Resource", + "sentry resource get", + [], + "Some diagnostic info" + ); + const formatted = err.format(); + expect(formatted).toContain("Resource is required."); + expect(formatted).not.toContain("Or:"); + expect(formatted).toContain("Note: Some diagnostic info"); + }); + + test("note field is stored on instance", () => { + const err = new ContextError( + "Organization", + "sentry org list", + undefined, + "test note" + ); + expect(err.note).toBe("test note"); + }); +}); + +describe("ResolutionError", () => { + test("format() includes 'not found' headline and Try hint", () => { + const err = new ResolutionError( + "Issue 99124558", + "not found", + "sentry issue view /99124558", + [ + "No issue with numeric ID 99124558 found", + "If this is a short ID suffix, try: sentry issue view -99124558", + ] + ); + const formatted = err.format(); + expect(formatted).toContain("Issue 99124558 not found."); + expect(formatted).toContain("Try:"); + expect(formatted).toContain("sentry issue view /99124558"); + expect(formatted).toContain("No issue with numeric ID 99124558 found"); + expect(formatted).toContain("short ID suffix"); + // Should NOT say "is required" + expect(formatted).not.toContain("is required"); + }); + + test("format() works with 'is ambiguous' headline", () => { + const err = new ResolutionError( + "Project 'cli'", + "is ambiguous", + "sentry issue view /cli-G", + ["Found in: sentry, acme"] + ); + const formatted = err.format(); + expect(formatted).toContain("Project 'cli' is ambiguous."); + expect(formatted).toContain("Try:"); + expect(formatted).toContain("Or:"); + expect(formatted).toContain("Found in: sentry, acme"); + }); + + test("format() works with empty suggestions (no Or: section)", () => { + const err = new ResolutionError( + 'Event abc123 in organization "acme"', + "not found", + "sentry event view acme/ abc123" + ); + const formatted = err.format(); + expect(formatted).toContain( + 'Event abc123 in organization "acme" not found.' + ); + expect(formatted).toContain("Try:"); + expect(formatted).not.toContain("Or:"); + }); + + test("stores resource, headline, hint, and suggestions", () => { + const err = new ResolutionError( + "Issue suffix 'G'", + "could not be resolved without project context", + "sentry issue view /-G" + ); + expect(err.resource).toBe("Issue suffix 'G'"); + expect(err.headline).toBe("could not be resolved without project context"); + expect(err.hint).toBe("sentry issue view /-G"); + expect(err.suggestions).toEqual([]); + }); + + test("is a CliError subclass", () => { + const err = new ResolutionError( + "Issue 1", + "not found", + "sentry issue view 1" + ); + expect(err).toBeInstanceOf(CliError); + expect(err.name).toBe("ResolutionError"); + expect(err.exitCode).toBe(EXIT.RESOLUTION); + }); +}); + +describe("ValidationError", () => { + test("stores field name", () => { + const err = new ValidationError("Invalid format", "email"); + expect(err.field).toBe("email"); + expect(err.message).toBe("Invalid format"); + }); + + test("field is optional", () => { + const err = new ValidationError("Invalid input"); + expect(err.field).toBeUndefined(); + }); +}); + +describe("buildValidationMessage", () => { + test("formats Try examples and Note section", () => { + expect( + buildValidationMessage( + "Invalid flag.", + ["sentry release set-commits 1.0.0 --from v0.9.0"], + "Range is always ..HEAD." + ) + ).toBe( + [ + "Invalid flag.", + "", + "Try:", + " sentry release set-commits 1.0.0 --from v0.9.0", + "", + "Note: Range is always ..HEAD.", + ].join("\n") + ); + }); + + test("validationError wrapper preserves field", () => { + const err = validationError("Bad ref.", ["git rev-parse v0.9.0"], "from"); + expect(err).toBeInstanceOf(ValidationError); + expect(err.field).toBe("from"); + expect(err.message).toContain("Try:"); + }); +}); + +describe("DeviceFlowError", () => { + test("stores error code", () => { + const err = new DeviceFlowError("slow_down", "Polling too fast"); + expect(err.code).toBe("slow_down"); + expect(err.message).toBe("Polling too fast"); + }); + + test("uses code as message if no description", () => { + const err = new DeviceFlowError("authorization_pending"); + expect(err.message).toBe("authorization_pending"); + }); +}); + +describe("UpgradeError", () => { + test("unknown_method has default message", () => { + const err = new UpgradeError("unknown_method"); + expect(err.message).toBe( + "Could not detect installation method. Use --method to specify." + ); + expect(err.reason).toBe("unknown_method"); + }); + + test("network_error has default message", () => { + const err = new UpgradeError("network_error"); + expect(err.message).toBe("Failed to fetch version information."); + expect(err.reason).toBe("network_error"); + }); + + test("execution_failed has default message", () => { + const err = new UpgradeError("execution_failed"); + expect(err.message).toBe("Upgrade command failed."); + expect(err.reason).toBe("execution_failed"); + }); + + test("version_not_found has default message", () => { + const err = new UpgradeError("version_not_found"); + expect(err.message).toBe("The specified version was not found."); + expect(err.reason).toBe("version_not_found"); + }); + + test("accepts custom message", () => { + const err = new UpgradeError("network_error", "Custom upgrade error"); + expect(err.message).toBe("Custom upgrade error"); + expect(err.reason).toBe("network_error"); + }); +}); + +describe("SeerError", () => { + test("not_enabled has default message", () => { + const err = new SeerError("not_enabled"); + expect(err.message).toBe("Seer is not enabled for this organization."); + expect(err.reason).toBe("not_enabled"); + }); + + test("no_budget has default message", () => { + const err = new SeerError("no_budget"); + expect(err.message).toBe("Seer requires a paid plan."); + expect(err.reason).toBe("no_budget"); + }); + + test("ai_disabled has default message", () => { + const err = new SeerError("ai_disabled"); + expect(err.message).toBe("AI features are disabled for this organization."); + expect(err.reason).toBe("ai_disabled"); + }); + + test("format() includes settings URL when orgSlug provided", () => { + const err = new SeerError("not_enabled", "my-org"); + const formatted = err.format(); + expect(formatted).toContain("Seer is not enabled for this organization."); + expect(formatted).toContain("To enable Seer:"); + expect(formatted).toContain("my-org"); + }); + + test("format() includes billing URL for no_budget with orgSlug", () => { + const err = new SeerError("no_budget", "my-org"); + const formatted = err.format(); + expect(formatted).toContain("Seer requires a paid plan."); + expect(formatted).toContain("upgrade your plan"); + expect(formatted).toContain("my-org"); + }); + + test("format() includes org settings URL for ai_disabled with orgSlug", () => { + const err = new SeerError("ai_disabled", "my-org"); + const formatted = err.format(); + expect(formatted).toContain("AI features are disabled"); + expect(formatted).toContain("To enable AI features:"); + expect(formatted).toContain("my-org"); + }); + + test("format() shows fallback message without orgSlug", () => { + const err = new SeerError("not_enabled"); + const formatted = err.format(); + expect(formatted).toContain("Seer is not enabled for this organization."); + expect(formatted).toContain("visit your organization's Seer settings"); + }); + + test("format() shows fallback for no_budget without orgSlug", () => { + const err = new SeerError("no_budget"); + const formatted = err.format(); + expect(formatted).toContain( + "upgrade your plan in your organization's billing settings" + ); + }); + + test("format() shows fallback for ai_disabled without orgSlug", () => { + const err = new SeerError("ai_disabled"); + const formatted = err.format(); + expect(formatted).toContain("Hide AI Features"); + }); +}); + +describe("stringifyUnknown", () => { + test("returns strings as-is", () => { + expect(stringifyUnknown("hello")).toBe("hello"); + expect(stringifyUnknown("")).toBe(""); + }); + + test("extracts message from Error instances", () => { + expect(stringifyUnknown(new Error("something broke"))).toBe( + "something broke" + ); + expect(stringifyUnknown(new TypeError("bad type"))).toBe("bad type"); + }); + + test("serializes plain objects to JSON", () => { + expect(stringifyUnknown({ code: "not_found" })).toBe( + '{"code":"not_found"}' + ); + expect(stringifyUnknown({ detail: { message: "Forbidden" } })).toBe( + '{"detail":{"message":"Forbidden"}}' + ); + }); + + test("serializes empty objects", () => { + expect(stringifyUnknown({})).toBe("{}"); + }); + + test("serializes arrays to JSON", () => { + expect(stringifyUnknown(["error1", "error2"])).toBe('["error1","error2"]'); + }); + + test("converts primitives via String()", () => { + expect(stringifyUnknown(42)).toBe("42"); + expect(stringifyUnknown(null)).toBe("null"); + expect(stringifyUnknown(undefined)).toBe("undefined"); + expect(stringifyUnknown(true)).toBe("true"); + expect(stringifyUnknown(0)).toBe("0"); + }); + + test("falls back to String() for circular references", () => { + const circular: Record = { name: "loop" }; + circular.self = circular; + // Should not throw — falls back to String() which returns [object Object] + expect(() => stringifyUnknown(circular)).not.toThrow(); + expect(stringifyUnknown(circular)).toBe("[object Object]"); + }); + + test("falls back to String() for BigInt values", () => { + const obj = { count: BigInt(42) }; + // JSON.stringify throws on BigInt — should fall back gracefully + expect(() => stringifyUnknown(obj)).not.toThrow(); + expect(stringifyUnknown(obj)).toBe("[object Object]"); + }); +}); + +describe("formatError", () => { + test("uses format() for CliError subclasses", () => { + const err = new ApiError("API failed", 500, "Server error"); + expect(formatError(err)).toBe("API failed\n Server error"); + }); + + test("uses message for standard Error", () => { + const err = new Error("Standard error"); + expect(formatError(err)).toBe("Standard error"); + }); + + test("converts non-errors to string", () => { + expect(formatError("string error")).toBe("string error"); + expect(formatError(42)).toBe("42"); + expect(formatError(null)).toBe("null"); + expect(formatError(undefined)).toBe("undefined"); + }); + + test("serializes plain objects instead of [object Object]", () => { + expect(formatError({ code: "not_found" })).toBe('{"code":"not_found"}'); + expect(formatError({})).toBe("{}"); + }); +}); + +describe("getExitCode", () => { + test("returns exitCode for CliError", () => { + const err = new CliError("Error", 5); + expect(getExitCode(err)).toBe(5); + }); + + test("returns 1 for standard Error", () => { + expect(getExitCode(new Error("test"))).toBe(1); + }); + + test("returns 1 for non-errors", () => { + expect(getExitCode("string")).toBe(1); + expect(getExitCode(null)).toBe(1); + }); +}); + +describe("isUserError", () => { + test.each([ + ["generic CliError", new CliError("message"), true], + ["HostScopeError", new HostScopeError("blocked"), true], + ["AuthError", new AuthError("invalid"), true], + ["ConfigError", new ConfigError("bad config"), true], + ["OutputError", new OutputError({ error: "not found" }), true], + ["ContextError", new ContextError("Organization", "sentry org list"), true], + [ + "ResolutionError", + new ResolutionError("Project 'x'", "not found", "sentry project list"), + true, + ], + ["ValidationError", new ValidationError("bad input"), true], + ["DeviceFlowError", new DeviceFlowError("slow_down"), true], + ["SeerError", new SeerError("not_enabled"), true], + ["WizardError", new WizardError("wizard failed"), true], + ["ApiError 0 (network)", new ApiError("network error", 0), true], + [ + "raw fetch failed TypeError (network)", + new TypeError("fetch failed"), + true, + ], + ["ApiError 400", new ApiError("bad request", 400), false], + [ + // A user's unparseable --query becomes a ValidationError at the command + // boundary (toSearchQueryError); a raw search-query 400 reaching here is + // a CLI-built bad request — a CLI bug, not a user error. + "ApiError 400 (search query parse)", + new ApiError("bad", 400, "Error parsing search query: invalid status"), + false, + ], + ["ApiError 401", new ApiError("unauthorized", 401), true], + ["ApiError 418", new ApiError("teapot", 418), true], + ["ApiError 499", new ApiError("client closed", 499), true], + ["ApiError 500", new ApiError("server error", 500), false], + ["TimeoutError", new TimeoutError("timed out"), false], + ["UpgradeError", new UpgradeError("network_error"), false], + ["AbortError", new AbortError(), false], + ["standard Error", new Error("boom"), false], + ["string throw", "boom", false], + ["null", null, false], + ])("classifies %s", (_label, errorValue, expected) => { + expect(isUserError(errorValue)).toBe(expected); + }); +}); + +describe("isNetworkError", () => { + test("true for a raw 'fetch failed' TypeError (undici network failure)", () => { + expect(isNetworkError(new TypeError("fetch failed"))).toBe(true); + }); + + test("false for ApiError status 0 (shared with TLS cert errors)", () => { + // status 0 is also used for TLS cert errors, which must stay actionable. + expect(isNetworkError(new ApiError("Network error", 0))).toBe(false); + expect(isNetworkError(new ApiError("TLS certificate error", 0))).toBe( + false + ); + }); + + test("false for an ApiError with an HTTP status", () => { + expect(isNetworkError(new ApiError("bad", 400))).toBe(false); + expect(isNetworkError(new ApiError("server", 500))).toBe(false); + }); + + test("false for an unrelated TypeError", () => { + expect(isNetworkError(new TypeError("x is not a function"))).toBe(false); + }); + + test("false for non-error values", () => { + expect(isNetworkError(new Error("boom"))).toBe(false); + expect(isNetworkError("fetch failed")).toBe(false); + expect(isNetworkError(null)).toBe(false); + }); +}); + +describe("isSearchQueryParseError", () => { + test("true for a 400 whose detail reports an unparseable query", () => { + expect( + isSearchQueryParseError( + new ApiError("bad", 400, "Error parsing search query: invalid status") + ) + ).toBe(true); + }); + + test("true when the parser detail is prepended by error enrichment", () => { + const detail = + "Error parsing search query: Empty string after 'status:'\n\nSuggestions:"; + expect(isSearchQueryParseError(new ApiError("wrapped", 400, detail))).toBe( + true + ); + }); + + test("false for a 400 without a query parse detail", () => { + expect( + isSearchQueryParseError(new ApiError("bad", 400, "Other failure")) + ).toBe(false); + expect(isSearchQueryParseError(new ApiError("no detail", 400))).toBe(false); + }); + + test("false when status is not 400 (other 4xx handled elsewhere)", () => { + expect( + isSearchQueryParseError( + new ApiError("x", 422, "Error parsing search query: ...") + ) + ).toBe(false); + }); +}); + +describe("toSearchQueryError", () => { + const parseError = () => + new ApiError( + "Failed to fetch issues", + 400, + "Error parsing search query: bad" + ); + + test("converts a parse 400 to a ValidationError when the user supplied --query", () => { + const result = toSearchQueryError(parseError(), "is:403"); + expect(result).toBeInstanceOf(ValidationError); + expect((result as ValidationError).field).toBe("query"); + }); + + test("includes the server detail and a search-syntax suggestion in the message", () => { + const result = toSearchQueryError( + parseError(), + "is:403" + ) as ValidationError; + expect(result.message).toContain("Error parsing search query: bad"); + expect(result.message).toContain("Sentry search reference"); + }); + + test("appends command-specific extra suggestions", () => { + const result = toSearchQueryError(parseError(), "is:403", [ + "Try a shorter time range", + ]) as ValidationError; + expect(result.message).toContain("Try a shorter time range"); + }); + + test("returns the original error untouched when the user supplied no --query", () => { + // No user query → the CLI built the bad query → keep it a reported 400. + const error = parseError(); + expect(toSearchQueryError(error, undefined)).toBe(error); + expect(toSearchQueryError(error, "")).toBe(error); + }); + + test("returns the original error for non-search-query 400s", () => { + const error = new ApiError("bad", 400, "Invalid widget configuration"); + expect(toSearchQueryError(error, "is:403")).toBe(error); + }); + + test("returns the original error for non-ApiError values", () => { + const error = new Error("boom"); + expect(toSearchQueryError(error, "is:403")).toBe(error); + }); +}); + +describe("exit codes", () => { + test("CliError base defaults to EXIT.GENERAL (1)", () => { + expect(new CliError("err").exitCode).toBe(EXIT.GENERAL); + }); + + test("AuthError maps reasons to exit codes", () => { + expect(new AuthError("not_authenticated").exitCode).toBe( + EXIT.AUTH_NOT_AUTHENTICATED + ); + expect(new AuthError("expired").exitCode).toBe(EXIT.AUTH_EXPIRED); + expect(new AuthError("invalid").exitCode).toBe(EXIT.AUTH_INVALID); + }); + + test("HostScopeError has exit code AUTH_HOST_SCOPE", () => { + // Freeform message form + expect(new HostScopeError("blocked").exitCode).toBe(EXIT.AUTH_HOST_SCOPE); + // URL mismatch form (no tokenHost) + expect( + new HostScopeError("Request", "https://other.sentry.io").exitCode + ).toBe(EXIT.AUTH_HOST_SCOPE); + // URL mismatch form (with tokenHost) + expect( + new HostScopeError( + "Request", + "https://other.sentry.io", + "https://sentry.io" + ).exitCode + ).toBe(EXIT.AUTH_HOST_SCOPE); + }); + + test("ApiError has exit code API", () => { + expect(new ApiError("fail", 500).exitCode).toBe(EXIT.API); + }); + + test("ConfigError has exit code CONFIG", () => { + expect(new ConfigError("bad config").exitCode).toBe(EXIT.CONFIG); + }); + + test("ValidationError has exit code VALIDATION", () => { + expect(new ValidationError("bad input").exitCode).toBe(EXIT.VALIDATION); + }); + + test("ContextError has exit code CONTEXT_MISSING", () => { + expect(new ContextError("Organization", "sentry org list").exitCode).toBe( + EXIT.CONTEXT_MISSING + ); + }); + + test("ResolutionError has exit code RESOLUTION", () => { + expect( + new ResolutionError("X", "not found", "sentry x view").exitCode + ).toBe(EXIT.RESOLUTION); + }); + + test("DeviceFlowError has exit code DEVICE_FLOW", () => { + expect(new DeviceFlowError("slow_down").exitCode).toBe(EXIT.DEVICE_FLOW); + }); + + test("UpgradeError has exit code UPGRADE", () => { + expect(new UpgradeError("network_error").exitCode).toBe(EXIT.UPGRADE); + }); + + test("SeerError maps reasons to exit codes", () => { + expect(new SeerError("not_enabled").exitCode).toBe(EXIT.SEER_NOT_ENABLED); + expect(new SeerError("no_budget").exitCode).toBe(EXIT.SEER_NO_BUDGET); + expect(new SeerError("ai_disabled").exitCode).toBe(EXIT.SEER_AI_DISABLED); + }); + + test("TimeoutError has exit code TIMEOUT", () => { + expect(new TimeoutError("timed out").exitCode).toBe(EXIT.TIMEOUT); + }); + + test("OutputError has exit code OUTPUT_ERROR", () => { + expect(new OutputError({ items: [] }).exitCode).toBe(EXIT.OUTPUT_ERROR); + }); + + test("WizardError defaults to exit code WIZARD", () => { + expect(new WizardError("wizard failed").exitCode).toBe(EXIT.WIZARD); + }); + + test("WizardError accepts custom exit code for workflow sub-codes", () => { + expect( + new WizardError("deps failed", { exitCode: EXIT.WIZARD_DEPS }).exitCode + ).toBe(EXIT.WIZARD_DEPS); + expect( + new WizardError("codemod failed", { exitCode: EXIT.WIZARD_CODEMOD }) + .exitCode + ).toBe(EXIT.WIZARD_CODEMOD); + expect( + new WizardError("verify stopped", { exitCode: EXIT.WIZARD_VERIFY }) + .exitCode + ).toBe(EXIT.WIZARD_VERIFY); + }); + + test("EXIT values are all unique", () => { + const values = Object.values(EXIT); + expect(new Set(values).size).toBe(values.length); + }); + + test("EXIT values are all positive integers below 128", () => { + for (const [, code] of Object.entries(EXIT)) { + expect(code).toBeGreaterThan(0); + expect(code).toBeLessThan(128); + expect(Number.isInteger(code)).toBe(true); + } + }); +}); + +describe("withAuthGuard", () => { + test("returns ok result on success", async () => { + const result = await withAuthGuard(() => Promise.resolve("hello")); + expect(result).toEqual({ ok: true, value: "hello" }); + }); + + test("rethrows AuthError('not_authenticated')", async () => { + await expect( + withAuthGuard(() => Promise.reject(new AuthError("not_authenticated"))) + ).rejects.toBeInstanceOf(AuthError); + }); + + test("rethrows AuthError('expired')", async () => { + await expect( + withAuthGuard(() => Promise.reject(new AuthError("expired"))) + ).rejects.toBeInstanceOf(AuthError); + }); + + test("rethrows AuthError('invalid')", async () => { + await expect( + withAuthGuard(() => Promise.reject(new AuthError("invalid"))) + ).rejects.toBeInstanceOf(AuthError); + }); + + test("returns failure result with error on non-AuthError", async () => { + const thrownError = new Error("network error"); + const result = await withAuthGuard(() => Promise.reject(thrownError)); + expect(result).toEqual({ ok: false, error: thrownError }); + }); + + test("returns failure result on ApiError", async () => { + const apiError = new ApiError("Not found", 404); + const result = await withAuthGuard(() => Promise.reject(apiError)); + expect(result).toEqual({ ok: false, error: apiError }); + }); + + test("preserves the original error object in failure result", async () => { + const thrownError = new Error("boom"); + const result = await withAuthGuard(() => Promise.reject(thrownError)); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(thrownError); + } + }); + + test("handles non-Error thrown values", async () => { + const result = await withAuthGuard(() => Promise.reject("string error")); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("string error"); + } + }); +}); diff --git a/packages/cli/test/lib/force-exit.test.ts b/packages/cli/test/lib/force-exit.test.ts new file mode 100644 index 000000000..68acb863c --- /dev/null +++ b/packages/cli/test/lib/force-exit.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { scheduleForceExit } from "../../src/lib/force-exit.js"; + +const originalPlatform = process.platform; +const originalNodeEnv = process.env.NODE_ENV; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +beforeEach(() => { + setPlatform("linux"); + process.env.NODE_ENV = "test"; +}); + +afterEach(() => { + vi.restoreAllMocks(); + setPlatform(originalPlatform); + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } +}); + +describe("force-exit safety net", () => { + test("schedules an unref'd 100ms timer on macOS outside tests", () => { + const unref = vi.fn(); + const timeout = { unref } as unknown as ReturnType; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockReturnValue(timeout); + setPlatform("darwin"); + process.env.NODE_ENV = "production"; + + scheduleForceExit(); + + expect(setTimeoutSpy).toHaveBeenCalledOnce(); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100); + expect(unref).toHaveBeenCalledOnce(); + }); + + test("does nothing outside macOS", () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + process.env.NODE_ENV = "production"; + + scheduleForceExit(); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + test("does nothing in the test environment even on macOS", () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + setPlatform("darwin"); + process.env.NODE_ENV = "test"; + + scheduleForceExit(); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/formatters/auto-compact.property.test.ts b/packages/cli/test/lib/formatters/auto-compact.property.test.ts new file mode 100644 index 000000000..36a014ac8 --- /dev/null +++ b/packages/cli/test/lib/formatters/auto-compact.property.test.ts @@ -0,0 +1,142 @@ +/** + * Property-Based Tests for shouldAutoCompact + * + * Verifies invariants of the terminal-height-based auto-compact heuristic. + * Since shouldAutoCompact reads process.stdout.rows, tests save/restore the + * property around each assertion. + */ + +import { assert as fcAssert, integer, nat, property, tuple } from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { shouldAutoCompact } from "../../../src/lib/formatters/human.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Lines per non-compact row (2 content + 1 separator). */ +const LINES_PER_ROW = 3; + +/** Fixed overhead: top border + header + header sep + bottom border − 1 (no trailing row sep). */ +const OVERHEAD = 3; + +/** Save original process.stdout.rows so we can restore it after each test. */ +let originalRows: number | undefined; + +beforeEach(() => { + originalRows = process.stdout.rows; +}); + +afterEach(() => { + Object.defineProperty(process.stdout, "rows", { + value: originalRows, + writable: true, + configurable: true, + }); +}); + +/** Set process.stdout.rows for testing. */ +function setTermHeight(rows: number | undefined): void { + Object.defineProperty(process.stdout, "rows", { + value: rows, + writable: true, + configurable: true, + }); +} + +describe("property: shouldAutoCompact", () => { + test("returns false when terminal height is undefined (non-TTY)", () => { + fcAssert( + property(nat(500), (rowCount) => { + setTermHeight(undefined); + expect(shouldAutoCompact(rowCount)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns false when terminal height is 0", () => { + fcAssert( + property(nat(500), (rowCount) => { + setTermHeight(0); + expect(shouldAutoCompact(rowCount)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("zero rows only triggers compact if overhead alone exceeds terminal", () => { + fcAssert( + property(integer({ min: 1, max: 500 }), (termHeight) => { + setTermHeight(termHeight); + // With 0 rows, estimated = OVERHEAD. Compact iff OVERHEAD > termHeight. + expect(shouldAutoCompact(0)).toBe(OVERHEAD > termHeight); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns true when estimated height exceeds terminal height", () => { + fcAssert( + property( + tuple(integer({ min: 1, max: 200 }), integer({ min: 5, max: 500 })), + ([rowCount, termHeight]) => { + const estimated = rowCount * LINES_PER_ROW + OVERHEAD; + if (estimated > termHeight) { + setTermHeight(termHeight); + expect(shouldAutoCompact(rowCount)).toBe(true); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns false when estimated height fits within terminal", () => { + fcAssert( + property( + tuple(integer({ min: 1, max: 200 }), integer({ min: 5, max: 2000 })), + ([rowCount, termHeight]) => { + const estimated = rowCount * LINES_PER_ROW + OVERHEAD; + if (estimated <= termHeight) { + setTermHeight(termHeight); + expect(shouldAutoCompact(rowCount)).toBe(false); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is monotonic: more rows never decreases compactness", () => { + fcAssert( + property( + tuple(nat(100), nat(100), integer({ min: 10, max: 200 })), + ([a, b, termHeight]) => { + setTermHeight(termHeight); + const smaller = Math.min(a, b); + const larger = Math.max(a, b); + const compactSmall = shouldAutoCompact(smaller); + const compactLarge = shouldAutoCompact(larger); + // If fewer rows triggers compact, more rows must also trigger compact + if (compactSmall) { + expect(compactLarge).toBe(true); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic: same inputs produce same output", () => { + fcAssert( + property( + tuple(nat(200), integer({ min: 1, max: 500 })), + ([rowCount, termHeight]) => { + setTermHeight(termHeight); + const result1 = shouldAutoCompact(rowCount); + const result2 = shouldAutoCompact(rowCount); + expect(result1).toBe(result2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/chart-core.test.ts b/packages/cli/test/lib/formatters/chart-core.test.ts new file mode 100644 index 000000000..c442bc2d7 --- /dev/null +++ b/packages/cli/test/lib/formatters/chart-core.test.ts @@ -0,0 +1,220 @@ +/** + * Shared chart core tests. + */ + +import { describe, expect, test } from "vitest"; +import { + buildCategoricalChartModel, + buildChartModel, + hexToRgb, + rasterizeChart, + SERIES_PALETTE, + seriesColor, +} from "../../../src/lib/formatters/chart-core.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +describe("seriesColor", () => { + test("returns muted gray for the Other bucket", () => { + expect(seriesColor("Other", 3)).toBe("#888888"); + }); + + test("cycles through the palette by index", () => { + expect(seriesColor("a", 0)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", SERIES_PALETTE.length)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", 1)).toBe(SERIES_PALETTE[1]); + }); +}); + +describe("hexToRgb", () => { + test("parses six-digit hex", () => { + expect(hexToRgb("#7553FF")).toEqual([0x75, 0x53, 0xff]); + }); + + test("parses shorthand three-digit hex", () => { + expect(hexToRgb("#0f8")).toEqual([0x00, 0xff, 0x88]); + }); +}); + +describe("buildChartModel", () => { + test("returns undefined for empty series", () => { + expect(buildChartModel(makeTimeseries({ series: [] }))).toBeUndefined(); + }); + + test("returns undefined when every series is empty", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }) + ); + expect(model).toBeUndefined(); + }); + + test("builds a single-series, non-stacked model with peak maxVal", () => { + const model = buildChartModel(makeTimeseries()); + expect(model).toBeDefined(); + expect(model?.stacked).toBe(false); + expect(model?.kind).toBe("timeseries"); + expect(model?.buckets).toBe(4); + expect(model?.maxVal).toBe(30); + }); + + test("builds a stacked model with per-bucket totals as maxVal", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1, value: 10 }, + { timestamp: 2, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1, value: 5 }, + { timestamp: 2, value: 10 }, + ], + }, + ], + }) + ); + expect(model?.stacked).toBe(true); + expect(model?.buckets).toBe(2); + // Largest per-bucket total is 20 + 10 = 30. + expect(model?.maxVal).toBe(30); + }); +}); + +describe("buildCategoricalChartModel", () => { + test("sorts category bars while keeping Other last and out of the scale", () => { + const model = buildCategoricalChartModel( + makeTimeseries({ + series: [ + { label: "Other", values: [{ timestamp: 1, value: 1000 }] }, + { label: "US", values: [{ timestamp: 1, value: 20 }] }, + { label: "GB", values: [{ timestamp: 1, value: 10 }] }, + ], + }) + ); + + expect(model?.kind).toBe("categorical"); + expect(model?.series.map((series) => series.label)).toEqual([ + "US", + "GB", + "Other", + ]); + expect(model?.maxVal).toBe(20); + }); +}); + +describe("rasterizeChart", () => { + test("returns a canvas at the requested resolution", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + expect(img).toBeDefined(); + expect(img?.width).toBe(64); + expect(img?.height).toBe(32); + expect(img?.data.length).toBe(64 * 32 * 4); + }); + + test("clamps resolution to a minimum size", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 1, height: 1 }); + expect(img?.width).toBe(16); + expect(img?.height).toBe(8); + }); + + test("draws opaque pixels for bars", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + let opaque = 0; + for (let i = 3; i < (img?.data.length ?? 0); i += 4) { + if ((img?.data[i] ?? 0) > 0) { + opaque += 1; + } + } + expect(opaque).toBeGreaterThan(0); + }); + + test("fills the background when transparency is off", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { + width: 32, + height: 16, + backgroundTransparent: false, + }); + // Top-left pixel is above the bars, so it shows the background fill. + expect(img?.data[3]).toBe(255); + }); + + test("downsamples dense series so late buckets stay on canvas", () => { + // Far more buckets than half the canvas width: without downsampling the + // rising tail would be clipped off the right edge. Put all the signal in + // the last quarter of the range so a clipped render would be near-empty. + const values = Array.from({ length: 400 }, (_, i) => ({ + timestamp: 1_700_000_000 + i * 60, + value: i < 300 ? 0 : i, + })); + const model = buildChartModel( + makeTimeseries({ series: [{ label: "c", values }] }) + ); + const width = 64; + const img = rasterizeChart(model!, { width, height: 32 }); + + // Count opaque pixels in the right quarter — the tail must survive. + let rightOpaque = 0; + const data = img?.data ?? new Uint8Array(); + for (let y = 0; y < 32; y++) { + for (let x = Math.floor(width * 0.75); x < width; x++) { + if ((data[(y * width + x) * 4 + 3] ?? 0) > 0) { + rightOpaque += 1; + } + } + } + expect(rightOpaque).toBeGreaterThan(0); + }); + + test("draws independent categorical bars instead of a stacked column", () => { + const model = buildCategoricalChartModel( + makeTimeseries({ + series: [ + { label: "alpha", values: [{ timestamp: 1, value: 10 }] }, + { label: "beta", values: [{ timestamp: 1, value: 20 }] }, + ], + }) + ); + const image = rasterizeChart(model!, { width: 40, height: 20 }); + const data = image?.data ?? new Uint8Array(); + const leftBarAlpha = data[(19 * 40 + 0) * 4 + 3] ?? 0; + const rightBarAlpha = data[(19 * 40 + 21) * 4 + 3] ?? 0; + const gapAlpha = data[(19 * 40 + 19) * 4 + 3] ?? 0; + + expect(leftBarAlpha).toBe(255); + expect(rightBarAlpha).toBe(255); + expect(gapAlpha).toBe(0); + }); +}); diff --git a/packages/cli/test/lib/formatters/colors.test.ts b/packages/cli/test/lib/formatters/colors.test.ts new file mode 100644 index 000000000..549618aae --- /dev/null +++ b/packages/cli/test/lib/formatters/colors.test.ts @@ -0,0 +1,229 @@ +/** + * Tests for terminal color utilities. + * + * Covers: statusColor, levelColor, fixabilityColor, terminalLink, + * and the base color functions. + */ + +import chalk from "chalk"; +import { describe, expect, test } from "vitest"; +import { + fixabilityColor, + levelColor, + statusColor, + terminalLink, +} from "../../../src/lib/formatters/colors.js"; + +// Force chalk colors even in test environment +chalk.level = 3; + +/** Strip ANSI escape codes for content assertions */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("statusColor", () => { + test("resolved → green-styled text", () => { + const result = statusColor("text", "resolved"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("unresolved → yellow-styled text", () => { + const result = statusColor("text", "unresolved"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("ignored → muted-styled text", () => { + const result = statusColor("text", "ignored"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("undefined defaults to unresolved styling", () => { + const result = statusColor("text", undefined); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("RESOLVED works case-insensitively", () => { + const result = statusColor("text", "RESOLVED"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("resolvedInNextRelease → green-styled text", () => { + const result = statusColor("text", "resolvedInNextRelease"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("muted → muted-styled text", () => { + const result = statusColor("text", "muted"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("unknown status defaults to unresolved styling", () => { + const result = statusColor("text", "somethingNew"); + expect(stripAnsi(result)).toBe("text"); + }); +}); + +describe("levelColor", () => { + test("fatal → colored text", () => { + const result = levelColor("text", "fatal"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("error → colored text", () => { + const result = levelColor("text", "error"); + expect(result).toContain("\x1b["); + }); + + test("warning → colored text", () => { + const result = levelColor("text", "warning"); + expect(result).toContain("\x1b["); + }); + + test("info → colored text", () => { + const result = levelColor("text", "info"); + expect(result).toContain("\x1b["); + }); + + test("debug → colored text", () => { + const result = levelColor("text", "debug"); + expect(result).toContain("\x1b["); + }); + + test("unknown level returns uncolored text", () => { + const result = levelColor("text", "unknown"); + expect(result).toBe("text"); + }); + + test("undefined returns uncolored text", () => { + const result = levelColor("text", undefined); + expect(result).toBe("text"); + }); +}); + +describe("fixabilityColor", () => { + test("high → green-styled text", () => { + const result = fixabilityColor("text", "high"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("med → yellow-styled text", () => { + const result = fixabilityColor("text", "med"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); + + test("low → red-styled text", () => { + const result = fixabilityColor("text", "low"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("text"); + }); +}); + +describe("terminalLink", () => { + function restoreEnv(key: string, saved: string | undefined): void { + if (saved !== undefined) { + process.env[key] = saved; + } else { + delete process.env[key]; + } + } + + /** + * Save/restore isTTY and ALL plain-output env vars around TTY-specific tests. + * `isPlainOutput()` precedence is SENTRY_PLAIN_OUTPUT > NO_COLOR > FORCE_COLOR + * > !isTTY — so in CI (where NO_COLOR=1) failing to clear NO_COLOR/FORCE_COLOR + * makes terminalLink return plain text even with isTTY forced true. + */ + function withTTY(fn: () => void): void { + const savedTTY = process.stdout.isTTY; + const savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + const savedNoColor = process.env.NO_COLOR; + const savedForceColor = process.env.FORCE_COLOR; + process.stdout.isTTY = true; + delete process.env.SENTRY_PLAIN_OUTPUT; + delete process.env.NO_COLOR; + delete process.env.FORCE_COLOR; + try { + fn(); + } finally { + process.stdout.isTTY = savedTTY; + restoreEnv("SENTRY_PLAIN_OUTPUT", savedPlain); + restoreEnv("NO_COLOR", savedNoColor); + restoreEnv("FORCE_COLOR", savedForceColor); + } + } + + test("wraps text in OSC 8 escape sequences on TTY", () => { + withTTY(() => { + const result = terminalLink("click me", "https://example.com"); + expect(result).toContain("]8;;https://example.com"); + expect(result).toContain("click me"); + expect(result).toContain("]8;;"); + }); + }); + + test("preserves display text on TTY", () => { + withTTY(() => { + const result = terminalLink("display", "https://url.com"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 uses control chars + const stripped = result.replace(/\x1b\]8;;[^\x07]*\x07/g, ""); + expect(stripped).toBe("display"); + }); + }); + + test("uses text as URL when url is omitted (TTY)", () => { + withTTY(() => { + const result = terminalLink("https://example.com"); + expect(result).toContain("]8;;https://example.com"); + expect(result).toContain("https://example.com"); + expect(result).toBe( + terminalLink("https://example.com", "https://example.com") + ); + }); + }); + + test("returns plain text without OSC 8 when piped", () => { + const savedTTY = process.stdout.isTTY; + const savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.stdout.isTTY = false as unknown as boolean; + delete process.env.SENTRY_PLAIN_OUTPUT; + try { + const result = terminalLink("click me", "https://example.com"); + expect(result).toBe("click me"); + expect(result).not.toContain("]8;;"); + } finally { + process.stdout.isTTY = savedTTY; + if (savedPlain !== undefined) { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } else { + delete process.env.SENTRY_PLAIN_OUTPUT; + } + } + }); + + test("returns plain text when SENTRY_PLAIN_OUTPUT=1", () => { + const savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "1"; + try { + const result = terminalLink("display", "https://url.com"); + expect(result).toBe("display"); + } finally { + if (savedPlain !== undefined) { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } else { + delete process.env.SENTRY_PLAIN_OUTPUT; + } + } + }); +}); diff --git a/packages/cli/test/lib/formatters/conversation.test.ts b/packages/cli/test/lib/formatters/conversation.test.ts new file mode 100644 index 000000000..b648f9aeb --- /dev/null +++ b/packages/cli/test/lib/formatters/conversation.test.ts @@ -0,0 +1,574 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { + buildTranscriptResult, + extractTurns, + formatConversationTable, + formatTranscriptResult, + type TranscriptResult, +} from "../../../src/lib/formatters/conversation.js"; +import type { + AgentConversationSpan, + ConversationListItem, +} from "../../../src/types/conversation.js"; + +function makeListItem( + overrides: Partial = {} +): ConversationListItem { + return { + conversationId: "conv-abc-123", + startTimestamp: 1_716_500_000, + totalTokens: 500, + toolCalls: 3, + errors: 0, + firstInput: "Hello world", + user: { email: "test@example.com" }, + ...overrides, + }; +} + +function makeSpan( + overrides: Partial = {} +): AgentConversationSpan { + return { + span_id: "aabb112233445566", + trace: "00112233445566778899aabbccddeeff", + project: "my-project", + "span.name": "gen_ai.invoke_agent", + "span.status": "ok", + "precise.start_ts": 1_716_500_000, + "precise.finish_ts": 1_716_500_010, + "gen_ai.operation.type": "ai_client", + "gen_ai.input.messages": '{"messages":[{"role":"user","content":"hi"}]}', + "gen_ai.output.messages": + '{"messages":[{"role":"assistant","content":"hello"}]}', + "gen_ai.usage.total_tokens": "100", + "gen_ai.request.model": "gpt-4", + "gen_ai.response.model": "gpt-4", + ...overrides, + }; +} + +describe("formatConversationTable", () => { + const originalColumns = process.stdout.columns; + + afterEach(() => { + Object.defineProperty(process.stdout, "columns", { + value: originalColumns, + configurable: true, + }); + }); + + function setTerminalWidth(width: number | undefined): void { + Object.defineProperty(process.stdout, "columns", { + value: width, + configurable: true, + }); + } + + test("renders the full column set on wide terminals", () => { + setTerminalWidth(200); + const items = [makeListItem({ title: "Refund a duplicate charge" })]; + const result = formatConversationTable(items); + // The table shrinks columns to terminal width, so long values may be + // truncated with "…"; assert on surviving prefixes and the token count. + expect(result).toContain("conv-"); + expect(result).toContain("Refund"); + expect(result).toContain("test@"); + expect(result).toContain("Hello"); + expect(result).toContain("500"); + // Wide mode keeps the Tools/Errs/User columns. + expect(result).toContain("Tools"); + expect(result).toContain("User"); + expect(result).toContain("Title"); + }); + + test("keeps Title column on narrow terminals", () => { + setTerminalWidth(80); + const items = [makeListItem({ title: "Refund a duplicate charge" })]; + const result = formatConversationTable(items); + expect(result).toContain("Title"); + expect(result).toContain("Refund"); + expect(result).toContain("conv-"); + // Tools/Errs/User still dropped on narrow terminals. + expect(result).not.toContain("Tools"); + expect(result).not.toContain("User"); + }); + + test("drops Tools/Errs/User columns on narrow terminals", () => { + setTerminalWidth(80); + const items = [makeListItem()]; + const result = formatConversationTable(items); + // Core columns survive. + expect(result).toContain("conv-"); + expect(result).toContain("Hello"); + expect(result).toContain("500"); + // Lower-value columns are dropped to avoid aggressive truncation. + expect(result).not.toContain("Tools"); + expect(result).not.toContain("test@"); + }); + + test("handles missing user and input", () => { + const items = [makeListItem({ user: undefined, firstInput: undefined })]; + const result = formatConversationTable(items); + expect(result).toContain("—"); + }); + + test("links conversation IDs to Sentry", () => { + const previousPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + try { + const webUrl = + "https://sentry.io/organizations/test-org/explore/agents/conversations/conv-abc-123/"; + const result = formatConversationTable([makeListItem({ webUrl })]); + expect(result).toContain(`]8;;${webUrl}`); + } finally { + if (previousPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = previousPlainOutput; + } + } + }); + + test("truncates long conversation IDs", () => { + const longId = "a".repeat(60); + const items = [makeListItem({ conversationId: longId })]; + const result = formatConversationTable(items); + expect(result).not.toContain(longId); + expect(result).toContain("…"); + }); + + test("keeps UUID conversation IDs intact on narrow terminals", () => { + setTerminalWidth(80); + const conversationId = "123e4567-e89b-12d3-a456-426614174000"; + const result = formatConversationTable([makeListItem({ conversationId })]); + expect(result).toContain(conversationId); + }); + + test("formats timestamps correctly (not 1970)", () => { + const items = [makeListItem({ startTimestamp: 1_716_500_000 })]; + const result = formatConversationTable(items); + expect(result).not.toContain("1970"); + }); + + test("shows dash for zero timestamp", () => { + const items = [makeListItem({ startTimestamp: 0 })]; + const result = formatConversationTable(items); + expect(result).toContain("—"); + }); +}); + +describe("extractTurns", () => { + test("extracts turns from ai_client spans", () => { + const spans = [makeSpan()]; + const turns = extractTurns(spans); + expect(turns).toHaveLength(1); + expect(turns[0].turn).toBe(1); + expect(turns[0].userContent).toBe("hi"); + expect(turns[0].assistantContent).toBe("hello"); + expect(turns[0].model).toBe("gpt-4"); + }); + + test("associates tool calls with the correct turn", () => { + const aiSpan = makeSpan({ + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const toolSpan = makeSpan({ + span_id: "tool111122223333", + "gen_ai.operation.type": "tool", + "span.name": "gen_ai.execute_tool", + "gen_ai.tool.name": "search", + "precise.start_ts": 105, + "precise.finish_ts": 106, + }); + const turns = extractTurns([aiSpan, toolSpan]); + expect(turns).toHaveLength(1); + expect(turns[0].toolCalls).toHaveLength(1); + expect(turns[0].toolCalls[0].name).toBe("search"); + }); + + test("returns empty array for no spans", () => { + expect(extractTurns([])).toEqual([]); + }); + + test("sorts spans by start timestamp", () => { + const span1 = makeSpan({ + span_id: "aaaa111122223333", + "precise.start_ts": 200, + "precise.finish_ts": 210, + }); + const span2 = makeSpan({ + span_id: "bbbb111122223333", + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const turns = extractTurns([span1, span2]); + expect(turns[0].started).toBe(100); + expect(turns[1].started).toBe(200); + }); + + test("handles filtered content", () => { + const span = makeSpan({ + "gen_ai.input.messages": "[Filtered]", + "gen_ai.output.messages": "[Filtered]", + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("[Filtered]"); + expect(turns[0].assistantContent).toBe("[Filtered]"); + }); + + test("handles null content messages gracefully", () => { + const span = makeSpan({ + "gen_ai.input.messages": JSON.stringify({ + messages: [{ role: "assistant", content: null }], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBeNull(); + }); +}); + +describe("buildTranscriptResult", () => { + test("builds result with span data", () => { + const spans = [makeSpan()]; + const result = buildTranscriptResult("conv-123", "my-org", spans); + expect(result.conversationId).toBe("conv-123"); + expect(result.org).toBe("my-org"); + expect(result.spanCount).toBe(1); + expect(result.totalTokens).toBe(100); + expect(result.projects).toEqual(["my-project"]); + expect(result.startTimestamp).toBe(1_716_500_000); + expect(result.endTimestamp).toBe(1_716_500_010); + }); + + test("includes title when provided", () => { + const result = buildTranscriptResult( + "conv-123", + "my-org", + [makeSpan()], + "Refund a duplicate charge" + ); + expect(result.title).toBe("Refund a duplicate charge"); + }); + + test("returns zero timestamps for empty spans", () => { + const result = buildTranscriptResult("conv-123", "my-org", []); + expect(result.startTimestamp).toBe(0); + expect(result.endTimestamp).toBe(0); + expect(result.spanCount).toBe(0); + }); + + test("deduplicates and sorts projects", () => { + const spans = [ + makeSpan({ project: "b-project" }), + makeSpan({ span_id: "cc11223344556677", project: "a-project" }), + makeSpan({ span_id: "dd11223344556677", project: "b-project" }), + ]; + const result = buildTranscriptResult("conv-123", "my-org", spans); + expect(result.projects).toEqual(["a-project", "b-project"]); + }); +}); + +describe("formatTranscriptResult", () => { + test("shows empty message when no spans found", () => { + const result: TranscriptResult = { + conversationId: "conv-123", + org: "my-org", + title: null, + turns: [], + totalTokens: 0, + spanCount: 0, + projects: [], + startTimestamp: 0, + endTimestamp: 0, + }; + const output = formatTranscriptResult(result); + expect(output).toContain("No spans found"); + expect(output).toContain("conv-123"); + }); + + test("includes title in empty-span message when present", () => { + const result: TranscriptResult = { + conversationId: "conv-123", + org: "my-org", + title: "Refund a duplicate charge", + turns: [], + totalTokens: 0, + spanCount: 0, + projects: [], + startTimestamp: 0, + endTimestamp: 0, + }; + const output = formatTranscriptResult(result); + expect(output).toContain("conv-123"); + expect(output).toContain("Refund a duplicate charge"); + }); + + test("renders transcript header and turns", () => { + const spans = [makeSpan()]; + const transcript = buildTranscriptResult("conv-123", "my-org", spans); + const output = formatTranscriptResult(transcript); + expect(output).toContain("Agent Conversation: conv-123"); + expect(output).toContain("my-org"); + expect(output).toContain("my-project"); + expect(output).toContain("user"); + expect(output).toContain("assistant"); + expect(output).toContain("Turn 1"); + }); + + test("keeps conversation ID in heading and shows title in table", () => { + const transcript = buildTranscriptResult( + "conv-123", + "my-org", + [makeSpan()], + "Refund a duplicate charge" + ); + const output = formatTranscriptResult(transcript); + expect(output).toContain("Agent Conversation: conv-123"); + expect(output).toContain("Refund a duplicate charge"); + }); + + test("shows truncation warning", () => { + const result: TranscriptResult = { + conversationId: "conv-123", + org: "my-org", + title: null, + turns: [], + totalTokens: 0, + spanCount: 1, + projects: [], + startTimestamp: 100, + endTimestamp: 200, + truncated: true, + }; + const output = formatTranscriptResult(result); + expect(output).toContain("truncated"); + }); + + test("preserves newlines in multi-line content", () => { + const span = makeSpan({ + "gen_ai.output.messages": JSON.stringify({ + messages: [ + { role: "assistant", content: "line one\nline two\nline three" }, + ], + }), + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [span]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("line one"); + expect(output).toContain("line two"); + expect(output).toContain("line three"); + }); + + test("renders tool calls in turns", () => { + const aiSpan = makeSpan({ + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const toolSpan = makeSpan({ + span_id: "tool111122223333", + "gen_ai.operation.type": "tool", + "span.name": "gen_ai.execute_tool", + "gen_ai.tool.name": "web_search", + "precise.start_ts": 105, + "precise.finish_ts": 106, + "span.status": "ok", + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [ + aiSpan, + toolSpan, + ]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("tools"); + expect(output).toContain("web_search"); + }); + + test("renders tool call with non-ok status", () => { + const aiSpan = makeSpan({ + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const toolSpan = makeSpan({ + span_id: "tool111122223333", + "gen_ai.operation.type": "tool", + "span.name": "gen_ai.execute_tool", + "gen_ai.tool.name": "db_query", + "precise.start_ts": 105, + "precise.finish_ts": 106, + "span.status": "internal_error", + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [ + aiSpan, + toolSpan, + ]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("(internal_error)"); + }); + + test("renders model and agent name in turn metadata", () => { + const span = makeSpan({ + "gen_ai.response.model": "claude-3", + "gen_ai.agent.name": "my-agent", + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [span]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("claude-3"); + expect(output).toContain("my-agent"); + }); + + test("renders token count in metadata", () => { + const span = makeSpan({ "gen_ai.usage.total_tokens": "1500" }); + const transcript = buildTranscriptResult("conv-123", "my-org", [span]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("1500 tokens"); + }); +}); + +describe("extractTurns: content extraction edge cases", () => { + test("extracts from gen_ai.response.text fallback", () => { + const span = makeSpan({ + "gen_ai.output.messages": undefined, + "gen_ai.response.text": "fallback text", + }); + const turns = extractTurns([span]); + expect(turns[0].assistantContent).toBe("fallback text"); + }); + + test("extracts from gen_ai.response.object fallback", () => { + const span = makeSpan({ + "gen_ai.output.messages": undefined, + "gen_ai.response.text": undefined, + "gen_ai.response.object": '{"result": true}', + }); + const turns = extractTurns([span]); + expect(turns[0].assistantContent).toBe('{"result": true}'); + }); + + test("handles plain string input messages", () => { + const span = makeSpan({ + "gen_ai.input.messages": "plain string input", + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("plain string input"); + }); + + test("handles array of string messages", () => { + const span = makeSpan({ + "gen_ai.input.messages": JSON.stringify(["msg1", "msg2"]), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("msg2"); + }); + + test("handles message with text field instead of content", () => { + const span = makeSpan({ + "gen_ai.input.messages": JSON.stringify({ + messages: [{ role: "user", text: "from text field" }], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("from text field"); + }); + + test("handles array content parts with text", () => { + const span = makeSpan({ + "gen_ai.output.messages": JSON.stringify({ + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "part one" }, { text: "part two" }], + }, + ], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].assistantContent).toContain("part one"); + expect(turns[0].assistantContent).toContain("part two"); + }); + + test("handles no input or output messages", () => { + const span = makeSpan({ + "gen_ai.input.messages": undefined, + "gen_ai.request.messages": undefined, + "gen_ai.output.messages": undefined, + "gen_ai.response.text": undefined, + "gen_ai.response.object": undefined, + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBeNull(); + expect(turns[0].assistantContent).toBeNull(); + }); + + test("extracts from gen_ai.request.messages fallback", () => { + const span = makeSpan({ + "gen_ai.input.messages": undefined, + "gen_ai.request.messages": JSON.stringify({ + messages: [{ role: "user", content: "from request" }], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("from request"); + }); + + test("handles numeric token values", () => { + const span = makeSpan({ + "gen_ai.usage.total_tokens": 42, + }); + const turns = extractTurns([span]); + expect(turns[0].totalTokens).toBe(42); + }); + + test("handles missing token values", () => { + const span = makeSpan({ + "gen_ai.usage.total_tokens": undefined, + }); + const turns = extractTurns([span]); + expect(turns[0].totalTokens).toBe(0); + }); + + test("detects operation type from span name when explicit type missing", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.execute_tool", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("detects agent operation type from span name", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.invoke_agent", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("ignores non-gen_ai span names", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "http.client", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("detects handoff operation type", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.handoff", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("falls back to ai_client for unknown gen_ai span names", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.something_new", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(1); + }); +}); diff --git a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts new file mode 100644 index 000000000..a89859c95 --- /dev/null +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -0,0 +1,489 @@ +/** + * Dashboard sixel integration tests. + * + * Stubs `selectGraphicsFormat`, `graphicsCellSize`, and `terminalPixelWidth` so + * the dashboard formatter takes the graphics rendering path deterministically, + * then verifies that the output contains one sixel DCS sequence (or kitty APC + * sequence) for the complete dashboard grid. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type DashboardViewData, + type DashboardViewWidget, + formatDashboardWithData, +} from "../../../src/lib/formatters/dashboard.js"; +import { logger } from "../../../src/lib/logger.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as sixelModule from "../../../src/lib/sixel.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as sixelImageModule from "../../../src/lib/sixel-image.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +function makeWidget( + overrides: Partial = {} +): DashboardViewWidget { + return { + title: "Test Widget", + displayType: "line", + data: makeTimeseries(), + ...overrides, + }; +} + +function makeDashboardData( + overrides: Partial = {} +): DashboardViewData { + return { + id: "12345", + title: "My Dashboard", + period: "24h", + fetchedAt: "2024-01-15T10:30:00Z", + url: "https://sentry.io/organizations/test-org/dashboard/12345/", + environment: ["production"], + widgets: [makeWidget()], + ...overrides, + }; +} + +describe("dashboard sixel integration", () => { + let savedPlainOutput: string | undefined; + let savedColumns: number | undefined; + + beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + savedColumns = process.stdout.columns; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + process.stdout.columns = 40; + vi.spyOn(sixelModule, "selectGraphicsFormat").mockReturnValue("sixel"); + vi.spyOn(sixelModule, "detectSixelCaps").mockReturnValue({ + supported: true, + }); + vi.spyOn(sixelModule, "graphicsCellSize").mockReturnValue({ + cellWidth: 8, + cellHeight: 12, + }); + vi.spyOn(sixelModule, "terminalPixelWidth").mockReturnValue(320); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } + process.stdout.columns = savedColumns; + }); + + test("renders one sixel canvas for a timeseries widget when enabled", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output.split(`${ESC}P`)).toHaveLength(2); + expect(output).toContain('"1;1;320;144'); + }); + + test("logs the selected sixel graphics renderer and its detected capabilities", () => { + const debugSpy = vi.spyOn(logger, "debug"); + + formatDashboardWithData(makeDashboardData()); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: sixel") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("capabilities: kitty=no, sixel=yes") + ); + }); + + test("prefers kitty encoding when the terminal supports it", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue("kitty"); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + kitty: true, + }); + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Kitty Chart", + displayType: "line", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + // Kitty graphics use the APC introducer ESC _ G, not the sixel DCS ESC P. + expect(output).toContain(`${ESC}_G`); + expect(output).not.toContain(`${ESC}P`); + }); + + test("uses a requested sixel renderer instead of an available kitty renderer", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockImplementation( + (renderer) => (renderer === "sixel" ? "sixel" : "kitty") + ); + + const output = formatDashboardWithData( + makeDashboardData({ rendererPreference: "sixel" }) + ); + + expect(output).toContain(`${ESC}P`); + expect(output).not.toContain(`${ESC}_G`); + }); + + test("logs the requested renderer when auto fallback selects kitty", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue("kitty"); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + kitty: true, + }); + const debugSpy = vi.spyOn(logger, "debug"); + + const output = formatDashboardWithData( + makeDashboardData({ rendererPreference: "sixel" }) + ); + + expect(output).toContain(`${ESC}_G`); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("requested=sixel") + ); + }); + + test("logs kitty when it is the selected graphics renderer", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue("kitty"); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + kitty: true, + }); + const debugSpy = vi.spyOn(logger, "debug"); + + formatDashboardWithData(makeDashboardData()); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: kitty") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("capabilities: kitty=yes, sixel=no") + ); + }); + + test("renders scalar and timeseries widgets in the same sixel canvas", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 1 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output.split(`${ESC}P`)).toHaveLength(2); + expect(output).not.toContain("Big Number"); + expect(output).not.toContain("Sixel Chart"); + }); + + test("renders every non-chart widget type inside the sixel canvas", () => { + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ + title: "Table Widget", + displayType: "table", + layout: { x: 0, y: 0, w: 3, h: 1 }, + data: { + type: "table", + columns: [{ name: "count" }], + rows: [{ count: 42 }], + }, + }), + makeWidget({ + title: "Text Widget", + displayType: "text", + layout: { x: 3, y: 0, w: 3, h: 1 }, + data: { type: "text", content: "Dashboard note" }, + }), + makeWidget({ + title: "Failed Widget", + displayType: "line", + layout: { x: 0, y: 1, w: 3, h: 1 }, + data: { type: "error", message: "Query failed" }, + }), + makeWidget({ + title: "Unsupported Widget", + displayType: "wheel", + layout: { x: 3, y: 1, w: 3, h: 1 }, + data: { type: "unsupported", reason: "Not implemented" }, + }), + ], + }) + ); + + expect(output.split(`${ESC}P`)).toHaveLength(2); + expect(output).not.toContain("Table Widget"); + expect(output).not.toContain("Dashboard note"); + expect(output).not.toContain("Query failed"); + expect(output).not.toContain("Unsupported Widget"); + }); + + test("renders categorical_bar widgets as sixel bars", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Categorical", + displayType: "categorical_bar", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + }); + + test("preserves side-by-side widget layout in one sixel canvas", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 2 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + // A DCS sequence reserves one full 320x144 pixel dashboard grid, so two + // adjacent widgets always share their original row instead of serializing. + expect(output).toContain('"1;1;320;144'); + expect(output.split(`${ESC}P`)).toHaveLength(2); + }); + + test("preserves wide terminal canvas width", () => { + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(1024); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [makeWidget({ layout: { x: 0, y: 0, w: 6, h: 1 } })], + }) + ); + + expect(output).toContain('"1;1;1024;72'); + }); + + test("uses the actual narrow terminal width for the sixel canvas", () => { + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(320); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [makeWidget({ layout: { x: 0, y: 0, w: 6, h: 1 } })], + }) + ); + + expect(output).toContain('"1;1;320;72'); + }); + + test("keeps charts inside exceptionally narrow widget bounds", () => { + process.stdout.columns = 4; + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(32); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ layout: { x: 0, y: 0, w: 1, h: 1 } }), + makeWidget({ + title: "Neighbor", + layout: { x: 1, y: 0, w: 1, h: 1 }, + }), + ], + }) + ); + + expect(output).toContain('"1;1;32;72'); + }); + + test("falls back to the complete character dashboard without a graphics format", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue(undefined); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + }); + vi.mocked(sixelModule.graphicsCellSize).mockReturnValue(undefined); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ + title: "Fallback Widget", + layout: { x: 0, y: 0, w: 6, h: 1 }, + }), + ], + }) + ); + + expect(output).not.toContain(`${ESC}P`); + expect(output).not.toContain(`${ESC}_G`); + expect(output).toContain("Fallback Widget"); + }); + + test("logs ASCII and the fallback reason when no graphics renderer is available", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue(undefined); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + }); + vi.mocked(sixelModule.graphicsCellSize).mockReturnValue(undefined); + const debugSpy = vi.spyOn(logger, "debug"); + + formatDashboardWithData(makeDashboardData()); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: ascii") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining( + "reason: no compatible graphics protocol detected" + ) + ); + }); + + test("logs ASCII when the selected graphics renderer cannot create a canvas", () => { + const debugSpy = vi.spyOn(logger, "debug"); + + const output = formatDashboardWithData(makeDashboardData({ widgets: [] })); + + expect(output).not.toContain(`${ESC}P`); + expect(output).not.toContain(`${ESC}_G`); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: ascii") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("reason: no dashboard widgets") + ); + }); + + test("logs canvas dimensions when the dashboard exceeds the graphics safety cap", () => { + process.stdout.columns = 244; + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(3416); + vi.mocked(sixelModule.graphicsCellSize).mockReturnValue({ + cellWidth: 14, + cellHeight: 32, + }); + const debugSpy = vi.spyOn(logger, "debug"); + + const output = formatDashboardWithData( + makeDashboardData({ + graphicsCap: false, + widgets: [makeWidget({ layout: { x: 0, y: 12, w: 6, h: 1 } })], + }) + ); + + expect(output).not.toContain(`${ESC}P`); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining( + "reason: canvas 3416x2496 (8.53M pixels) exceeds the 8M pixel limit" + ) + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining( + "native pixel width=3416; effective pixel width=3416; graphics cap=not applied" + ) + ); + }); + + test("caps a high-DPI canvas instead of falling back to ASCII", () => { + process.stdout.columns = 244; + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(3416); + vi.mocked(sixelModule.graphicsCellSize).mockReturnValue({ + cellWidth: 14, + cellHeight: 32, + }); + vi.spyOn(sixelImageModule, "encodeImageToSixel").mockImplementation( + (image) => `${ESC}P${image.width}x${image.height}${ESC}\\` + ); + const debugSpy = vi.spyOn(logger, "debug"); + + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [makeWidget({ layout: { x: 0, y: 12, w: 6, h: 1 } })], + }) + ); + + expect(output).toContain(`${ESC}P2548x2496${ESC}\\`); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining( + "native pixel width=3416; effective pixel width=2548; graphics cap=applied" + ) + ); + }); + + test("renders graphics on a kitty terminal that never reports cell geometry", () => { + // Regression for #1506: kitty terminals frequently answer the graphics + // query but never send `CSI 16 t`, so terminalPixelWidth is undefined. + // graphicsCellSize supplies default cell dimensions so the dashboard still + // renders as kitty graphics instead of dropping to ASCII. + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue("kitty"); + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(undefined); + vi.mocked(sixelModule.graphicsCellSize).mockReturnValue({ + cellWidth: 10, + cellHeight: 20, + }); + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [ + makeWidget({ + title: "Kitty No Geometry", + layout: { x: 0, y: 0, w: 6, h: 1 }, + }), + ], + }) + ); + + expect(output).toContain(`${ESC}_G`); + expect(output).not.toContain(`${ESC}P`); + expect(output).not.toContain("Kitty No Geometry"); + }); +}); diff --git a/packages/cli/test/lib/formatters/dashboard.test.ts b/packages/cli/test/lib/formatters/dashboard.test.ts new file mode 100644 index 000000000..649220192 --- /dev/null +++ b/packages/cli/test/lib/formatters/dashboard.test.ts @@ -0,0 +1,1570 @@ +/** + * Dashboard Formatter Tests + * + * Tests for formatDashboardWithData, createDashboardViewRenderer, + * from src/lib/formatters/dashboard.ts. + * + * Plain mode: set `NO_COLOR=1` (and delete `FORCE_COLOR`) so that both + * `isPlainOutput()` returns true AND chalk strips all ANSI codes. + * + * Rendered mode: set `SENTRY_PLAIN_OUTPUT=0` to force `isPlainOutput()` false, + * plus `chalk.level = 3` so chalk actually emits ANSI codes in the piped + * test environment. + * + * Widget body lines are raw terminal strings (direct chalk), NOT markdown. + * The header is a compact inline format (not a KV table). + * + * The grid renderer uses a framebuffer approach: each widget is rendered + * into its grid-allocated region of a virtual screen buffer. Widgets at + * the same `y` appear in the same terminal row range (LINES_PER_UNIT = 6 + * terminal lines per grid height unit). Tall widgets (h > 1) span multiple + * row ranges. + */ + +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + createDashboardViewRenderer, + type DashboardViewData, + type DashboardViewWidget, + formatDashboardWithData, +} from "../../../src/lib/formatters/dashboard.js"; +import type { + ErrorResult, + ScalarResult, + TableResult, + TextResult, + TimeseriesResult, + UnsupportedResult, +} from "../../../src/types/dashboard.js"; + +// --------------------------------------------------------------------------- +// Constants (must match source) +// --------------------------------------------------------------------------- + +/** Terminal lines per grid height unit — mirrors LINES_PER_UNIT in dashboard.ts */ +const LINES_PER_UNIT = 6; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Strip ANSI escape codes for content assertions in rendered mode. */ +function stripAnsi(str: string): string { + return ( + str + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI SGR escape codes + .replace(/\x1b\[[0-9;]*m/g, "") + // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 hyperlink sequences + .replace(/\x1b\]8;;[^\x07]*\x07/g, "") + ); +} + +/** + * Set up plain mode for a describe block. + * + * Uses `NO_COLOR=1` which makes both `isPlainOutput()` true and + * chalk level 0 (no ANSI output). Deletes `FORCE_COLOR` to avoid + * interference. + */ +function usePlainMode() { + let savedNoColor: string | undefined; + let savedForceColor: string | undefined; + let savedPlain: string | undefined; + + beforeEach(() => { + savedNoColor = process.env.NO_COLOR; + savedForceColor = process.env.FORCE_COLOR; + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + + process.env.NO_COLOR = "1"; + delete process.env.FORCE_COLOR; + delete process.env.SENTRY_PLAIN_OUTPUT; + }); + + afterEach(() => { + if (savedNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = savedNoColor; + } + if (savedForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = savedForceColor; + } + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + +/** + * Set up rendered (TTY-like) mode for a describe block. + * + * Uses `SENTRY_PLAIN_OUTPUT=0` so `isPlainOutput()` returns false, + * and sets `chalk.level = 3` so chalk emits ANSI codes in the piped + * test environment. + */ +function useRenderedMode() { + let savedPlain: string | undefined; + let savedNoColor: string | undefined; + let savedChalkLevel: typeof chalk.level; + + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + savedNoColor = process.env.NO_COLOR; + savedChalkLevel = chalk.level; + + process.env.SENTRY_PLAIN_OUTPUT = "0"; + delete process.env.NO_COLOR; + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = savedChalkLevel; + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + if (savedNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = savedNoColor; + } + }); +} + +// --------------------------------------------------------------------------- +// Test data factories +// --------------------------------------------------------------------------- + +function makeTimeseriesData( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +function makeTableData(overrides: Partial = {}): TableResult { + return { + type: "table", + columns: [ + { name: "transaction", type: "string" }, + { name: "count", type: "integer" }, + ], + rows: [ + { transaction: "/api/users", count: 142 }, + { transaction: "/api/orders", count: 87 }, + ], + ...overrides, + }; +} + +function makeScalarData(overrides: Partial = {}): ScalarResult { + return { + type: "scalar", + value: 1247, + ...overrides, + }; +} + +function makeTextData(overrides: Partial = {}): TextResult { + return { + type: "text", + content: "# Notes\nSome **important** text here.", + ...overrides, + }; +} + +function makeUnsupportedData( + overrides: Partial = {} +): UnsupportedResult { + return { + type: "unsupported", + reason: "issue widgets not yet supported", + ...overrides, + }; +} + +function makeErrorData(overrides: Partial = {}): ErrorResult { + return { + type: "error", + message: "Query timeout exceeded", + ...overrides, + }; +} + +function makeWidget( + overrides: Partial = {} +): DashboardViewWidget { + return { + title: "Test Widget", + displayType: "line", + data: makeTimeseriesData(), + ...overrides, + }; +} + +function makeDashboardData( + overrides: Partial = {} +): DashboardViewData { + return { + id: "12345", + title: "My Dashboard", + period: "24h", + fetchedAt: "2024-01-15T10:30:00Z", + url: "https://sentry.io/organizations/test-org/dashboard/12345/", + environment: ["production"], + widgets: [makeWidget()], + ...overrides, + }; +} + +// =========================================================================== +// formatDashboardWithData +// =========================================================================== + +describe("formatDashboardWithData", () => { + usePlainMode(); + + describe("dashboard header (plain mode)", () => { + test("renders title on first line", () => { + const data = makeDashboardData({ + title: "Production Overview", + period: "7d", + }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + expect(lines[0]).toContain("Production Overview"); + }); + + test("renders period with label on second line", () => { + const data = makeDashboardData({ period: "7d" }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + expect(lines[1]).toContain("Period: 7d"); + }); + + test("renders environment with label on second line", () => { + const data = makeDashboardData({ + environment: ["production"], + }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + expect(lines[1]).toContain("Env: production"); + }); + + test("renders multiple environments comma-separated", () => { + const data = makeDashboardData({ + environment: ["production", "staging"], + }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + expect(lines[1]).toContain("Env: production, staging"); + }); + + test("omits env part when environment is empty", () => { + const data = makeDashboardData({ environment: [] }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + expect(lines[1]).not.toContain("Env:"); + }); + + test("omits env part when environment is undefined", () => { + const data = makeDashboardData({ environment: undefined }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + expect(lines[1]).not.toContain("Env:"); + }); + + test("renders URL on third line", () => { + const url = "https://sentry.io/organizations/my-org/dashboard/42/"; + const data = makeDashboardData({ url }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + expect(lines[2]).toContain(url); + }); + + test("header has underline after URL", () => { + const data = makeDashboardData(); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + // After title, meta, URL → underline → blank line separator + expect(lines[3]).toMatch(/^─+$/); + expect(lines[4]).toBe(""); + }); + }); + + describe("dashboard header (rendered mode)", () => { + useRenderedMode(); + + test("renders title in bold white", () => { + const data = makeDashboardData({ title: "My Dashboard" }); + const output = formatDashboardWithData(data); + const plain = stripAnsi(output); + expect(plain).toContain("My Dashboard"); + // In rendered mode, title has ANSI codes (bold + hex color) + expect(output).not.toBe(plain); + }); + + test("renders period with cyan color", () => { + const data = makeDashboardData({ period: "24h" }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + // First line contains title and period + const firstLine = stripAnsi(lines[0]); + expect(firstLine).toContain("24h"); + }); + + test("renders environment with green color", () => { + const data = makeDashboardData({ environment: ["production"] }); + const output = formatDashboardWithData(data); + const firstLine = stripAnsi(output.split("\n")[0]); + expect(firstLine).toContain("production"); + }); + + test("renders underline below title in rendered mode", () => { + const url = "https://sentry.io/organizations/my-org/dashboard/42/"; + const data = makeDashboardData({ url }); + const output = formatDashboardWithData(data); + // Rendered mode: title line, muted underline, blank separator + const secondLine = stripAnsi(output.split("\n")[1]); + expect(secondLine).toMatch(/^─+$/); + expect(stripAnsi(output.split("\n")[2])).toBe(""); + }); + }); + + describe("timeseries widget", () => { + test("renders widget title", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Error Rate", + displayType: "line", + data: makeTimeseriesData(), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Error Rate"); + }); + + test("renders series label", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "area", + data: makeTimeseriesData({ + series: [ + { + label: "p95(span.duration)", + values: [{ timestamp: 1_700_000_000, value: 342 }], + unit: "millisecond", + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("p95(span.duration)"); + }); + + test("renders latest value", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "line", + data: makeTimeseriesData({ + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 42 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("42"); + }); + + test("renders sparkline characters", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "line", + data: makeTimeseriesData({ + series: [ + { + label: "count()", + values: [ + { timestamp: 1, value: 1 }, + { timestamp: 2, value: 5 }, + { timestamp: 3, value: 10 }, + { timestamp: 4, value: 3 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + // Sparkline uses Unicode block characters (▁▂▃▄▅▆▇█) or zero char (⎽) + expect(output).toMatch(/[▁▂▃▄▅▆▇█⎽]/); + }); + + test("renders multiple series on separate lines", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "line", + data: makeTimeseriesData({ + series: [ + { + label: "count()", + values: [{ timestamp: 1, value: 10 }], + }, + { + label: "avg(span.duration)", + values: [{ timestamp: 1, value: 250 }], + unit: "millisecond", + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("count()"); + expect(output).toContain("avg(span.duration)"); + expect(output).toContain("250ms"); + }); + + test("renders unit suffix on values", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "line", + data: makeTimeseriesData({ + series: [ + { + label: "p50(span.duration)", + values: [{ timestamp: 1, value: 120 }], + unit: "millisecond", + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("120ms"); + }); + + test("shows no data for empty series", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "line", + data: makeTimeseriesData({ series: [] }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("(no data)"); + }); + }); + + describe("timeseries widget (rendered mode colors)", () => { + useRenderedMode(); + + test("sparkline uses magenta color", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "line", + data: makeTimeseriesData({ + series: [ + { + label: "count()", + values: [ + { timestamp: 1, value: 1 }, + { timestamp: 2, value: 5 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + // COLORS.magenta = "#FF45A8" — chalk.hex produces ANSI sequences + // Verify the output contains ANSI codes (not plain) and sparkline chars + const plain = stripAnsi(output); + expect(plain).toMatch(/[▁▂▃▄▅▆▇█⎽]/); + // Output should be longer than plain (contains ANSI color codes) + expect(output.length).toBeGreaterThan(plain.length); + }); + }); + + describe("bar widget", () => { + test("renders horizontal bars with labels", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Top Transactions", + displayType: "bar", + data: makeTimeseriesData({ + series: [ + { + label: "/api/users", + values: [ + { timestamp: 1, value: 100 }, + { timestamp: 2, value: 200 }, + ], + }, + { + label: "/api/orders", + values: [ + { timestamp: 1, value: 50 }, + { timestamp: 2, value: 30 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Top Transactions"); + expect(output).toContain("/api/users"); + expect(output).toContain("/api/orders"); + // Bar uses █ characters + expect(output).toContain("█"); + }); + + test("renders categorical_bar as bar chart", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "By Browser", + displayType: "categorical_bar", + data: makeTimeseriesData({ + series: [ + { + label: "Chrome", + values: [{ timestamp: 1, value: 500 }], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("By Browser"); + // Vertical bar labels appear in empty space above bars; + // a maxed-out bar may not show all chars but bars render + expect(output).toContain("█"); + // Bottom axis should be present + expect(output).toContain("└"); + }); + + test("shows no data for empty series in bar chart", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "bar", + data: makeTimeseriesData({ series: [] }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("(no data)"); + }); + + test("renders heatmap as one row per series with a legend", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Errors by Browser", + displayType: "heatmap", + layout: { x: 0, y: 0, w: 3, h: 3 }, + data: makeTimeseriesData({ + series: [ + { + label: "Chrome", + values: [ + { timestamp: 1_700_000_000, value: 0 }, + { timestamp: 1_700_000_060, value: 50 }, + { timestamp: 1_700_000_120, value: 100 }, + ], + }, + { + label: "Firefox", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 10 }, + { timestamp: 1_700_000_120, value: 20 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Errors by Browser"); + // Category labels appear as row headers + expect(output).toContain("Chrome"); + expect(output).toContain("Firefox"); + // Intensity legend is present + expect(output).toContain("low"); + expect(output).toContain("high"); + }); + + test("shows no data for empty series in heatmap", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "heatmap", + data: makeTimeseriesData({ series: [] }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("(no data)"); + }); + + test("bar chart fills full widget width (no trailing gap)", () => { + // Create enough data points that bars should fill the chart area. + // With a tall widget (h=3), the renderer uses bar mode (not sparkline). + const values = Array.from({ length: 20 }, (_, i) => ({ + timestamp: 1_700_000_000 + i * 3600, + value: 10 + (i % 5) * 10, + })); + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "bar", + layout: { x: 0, y: 0, w: 2, h: 3 }, + data: makeTimeseriesData({ + series: [{ label: "count()", values }], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + // The bars should contain block characters + expect(output).toContain("█"); + // Should have Y-axis tick marks + expect(output).toContain("┤"); + }); + + test("stacked bar chart renders with multiple series", () => { + const timestamps = Array.from({ length: 10 }, (_, i) => ({ + timestamp: 1_700_000_000 + i * 3600, + value: 0, + })); + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "bar", + layout: { x: 0, y: 0, w: 2, h: 3 }, + data: makeTimeseriesData({ + series: [ + { + label: "series-a", + values: timestamps.map((t, i) => ({ + ...t, + value: 10 + i * 5, + })), + }, + { + label: "series-b", + values: timestamps.map((t, i) => ({ + ...t, + value: 5 + i * 2, + })), + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + // Should render bar blocks + expect(output).toContain("█"); + // Legend for multi-series + expect(output).toContain("series-a"); + expect(output).toContain("series-b"); + }); + }); + + describe("bar widget (rendered mode colors)", () => { + useRenderedMode(); + + test("bar displayType renders as time-series with ANSI colors", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Colored Bars", + displayType: "bar", + data: makeTimeseriesData({ + series: [ + { + label: "foo", + values: [ + { timestamp: 1, value: 50 }, + { timestamp: 2, value: 80 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + const plain = stripAnsi(output); + // In rendered mode, bars/sparklines have ANSI color codes + expect(output.length).toBeGreaterThan(plain.length); + // Renders timeseries content (sparkline or bar chart) + expect(plain).toContain("█"); + }); + }); + + describe("table widget", () => { + test("renders column headers", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Slow Endpoints", + displayType: "table", + data: makeTableData(), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Slow Endpoints"); + // Column names are uppercased + expect(output).toContain("TRANSACTION"); + expect(output).toContain("COUNT"); + }); + + test("renders row data", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + data: makeTableData({ + columns: [ + { name: "endpoint" }, + { name: "p95", unit: "millisecond" }, + ], + rows: [ + { endpoint: "/api/search", p95: 420 }, + { endpoint: "/api/health", p95: 12 }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("/api/search"); + expect(output).toContain("/api/health"); + expect(output).toContain("420ms"); + expect(output).toContain("12ms"); + }); + + test("renders null/undefined cells as empty", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + data: makeTableData({ + columns: [{ name: "name" }, { name: "value" }], + rows: [{ name: "test", value: null }], + }), + }), + ], + }); + // Should not throw + const output = formatDashboardWithData(data); + expect(output).toContain("test"); + }); + + test("shows no data for empty rows", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + data: makeTableData({ rows: [] }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("(no data)"); + }); + + test("right-aligns numeric columns", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + data: makeTableData({ + columns: [ + { name: "endpoint", type: "string" }, + { name: "count", type: "integer" }, + ], + rows: [ + { endpoint: "/api/a", count: 100 }, + { endpoint: "/api/b", count: 5 }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + // Numeric column header and values should appear + expect(output).toContain("COUNT"); + expect(output).toContain("100"); + expect(output).toContain("5"); + // Separator line with ─ should be present + expect(output).toContain("\u2500"); + }); + + test("uses compact numbers when columns overflow", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + // Very narrow widget + layout: { x: 0, y: 0, w: 1, h: 2 }, + data: makeTableData({ + columns: [ + { name: "very_long_column_name_here", type: "string" }, + { name: "another_long_column_count", type: "integer" }, + ], + rows: [ + { + very_long_column_name_here: "long-value-text-here", + another_long_column_count: 1_500_000, + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + // Should use compact notation (1.5M instead of 1,500,000) + expect(output).toContain("1.5M"); + }); + + test("truncates long cell values with ellipsis", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + layout: { x: 0, y: 0, w: 1, h: 2 }, + data: makeTableData({ + columns: [ + { name: "name", type: "string" }, + { name: "very_long_description_column", type: "string" }, + ], + rows: [ + { + name: "short", + very_long_description_column: + "this is a very long description that should be truncated", + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + // Should contain ellipsis from truncation + expect(output).toContain("\u2026"); + }); + + test("expands last column to fill widget width", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + // Full-width widget + layout: { x: 0, y: 0, w: 6, h: 2 }, + data: makeTableData({ + columns: [{ name: "id" }, { name: "val" }], + rows: [{ id: "a", val: 1 }], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("ID"); + expect(output).toContain("VAL"); + }); + + test("formats number units correctly", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "table", + data: makeTableData({ + columns: [ + { name: "endpoint" }, + { name: "duration", unit: "millisecond" }, + { name: "size", unit: "byte" }, + { name: "latency", unit: "second" }, + ], + rows: [ + { + endpoint: "/api", + duration: 250, + size: 1024, + latency: 3, + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("250ms"); + expect(output).toContain("1,024B"); + expect(output).toContain("3s"); + }); + }); + + describe("big number widget (plain mode)", () => { + test("renders plain formatted number", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Total Errors", + displayType: "big_number", + data: makeScalarData({ value: 42 }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Total Errors"); + // In plain mode renderBigNumber returns " " (single line) + expect(output).toContain("42"); + }); + + test("renders value with unit", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "big_number", + data: makeScalarData({ value: 350, unit: "millisecond" }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("350ms"); + }); + + test("formats large numbers with compact notation", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "big_number", + data: makeScalarData({ value: 2_500_000 }), + }), + ], + }); + const output = formatDashboardWithData(data); + // Compact notation for >= 1M: "2.5M" + expect(output).toContain("2.5M"); + }); + }); + + describe("big number widget (rendered mode)", () => { + useRenderedMode(); + + test("renders 3-line ASCII art with block characters", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Total Events", + displayType: "big_number", + data: makeScalarData({ value: 42 }), + }), + ], + }); + const output = formatDashboardWithData(data); + const plain = stripAnsi(output); + // Block characters used in digit font: █ ▀ ▄ + expect(plain).toMatch(/[█▀▄]/); + }); + + test("renders multiple lines for big number digits", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Num", + displayType: "big_number", + data: makeScalarData({ value: 0 }), + }), + ], + }); + const output = formatDashboardWithData(data); + const plain = stripAnsi(output); + // "0" digit has glyph "█▀█" / "█ █" / "▀▀▀" — 3 rows + expect(plain).toContain("█▀█"); + expect(plain).toContain("█ █"); + expect(plain).toContain("▀▀▀"); + }); + + test("big number uses green color", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Count", + displayType: "big_number", + data: makeScalarData({ value: 7 }), + }), + ], + }); + const output = formatDashboardWithData(data); + // In rendered mode, big number glyphs are wrapped in ANSI color codes + const plain = stripAnsi(output); + expect(output.length).toBeGreaterThan(plain.length); + // Should contain block chars from the digit font + expect(plain).toMatch(/[█▀▄]/); + }); + }); + + describe("unsupported widget", () => { + test("shows placeholder with reason", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Issue Widget", + displayType: "table", + data: makeUnsupportedData({ + reason: "issue widgets not yet supported", + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Issue Widget"); + expect(output).toContain("issue widgets not yet supported"); + }); + }); + + describe("error widget", () => { + test("shows error message", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Broken Widget", + displayType: "line", + data: makeErrorData({ + message: "Query timeout exceeded", + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Broken Widget"); + expect(output).toContain("query failed: Query timeout exceeded"); + }); + }); + + describe("text widget", () => { + test("renders markdown content", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Notes", + displayType: "text", + data: makeTextData({ + content: "# Hello\nSome **bold** text", + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Notes"); + expect(output).toContain("Hello"); + expect(output).toContain("bold"); + }); + + test("renders empty placeholder for missing content", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Empty Notes", + displayType: "text", + data: makeTextData({ content: "" }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Empty Notes"); + expect(output).toContain("(empty)"); + }); + + test("renders empty placeholder for whitespace-only content", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Whitespace Notes", + displayType: "text", + data: makeTextData({ content: " \n " }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("(empty)"); + }); + }); + + describe("mixed widget types (no layout — sequential blocks)", () => { + test("renders all widget types in order", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "Timeseries Widget", + displayType: "line", + data: makeTimeseriesData(), + }), + makeWidget({ + title: "Bar Widget", + displayType: "bar", + data: makeTimeseriesData({ + series: [ + { + label: "group-a", + values: [{ timestamp: 1, value: 100 }], + }, + ], + }), + }), + makeWidget({ + title: "Table Widget", + displayType: "table", + data: makeTableData(), + }), + makeWidget({ + title: "Big Number Widget", + displayType: "big_number", + data: makeScalarData({ value: 999 }), + }), + makeWidget({ + title: "Unsupported Widget", + displayType: "line", + data: makeUnsupportedData({ reason: "not supported" }), + }), + makeWidget({ + title: "Error Widget", + displayType: "line", + data: makeErrorData({ message: "boom" }), + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + + // All titles appear + expect(output).toContain("Timeseries Widget"); + expect(output).toContain("Bar Widget"); + expect(output).toContain("Table Widget"); + expect(output).toContain("Big Number Widget"); + expect(output).toContain("Unsupported Widget"); + expect(output).toContain("Error Widget"); + + // Ordering: each title appears before the next + const tsIdx = output.indexOf("Timeseries Widget"); + const barIdx = output.indexOf("Bar Widget"); + const tblIdx = output.indexOf("Table Widget"); + const bnIdx = output.indexOf("Big Number Widget"); + const unsIdx = output.indexOf("Unsupported Widget"); + const errIdx = output.indexOf("Error Widget"); + expect(tsIdx).toBeLessThan(barIdx); + expect(barIdx).toBeLessThan(tblIdx); + expect(tblIdx).toBeLessThan(bnIdx); + expect(bnIdx).toBeLessThan(unsIdx); + expect(unsIdx).toBeLessThan(errIdx); + }); + }); + + describe("grid layout (framebuffer)", () => { + test("widgets at same y appear in the same terminal row range", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "Left Widget", + displayType: "big_number", + data: makeScalarData({ value: 10 }), + layout: { x: 0, y: 0, w: 3, h: 1 }, + }), + makeWidget({ + title: "Right Widget", + displayType: "big_number", + data: makeScalarData({ value: 20 }), + layout: { x: 3, y: 0, w: 3, h: 1 }, + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + + // Both titles should appear on the same terminal line + // (framebuffer composes widgets side-by-side within the same row range) + const titleLine = lines.find( + (l) => l.includes("Left Widget") && l.includes("Right Widget") + ); + expect(titleLine).toBeDefined(); + }); + + test("widgets at different y positions are in different row ranges", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "Top Widget", + displayType: "big_number", + data: makeScalarData({ value: 1 }), + layout: { x: 0, y: 0, w: 6, h: 1 }, + }), + makeWidget({ + title: "Bottom Widget", + displayType: "big_number", + data: makeScalarData({ value: 2 }), + layout: { x: 0, y: 1, w: 6, h: 1 }, + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + + // Titles should NOT be on the same line + const combinedLine = lines.find( + (l) => l.includes("Top Widget") && l.includes("Bottom Widget") + ); + expect(combinedLine).toBeUndefined(); + + // Both should still appear in the output + expect(output).toContain("Top Widget"); + expect(output).toContain("Bottom Widget"); + + // Top appears before Bottom + expect(output.indexOf("Top Widget")).toBeLessThan( + output.indexOf("Bottom Widget") + ); + }); + + test("row range separation matches LINES_PER_UNIT", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "Row0Widget", + displayType: "big_number", + data: makeScalarData({ value: 1 }), + layout: { x: 0, y: 0, w: 6, h: 1 }, + }), + makeWidget({ + title: "Row1Widget", + displayType: "big_number", + data: makeScalarData({ value: 2 }), + layout: { x: 0, y: 1, w: 6, h: 1 }, + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + + // Find line indices for each widget title (skip header lines) + const row0Line = lines.findIndex((l) => l.includes("Row0Widget")); + const row1Line = lines.findIndex((l) => l.includes("Row1Widget")); + expect(row0Line).toBeGreaterThanOrEqual(0); + expect(row1Line).toBeGreaterThanOrEqual(0); + + // The gap between the two title lines should be LINES_PER_UNIT + // (y=0 starts at row 0, y=1 starts at row LINES_PER_UNIT) + expect(row1Line - row0Line).toBe(LINES_PER_UNIT); + }); + + test("tall widget (h>1) spans multiple terminal row ranges", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "Tall Widget", + displayType: "line", + data: makeTimeseriesData(), + layout: { x: 0, y: 0, w: 3, h: 2 }, + }), + makeWidget({ + title: "Adjacent Widget", + displayType: "big_number", + data: makeScalarData({ value: 5 }), + layout: { x: 3, y: 1, w: 3, h: 1 }, + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + + // Total rows = max(0+2, 1+1) * LINES_PER_UNIT = 2 * 6 = 12 grid rows + // The tall widget covers rows 0..11, adjacent covers rows 6..11 + expect(output).toContain("Tall Widget"); + expect(output).toContain("Adjacent Widget"); + }); + + test("three widgets at same y are all composed side-by-side", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "W1", + displayType: "big_number", + data: makeScalarData({ value: 1 }), + layout: { x: 0, y: 0, w: 2, h: 1 }, + }), + makeWidget({ + title: "W2", + displayType: "big_number", + data: makeScalarData({ value: 2 }), + layout: { x: 2, y: 0, w: 2, h: 1 }, + }), + makeWidget({ + title: "W3", + displayType: "big_number", + data: makeScalarData({ value: 3 }), + layout: { x: 4, y: 0, w: 2, h: 1 }, + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + + // All three titles should be on the same line + const titleLine = lines.find( + (l) => l.includes("W1") && l.includes("W2") && l.includes("W3") + ); + expect(titleLine).toBeDefined(); + }); + + test("widgets without layout are placed as sequential blocks after grid", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "Laid Out", + displayType: "big_number", + data: makeScalarData({ value: 1 }), + layout: { x: 0, y: 0, w: 6, h: 1 }, + }), + makeWidget({ + title: "No Layout", + displayType: "big_number", + data: makeScalarData({ value: 2 }), + // no layout field + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + + // Both appear, not on the same line + expect(output).toContain("Laid Out"); + expect(output).toContain("No Layout"); + const lines = output.split("\n"); + const combinedLine = lines.find( + (l) => l.includes("Laid Out") && l.includes("No Layout") + ); + expect(combinedLine).toBeUndefined(); + + // "No Layout" appears after "Laid Out" (appended after grid) + expect(output.indexOf("Laid Out")).toBeLessThan( + output.indexOf("No Layout") + ); + }); + + test("widgets sorted by x within same y row", () => { + const widgets: DashboardViewWidget[] = [ + // Intentionally reversed x order in array + makeWidget({ + title: "RightFirst", + displayType: "big_number", + data: makeScalarData({ value: 2 }), + layout: { x: 3, y: 0, w: 3, h: 1 }, + }), + makeWidget({ + title: "LeftFirst", + displayType: "big_number", + data: makeScalarData({ value: 1 }), + layout: { x: 0, y: 0, w: 3, h: 1 }, + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + + // Both on same line, LeftFirst before RightFirst + const titleLine = lines.find( + (l) => l.includes("LeftFirst") && l.includes("RightFirst") + ); + expect(titleLine).toBeDefined(); + expect(titleLine!.indexOf("LeftFirst")).toBeLessThan( + titleLine!.indexOf("RightFirst") + ); + }); + + test("framebuffer total rows equals maxGridBottom * LINES_PER_UNIT", () => { + const widgets: DashboardViewWidget[] = [ + makeWidget({ + title: "Only Widget", + displayType: "big_number", + data: makeScalarData({ value: 1 }), + layout: { x: 0, y: 0, w: 6, h: 1 }, + }), + ]; + + const data = makeDashboardData({ widgets }); + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + + // Header: title, meta (Period/Env), URL, underline, blank separator = 5 lines + // Grid: 1 (h) * LINES_PER_UNIT = 6 rows + // Count grid-area lines: skip header (5 lines), count LINES_PER_UNIT rows + const headerLines = 5; // title, meta, URL, underline, blank + const gridLines = lines.slice(headerLines, headerLines + LINES_PER_UNIT); + // The first grid line should contain the widget title + expect(gridLines[0]).toContain("Only Widget"); + }); + }); + + describe("empty widgets array", () => { + test("renders header without errors", () => { + const data = makeDashboardData({ widgets: [] }); + const output = formatDashboardWithData(data); + // Header still renders + expect(output).toContain("My Dashboard"); + expect(output).toContain("Period: 24h"); + }); + }); +}); + +// =========================================================================== +// createDashboardViewRenderer +// =========================================================================== + +describe("createDashboardViewRenderer", () => { + describe("plain mode", () => { + usePlainMode(); + + test("first render returns output without clear-screen code", () => { + const renderer = createDashboardViewRenderer(); + const data = makeDashboardData(); + const output = renderer.render(data); + + // Should not start with ANSI clear-screen sequence + expect(output).not.toContain("\x1b[2J\x1b[H"); + // Should contain dashboard content + expect(output).toContain("My Dashboard"); + }); + + test("second render does NOT prepend clear-screen in plain mode", () => { + const renderer = createDashboardViewRenderer(); + const data = makeDashboardData(); + + // First render + renderer.render(data); + // Second render — plain mode skips clear-screen + const output = renderer.render(data); + + expect(output).not.toContain("\x1b[2J\x1b[H"); + expect(output).toContain("My Dashboard"); + }); + }); + + describe("rendered mode", () => { + useRenderedMode(); + + test("first render has no clear-screen", () => { + const renderer = createDashboardViewRenderer(); + const data = makeDashboardData(); + const output = renderer.render(data); + + expect(output).not.toContain("\x1b[2J\x1b[H"); + }); + + test("renderer does not include clear-screen (handled by ClearScreen token)", () => { + const renderer = createDashboardViewRenderer(); + const data = makeDashboardData(); + + // First render + renderer.render(data); + // Second render — no clear-screen (ClearScreen token in wrapper handles it) + const output = renderer.render(data); + + expect(output).not.toContain("\x1b[2J\x1b[H"); + expect(output).not.toContain("\x1b[H\x1b[J"); + }); + }); + + describe("finalize", () => { + describe("plain mode", () => { + usePlainMode(); + + test("returns hint wrapped in newlines", () => { + const renderer = createDashboardViewRenderer(); + const result = renderer.finalize!( + "Tip: use --json for machine-readable output" + ); + expect(result).toContain("Tip: use --json for machine-readable output"); + expect(result.startsWith("\n")).toBe(true); + expect(result.endsWith("\n")).toBe(true); + }); + + test("returns empty string when no hint", () => { + const renderer = createDashboardViewRenderer(); + expect(renderer.finalize!()).toBe(""); + }); + + test("returns empty string for undefined hint", () => { + const renderer = createDashboardViewRenderer(); + expect(renderer.finalize!(undefined)).toBe(""); + }); + }); + + describe("rendered mode (TTY)", () => { + useRenderedMode(); + + test("returns empty string even with hint (URL is in header)", () => { + const renderer = createDashboardViewRenderer(); + const result = renderer.finalize!("some hint"); + expect(result).toBe(""); + }); + + test("returns empty string when no hint", () => { + const renderer = createDashboardViewRenderer(); + expect(renderer.finalize!()).toBe(""); + }); + }); + }); +}); diff --git a/packages/cli/test/lib/formatters/feedback.test.ts b/packages/cli/test/lib/formatters/feedback.test.ts new file mode 100644 index 000000000..ee6df8df4 --- /dev/null +++ b/packages/cli/test/lib/formatters/feedback.test.ts @@ -0,0 +1,251 @@ +/** + * Feedback formatter tests. + */ + +import stringWidth from "string-width"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + formatFeedbackList, + formatFeedbackView, +} from "../../../src/lib/formatters/feedback.js"; +import type { SentryFeedback } from "../../../src/types/index.js"; + +const originalPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + +function feedback(overrides: Partial = {}): SentryFeedback { + return { + id: "1", + shortId: "WEB-1", + title: "User Feedback", + issueCategory: "feedback", + issueType: "feedback", + status: "unresolved", + hasSeen: false, + firstSeen: "2026-07-16T12:00:00Z", + project: { id: "1", slug: "web", name: "Web" }, + metadata: { message: "Button | failed\nwith **markdown**" }, + ...overrides, + }; +} + +beforeAll(() => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; +}); + +afterAll(() => { + if (originalPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = originalPlainOutput; + } +}); + +describe("formatFeedbackList", () => { + test("renders anonymous, unread feedback without breaking the table", () => { + const output = formatFeedbackList({ + feedback: [feedback()], + hasMore: false, + hasPrev: false, + org: "test-org", + project: "web", + }); + + expect(output).toContain("Anonymous"); + expect(output).toContain("Unread"); + expect(output).toContain("Button"); + }); + + test("maps ignored feedback to Spam and combines name with email", () => { + const output = formatFeedbackList({ + feedback: [ + feedback({ + status: "ignored", + hasSeen: true, + metadata: { + message: "Spam", + name: "Ada", + contact_email: "ada@example.com", + }, + }), + ], + hasMore: false, + hasPrev: false, + org: "test-org", + }); + + expect(output).toContain("Ada"); + expect(output).toContain("Spam"); + expect(output).toContain("Read"); + }); + + test("does not label an absent read state as unread", () => { + const output = formatFeedbackList({ + feedback: [feedback({ hasSeen: undefined })], + hasMore: false, + hasPrev: false, + org: "test-org", + }); + + expect(output).toContain("Unknown"); + expect(output).not.toContain("Unread"); + }); + + test("describes an empty previous page as a page, not an empty result", () => { + const output = formatFeedbackList({ + feedback: [], + hasMore: false, + hasPrev: true, + org: "test-org", + }); + + expect(output).toBe("No feedback on this page."); + }); + + test("flattens every message line break in compact output", () => { + const savedColumns = process.stdout.columns; + process.stdout.columns = 60; + + try { + const output = formatFeedbackList({ + feedback: [feedback({ metadata: { message: "First\nSecond\nThird" } })], + hasMore: false, + hasPrev: false, + org: "test-org", + }); + + expect(output).toContain("First Second Third"); + expect(output).not.toContain("\nSecond"); + expect(output).not.toContain("\nThird"); + } finally { + process.stdout.columns = savedColumns; + } + }); +}); + +describe("formatFeedbackView", () => { + test("escapes message markdown while preserving multiple lines", () => { + const output = formatFeedbackView({ + org: "test-org", + feedback: feedback(), + event: null, + replayIds: [], + attachments: [], + }); + + expect(output).toContain("Button | failed"); + expect(output).toContain("with **markdown**"); + }); + + test("truncates long list messages but preserves the complete detail message", () => { + const savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + const savedColumns = process.stdout.columns; + const longMessage = `${"Checkout failed ".repeat(20)}\nTAIL **marker**`; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + process.stdout.columns = 60; + + try { + const item = feedback({ metadata: { message: longMessage } }); + const listOutput = formatFeedbackList({ + feedback: [item], + hasMore: false, + hasPrev: false, + org: "test-org", + project: "web", + }); + const viewOutput = formatFeedbackView({ + org: "test-org", + feedback: item, + event: null, + replayIds: [], + attachments: [], + }); + + expect(listOutput).toContain("…"); + expect( + Math.max(...listOutput.split("\n").map((line) => stringWidth(line))) + ).toBeLessThanOrEqual(60); + expect(viewOutput).toContain("TAIL **marker**"); + } finally { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + process.stdout.columns = savedColumns; + } + }); + + test("removes terminal control sequences from untrusted fields", () => { + const savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const maliciousControlSequences = [ + "\u001b[2J", + "\u001b]52;c;dGVzdA==\u0007", + "\u009b2J", + "\u202e", + ]; + const item = feedback({ + metadata: { + message: `Checkout ${maliciousControlSequences.join("")}failed`, + name: `Ada${maliciousControlSequences.join("")}`, + summary: `Summary${maliciousControlSequences.join("")}`, + }, + }); + + try { + const listOutput = formatFeedbackList({ + feedback: [item], + hasMore: false, + hasPrev: false, + org: "test-org", + project: "web", + }); + const viewOutput = formatFeedbackView({ + org: "test-org", + feedback: item, + event: { + eventID: "abc123def456abc123def456abc12345", + title: "User Feedback", + user: { + name: `Mallory${maliciousControlSequences.join("")}`, + }, + tags: [ + { + key: "unsafe", + value: `tag${maliciousControlSequences.join("")}`, + }, + ], + }, + replayIds: [], + attachments: [ + { + id: "attachment-1", + event_id: "event-1", + type: "event.attachment", + name: `screen${maliciousControlSequences.join("")}.png`, + mimetype: "image/png", + dateCreated: "2026-07-16T12:00:00Z", + size: 10, + headers: {}, + sha1: null, + }, + ], + }); + + for (const sequence of maliciousControlSequences) { + expect(listOutput).not.toContain(sequence); + expect(viewOutput).not.toContain(sequence); + } + expect(listOutput).toContain("Ada"); + expect(viewOutput).toContain("Checkout"); + expect(viewOutput).toContain("Mallory"); + expect(viewOutput).toContain("screen.png"); + } finally { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + } + }); +}); diff --git a/packages/cli/test/lib/formatters/human.details.test.ts b/packages/cli/test/lib/formatters/human.details.test.ts new file mode 100644 index 000000000..3d0849231 --- /dev/null +++ b/packages/cli/test/lib/formatters/human.details.test.ts @@ -0,0 +1,962 @@ +/** + * Tests for human formatter detail functions + * + * These tests cover formatters that display detailed information about + * organizations, projects, and issues. They use mock data objects. + */ + +import { describe, expect, test } from "vitest"; +import { + formatEventDetails, + formatFixability, + formatFixabilityDetail, + formatIssueDetails, + formatOrgDetails, + formatProjectDetails, + getSeerFixabilityLabel, +} from "../../../src/lib/formatters/human.js"; +import type { + SentryEvent, + SentryIssue, + SentryOrganization, + SentryProject, +} from "../../../src/types/index.js"; +import { useTestConfigDir } from "../../helpers.js"; + +// Isolated per-test config dir so `resolveOrgDisplayName`'s cached-org lookup +// (via `getCachedOrganizations`) starts empty. Without this, an earlier test +// run in the same process could seed `org_regions` and mask the slug-fallback +// path below. +useTestConfigDir("human-details-"); + +// Helper to strip ANSI codes for content testing +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +// Mock data factories + +function createMockOrg( + overrides: Partial = {} +): SentryOrganization { + return { + id: "123", + slug: "acme-corp", + name: "Acme Corporation", + dateCreated: "2024-01-01T00:00:00Z", + require2FA: false, + isEarlyAdopter: false, + features: [], + ...overrides, + }; +} + +function createMockProject( + overrides: Partial = {} +): SentryProject & { orgSlug?: string } { + return { + id: "456", + slug: "frontend", + name: "Frontend App", + platform: "javascript", + status: "active", + dateCreated: "2024-01-01T00:00:00Z", + hasSessions: true, + hasReplays: false, + hasProfiles: false, + hasMonitors: false, + features: [], + ...overrides, + }; +} + +function createMockIssue(overrides: Partial = {}): SentryIssue { + return { + id: "789", + shortId: "FRONTEND-ABC", + title: "TypeError: Cannot read property 'foo' of undefined", + level: "error", + status: "unresolved", + count: "42", + userCount: 10, + firstSeen: "2024-01-01T00:00:00Z", + lastSeen: "2024-01-15T12:30:00Z", + permalink: "https://sentry.io/issues/789", + ...overrides, + }; +} + +// Organization Formatting Tests + +describe("formatOrgDetails", () => { + test("formats basic organization details", () => { + const org = createMockOrg({ + slug: "acme", + name: "Acme Corp", + id: "123", + require2FA: true, + isEarlyAdopter: true, + }); + + const result = stripAnsi(formatOrgDetails(org)); + const lines = result.split("\n"); + + // Header contains slug and name + expect(lines[0]).toContain("acme"); + expect(lines[0]).toContain("Acme Corp"); + + // Table rows contain the right values + expect(result).toContain("acme"); + expect(result).toContain("Acme Corp"); + expect(result).toContain("123"); + expect(result).toContain("Required"); + expect(result).toContain("Yes"); + }); + + test("formats organization without 2FA", () => { + const org = createMockOrg({ require2FA: false, isEarlyAdopter: false }); + const result = stripAnsi(formatOrgDetails(org)); + + expect(result).toContain("Not required"); + expect(result).toContain("No"); + }); + + test("includes features when present", () => { + const org = createMockOrg({ + features: ["feature-a", "feature-b", "feature-c"], + }); + const result = stripAnsi(formatOrgDetails(org)); + + expect(result).toContain("Features"); + expect(result).toContain("feature-a"); + }); + + test("handles missing dateCreated", () => { + const org = createMockOrg({ dateCreated: undefined }); + // Should not throw + const result = stripAnsi(formatOrgDetails(org)); + expect(result).not.toContain("Created"); + }); +}); + +// Project Formatting Tests + +describe("formatProjectDetails", () => { + test("formats basic project details", () => { + const project = createMockProject({ + slug: "frontend", + name: "Frontend App", + id: "456", + platform: "javascript", + status: "active", + }); + + const result = stripAnsi(formatProjectDetails(project)); + const lines = result.split("\n"); + + // Header contains slug and name + expect(lines[0]).toContain("frontend"); + expect(lines[0]).toContain("Frontend App"); + + // Table rows contain the right values + expect(result).toContain("frontend"); + expect(result).toContain("Frontend App"); + expect(result).toContain("456"); + expect(result).toContain("javascript"); + expect(result).toContain("active"); + }); + + test("includes DSN when provided", () => { + const project = createMockProject(); + const dsn = "https://abc123@sentry.io/456"; + + const result = stripAnsi(formatProjectDetails(project, dsn)); + expect(result).toContain(dsn); + }); + + test("shows 'No DSN available' when DSN is null", () => { + const project = createMockProject(); + + const result = stripAnsi(formatProjectDetails(project, null)); + expect(result).toContain("No DSN available"); + }); + + test("includes organization context when present", () => { + const project = createMockProject({ + organization: { id: "1", slug: "acme", name: "Acme Corp" }, + }); + + const result = stripAnsi(formatProjectDetails(project)); + expect(result).toContain("Acme Corp"); + expect(result).toContain("acme"); + }); + + test("falls back to org slug when collapsed response omits name", () => { + // `getProject()` passes `?collapse=organization` to save ~400-500ms, + // which drops `organization.name`. `formatProjectDetails` must still + // render a reasonable Organization row. With no cached org name + // available (empty config dir + no seeded org_regions), the slug is + // the last-resort fallback. + const project = createMockProject({ + organization: { id: "1", slug: "acme-only-slug" }, + }); + + const result = stripAnsi(formatProjectDetails(project)); + + // Organization row renders the slug twice — once as the display name + // (fallback when no cached name exists) and once inside the parens + // as the raw slug identifier. Matching both positions guarantees the + // fallback actually fired; a stray cached name would show up between + // the "Organization" label and `(acme-only-slug)`. + // Renders as `Organization │ acme-only-slug (acme-only-slug)` (box-drawing + // separator). Matching both positions guarantees the fallback actually + // fired; a stray cached display name would show up between the + // "Organization" label and `(acme-only-slug)`. + expect(result).toMatch( + /Organization\s*\S\s*acme-only-slug\s*\(\s*acme-only-slug\s*\)/ + ); + }); + + test("includes capability flags", () => { + const project = createMockProject({ + hasSessions: true, + hasReplays: true, + hasProfiles: false, + hasMonitors: true, + }); + + const result = stripAnsi(formatProjectDetails(project)); + expect(result).toContain("Sessions"); + expect(result).toContain("Replays"); + expect(result).toContain("Profiles"); + expect(result).toContain("Monitors"); + }); + + test("handles missing firstEvent", () => { + const project = createMockProject({ firstEvent: undefined }); + const result = stripAnsi(formatProjectDetails(project)); + expect(result).toContain("No events yet"); + }); + + test("formats firstEvent date when present", () => { + const project = createMockProject({ + firstEvent: "2024-06-15T10:30:00Z", + }); + const result = stripAnsi(formatProjectDetails(project)); + // Should contain year (locale-dependent format) + expect(result).toContain("2024"); + }); +}); + +// Issue Formatting Tests + +describe("formatIssueDetails", () => { + test("formats basic issue details", () => { + const issue = createMockIssue({ + shortId: "PROJ-ABC", + title: "Test Error", + status: "unresolved", + level: "error", + count: "100", + userCount: 25, + }); + + const result = stripAnsi(formatIssueDetails(issue)); + const lines = result.split("\n"); + + // Header contains shortId and title + expect(lines[0]).toContain("PROJ-ABC"); + expect(lines[0]).toContain("Test Error"); + + // Table contains key fields + expect(result).toContain("Status"); + expect(result).toContain("Level"); + expect(result).toContain("error"); + expect(result).toContain("100"); + expect(result).toContain("25"); + }); + + test("includes substatus when present", () => { + const issue = createMockIssue({ + status: "unresolved", + substatus: "ongoing", + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Ongoing"); + }); + + test("includes priority when present", () => { + const issue = createMockIssue({ priority: "high" }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Priority"); + expect(result).toContain("High"); + }); + + test("shows unhandled indicator", () => { + const issue = createMockIssue({ isUnhandled: true }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("unhandled"); + }); + + test("includes project info when present", () => { + const issue = createMockIssue({ + project: { slug: "frontend", name: "Frontend App" }, + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Frontend App"); + expect(result).toContain("frontend"); + }); + + test("formats single release correctly", () => { + const issue = createMockIssue({ + firstRelease: { shortVersion: "1.0.0" }, + lastRelease: { shortVersion: "1.0.0" }, + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("1.0.0"); + expect(result).toContain("Release"); + }); + + test("formats release range when different", () => { + const issue = createMockIssue({ + firstRelease: { shortVersion: "1.0.0" }, + lastRelease: { shortVersion: "2.0.0" }, + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("1.0.0"); + expect(result).toContain("2.0.0"); + expect(result).toContain("→"); + }); + + test("includes assignee name", () => { + const issue = createMockIssue({ + assignedTo: { name: "John Doe" }, + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("John Doe"); + }); + + test("shows Unassigned when no assignee", () => { + const issue = createMockIssue({ assignedTo: undefined }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Unassigned"); + }); + + test("includes culprit when present", () => { + const issue = createMockIssue({ culprit: "app.js in handleClick" }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("app.js in handleClick"); + }); + + test("includes metadata message when present", () => { + const issue = createMockIssue({ + metadata: { value: "Cannot read property 'x' of null" }, + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Message"); + expect(result).toContain("Cannot read property"); + }); + + test("includes metadata filename and function", () => { + const issue = createMockIssue({ + metadata: { filename: "src/app.js", function: "handleClick" }, + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("src/app.js"); + expect(result).toContain("handleClick"); + }); + + test("includes permalink", () => { + const issue = createMockIssue({ + permalink: "https://sentry.io/issues/123", + }); + + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("https://sentry.io/issues/123"); + }); + + test("handles missing optional fields gracefully", () => { + const issue = createMockIssue({ + platform: undefined, + type: undefined, + priority: undefined, + substatus: undefined, + isUnhandled: undefined, + project: undefined, + firstRelease: undefined, + lastRelease: undefined, + culprit: undefined, + metadata: undefined, + }); + + // Should not throw + const result = stripAnsi(formatIssueDetails(issue)); + expect(result.length).toBeGreaterThan(50); + expect(result).toContain("Platform"); + expect(result).toContain("unknown"); + expect(result).toContain("Type"); + }); + + test("includes fixability with percentage when seerFixabilityScore is present", () => { + const issue = createMockIssue({ seerFixabilityScore: 0.7 }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Fixability"); + expect(result).toContain("High"); + expect(result).toContain("70%"); + }); + + test("omits fixability when seerFixabilityScore is null", () => { + const issue = createMockIssue({ seerFixabilityScore: null }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).not.toContain("Fixability"); + }); + + test("omits fixability when seerFixabilityScore is undefined", () => { + const issue = createMockIssue({ seerFixabilityScore: undefined }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).not.toContain("Fixability"); + }); + + test("shows med label for medium score", () => { + const issue = createMockIssue({ seerFixabilityScore: 0.5 }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Fixability"); + expect(result).toContain("Med"); + expect(result).toContain("50%"); + }); + + test("shows low label for low score", () => { + const issue = createMockIssue({ seerFixabilityScore: 0.1 }); + const result = stripAnsi(formatIssueDetails(issue)); + expect(result).toContain("Fixability"); + expect(result).toContain("Low"); + expect(result).toContain("10%"); + }); +}); + +// Seer Fixability Tests + +describe("getSeerFixabilityLabel", () => { + test("returns high for scores above 0.66", () => { + expect(getSeerFixabilityLabel(0.67)).toBe("high"); + expect(getSeerFixabilityLabel(0.99)).toBe("high"); + expect(getSeerFixabilityLabel(0.8)).toBe("high"); + }); + + test("returns med for scores between 0.33 and 0.66", () => { + expect(getSeerFixabilityLabel(0.66)).toBe("med"); + expect(getSeerFixabilityLabel(0.5)).toBe("med"); + expect(getSeerFixabilityLabel(0.34)).toBe("med"); + }); + + test("returns low for scores at or below 0.33", () => { + expect(getSeerFixabilityLabel(0.33)).toBe("low"); + expect(getSeerFixabilityLabel(0.1)).toBe("low"); + expect(getSeerFixabilityLabel(0)).toBe("low"); + }); + + test("handles extreme boundary values", () => { + expect(getSeerFixabilityLabel(1)).toBe("high"); + expect(getSeerFixabilityLabel(0)).toBe("low"); + }); +}); + +describe("formatFixability", () => { + test("formats score as label(pct%)", () => { + expect(formatFixability(0.5)).toBe("med(50%)"); + expect(formatFixability(0.8)).toBe("high(80%)"); + expect(formatFixability(0.2)).toBe("low(20%)"); + }); + + test("rounds percentage to nearest integer", () => { + expect(formatFixability(0.495)).toBe("med(50%)"); + expect(formatFixability(0.333)).toBe("med(33%)"); + }); + + test("handles boundary values", () => { + expect(formatFixability(0)).toBe("low(0%)"); + expect(formatFixability(1)).toBe("high(100%)"); + }); + + test("max output fits within column width", () => { + // "high(100%)" = 10 chars, matching COL_FIX + expect(formatFixability(1).length).toBeLessThanOrEqual(10); + }); + + test("returns empty string for null or undefined", () => { + expect(formatFixability(null)).toBe(""); + expect(formatFixability(undefined)).toBe(""); + }); +}); + +describe("formatFixabilityDetail", () => { + test("formats with capitalized label and space", () => { + expect(formatFixabilityDetail(0.5)).toBe("Med (50%)"); + expect(formatFixabilityDetail(0.8)).toBe("High (80%)"); + expect(formatFixabilityDetail(0.2)).toBe("Low (20%)"); + }); + + test("handles boundary values", () => { + expect(formatFixabilityDetail(0)).toBe("Low (0%)"); + expect(formatFixabilityDetail(1)).toBe("High (100%)"); + }); + + test("returns empty string for null or undefined", () => { + expect(formatFixabilityDetail(null)).toBe(""); + expect(formatFixabilityDetail(undefined)).toBe(""); + }); +}); + +// Event Formatting Tests + +function createMockEvent(overrides: Partial = {}): SentryEvent { + return { + eventID: "abc123def456abc7890", + dateReceived: "2024-01-15T12:30:00Z", + ...overrides, + }; +} + +describe("formatEventDetails", () => { + test("returns a string", () => { + const result = formatEventDetails(createMockEvent()); + expect(typeof result).toBe("string"); + }); + + test("includes event ID in header", () => { + const result = stripAnsi(formatEventDetails(createMockEvent())); + expect(result).toContain("abc123de"); + }); + + test("includes custom header text", () => { + const result = stripAnsi( + formatEventDetails(createMockEvent(), "My Custom Header") + ); + expect(result).toContain("My Custom Header"); + }); + + test("includes event ID and received date", () => { + const result = stripAnsi(formatEventDetails(createMockEvent())); + expect(result).toContain("abc123def456abc7890"); + expect(result).toContain("Event ID"); + expect(result).toContain("Received"); + }); + + test("includes location when present", () => { + const result = stripAnsi( + formatEventDetails(createMockEvent({ location: "app/main.py" })) + ); + expect(result).toContain("Location"); + expect(result).toContain("app/main.py"); + }); + + test("includes trace context when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + contexts: { trace: { trace_id: "aabbccdd11223344" } }, + }) + ) + ); + expect(result).toContain("Trace"); + expect(result).toContain("aabbccdd11223344"); + }); + + test("includes SDK info when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + sdk: { name: "sentry.javascript.browser", version: "7.0.0" }, + }) + ) + ); + expect(result).toContain("SDK"); + expect(result).toContain("sentry.javascript.browser"); + expect(result).toContain("7.0.0"); + }); + + test("includes release when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + release: { version: "1.0.0", shortVersion: "1.0.0" }, + }) + ) + ); + expect(result).toContain("Release"); + expect(result).toContain("1.0.0"); + }); + + test("includes user section when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + user: { + email: "test@example.com", + username: "testuser", + id: "42", + ip_address: "192.168.1.1", + }, + }) + ) + ); + expect(result).toContain("User"); + expect(result).toContain("test@example.com"); + expect(result).toContain("testuser"); + expect(result).toContain("192.168.1.1"); + }); + + test("includes user geo when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + user: { + email: "test@example.com", + geo: { city: "Berlin", region: "Berlin", country_code: "DE" }, + }, + }) + ) + ); + expect(result).toContain("Location"); + expect(result).toContain("Berlin"); + expect(result).toContain("DE"); + }); + + test("includes environment contexts when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + contexts: { + browser: { name: "Chrome", version: "120.0" }, + os: { name: "Windows", version: "11" }, + device: { family: "Desktop", brand: "Apple" }, + }, + }) + ) + ); + expect(result).toContain("Environment"); + expect(result).toContain("Chrome"); + expect(result).toContain("Windows"); + expect(result).toContain("Desktop"); + }); + + test("includes request section when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "request", + data: { + url: "https://api.example.com/users", + method: "POST", + headers: [["User-Agent", "Mozilla/5.0"]], + }, + }, + ], + }) + ) + ); + expect(result).toContain("Request"); + expect(result).toContain("POST https://api.example.com/users"); + expect(result).toContain("Mozilla/5.0"); + }); + + test("includes stack trace when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "exception", + data: { + values: [ + { + type: "TypeError", + value: "Cannot read property", + mechanism: { type: "generic", handled: false }, + stacktrace: { + frames: [ + { + function: "handleClick", + filename: "app.js", + lineNo: 42, + colNo: 10, + inApp: true, + }, + ], + }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("Stack Trace"); + expect(result).toContain("TypeError: Cannot read property"); + expect(result).toContain("handleClick"); + expect(result).toContain("app.js"); + expect(result).toContain("[in-app]"); + expect(result).toContain("unhandled"); + }); + + test("includes stack frame code context when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "exception", + data: { + values: [ + { + type: "Error", + value: "fail", + stacktrace: { + frames: [ + { + function: "foo", + filename: "bar.js", + lineNo: 10, + colNo: 1, + context: [ + [9, " const x = 1;"], + [10, ' throw new Error("fail");'], + [11, "}"], + ], + }, + ], + }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("const x = 1"); + expect(result).toContain("throw new Error"); + }); + + test("includes breadcrumbs when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { + timestamp: "2024-01-15T12:29:55Z", + level: "info", + category: "navigation", + message: "User clicked button", + }, + { + timestamp: "2024-01-15T12:30:00Z", + level: "error", + category: "http", + data: { + url: "https://api.example.com/data", + method: "GET", + status_code: 500, + }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("Breadcrumbs"); + expect(result).toContain("navigation"); + expect(result).toContain("User clicked button"); + expect(result).toContain("GET"); + expect(result).toContain("500"); + }); + + test("includes replay link when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + tags: [ + { + key: "replayId", + value: "346789a703f6454384f1de473b8b9fcc", + }, + ], + }), + "Latest Event", + "https://acme.sentry.io/issues/789/" + ) + ); + expect(result).toContain("Replay"); + expect(result).toContain("346789a703f6454384f1de473b8b9fcc"); + expect(result).toContain( + "https://acme.sentry.io/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + }); + + test("includes replay link from replay.id tag", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + tags: [ + { + key: "replay.id", + value: "346789a703f6454384f1de473b8b9fcc", + }, + ], + }), + "Latest Event", + "https://acme.sentry.io/issues/789/" + ) + ); + expect(result).toContain("346789a703f6454384f1de473b8b9fcc"); + }); + + test("includes replay link from replay context", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + contexts: { + replay: { replay_id: "346789a703f6454384f1de473b8b9fcc" }, + }, + tags: [], + }), + "Latest Event", + "https://acme.sentry.io/issues/789/" + ) + ); + expect(result).toContain("346789a703f6454384f1de473b8b9fcc"); + }); + + test("includes tags when present", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + tags: [ + { key: "browser", value: "Chrome 120" }, + { key: "os", value: "Windows 11" }, + ], + }) + ) + ); + expect(result).toContain("Tags"); + expect(result).toContain("browser"); + expect(result).toContain("Chrome 120"); + }); + + test("handles minimal event", () => { + const result = formatEventDetails( + createMockEvent({ dateReceived: undefined }) + ); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + test("handles breadcrumb navigation data", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { + category: "navigation", + data: { from: "/home", to: "/profile" }, + }, + ], + }, + }, + ], + }) + ) + ); + expect(result).toContain("/home"); + expect(result).toContain("/profile"); + }); + + test("truncates long breadcrumb messages", () => { + const longMsg = "X".repeat(100); + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { category: "console", message: longMsg, level: "info" }, + ], + }, + }, + ], + }) + ) + ); + expect(result).not.toContain(longMsg); + expect(result).toContain("..."); + }); + + test("skips empty breadcrumbs array", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [{ type: "breadcrumbs", data: { values: [] } }], + }) + ) + ); + expect(result).not.toContain("Breadcrumbs"); + }); + + test("shows user name when available", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + user: { name: "John Doe" }, + }) + ) + ); + expect(result).toContain("Name"); + expect(result).toContain("John Doe"); + }); + + test("skips user section when user has no data", () => { + const result = stripAnsi(formatEventDetails(createMockEvent({ user: {} }))); + expect(result).not.toContain("User"); + }); + + test("skips environment section when no contexts", () => { + const result = stripAnsi( + formatEventDetails(createMockEvent({ contexts: null })) + ); + expect(result).not.toContain("Environment"); + }); + + test("skips request section when no URL", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + entries: [{ type: "request", data: {} }], + }) + ) + ); + expect(result).not.toContain("Request"); + }); +}); diff --git a/packages/cli/test/lib/formatters/human.property.test.ts b/packages/cli/test/lib/formatters/human.property.test.ts new file mode 100644 index 000000000..f0431df53 --- /dev/null +++ b/packages/cli/test/lib/formatters/human.property.test.ts @@ -0,0 +1,340 @@ +/** + * Property-Based Tests for Human Formatters + * + * Uses fast-check to verify invariants of formatting functions + * that are difficult to exhaustively test with example-based tests. + */ + +import { + constant, + double, + assert as fcAssert, + oneof, + property, + stringMatching, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + formatFixability, + formatFixabilityDetail, + formatShortId, + formatUserIdentity, + getSeerFixabilityLabel, +} from "../../../src/lib/formatters/human.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// Helper to strip ANSI codes for content testing +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** Strip ANSI escape codes and color tags for content testing. */ +function stripFormatting(s: string): string { + return stripAnsi(s).replace(/<\/?[a-z]+>/g, ""); +} + +// Arbitraries + +/** Project slug (lowercase, alphanumeric with hyphens) */ +const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Issue suffix (1-10 alphanumeric chars) */ +const suffixArb = stringMatching(/^[a-zA-Z0-9]{1,10}$/); + +/** Full short ID like "PROJECT-A3" */ +const shortIdArb = tuple(projectSlugArb, suffixArb).map( + ([project, suffix]) => `${project}-${suffix}` +); + +/** User name (non-empty string with letters and spaces) */ +const nameArb = stringMatching(/^[A-Za-z][A-Za-z ]{0,20}$/); + +/** Username (alphanumeric with underscores) */ +const usernameArb = stringMatching(/^[a-z][a-z0-9_]{2,15}$/); + +/** Email address */ +const emailArb = stringMatching(/^[a-z][a-z0-9]{2,10}@[a-z]{3,8}\.[a-z]{2,4}$/); + +/** User ID */ +const userIdArb = stringMatching(/^[1-9][0-9]{0,8}$/); + +describe("formatShortId properties", () => { + test("output (stripped of ANSI) always equals input uppercased", async () => { + await fcAssert( + property(shortIdArb, (shortId) => { + const result = formatShortId(shortId); + expect(stripFormatting(result)).toBe(shortId.toUpperCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output length (stripped) always equals input length", async () => { + await fcAssert( + property(shortIdArb, (shortId) => { + const result = formatShortId(shortId); + expect(stripFormatting(result).length).toBe(shortId.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("with projectSlug option, output still equals input uppercased", async () => { + await fcAssert( + property(tuple(projectSlugArb, suffixArb), ([project, suffix]) => { + const shortId = `${project}-${suffix}`; + const result = formatShortId(shortId, { projectSlug: project }); + expect(stripFormatting(result)).toBe(shortId.toUpperCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("with projectSlug and projectAlias, output still equals input uppercased", async () => { + await fcAssert( + property(tuple(projectSlugArb, suffixArb), ([project, suffix]) => { + const shortId = `${project}-${suffix}`; + const alias = project.charAt(0); // Use first char as alias + const result = formatShortId(shortId, { + projectSlug: project, + projectAlias: alias, + }); + expect(stripFormatting(result)).toBe(shortId.toUpperCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result is always uppercase", async () => { + await fcAssert( + property(shortIdArb, (shortId) => { + const result = stripAnsi(formatShortId(shortId)); + expect(result).toBe(result.toUpperCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("deterministic: same input produces same output", async () => { + await fcAssert( + property(shortIdArb, (shortId) => { + const result1 = formatShortId(shortId); + const result2 = formatShortId(shortId); + expect(result1).toBe(result2); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("handles case variations consistently", async () => { + await fcAssert( + property(tuple(projectSlugArb, suffixArb), ([project, suffix]) => { + const lowerShortId = `${project.toLowerCase()}-${suffix.toLowerCase()}`; + const upperShortId = `${project.toUpperCase()}-${suffix.toUpperCase()}`; + const mixedShortId = `${project}-${suffix}`; + + // All should produce the same stripped result + const lowerResult = stripAnsi(formatShortId(lowerShortId)); + const upperResult = stripAnsi(formatShortId(upperShortId)); + const mixedResult = stripAnsi(formatShortId(mixedShortId)); + + expect(lowerResult).toBe(upperResult); + expect(upperResult).toBe(mixedResult); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("formatUserIdentity properties", () => { + test("name + email formats as 'name '", async () => { + await fcAssert( + property(tuple(nameArb, emailArb), ([name, email]) => { + const result = formatUserIdentity({ id: "1", name, email }); + expect(result).toBe(`${name} <${email}>`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("username + email (no name) formats as 'username '", async () => { + await fcAssert( + property(tuple(usernameArb, emailArb), ([username, email]) => { + const result = formatUserIdentity({ id: "1", username, email }); + expect(result).toBe(`${username} <${email}>`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("name takes precedence over username", async () => { + await fcAssert( + property( + tuple(nameArb, usernameArb, emailArb), + ([name, username, email]) => { + const result = formatUserIdentity({ id: "1", name, username, email }); + // If this passes, name is used (proving username is ignored when name exists) + expect(result).toBe(`${name} <${email}>`); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("only name returns just name", async () => { + await fcAssert( + property(nameArb, (name) => { + const result = formatUserIdentity({ id: "1", name }); + expect(result).toBe(name); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("only username returns just username", async () => { + await fcAssert( + property(usernameArb, (username) => { + const result = formatUserIdentity({ id: "1", username }); + expect(result).toBe(username); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("only email returns just email", async () => { + await fcAssert( + property(emailArb, (email) => { + const result = formatUserIdentity({ id: "1", email }); + expect(result).toBe(email); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("only id returns 'user {id}'", async () => { + await fcAssert( + property(userIdArb, (id) => { + const result = formatUserIdentity({ id }); + expect(result).toBe(`user ${id}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("only userId returns 'user {userId}'", async () => { + await fcAssert( + property(userIdArb, (userId) => { + const result = formatUserIdentity({ userId }); + expect(result).toBe(`user ${userId}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("id takes precedence over userId for fallback", async () => { + await fcAssert( + property(tuple(userIdArb, userIdArb), ([id, userId]) => { + const result = formatUserIdentity({ id, userId }); + expect(result).toBe(`user ${id}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Fixability Formatting Properties + +/** Score in valid API range [0, 1] */ +const scoreArb = double({ min: 0, max: 1, noNaN: true }); + +/** Score or null/undefined (full input space for formatFixability) */ +const nullableScoreArb = oneof( + scoreArb, + constant(null as null), + constant(undefined as undefined) +); + +describe("property: getSeerFixabilityLabel", () => { + test("always returns one of the three valid tiers", () => { + fcAssert( + property(scoreArb, (score) => { + const label = getSeerFixabilityLabel(score); + expect(["high", "med", "low"]).toContain(label); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is monotonic: higher scores never produce lower tiers", () => { + const tierRank = { low: 0, med: 1, high: 2 }; + fcAssert( + property(scoreArb, scoreArb, (a, b) => { + if (a <= b) { + const rankA = + tierRank[getSeerFixabilityLabel(a) as keyof typeof tierRank]; + const rankB = + tierRank[getSeerFixabilityLabel(b) as keyof typeof tierRank]; + expect(rankA).toBeLessThanOrEqual(rankB); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: formatFixability", () => { + test("matches expected pattern for valid scores", () => { + fcAssert( + property(scoreArb, (score) => { + const result = formatFixability(score); + expect(result).toMatch(/^(high|med|low)\(\d+%\)$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output never exceeds column width (10 chars)", () => { + fcAssert( + property(scoreArb, (score) => { + expect(formatFixability(score).length).toBeLessThanOrEqual(10); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty string for null or undefined", () => { + fcAssert( + property(nullableScoreArb, (score) => { + if (score === null || score === undefined) { + expect(formatFixability(score)).toBe(""); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: formatFixabilityDetail", () => { + test("matches expected pattern for valid scores", () => { + fcAssert( + property(scoreArb, (score) => { + const result = formatFixabilityDetail(score); + expect(result).toMatch(/^(High|Med|Low) \(\d+%\)$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty string for null or undefined", () => { + fcAssert( + property(nullableScoreArb, (score) => { + if (score === null || score === undefined) { + expect(formatFixabilityDetail(score)).toBe(""); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/human.test.ts b/packages/cli/test/lib/formatters/human.test.ts new file mode 100644 index 000000000..6ac42f73c --- /dev/null +++ b/packages/cli/test/lib/formatters/human.test.ts @@ -0,0 +1,916 @@ +/** + * Tests for human-readable formatters + * + * Note: Core invariants (uppercase, length preservation, determinism) are tested + * via property-based tests in human.property.test.ts. These tests focus on + * specific edge cases and environment-dependent behavior. + */ + +import { describe, expect, test } from "vitest"; +import { + extractStatsPoints, + formatDashboardCreated, + formatIssueSubtitle, + formatProjectCreated, + formatShortId, + formatStatusIcon, + formatStatusLabel, + formatUpgradeResult, + formatUserIdentity, + type IssueTableRow, + type ProjectCreatedResult, + substatusLabel, + writeIssueTable, +} from "../../../src/lib/formatters/human.js"; +import type { SentryIssue } from "../../../src/types/index.js"; + +// Helper to strip ANSI codes for content testing +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** Strip ANSI escape codes and color tags for content testing. */ +function stripFormatting(s: string): string { + return ( + s + .replace(/<\/?[a-z]+>/g, "") + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + .replace(/\x1b\[[0-9;]*m/g, "") + ); +} + +describe("formatShortId edge cases", () => { + test("handles empty options object", () => { + expect(stripFormatting(formatShortId("CRAFT-G", {}))).toBe("CRAFT-G"); + }); + + test("handles undefined options", () => { + expect(stripFormatting(formatShortId("CRAFT-G", undefined))).toBe( + "CRAFT-G" + ); + }); + + test("handles mismatched project slug gracefully", () => { + const result = formatShortId("CRAFT-G", { projectSlug: "other" }); + expect(stripFormatting(result)).toBe("CRAFT-G"); + }); + + test("handles legacy string parameter", () => { + const result = formatShortId("CRAFT-G", "craft"); + expect(stripFormatting(result)).toBe("CRAFT-G"); + }); +}); + +describe("formatShortId formatting", () => { + test("single project mode applies formatting to suffix", () => { + const result = formatShortId("CRAFT-G", { projectSlug: "craft" }); + expect(stripFormatting(result)).toBe("CRAFT-G"); + // Suffix should be wrapped in bold+underline tag + expect(result).toContain("G"); + }); + + test("multi-project mode applies formatting to suffix", () => { + const result = formatShortId("SPOTLIGHT-ELECTRON-4Y", { + projectSlug: "spotlight-electron", + projectAlias: "e", + isMultiProject: true, + }); + expect(stripFormatting(result)).toBe("SPOTLIGHT-ELECTRON-4Y"); + // Alias char and suffix should be bold+underlined + expect(result).toContain("E"); + expect(result).toContain("4Y"); + }); + + test("no formatting when no options provided", () => { + const result = formatShortId("CRAFT-G"); + expect(result).toBe("CRAFT-G"); + expect(result).toBe(stripFormatting(result)); + }); +}); + +describe("formatShortId multi-project alias highlighting", () => { + // These tests verify the highlighting logic finds the correct part to highlight. + // Content is always verified (ANSI codes stripped); formatting presence depends on FORCE_COLOR. + + test("highlights rightmost matching part for ambiguous aliases", () => { + // Bug fix: For projects api-app, api-admin with aliases ap, ad + // API-APP-5 with alias "ap" should highlight APP (not API) + const result = formatShortId("API-APP-5", { + projectAlias: "ap", + isMultiProject: true, + }); + // Content is always correct - the text should be unchanged + expect(stripFormatting(result)).toBe("API-APP-5"); + }); + + test("highlights alias with embedded dash correctly", () => { + // Bug fix: For projects x-ab, xyz with aliases x-a, xy + // X-AB-5 with alias "x-a" should highlight X-A (joined project portion) + const result = formatShortId("X-AB-5", { + projectAlias: "x-a", + isMultiProject: true, + }); + expect(stripFormatting(result)).toBe("X-AB-5"); + }); + + test("highlights single char alias at start of multi-part short ID", () => { + // CLI-WEBSITE-4 with alias "w" should highlight W in WEBSITE (not CLI) + const result = formatShortId("CLI-WEBSITE-4", { + projectAlias: "w", + isMultiProject: true, + }); + expect(stripFormatting(result)).toBe("CLI-WEBSITE-4"); + }); + + test("highlights single char alias in simple short ID", () => { + // CLI-25 with alias "c" should highlight C in CLI + const result = formatShortId("CLI-25", { + projectAlias: "c", + isMultiProject: true, + }); + expect(stripFormatting(result)).toBe("CLI-25"); + }); + + test("handles org-prefixed alias format", () => { + // Alias "o1/d" should use "d" for matching against DASHBOARD-A3 + const result = formatShortId("DASHBOARD-A3", { + projectAlias: "o1/d", + isMultiProject: true, + }); + expect(stripFormatting(result)).toBe("DASHBOARD-A3"); + }); + + test("falls back gracefully when alias doesn't match", () => { + // If alias doesn't match any part, return plain text + const result = formatShortId("CLI-25", { + projectAlias: "xyz", + isMultiProject: true, + }); + expect(stripFormatting(result)).toBe("CLI-25"); + }); +}); + +/** Capture stdout writes for table output testing */ +function capture(): { + writer: { write: (s: string) => boolean }; + output: () => string; +} { + let buf = ""; + return { + writer: { + write: (s: string) => { + buf += s; + return true; + }, + }, + output: () => buf, + }; +} + +describe("writeIssueTable", () => { + const mockIssue: SentryIssue = { + id: "123", + shortId: "DASHBOARD-A3", + title: "Test issue", + level: "error", + status: "unresolved", + count: "42", + userCount: 10, + firstSeen: "2024-01-01T00:00:00Z", + lastSeen: "2024-01-02T00:00:00Z", + permalink: "https://sentry.io/issues/123", + }; + + test("single project mode shows short ID without alias subtitle", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: mockIssue, + orgSlug: "test-org", + formatOptions: { projectSlug: "dashboard" }, + }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + expect(text).not.toContain("ALIAS"); + expect(text).toContain("DASHBOARD-"); + expect(text).toContain("A3"); + expect(text).toContain("Test issue"); + }); + + test("multi-project mode shows alias alongside SHORT ID", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: mockIssue, + orgSlug: "test-org", + formatOptions: { + projectSlug: "dashboard", + projectAlias: "o1:d", + isMultiProject: true, + }, + }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + // No separate ALIAS column header + expect(text).not.toContain("ALIAS"); + // Alias shorthand appears alongside SHORT ID on the same line + expect(text).toContain("o1:d-a3"); + }); + + test("table contains all essential columns", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: mockIssue, + orgSlug: "test-org", + formatOptions: { projectSlug: "dashboard" }, + }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + // Column set matching Sentry web UI + for (const col of [ + "SHORT ID", + "ISSUE", + "SEEN", + "AGE", + "EVENTS", + "USERS", + "TRIAGE", + ]) { + expect(text).toContain(col); + } + // Old/removed columns should not appear + for (const col of [ + "LEVEL", + "FIXABILITY", + "TITLE", + "GRAPH", + "ALIAS", + "ASSIGNEE", + "PRIORITY", + ]) { + expect(text).not.toContain(col); + } + // COUNT was renamed to EVENTS + expect(text).not.toContain(" COUNT "); + }); + + test("issue title and event count appear in output", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { issue: mockIssue, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + expect(text).toContain("Test issue"); + expect(text).toContain("42"); + }); + + test("substatus label appears in TREND column on wide terminals", () => { + const { writer, output } = capture(); + const issueWithSubstatus: SentryIssue = { + ...mockIssue, + substatus: "regressed", + }; + const rows: IssueTableRow[] = [ + { issue: issueWithSubstatus, orgSlug: "test-org", formatOptions: {} }, + ]; + // TREND column requires terminal width >= 100 + const saved = process.stdout.columns; + process.stdout.columns = 200; + try { + writeIssueTable(writer, rows); + } finally { + process.stdout.columns = saved; + } + const text = stripAnsi(output()); + expect(text).toContain("Regressed"); + expect(text).toContain("Test issue"); + }); + + test("default mode shows title and subtitle (2-line)", () => { + // Use a wide terminal so the subtitle doesn't get truncated by column fitting + const savedCols = process.stdout.columns; + process.stdout.columns = 200; + try { + const { writer, output } = capture(); + const issueWithMeta: SentryIssue = { + ...mockIssue, + metadata: { value: "Cannot read property 'x' of null" }, + }; + const rows: IssueTableRow[] = [ + { issue: issueWithMeta, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + expect(text).toContain("Test issue"); + expect(text).toContain("Cannot read property"); + } finally { + process.stdout.columns = savedCols; + } + }); + + test("compact mode shows title only, no subtitle", () => { + const { writer, output } = capture(); + const issueWithMeta: SentryIssue = { + ...mockIssue, + metadata: { value: "Cannot read property 'x' of null" }, + }; + const rows: IssueTableRow[] = [ + { issue: issueWithMeta, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows, { compact: true }); + const text = stripAnsi(output()); + expect(text).toContain("Test issue"); + expect(text).not.toContain("Cannot read property"); + }); + + test("user count appears in USERS column", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { issue: mockIssue, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + expect(text).toContain("10"); + }); + + test("priority appears in TRIAGE column", () => { + const { writer, output } = capture(); + const issueWithPriority: SentryIssue = { + ...mockIssue, + priority: "high", + }; + const rows: IssueTableRow[] = [ + { issue: issueWithPriority, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + expect(text).toContain("High"); + expect(text).toContain("TRIAGE"); + }); + + test("triage shows priority label with composite score", () => { + const { writer, output } = capture(); + const issueWithBoth: SentryIssue = { + ...mockIssue, + priority: "high", + seerFixabilityScore: 0.85, + }; + const rows: IssueTableRow[] = [ + { issue: issueWithBoth, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + // high=0.75*0.6 + 0.85*0.4 = 0.45+0.34 = 0.79 → 79% + expect(text).toContain("High"); + expect(text).toContain("79%"); + }); + + test("triage shows only priority label without fixability", () => { + const { writer, output } = capture(); + const issuePriorityOnly: SentryIssue = { + ...mockIssue, + priority: "medium", + }; + const rows: IssueTableRow[] = [ + { issue: issuePriorityOnly, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + expect(text).toContain("Med"); + expect(text).not.toContain("%"); + }); + + test("triage shows only composite score without priority", () => { + const { writer, output } = capture(); + const issueFixOnly: SentryIssue = { + ...mockIssue, + seerFixabilityScore: 0.75, + }; + const rows: IssueTableRow[] = [ + { issue: issueFixOnly, orgSlug: "test-org", formatOptions: {} }, + ]; + writeIssueTable(writer, rows); + const text = stripAnsi(output()); + // default impact (0.5)*0.6 + 0.75*0.4 = 0.30+0.30 = 0.60 → 60% + expect(text).toContain("60%"); + }); + + test("renders table with null lastSeen and firstSeen (covers ?? null branches)", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: { + ...mockIssue, + lastSeen: null as unknown as string, + firstSeen: null as unknown as string, + }, + orgSlug: "test-org", + formatOptions: { projectSlug: "dashboard" }, + }, + ]; + writeIssueTable(writer, rows); + // Should render without throwing; SEEN/AGE columns receive undefined + expect(stripAnsi(output())).toContain("Test issue"); + }); +}); + +describe("substatusLabel", () => { + test("regressed returns colored label", () => { + expect(stripFormatting(substatusLabel("regressed"))).toBe("Regressed"); + }); + + test("escalating returns colored label", () => { + expect(stripFormatting(substatusLabel("escalating"))).toBe("Escalating"); + }); + + test("new returns colored label", () => { + expect(stripFormatting(substatusLabel("new"))).toBe("New"); + }); + + test("ongoing returns muted label", () => { + expect(stripFormatting(substatusLabel("ongoing"))).toBe("Ongoing"); + }); + + test("null returns empty string", () => { + expect(substatusLabel(null)).toBe(""); + }); + + test("undefined returns empty string", () => { + expect(substatusLabel(undefined)).toBe(""); + }); +}); + +describe("formatIssueSubtitle", () => { + test("returns empty string for undefined metadata", () => { + expect(formatIssueSubtitle(undefined)).toBe(""); + }); + + test("returns empty string for empty metadata", () => { + expect(formatIssueSubtitle({})).toBe(""); + }); + + test("returns value when present", () => { + expect(formatIssueSubtitle({ value: "Some error message" })).toBe( + "Some error message" + ); + }); + + test("collapses whitespace in multi-line values", () => { + const multiLine = "Error on line 1\n\nDetails on line 3\n indented"; + const result = formatIssueSubtitle({ value: multiLine }); + expect(result).toBe("Error on line 1 Details on line 3 indented"); + }); + + test("preserves long values without truncation", () => { + const longValue = "A".repeat(200); + const result = formatIssueSubtitle({ value: longValue }); + expect(result).toBe(longValue); + }); + + test("falls back to type + function", () => { + expect( + formatIssueSubtitle({ type: "TypeError", function: "handleClick" }) + ).toBe("TypeError in handleClick"); + }); + + test("returns type alone when no function", () => { + expect(formatIssueSubtitle({ type: "TypeError" })).toBe("TypeError"); + }); + + test("prefers value over type + function", () => { + expect( + formatIssueSubtitle({ + value: "Error msg", + type: "TypeError", + function: "fn", + }) + ).toBe("Error msg"); + }); +}); + +describe("extractStatsPoints", () => { + test("returns empty array for undefined stats", () => { + expect(extractStatsPoints(undefined)).toEqual([]); + }); + + test("returns empty array for empty stats", () => { + expect(extractStatsPoints({})).toEqual([]); + }); + + test("extracts counts from time-series buckets", () => { + const stats = { + "24h": [ + [1_700_000_000, 5], + [1_700_003_600, 10], + [1_700_007_200, 3], + ], + }; + expect(extractStatsPoints(stats)).toEqual([5, 10, 3]); + }); + + test("handles non-array stats values", () => { + expect(extractStatsPoints({ "24h": "not-an-array" })).toEqual([]); + }); + + test("handles malformed bucket entries", () => { + const stats = { + auto: [[1_700_000_000], [1_700_003_600, 5], "bad", [1_700_007_200, 3]], + }; + expect(extractStatsPoints(stats)).toEqual([0, 5, 0, 3]); + }); +}); + +describe("formatUserIdentity API shapes", () => { + // Note: Core behavior is tested via property-based tests. + // These tests verify specific API contract shapes. + + test("handles UserInfo shape (from database)", () => { + const result = formatUserIdentity({ + userId: "12345", + email: "test@example.com", + username: "testuser", + name: "Test User", + }); + expect(result).toBe("Test User "); + }); + + test("handles UserInfo without name", () => { + const result = formatUserIdentity({ + userId: "12345", + email: "test@example.com", + username: "testuser", + }); + expect(result).toBe("testuser "); + }); + + test("handles token response user with id field", () => { + const result = formatUserIdentity({ + id: "67890", + name: "OAuth User", + email: "oauth@example.com", + }); + expect(result).toBe("OAuth User "); + }); +}); + +describe("writeIssueTable priority labels", () => { + const baseMockIssue: SentryIssue = { + id: "1", + shortId: "TEST-1", + title: "Test", + culprit: "", + permalink: "https://sentry.io/issues/1/", + status: "unresolved", + level: "error", + count: "1", + userCount: 0, + firstSeen: "2024-01-01T00:00:00Z", + lastSeen: "2024-01-01T00:00:00Z", + metadata: {}, + }; + + test("critical priority renders in triage column", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: { ...baseMockIssue, priority: "critical" }, + orgSlug: "org", + formatOptions: {}, + }, + ]; + writeIssueTable(writer, rows); + expect(stripAnsi(output())).toContain("Critical"); + }); + + test("low priority renders in triage column", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: { ...baseMockIssue, priority: "low" }, + orgSlug: "org", + formatOptions: {}, + }, + ]; + writeIssueTable(writer, rows); + expect(stripAnsi(output())).toContain("Low"); + }); + + test("unknown priority renders raw value", () => { + const { writer, output } = capture(); + const rows: IssueTableRow[] = [ + { + issue: { ...baseMockIssue, priority: "urgent" }, + orgSlug: "org", + formatOptions: {}, + }, + ]; + writeIssueTable(writer, rows); + expect(stripAnsi(output())).toContain("urgent"); + }); +}); + +describe("formatProjectCreated", () => { + const baseResult: ProjectCreatedResult = { + project: { + id: "42", + name: "My Project", + slug: "my-project", + platform: "javascript", + } as ProjectCreatedResult["project"], + orgSlug: "my-org", + teamSlug: "my-team", + teamSource: "explicit", + requestedPlatform: "javascript", + dsn: "https://abc@o123.ingest.us.sentry.io/456", + url: "https://my-org.sentry.io/settings/projects/my-project/", + slugDiverged: false, + expectedSlug: "my-project", + }; + + test("includes project name and org in heading", () => { + const result = stripAnsi(formatProjectCreated(baseResult)); + expect(result).toContain("My Project"); + expect(result).toContain("my-org"); + }); + + test("includes DSN when provided", () => { + const result = stripAnsi(formatProjectCreated(baseResult)); + expect(result).toContain("abc@o123.ingest.us.sentry.io"); + }); + + test("omits DSN row when null", () => { + const result = stripAnsi( + formatProjectCreated({ ...baseResult, dsn: null }) + ); + expect(result).not.toContain("DSN"); + }); + + test("shows slug divergence note", () => { + const result = stripAnsi( + formatProjectCreated({ + ...baseResult, + slugDiverged: true, + project: { + ...baseResult.project, + slug: "my-project-1", + } as ProjectCreatedResult["project"], + expectedSlug: "my-project", + }) + ); + expect(result).toContain("my-project-1"); + expect(result).toContain("already taken"); + }); + + test("shows auto-created team note", () => { + const result = stripAnsi( + formatProjectCreated({ ...baseResult, teamSource: "auto-created" }) + ); + expect(result).toContain("Created team"); + expect(result).toContain("my-team"); + }); + + test("shows auto-selected team note", () => { + const result = stripAnsi( + formatProjectCreated({ ...baseResult, teamSource: "auto-selected" }) + ); + expect(result).toContain("Using team"); + expect(result).toContain("sentry team list"); + }); + + test("uses requestedPlatform as fallback when project.platform is empty", () => { + const result = stripAnsi( + formatProjectCreated({ + ...baseResult, + project: { + ...baseResult.project, + platform: "", + } as ProjectCreatedResult["project"], + }) + ); + expect(result).toContain("javascript"); + }); + + test("includes tip with project view command", () => { + const result = stripAnsi(formatProjectCreated(baseResult)); + expect(result).toContain("sentry project view my-org/my-project"); + }); +}); + +describe("formatDashboardCreated", () => { + test("output contains title, ID, and URL", () => { + const result = stripAnsi( + formatDashboardCreated({ + id: "42", + title: "My Dashboard", + url: "https://acme.sentry.io/dashboard/42/", + }) + ); + expect(result).toContain("My Dashboard"); + expect(result).toContain("42"); + expect(result).toContain("https://acme.sentry.io/dashboard/42/"); + }); + + test("title with special chars is escaped", () => { + const result = stripAnsi( + formatDashboardCreated({ + id: "1", + title: "Dash | with * special", + url: "https://acme.sentry.io/dashboard/1/", + }) + ); + expect(result).toContain("Dash"); + expect(result).toContain("special"); + }); +}); + +describe("formatUpgradeResult", () => { + // Force plain output so we get raw markdown (no ANSI codes) + const origPlain = process.env.SENTRY_PLAIN_OUTPUT; + function withPlain(fn: () => void) { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + try { + fn(); + } finally { + if (origPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = origPlain; + } + } + } + + test("renders compact metadata line with method and channel", () => { + withPlain(() => { + const result = formatUpgradeResult({ + action: "upgraded", + currentVersion: "0.5.0", + targetVersion: "0.6.0", + channel: "stable", + method: "curl", + forced: false, + }); + expect(result).toContain("Method: curl"); + expect(result).toContain("Channel: stable"); + }); + }); + + test("includes from-version in upgraded action", () => { + withPlain(() => { + const result = formatUpgradeResult({ + action: "upgraded", + currentVersion: "0.5.0", + targetVersion: "0.6.0", + channel: "stable", + method: "curl", + forced: false, + }); + expect(result).toContain("Upgraded to"); + expect(result).toContain("0.6.0"); + expect(result).toContain("(from 0.5.0)"); + }); + }); + + test("renders nightly channel in metadata line", () => { + withPlain(() => { + const result = formatUpgradeResult({ + action: "checked", + currentVersion: "0.5.0", + targetVersion: "0.6.0-dev.123", + channel: "nightly", + method: "npm", + forced: false, + }); + expect(result).toContain("Method: npm"); + expect(result).toContain("Channel: nightly"); + }); + }); + + test("up-to-date action includes compact metadata", () => { + withPlain(() => { + const result = formatUpgradeResult({ + action: "up-to-date", + currentVersion: "0.5.0", + targetVersion: "0.5.0", + channel: "stable", + method: "brew", + forced: false, + }); + expect(result).toContain("Already up to date"); + expect(result).toContain("Method: brew"); + expect(result).toContain("Channel: stable"); + }); + }); + + test("offline upgrade shows offline note instead of from-version", () => { + withPlain(() => { + const result = formatUpgradeResult({ + action: "upgraded", + currentVersion: "0.5.0", + targetVersion: "0.6.0", + channel: "stable", + method: "curl", + forced: false, + offline: true, + }); + expect(result).toContain("(offline, from cache)"); + expect(result).not.toContain("(from 0.5.0)"); + }); + }); + + test("does not include from-version when versions match", () => { + withPlain(() => { + const result = formatUpgradeResult({ + action: "upgraded", + currentVersion: "0.5.0", + targetVersion: "0.5.0", + channel: "stable", + method: "curl", + forced: true, + }); + expect(result).toContain("Upgraded to"); + expect(result).not.toContain("(from"); + }); + }); + + test("changelog is separated from metadata by a blank line", () => { + withPlain(() => { + const result = formatUpgradeResult({ + action: "upgraded", + currentVersion: "0.5.0", + targetVersion: "0.6.0", + channel: "stable", + method: "curl", + forced: false, + changelog: { + fromVersion: "0.5.0", + toVersion: "0.6.0", + sections: [ + { category: "features", markdown: "- add cool feature (#1)" }, + ], + totalItems: 1, + truncated: false, + originalCount: 1, + }, + }); + // Blank line between metadata and changelog heading + expect(result).toContain("Channel: stable\n\n"); + // Changelog content present + expect(result).toContain("New Features"); + expect(result).toContain("add cool feature (#1)"); + // Trailing newline after changelog + expect(result).toMatch(/\n$/); + }); + }); +}); + +describe("formatStatusIcon", () => { + test("resolved shows green icon", () => { + expect(stripFormatting(formatStatusIcon("resolved"))).toContain("✓"); + }); + + test("unresolved shows yellow icon", () => { + expect(stripFormatting(formatStatusIcon("unresolved"))).toContain("●"); + }); + + test("resolvedInNextRelease shows green icon", () => { + expect( + stripFormatting(formatStatusIcon("resolvedInNextRelease")) + ).toContain("✓"); + }); + + test("muted shows muted icon", () => { + expect(stripFormatting(formatStatusIcon("muted"))).toContain("−"); + }); + + test("unknown status falls back to yellow icon", () => { + expect(stripFormatting(formatStatusIcon("unknown"))).toContain("●"); + }); +}); + +describe("formatStatusLabel", () => { + test("resolved → Resolved label", () => { + expect(stripFormatting(formatStatusLabel("resolved"))).toContain( + "Resolved" + ); + }); + + test("resolvedInNextRelease → Resolved in Next Release label", () => { + expect( + stripFormatting(formatStatusLabel("resolvedInNextRelease")) + ).toContain("Resolved in Next Release"); + }); + + test("muted → Muted label", () => { + expect(stripFormatting(formatStatusLabel("muted"))).toContain("Muted"); + }); + + test("unknown status falls back to Unknown label", () => { + expect(stripFormatting(formatStatusLabel("unknown"))).toContain("Unknown"); + }); +}); diff --git a/packages/cli/test/lib/formatters/human.utils.test.ts b/packages/cli/test/lib/formatters/human.utils.test.ts new file mode 100644 index 000000000..d6801b9f1 --- /dev/null +++ b/packages/cli/test/lib/formatters/human.utils.test.ts @@ -0,0 +1,322 @@ +/** + * Tests for human formatter utility functions + * + * These tests cover pure utility functions that don't depend on external state. + * Functions tested: formatStatusIcon, formatStatusLabel, + * formatRelativeTime, maskToken, formatDuration, formatExpiration + */ + +import { + assert as fcAssert, + integer, + nat, + property, + stringMatching, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + formatDuration, + formatExpiration, + formatStatusIcon, + formatStatusLabel, + maskToken, +} from "../../../src/lib/formatters/human.js"; +import { formatRelativeTime } from "../../../src/lib/formatters/time-utils.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// Helper to strip ANSI codes and markdown color tags for content testing. +// Strips color tags first to avoid incomplete multi-character sanitization +// (ANSI removal could otherwise join fragments into tag-like sequences). +function stripAnsi(str: string): string { + let result = str.replace(/<\/?[a-z]+>/g, ""); + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + result = result.replace(/\x1b\[[0-9;]*m/g, ""); + return result; +} + +// Status Formatting + +describe("formatStatusIcon", () => { + test("returns checkmark for resolved status", () => { + const result = stripAnsi(formatStatusIcon("resolved")); + expect(result).toBe("✓"); + }); + + test("returns bullet for unresolved status", () => { + const result = stripAnsi(formatStatusIcon("unresolved")); + expect(result).toBe("●"); + }); + + test("returns dash for ignored status", () => { + const result = stripAnsi(formatStatusIcon("ignored")); + expect(result).toBe("−"); + }); + + test("returns bullet for undefined status", () => { + const result = stripAnsi(formatStatusIcon(undefined)); + expect(result).toBe("●"); + }); + + test("returns bullet for unknown status", () => { + const result = stripAnsi(formatStatusIcon("unknown-status")); + expect(result).toBe("●"); + }); +}); + +describe("formatStatusLabel", () => { + test("returns full label for resolved status", () => { + const result = stripAnsi(formatStatusLabel("resolved")); + expect(result).toBe("✓ Resolved"); + }); + + test("returns full label for unresolved status", () => { + const result = stripAnsi(formatStatusLabel("unresolved")); + expect(result).toBe("● Unresolved"); + }); + + test("returns full label for ignored status", () => { + const result = stripAnsi(formatStatusLabel("ignored")); + expect(result).toBe("− Ignored"); + }); + + test("returns Unknown for undefined status", () => { + const result = stripAnsi(formatStatusLabel(undefined)); + expect(result).toBe("● Unknown"); + }); + + test("returns Unknown for unrecognized status", () => { + const result = stripAnsi(formatStatusLabel("something-else")); + expect(result).toBe("● Unknown"); + }); +}); + +// Relative Time Formatting + +describe("formatRelativeTime", () => { + test("returns em-dash for undefined input", () => { + const result = formatRelativeTime(undefined); + // Verify the content is an em-dash (with optional padding) + // Note: padEnd(10) only pads correctly when ANSI colors are disabled. + // With colors enabled, the ANSI-wrapped string is already >10 chars, + // so no padding is added. We only verify content, not length. + expect(stripAnsi(result).trim()).toBe("—"); + }); + + test("formats minutes ago for recent times", () => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const result = stripAnsi(formatRelativeTime(fiveMinutesAgo)); + expect(result.trim()).toMatch(/^\d+m ago$/); + }); + + test("formats hours ago for times within 24 hours", () => { + const threeHoursAgo = new Date( + Date.now() - 3 * 60 * 60 * 1000 + ).toISOString(); + const result = stripAnsi(formatRelativeTime(threeHoursAgo)); + expect(result.trim()).toMatch(/^\d+h ago$/); + }); + + test("formats days ago for times within 3 days", () => { + const twoDaysAgo = new Date( + Date.now() - 2 * 24 * 60 * 60 * 1000 + ).toISOString(); + const result = stripAnsi(formatRelativeTime(twoDaysAgo)); + expect(result.trim()).toMatch(/^\d+d ago$/); + }); + + test("formats short date for times older than 3 days", () => { + const tenDaysAgo = new Date( + Date.now() - 10 * 24 * 60 * 60 * 1000 + ).toISOString(); + const result = stripAnsi(formatRelativeTime(tenDaysAgo)); + // Should be like "Jan 18" or "Dec 5" + expect(result.trim()).toMatch(/^[A-Z][a-z]{2} \d{1,2}$/); + }); + + test("result contains valid relative time format", () => { + const now = new Date().toISOString(); + const result = stripAnsi(formatRelativeTime(now)); + // Should be "0m ago" or similar for very recent times + expect(result.trim()).toMatch(/^\d+[mhd] ago$|^[A-Z][a-z]{2} \d{1,2}$/); + }); + + test("clamps future timestamps to '0m ago' instead of negative", () => { + // Clock skew / scheduled timestamps can be in the future; diffMs is clamped + // to >= 0 so we never render "-5m ago". + const fiveMinutesAhead = new Date(Date.now() + 5 * 60_000).toISOString(); + const result = stripAnsi(formatRelativeTime(fiveMinutesAhead)); + expect(result.trim()).toBe("0m ago"); + expect(result).not.toContain("-"); + }); +}); + +// Token Masking + +describe("maskToken", () => { + test("masks short tokens completely", () => { + expect(maskToken("abc")).toBe("****"); + expect(maskToken("123456789012")).toBe("****"); // Exactly 12 chars + }); + + test("shows first 8 and last 4 chars for longer tokens", () => { + const token = "sntrys_1234567890abcdef"; + const result = maskToken(token); + expect(result).toBe("sntrys_1...cdef"); + }); + + test("property: masked token never reveals middle characters", async () => { + const longTokenArb = stringMatching(/^[a-zA-Z0-9_]{13,50}$/); + + await fcAssert( + property(longTokenArb, (token) => { + const masked = maskToken(token); + // Should show first 8, then ..., then last 4 + expect(masked).toBe( + `${token.substring(0, 8)}...${token.substring(token.length - 4)}` + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("property: short tokens are completely masked", async () => { + const shortTokenArb = stringMatching(/^[a-zA-Z0-9]{1,12}$/); + + await fcAssert( + property(shortTokenArb, (token) => { + expect(maskToken(token)).toBe("****"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Duration Formatting + +describe("formatDuration", () => { + test("formats singular minute", () => { + expect(formatDuration(60)).toBe("1 minute"); + }); + + test("formats plural minutes", () => { + expect(formatDuration(300)).toBe("5 minutes"); + }); + + test("formats singular hour", () => { + expect(formatDuration(3600)).toBe("1 hour"); + }); + + test("formats plural hours", () => { + expect(formatDuration(7200)).toBe("2 hours"); + }); + + test("formats hours and minutes combined", () => { + expect(formatDuration(5400)).toBe("1 hour and 30 minutes"); + }); + + test("formats multiple hours and singular minute", () => { + expect(formatDuration(7260)).toBe("2 hours and 1 minute"); + }); + + test("formats zero minutes", () => { + expect(formatDuration(0)).toBe("0 minutes"); + }); + + test("formats less than a minute as 0 minutes", () => { + expect(formatDuration(30)).toBe("0 minutes"); + }); + + test("formats singular day", () => { + expect(formatDuration(86_400)).toBe("1 day"); + }); + + test("formats plural days", () => { + expect(formatDuration(3 * 86_400)).toBe("3 days"); + }); + + test("formats days and hours combined", () => { + expect(formatDuration(86_400 + 4 * 3600)).toBe("1 day and 4 hours"); + }); + + test("formats days and singular hour", () => { + expect(formatDuration(2 * 86_400 + 3600)).toBe("2 days and 1 hour"); + }); + + test("formats singular week", () => { + expect(formatDuration(7 * 86_400)).toBe("1 week"); + }); + + test("formats plural weeks", () => { + expect(formatDuration(14 * 86_400)).toBe("2 weeks"); + }); + + test("formats weeks and days combined", () => { + expect(formatDuration(25 * 86_400)).toBe("3 weeks and 4 days"); + }); + + test("formats weeks and singular day", () => { + expect(formatDuration(8 * 86_400)).toBe("1 week and 1 day"); + }); + + test("formats ~580 hours as days", () => { + // 580 hours = 24 days and 4 hours → 3 weeks and 3 days + expect(formatDuration(580 * 3600)).toBe("3 weeks and 3 days"); + }); + + test("property: duration formatting always contains a time unit", async () => { + await fcAssert( + property(nat({ max: 86_400 * 60 }), (seconds) => { + const result = formatDuration(seconds); + expect(result).toMatch(/minute|hour|day|week/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Expiration Formatting + +describe("formatExpiration", () => { + test("returns Expired for past timestamps", () => { + const pastTime = Date.now() - 1000; + expect(formatExpiration(pastTime)).toBe("Expired"); + }); + + test("includes remaining time for future timestamps", () => { + // 2 hours from now + const futureTime = Date.now() + 2 * 60 * 60 * 1000; + const result = formatExpiration(futureTime); + expect(result).toContain("remaining"); + expect(result).toContain("hour"); + }); + + test("includes date string for future timestamps", () => { + const futureTime = Date.now() + 60 * 60 * 1000; // 1 hour from now + const result = formatExpiration(futureTime); + // Should include a date/time string + expect(result).toMatch(/\d/); + expect(result).toContain("("); + expect(result).toContain(")"); + }); + + test("property: expired times always return 'Expired'", async () => { + await fcAssert( + property(integer({ min: 1, max: 1_000_000 }), (msAgo) => { + const pastTime = Date.now() - msAgo; + expect(formatExpiration(pastTime)).toBe("Expired"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("property: future times always include 'remaining'", async () => { + await fcAssert( + property(integer({ min: 60_000, max: 86_400_000 }), (msAhead) => { + const futureTime = Date.now() + msAhead; + const result = formatExpiration(futureTime); + expect(result).toContain("remaining"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/json.property.test.ts b/packages/cli/test/lib/formatters/json.property.test.ts new file mode 100644 index 000000000..f7d9535a1 --- /dev/null +++ b/packages/cli/test/lib/formatters/json.property.test.ts @@ -0,0 +1,274 @@ +/** + * Property-based tests for JSON field filtering and parsing. + * + * Tests invariants of {@link filterFields} and {@link parseFieldsList} + * that should hold for any valid input. + */ + +import { + array, + constantFrom, + dictionary, + assert as fcAssert, + integer, + jsonValue, + oneof, + property, + string, + uniqueArray, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + filterFields, + parseFieldsList, +} from "../../../src/lib/formatters/json.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Generates simple field names (lowercase letters, 1-8 chars) */ +const fieldNameArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz".split("")), + { minLength: 1, maxLength: 8 } +).map((chars) => chars.join("")); + +/** Generates dot-notated paths (1-3 segments) */ +const fieldPathArb = array(fieldNameArb, { minLength: 1, maxLength: 3 }).map( + (segments) => segments.join(".") +); + +/** Generates a flat object with known string keys and JSON-compatible values */ +const flatObjectArb = dictionary(fieldNameArb, jsonValue(), { + minKeys: 0, + maxKeys: 10, +}); + +// --------------------------------------------------------------------------- +// filterFields properties +// --------------------------------------------------------------------------- + +describe("property: filterFields", () => { + test("identity: no fields means empty result", () => { + fcAssert( + property(flatObjectArb, (obj) => { + const result = filterFields(obj, []); + expect(result).toEqual({}); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("subset: result keys are a subset of requested fields", () => { + fcAssert( + property( + flatObjectArb, + uniqueArray(fieldNameArb, { minLength: 1, maxLength: 5 }), + (obj, fields) => { + const result = filterFields(obj, fields) as Record; + const resultKeys = Object.keys(result); + for (const key of resultKeys) { + expect(fields).toContain(key); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("existing fields are preserved exactly", () => { + fcAssert( + property(flatObjectArb, (obj) => { + const keys = Object.keys(obj); + if (keys.length === 0) { + return; + } + const result = filterFields(obj, keys) as Record; + for (const key of keys) { + expect(result[key]).toEqual(obj[key]); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("missing fields are silently ignored", () => { + fcAssert( + property( + flatObjectArb, + uniqueArray(fieldNameArb, { minLength: 1, maxLength: 5 }), + (obj, extraFields) => { + // Filter with fields that may or may not exist + const result = filterFields(obj, extraFields); + // Result should never have more keys than the source + expect( + Object.keys(result as Record).length + ).toBeLessThanOrEqual(Object.keys(obj).length); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent: filtering twice gives same result", () => { + fcAssert( + property( + flatObjectArb, + uniqueArray(fieldNameArb, { minLength: 1, maxLength: 5 }), + (obj, fields) => { + const once = filterFields(obj, fields); + const twice = filterFields(once, fields); + expect(twice).toEqual(once); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("arrays: each element is filtered independently", () => { + fcAssert( + property( + array(flatObjectArb, { minLength: 1, maxLength: 5 }), + uniqueArray(fieldNameArb, { minLength: 1, maxLength: 3 }), + (items, fields) => { + const result = filterFields(items, fields) as Record< + string, + unknown + >[]; + expect(result).toHaveLength(items.length); + for (let i = 0; i < items.length; i++) { + expect(result[i]).toEqual(filterFields(items[i], fields)); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("dot-notation: nested fields are extracted correctly", () => { + // Use a controlled nested object where all keys are simple field names + // (no dots or special chars) so dot-notation paths are unambiguous + const safeNestedArb = dictionary( + fieldNameArb, + dictionary(fieldNameArb, jsonValue(), { minKeys: 1, maxKeys: 5 }), + { minKeys: 1, maxKeys: 5 } + ); + + fcAssert( + property(safeNestedArb, (obj) => { + const topKeys = Object.keys(obj); + if (topKeys.length === 0) { + return; + } + const nestedKey = topKeys[0]; + if (!nestedKey) { + return; + } + const inner = obj[nestedKey] as Record; + const innerKeys = Object.keys(inner); + if (innerKeys.length === 0) { + return; + } + const innerKey = innerKeys[0]; + if (!innerKey) { + return; + } + const path = `${nestedKey}.${innerKey}`; + + const result = filterFields(obj, [path]) as Record< + string, + Record + >; + expect(result[nestedKey]?.[innerKey]).toEqual(inner[innerKey]); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("primitives pass through unchanged", () => { + fcAssert( + property( + oneof(string(), integer(), constantFrom(null, undefined, true, false)), + uniqueArray(fieldNameArb, { minLength: 1, maxLength: 3 }), + (value, fields) => { + const result = filterFields(value, fields); + expect(result).toBe(value); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// parseFieldsList properties +// --------------------------------------------------------------------------- + +describe("property: parseFieldsList", () => { + test("round-trip: joining with commas and re-parsing yields same set", () => { + fcAssert( + property( + uniqueArray(fieldPathArb, { minLength: 1, maxLength: 10 }), + (fields) => { + const joined = fields.join(","); + const parsed = parseFieldsList(joined); + expect(new Set(parsed)).toEqual(new Set(fields)); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("deduplication: result has no duplicates", () => { + fcAssert( + property( + array(fieldPathArb, { minLength: 1, maxLength: 10 }), + (fields) => { + const input = fields.join(","); + const parsed = parseFieldsList(input); + expect(parsed.length).toBe(new Set(parsed).size); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("whitespace tolerance: extra spaces don't affect result", () => { + fcAssert( + property( + uniqueArray(fieldPathArb, { minLength: 1, maxLength: 5 }), + (fields) => { + const withSpaces = fields.map((f) => ` ${f} `).join(" , "); + const clean = fields.join(","); + expect(parseFieldsList(withSpaces)).toEqual(parseFieldsList(clean)); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty segments are filtered out", () => { + fcAssert( + property( + uniqueArray(fieldPathArb, { minLength: 1, maxLength: 5 }), + (fields) => { + const withEmpty = `,${fields.join(",,")},`; + const parsed = parseFieldsList(withEmpty); + expect(parsed).toEqual(parseFieldsList(fields.join(","))); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("all-commas yields empty list", () => { + fcAssert( + property(integer({ min: 1, max: 10 }), (count) => { + const input = ",".repeat(count); + expect(parseFieldsList(input)).toEqual([]); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/json.test.ts b/packages/cli/test/lib/formatters/json.test.ts new file mode 100644 index 000000000..63bd21c3b --- /dev/null +++ b/packages/cli/test/lib/formatters/json.test.ts @@ -0,0 +1,410 @@ +/** + * Unit tests for JSON formatting and field filtering. + * + * Note: Core invariants (idempotency, subset property, missing field handling, + * primitive pass-through, array element filtering, deduplication, whitespace + * tolerance) are tested via property-based tests in json.property.test.ts. + * These tests focus on specific output documentation, edge cases, and the + * writeJson/writeJsonList/formatJson APIs not covered by property tests. + */ + +import { describe, expect, test } from "vitest"; +import { + filterFields, + formatJson, + parseFieldsList, + writeJson, + writeJsonList, +} from "../../../src/lib/formatters/json.js"; + +/** + * Helper: cast to Record so filterFields calls don't fight + * TypeScript's strict `Partial` return type on `toEqual`. + */ +function filter(data: unknown, fields: string[]): unknown { + return filterFields(data, fields); +} + +describe("filterFields edge cases", () => { + test("picks nested fields via dot-notation", () => { + const data = { + id: 1, + metadata: { type: "error", value: "ReferenceError" }, + contexts: { trace: { traceId: "abc" } }, + }; + expect( + filter(data, ["id", "metadata.value", "contexts.trace.traceId"]) + ).toEqual({ + id: 1, + metadata: { value: "ReferenceError" }, + contexts: { trace: { traceId: "abc" } }, + }); + }); + + test("preserves null values", () => { + expect(filter({ id: 1, value: null }, ["id", "value"])).toEqual({ + id: 1, + value: null, + }); + }); + + test("preserves undefined values when key exists", () => { + expect(filter({ id: 1, value: undefined }, ["id", "value"])).toEqual({ + id: 1, + value: undefined, + }); + }); + + test("stops at null intermediate in dot-path", () => { + expect(filter({ a: { b: null } }, ["a.b.c"])).toEqual({}); + }); + + test("stops at primitive intermediate in dot-path", () => { + expect(filter({ a: { b: 42 } }, ["a.b.c"])).toEqual({}); + }); + + test("merges nested paths into shared parents", () => { + expect( + filter({ user: { name: "Alice", email: "alice@example.com", age: 30 } }, [ + "user.name", + "user.email", + ]) + ).toEqual({ + user: { name: "Alice", email: "alice@example.com" }, + }); + }); + + test("handles boolean values in objects", () => { + expect( + filter({ active: true, deleted: false, name: "test" }, [ + "active", + "deleted", + ]) + ).toEqual({ + active: true, + deleted: false, + }); + }); + + test("handles nested array values", () => { + expect( + filter({ id: 1, tags: ["bug", "critical"], title: "test" }, [ + "id", + "tags", + ]) + ).toEqual({ + id: 1, + tags: ["bug", "critical"], + }); + }); +}); + +describe("parseFieldsList", () => { + test("parses comma-separated fields", () => { + expect(parseFieldsList("id,title,status")).toEqual([ + "id", + "title", + "status", + ]); + }); + + test("trims whitespace around fields", () => { + expect(parseFieldsList(" id , title , status ")).toEqual([ + "id", + "title", + "status", + ]); + }); + + test("handles dot-notation fields", () => { + expect(parseFieldsList("id,metadata.value,contexts.trace.traceId")).toEqual( + ["id", "metadata.value", "contexts.trace.traceId"] + ); + }); + + test("handles single field", () => { + expect(parseFieldsList("id")).toEqual(["id"]); + }); + + test("returns empty array for empty string", () => { + expect(parseFieldsList("")).toEqual([]); + }); +}); + +describe("writeJson with fields", () => { + function capture(): { + writer: { write: (s: string) => void }; + output: () => string; + } { + let buf = ""; + return { + writer: { + write: (s: string) => { + buf += s; + }, + }, + output: () => buf, + }; + } + + test("without fields: outputs full object", () => { + const { writer, output } = capture(); + const data = { id: 1, title: "bug", status: "open" }; + writeJson(writer, data); + expect(JSON.parse(output())).toEqual(data); + }); + + test("with fields: outputs filtered object", () => { + const { writer, output } = capture(); + writeJson(writer, { id: 1, title: "bug", status: "open", extra: true }, [ + "id", + "title", + ]); + expect(JSON.parse(output())).toEqual({ id: 1, title: "bug" }); + }); + + test("with empty fields array: outputs full object", () => { + const { writer, output } = capture(); + const data = { id: 1, title: "bug" }; + writeJson(writer, data, []); + expect(JSON.parse(output())).toEqual(data); + }); + + test("with undefined fields: outputs full object", () => { + const { writer, output } = capture(); + const data = { id: 1, title: "bug" }; + writeJson(writer, data, undefined); + expect(JSON.parse(output())).toEqual(data); + }); + + test("with dot-notation fields: outputs nested subset", () => { + const { writer, output } = capture(); + writeJson( + writer, + { + issue: { id: 1, title: "bug" }, + event: { id: "abc", contexts: { trace: { traceId: "def" } } }, + }, + ["issue.id", "event.contexts.trace.traceId"] + ); + expect(JSON.parse(output())).toEqual({ + issue: { id: 1 }, + event: { contexts: { trace: { traceId: "def" } } }, + }); + }); + + test("with array data: filters each element", () => { + const { writer, output } = capture(); + writeJson( + writer, + [ + { id: 1, title: "first", extra: true }, + { id: 2, title: "second", extra: false }, + ], + ["id", "title"] + ); + expect(JSON.parse(output())).toEqual([ + { id: 1, title: "first" }, + { id: 2, title: "second" }, + ]); + }); +}); + +describe("formatJson", () => { + test("pretty-prints with 2-space indentation", () => { + expect(formatJson({ a: 1 })).toBe('{\n "a": 1\n}'); + }); + + test("handles null", () => { + expect(formatJson(null)).toBe("null"); + }); + + test("handles arrays", () => { + expect(formatJson([1, 2])).toBe("[\n 1,\n 2\n]"); + }); +}); + +describe("writeJsonList", () => { + function capture(): { + writer: { write: (s: string) => void }; + output: () => string; + parsed: () => unknown; + } { + let buf = ""; + return { + writer: { + write: (s: string) => { + buf += s; + }, + }, + output: () => buf, + parsed: () => JSON.parse(buf), + }; + } + + test("wraps items in {data, hasMore} envelope", () => { + const { writer, parsed } = capture(); + const items = [{ id: 1 }, { id: 2 }]; + writeJsonList(writer, items, { hasMore: false }); + expect(parsed()).toEqual({ data: [{ id: 1 }, { id: 2 }], hasMore: false }); + }); + + test("includes nextCursor when provided", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1 }], { + hasMore: true, + nextCursor: "abc123", + }); + const result = parsed() as Record; + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe("abc123"); + }); + + test("omits nextCursor when null", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1 }], { + hasMore: false, + nextCursor: null, + }); + const result = parsed() as Record; + expect(result.nextCursor).toBeUndefined(); + }); + + test("omits nextCursor when undefined", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1 }], { hasMore: false }); + const result = parsed() as Record; + expect(result.nextCursor).toBeUndefined(); + }); + + test("omits nextCursor when empty string", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1 }], { + hasMore: false, + nextCursor: "", + }); + const result = parsed() as Record; + expect(result.nextCursor).toBeUndefined(); + }); + + test("includes errors when non-empty", () => { + const { writer, parsed } = capture(); + const errors = [{ message: "org failed" }]; + writeJsonList(writer, [{ id: 1 }], { hasMore: false, errors }); + const result = parsed() as Record; + expect(result.errors).toEqual(errors); + }); + + test("omits errors when empty array", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1 }], { hasMore: false, errors: [] }); + const result = parsed() as Record; + expect(result.errors).toBeUndefined(); + }); + + test("omits errors when undefined", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1 }], { hasMore: false }); + const result = parsed() as Record; + expect(result.errors).toBeUndefined(); + }); + + test("handles empty items array", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [], { hasMore: false }); + expect(parsed()).toEqual({ data: [], hasMore: false }); + }); + + test("filters each array element when fields provided", () => { + const { writer, parsed } = capture(); + const items = [ + { id: 1, title: "Bug", status: "open", extra: true }, + { id: 2, title: "Feature", status: "closed", extra: false }, + ]; + writeJsonList(writer, items, { + hasMore: true, + fields: ["id", "title"], + }); + const result = parsed() as Record; + expect(result.data).toEqual([ + { id: 1, title: "Bug" }, + { id: 2, title: "Feature" }, + ]); + expect(result.hasMore).toBe(true); + }); + + test("does not filter wrapper metadata keys", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1, title: "Bug" }], { + hasMore: true, + nextCursor: "xyz", + fields: ["id"], + }); + const result = parsed() as Record; + expect(result.data).toEqual([{ id: 1 }]); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe("xyz"); + }); + + test("with empty fields array: outputs full items (no filtering)", () => { + const { writer, parsed } = capture(); + const items = [{ id: 1, title: "Bug", extra: true }]; + writeJsonList(writer, items, { hasMore: false, fields: [] }); + const result = parsed() as Record; + expect(result.data).toEqual(items); + }); + + test("with undefined fields: outputs full items (no filtering)", () => { + const { writer, parsed } = capture(); + const items = [{ id: 1, title: "Bug", extra: true }]; + writeJsonList(writer, items, { hasMore: false, fields: undefined }); + const result = parsed() as Record; + expect(result.data).toEqual(items); + }); + + test("supports dot-notation field filtering on nested items", () => { + const { writer, parsed } = capture(); + const items = [ + { id: 1, metadata: { type: "error", value: "ReferenceError" } }, + { id: 2, metadata: { type: "warning", value: "DeprecationWarning" } }, + ]; + writeJsonList(writer, items, { + hasMore: false, + fields: ["id", "metadata.value"], + }); + const result = parsed() as Record; + expect(result.data).toEqual([ + { id: 1, metadata: { value: "ReferenceError" } }, + { id: 2, metadata: { value: "DeprecationWarning" } }, + ]); + }); + + test("includes extra metadata in wrapper", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1 }], { + hasMore: true, + extra: { hint: "sentry project list my-org/ --json" }, + }); + const result = parsed() as Record; + expect(result.hint).toBe("sentry project list my-org/ --json"); + expect(result.hasMore).toBe(true); + }); + + test("extra metadata does not interfere with fields filtering", () => { + const { writer, parsed } = capture(); + writeJsonList(writer, [{ id: 1, title: "Bug", extra: true }], { + hasMore: true, + fields: ["id"], + extra: { hint: "use --cursor" }, + }); + const result = parsed() as Record; + expect(result.data).toEqual([{ id: 1 }]); + expect(result.hint).toBe("use --cursor"); + }); + + test("output ends with newline", () => { + const { writer, output } = capture(); + writeJsonList(writer, [{ id: 1 }], { hasMore: false }); + expect(output().endsWith("\n")).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/formatters/local.property.test.ts b/packages/cli/test/lib/formatters/local.property.test.ts new file mode 100644 index 000000000..158e35769 --- /dev/null +++ b/packages/cli/test/lib/formatters/local.property.test.ts @@ -0,0 +1,187 @@ +/** + * Property-Based Tests for Local Dev Server Formatters + * + * Uses fast-check to verify invariants that should hold for any valid input. + */ + +import { + constantFrom, + double, + assert as fcAssert, + integer, + oneof, + option, + property, + string, + stringMatching, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import type { FilterValue } from "../../../src/lib/formatters/local.js"; +import { + FILTER_VALUES, + formatTime, + isItemIncluded, + itemTypeToFilterCategory, + sanitize, +} from "../../../src/lib/formatters/local.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** ANSI escape pattern — should not appear in sanitize output. */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars +const ANSI_RE = /\x1b\[[0-9;]*m/; + +describe("property: sanitize", () => { + test("output never contains ANSI escapes", () => { + fcAssert( + property(string(), (input) => { + const result = sanitize(input); + expect(ANSI_RE.test(result)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output never contains newlines", () => { + fcAssert( + property(string(), (input) => { + const result = sanitize(input); + expect(result).not.toContain("\n"); + expect(result).not.toContain("\r"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent: sanitize(sanitize(x)) === sanitize(x)", () => { + fcAssert( + property(string(), (input) => { + const once = sanitize(input); + const twice = sanitize(once); + expect(twice).toBe(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: formatTime", () => { + test("never throws for any number input", () => { + fcAssert( + property(double({ noNaN: false }), (n) => { + const result = formatTime(n); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("never throws for any string input", () => { + fcAssert( + property(string(), (s) => { + const result = formatTime(s); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("falsy number inputs (0, NaN) fall through to current time", () => { + // NaN and 0 are falsy, so !timestamp is true → uses new Date() + const result = formatTime(Number.NaN); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + const result0 = formatTime(0); + expect(result0).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + test("valid epoch seconds produce HH:MM:SS format", () => { + fcAssert( + property(integer({ min: 0, max: 4_102_444_800 }), (epoch) => { + const result = formatTime(epoch); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: isItemIncluded", () => { + test("empty filter set always returns true", () => { + const itemTypes = oneof( + constantFrom( + "error", + "event", + "transaction", + "log", + "attachment", + "session" + ), + option(string(), { nil: undefined }) + ); + fcAssert( + property(itemTypes, (itemType) => { + const empty = new Set(); + expect(isItemIncluded(itemType as string | undefined, empty)).toBe( + true + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("known filter categories are self-consistent with itemTypeToFilterCategory", () => { + const knownTypes = constantFrom("error", "event", "transaction", "log"); + const filterArb = constantFrom(...FILTER_VALUES); + fcAssert( + property(knownTypes, filterArb, (itemType, filter) => { + const filters = new Set([filter]); + const category = itemTypeToFilterCategory(itemType); + const included = isItemIncluded(itemType, filters); + if (category === filter) { + expect(included).toBe(true); + } else { + expect(included).toBe(false); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: itemTypeToFilterCategory", () => { + test("error types map to error", () => { + const errorTypes = constantFrom("error", "event"); + fcAssert( + property(errorTypes, (itemType) => { + expect(itemTypeToFilterCategory(itemType)).toBe("error"); + }), + { numRuns: 10 } + ); + }); + + test("transaction maps to transaction", () => { + expect(itemTypeToFilterCategory("transaction")).toBe("transaction"); + }); + + test("log maps to log", () => { + expect(itemTypeToFilterCategory("log")).toBe("log"); + }); + + test("random non-matching strings return undefined", () => { + const nonMatching = stringMatching(/^[a-z]{3,10}$/).filter( + (s) => + s !== "error" && s !== "event" && s !== "transaction" && s !== "log" + ); + fcAssert( + property(nonMatching, (itemType) => { + expect(itemTypeToFilterCategory(itemType)).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("undefined input returns undefined", () => { + expect(itemTypeToFilterCategory(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/formatters/local.test.ts b/packages/cli/test/lib/formatters/local.test.ts new file mode 100644 index 000000000..530b92b46 --- /dev/null +++ b/packages/cli/test/lib/formatters/local.test.ts @@ -0,0 +1,1254 @@ +/** + * Unit tests for local dev server formatters. + * + * Note: Core invariants (never-throw, sanitization round-trips, filter + * emptiness) are tested via property-based tests in local.property.test.ts. + * These tests focus on specific output formatting and edge cases. + */ + +import { describe, expect, test } from "vitest"; +import type { FilterValue } from "../../../src/lib/formatters/local.js"; +import { + extractTraceId, + formatAttributeTable, + formatErrorItem, + formatItem, + formatItemJson, + formatSingleLog, + formatSingleSpan, + formatSpanItem, + formatTime, + formatTransactionItem, + inferSource, + isItemIncluded, + itemTypeToFilterCategory, +} from "../../../src/lib/formatters/local.js"; +import { stripAnsi } from "../../../src/lib/formatters/plain-detect.js"; + +describe("formatTime", () => { + test("formats epoch seconds", () => { + const result = formatTime(1_700_000_000); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + test("formats ISO string", () => { + const result = formatTime("2024-01-15T12:30:45Z"); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + test("returns current time for NaN (NaN is falsy)", () => { + // NaN is falsy, so !timestamp is true → uses new Date() + const result = formatTime(Number.NaN); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + test("returns ??:??:?? for unparseable string", () => { + expect(formatTime("not-a-date")).toBe("??:??:??"); + }); + + test("returns current time when missing", () => { + const result = formatTime(); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + test("returns current time for undefined", () => { + const result = formatTime(undefined); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); +}); + +describe("formatErrorItem", () => { + const serverHeader = { sdk: { name: "sentry.node" } }; + + test("formats error with exception values", () => { + const event = { + timestamp: 1_700_000_000, + exception: { + values: [{ type: "TypeError", value: "x is not a function" }], + }, + }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).toContain("[ERROR]"); + expect(result).toContain("TypeError: x is not a function"); + }); + + test("falls back to message when no exception", () => { + const event = { + timestamp: 1_700_000_000, + message: "Something went wrong", + }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).toContain("Error: Something went wrong"); + }); + + test("falls back to Unknown error when no exception or message", () => { + const event = { timestamp: 1_700_000_000 }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).toContain("Error: Unknown error"); + }); + + test("includes stack frame hint for in_app frame", () => { + const event = { + timestamp: 1_700_000_000, + exception: { + values: [ + { + type: "Error", + value: "boom", + stacktrace: { + frames: [ + { filename: "node_modules/lib.js", lineno: 10, in_app: false }, + { + filename: "src/handler.ts", + lineno: 42, + colno: 5, + function: "handleRequest", + in_app: true, + }, + ], + }, + }, + ], + }, + }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).toContain("[src/handler.ts:42:5]"); + expect(result).toContain("[handleRequest]"); + }); + + test("prefers in_app frame over last frame", () => { + const event = { + timestamp: 1_700_000_000, + exception: { + values: [ + { + type: "Error", + value: "boom", + stacktrace: { + frames: [ + { filename: "src/app.ts", lineno: 10, in_app: true }, + { filename: "node_modules/lib.js", lineno: 99, in_app: false }, + ], + }, + }, + ], + }, + }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).toContain("[src/app.ts:10]"); + }); + + test("surfaces a short trace ID when present", () => { + const event = { + timestamp: 1_700_000_000, + message: "boom", + contexts: { trace: { trace_id: "1a2b3c4d5e6f70819a2b3c4d5e6f7081" } }, + }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).toContain("[trace:1a2b3c4d]"); + }); + + test("omits the trace token when no trace ID", () => { + const event = { timestamp: 1_700_000_000, message: "boom" }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).not.toContain("[trace:"); + }); +}); + +describe("formatTransactionItem", () => { + const browserHeader = { sdk: { name: "sentry.javascript.browser" } }; + + test("formats transaction with op", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /api/users", + contexts: { trace: { op: "http.client", status: "ok" } }, + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).toContain("[TRACE]"); + expect(result).toContain("[http.client]"); + expect(result).toContain("GET /api/users"); + expect(result).toContain("[1000ms]"); + }); + + test("omits op when default", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "my-txn", + contexts: { trace: { op: "default" } }, + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).not.toContain("[default]"); + expect(result).toContain("my-txn"); + }); + + test("shows non-ok status", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "POST /api", + contexts: { trace: { op: "http.server", status: "internal_error" } }, + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).toContain("[internal_error]"); + }); + + test("shows span count", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "my-txn", + contexts: { trace: { op: "http.server" } }, + spans: [{}, {}, {}], + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).toContain("[3 spans]"); + }); + + test("uses singular span for count of 1", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "my-txn", + contexts: { trace: { op: "http.server" } }, + spans: [{}], + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).toContain("[1 span]"); + }); + + test("falls back to Transaction when no transaction name", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + contexts: { trace: {} }, + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).toContain("Transaction"); + }); + + test("surfaces a short trace ID when present", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /", + contexts: { + trace: { + op: "http.server", + trace_id: "deadbeefcafebabe0123456789abcdef", + }, + }, + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).toContain("[trace:deadbeef]"); + }); + + describe("semantic display from OTel attributes", () => { + const serverHeader = { sdk: { name: "sentry.python" } }; + + test("renders GenAI operation with model from trace data", () => { + const event = { + timestamp: 1_700_000_002, + start_timestamp: 1_700_000_000, + transaction: "process_user_request", + contexts: { + trace: { + op: "ai.pipeline", + data: { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "claude-4-sonnet", + "gen_ai.provider.name": "anthropic", + }, + }, + }, + spans: [{}, {}, {}, {}, {}], + }; + const result = stripAnsi(formatTransactionItem(event, serverHeader)); + expect(result).toContain("[gen_ai]"); + expect(result).toContain("chat anthropic/claude-4-sonnet"); + expect(result).toContain("[2000ms]"); + expect(result).toContain("[5 spans]"); + }); + + test("renders MCP tool call from trace data", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "mcp-request", + contexts: { + trace: { + op: "http.client", + data: { + "mcp.method.name": "tools/call", + "gen_ai.tool.name": "search_files", + }, + }, + }, + }; + const result = stripAnsi(formatTransactionItem(event, serverHeader)); + expect(result).toContain("[mcp]"); + expect(result).toContain("tools/call search_files"); + }); + + test("renders HTTP with server address from OTel attributes", () => { + const event = { + timestamp: 1_700_000_002, + start_timestamp: 1_700_000_000, + transaction: "POST", + contexts: { + trace: { + op: "http.client", + data: { + "http.request.method": "POST", + "server.address": "api.anthropic.com", + "http.response.status_code": "200", + }, + }, + }, + }; + const result = stripAnsi(formatTransactionItem(event, serverHeader)); + expect(result).toContain("[http.client]"); + expect(result).toContain("POST api.anthropic.com"); + expect(result).toContain("[200]"); + }); + + test("renders database query from OTel attributes", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "db-query", + contexts: { + trace: { + op: "db", + data: { + "db.system.name": "postgresql", + "db.query.summary": "SELECT users", + }, + }, + }, + }; + const result = stripAnsi(formatTransactionItem(event, serverHeader)); + expect(result).toContain("[db]"); + expect(result).toContain("SELECT users"); + expect(result).toContain("[postgresql]"); + }); + + test("falls back to transaction name when no semantic attributes", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /api/users", + contexts: { + trace: { + op: "http.server", + data: {}, + }, + }, + }; + const result = stripAnsi(formatTransactionItem(event, serverHeader)); + expect(result).toContain("[http.server]"); + expect(result).toContain("GET /api/users"); + }); + + test("renders GenAI error with error type metadata", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "ai-chat", + contexts: { + trace: { + op: "ai.pipeline", + data: { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "gpt-4o", + "error.type": "RateLimitError", + }, + }, + }, + }; + const result = stripAnsi(formatTransactionItem(event, serverHeader)); + expect(result).toContain("[gen_ai]"); + expect(result).toContain("chat gpt-4o"); + expect(result).toContain("[RateLimitError]"); + }); + }); +}); + +describe("formatSingleLog", () => { + test("formats log with body", () => { + const result = stripAnsi( + formatSingleLog( + { level: "info", body: "User logged in", timestamp: 1_700_000_000 }, + "[SERVER] " + ) + ); + expect(result).toContain("[INFO]"); + expect(result).toContain("User logged in"); + }); + + test("filters sentry.* attributes", () => { + const result = stripAnsi( + formatSingleLog( + { + level: "info", + body: "hello", + attributes: { + "sentry.sdk.name": { value: "node" }, + user_id: { value: 42 }, + }, + }, + "[SERVER] " + ) + ); + expect(result).toContain("[user_id=42]"); + expect(result).not.toContain("sentry.sdk.name"); + }); + + test("formats without body", () => { + const result = stripAnsi(formatSingleLog({ level: "debug" }, "[SERVER] ")); + expect(result).toContain("[DEBUG]"); + }); + + test("defaults level to log when missing", () => { + const result = stripAnsi(formatSingleLog({}, "[SERVER] ")); + expect(result).toContain("[LOG]"); + }); + + test("renders user attributes in alphabetical order", () => { + const result = stripAnsi( + formatSingleLog( + { + level: "info", + body: "hello", + attributes: { + zeta: { value: 1 }, + alpha: { value: 2 }, + mid: { value: 3 }, + }, + }, + "[SERVER] " + ) + ); + const alphaIdx = result.indexOf("[alpha=2]"); + const midIdx = result.indexOf("[mid=3]"); + const zetaIdx = result.indexOf("[zeta=1]"); + expect(alphaIdx).toBeGreaterThan(-1); + expect(alphaIdx).toBeLessThan(midIdx); + expect(midIdx).toBeLessThan(zetaIdx); + }); + + test("surfaces trace ID from sentry.trace.trace_id attribute", () => { + const result = stripAnsi( + formatSingleLog( + { + level: "info", + body: "hello", + attributes: { + "sentry.trace.trace_id": { + value: "abcdef0123456789abcdef0123456789", + }, + }, + }, + "[SERVER] " + ) + ); + expect(result).toContain("[trace:abcdef01]"); + expect(result).not.toContain("sentry.trace.trace_id"); + }); +}); + +describe("formatItem", () => { + const header = { sdk: { name: "sentry.node" } }; + + test("dispatches error type to error formatter", () => { + const event = { timestamp: 1_700_000_000, message: "boom" }; + const lines = formatItem("error", event, header, "fallback"); + expect(lines).toHaveLength(1); + expect(stripAnsi(lines[0])).toContain("[ERROR]"); + }); + + test("dispatches event type to error formatter", () => { + const event = { timestamp: 1_700_000_000, message: "boom" }; + const lines = formatItem("event", event, header, "fallback"); + expect(lines).toHaveLength(1); + expect(stripAnsi(lines[0])).toContain("[ERROR]"); + }); + + test("dispatches transaction type to transaction formatter", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /", + contexts: { trace: { op: "http.server" } }, + }; + const lines = formatItem("transaction", event, header, "fallback"); + expect(lines).toHaveLength(1); + expect(stripAnsi(lines[0])).toContain("[TRACE]"); + }); + + test("dispatches log type to log formatter", () => { + const event = { + items: [{ level: "info", body: "hello" }], + }; + const lines = formatItem("log", event, header, "fallback"); + expect(lines).toHaveLength(1); + expect(stripAnsi(lines[0])).toContain("[INFO]"); + }); + + test("falls back for unknown types", () => { + const lines = formatItem("attachment", {}, header, "attachment"); + expect(lines).toHaveLength(1); + expect(stripAnsi(lines[0])).toContain("attachment"); + }); + + test("falls back for undefined type", () => { + const lines = formatItem(undefined, {}, header, "unknown"); + expect(lines).toHaveLength(1); + expect(stripAnsi(lines[0])).toContain("unknown"); + }); +}); + +describe("isItemIncluded", () => { + test("empty filters includes everything", () => { + const empty = new Set(); + expect(isItemIncluded("error", empty)).toBe(true); + expect(isItemIncluded("transaction", empty)).toBe(true); + expect(isItemIncluded("log", empty)).toBe(true); + expect(isItemIncluded("attachment", empty)).toBe(true); + expect(isItemIncluded(undefined, empty)).toBe(true); + }); + + test("error filter matches error and event types", () => { + const filters = new Set(["error"]); + expect(isItemIncluded("error", filters)).toBe(true); + expect(isItemIncluded("event", filters)).toBe(true); + expect(isItemIncluded("transaction", filters)).toBe(false); + expect(isItemIncluded("log", filters)).toBe(false); + }); + + test("transaction filter matches only transaction", () => { + const filters = new Set(["transaction"]); + expect(isItemIncluded("transaction", filters)).toBe(true); + expect(isItemIncluded("error", filters)).toBe(false); + }); + + test("log filter matches only log", () => { + const filters = new Set(["log"]); + expect(isItemIncluded("log", filters)).toBe(true); + expect(isItemIncluded("error", filters)).toBe(false); + }); + + test("non-matching item type excluded with active filters", () => { + const filters = new Set(["error"]); + expect(isItemIncluded("attachment", filters)).toBe(false); + expect(isItemIncluded(undefined, filters)).toBe(false); + }); + + describe("ai filter", () => { + const ai = new Set(["ai"]); + + test("matches transaction with gen_ai attributes on the trace root", () => { + const payload = { + contexts: { + trace: { + op: "gen_ai.chat", + data: { "gen_ai.operation.name": "chat" }, + }, + }, + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches Vercel AI transaction with gen_ai on a child span", () => { + // The /api/ai/chat HTTP handler is the transaction root; the GenAI + // generation lives on a child span, where gen_ai.* attributes are set. + const payload = { + transaction: "POST /api/ai/chat", + contexts: { trace: { op: "http.server" } }, + spans: [ + { op: "http.server", data: { "http.request.method": "POST" } }, + { + op: "gen_ai.generate_text", + data: { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "gpt-4o", + }, + }, + ], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches transaction with mcp attributes on a child span", () => { + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "mcp.method.name": "tools/call" } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches a child span carrying only gen_ai.request.model", () => { + // No gen_ai.operation.name/tool/agent key, so inferSemanticOp would not + // return "gen_ai" — prefix-based detection is required to catch this. + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "gen_ai.request.model": "gpt-4o" } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches a child span carrying only gen_ai.usage.input_tokens", () => { + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "gen_ai.usage.input_tokens": 1234 } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches a trace root carrying only gen_ai.provider.name", () => { + const payload = { + contexts: { + trace: { + op: "http.server", + data: { "gen_ai.provider.name": "anthropic" }, + }, + }, + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("excludes a plain HTTP transaction with no AI spans", () => { + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "http.request.method": "GET" } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(false); + }); + + test("excludes errors and logs", () => { + expect(isItemIncluded("error", ai, {})).toBe(false); + expect(isItemIncluded("log", ai, {})).toBe(false); + }); + }); +}); + +describe("extractTraceId", () => { + test("returns the lowercase trace ID from contexts.trace", () => { + const event = { + contexts: { trace: { trace_id: "ABCDEF0123456789ABCDEF0123456789" } }, + }; + expect(extractTraceId(event)).toBe("abcdef0123456789abcdef0123456789"); + }); + + test("returns undefined for a malformed trace ID", () => { + expect( + extractTraceId({ contexts: { trace: { trace_id: "nope" } } }) + ).toBeUndefined(); + }); + + test("returns undefined when absent", () => { + expect(extractTraceId({})).toBeUndefined(); + expect(extractTraceId({ contexts: {} })).toBeUndefined(); + }); +}); + +describe("itemTypeToFilterCategory", () => { + test("maps error types to error", () => { + expect(itemTypeToFilterCategory("error")).toBe("error"); + expect(itemTypeToFilterCategory("event")).toBe("error"); + }); + + test("maps transaction to transaction", () => { + expect(itemTypeToFilterCategory("transaction")).toBe("transaction"); + }); + + test("maps log to log", () => { + expect(itemTypeToFilterCategory("log")).toBe("log"); + }); + + test("returns undefined for unknown types", () => { + expect(itemTypeToFilterCategory("attachment")).toBeUndefined(); + expect(itemTypeToFilterCategory("session")).toBeUndefined(); + expect(itemTypeToFilterCategory(undefined)).toBeUndefined(); + }); +}); + +describe("inferSource", () => { + test("detects mobile SDK (cocoa)", () => { + const result = stripAnsi(inferSource({ sdk: { name: "sentry.cocoa" } })); + expect(result).toContain("[MOBILE]"); + }); + + test("detects mobile SDK (android)", () => { + const result = stripAnsi(inferSource({ sdk: { name: "sentry.android" } })); + expect(result).toContain("[MOBILE]"); + }); + + test("detects mobile SDK (react-native)", () => { + const result = stripAnsi( + inferSource({ sdk: { name: "sentry.javascript.react-native" } }) + ); + expect(result).toContain("[MOBILE]"); + }); + + test("detects mobile SDK (flutter)", () => { + const result = stripAnsi( + inferSource({ sdk: { name: "sentry.dart.flutter" } }) + ); + expect(result).toContain("[MOBILE]"); + }); + + test("detects browser SDK", () => { + const result = stripAnsi( + inferSource({ sdk: { name: "sentry.javascript.browser" } }) + ); + expect(result).toContain("[BROWSER]"); + }); + + test("detects server JS SDK (node)", () => { + const result = stripAnsi( + inferSource({ sdk: { name: "sentry.javascript.node" } }) + ); + expect(result).toContain("[SERVER]"); + }); + + test("detects server JS SDK (nextjs)", () => { + const result = stripAnsi( + inferSource({ sdk: { name: "sentry.javascript.nextjs" } }) + ); + expect(result).toContain("[SERVER]"); + }); + + test("defaults to server for unknown SDK", () => { + const result = stripAnsi(inferSource({ sdk: { name: "sentry.python" } })); + expect(result).toContain("[SERVER]"); + }); + + test("defaults to server when no SDK", () => { + const result = stripAnsi(inferSource({})); + expect(result).toContain("[SERVER]"); + }); +}); + +describe("formatItemJson", () => { + const serverHeader = { sdk: { name: "sentry.node" } }; + const browserHeader = { sdk: { name: "sentry.javascript.browser" } }; + + test("formats error with exception and stack frame", () => { + const event = { + timestamp: 1_700_000_000, + exception: { + values: [ + { + type: "TypeError", + value: "x is not a function", + stacktrace: { + frames: [ + { + filename: "src/handler.ts", + lineno: 42, + colno: 5, + function: "handleRequest", + in_app: true, + }, + ], + }, + }, + ], + }, + }; + const lines = formatItemJson("error", event, serverHeader); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]); + expect(parsed.type).toBe("error"); + expect(parsed.error_type).toBe("TypeError"); + expect(parsed.message).toBe("x is not a function"); + expect(parsed.filename).toBe("src/handler.ts"); + expect(parsed.lineno).toBe(42); + expect(parsed.colno).toBe(5); + expect(parsed.function).toBe("handleRequest"); + expect(parsed.source).toBe("server"); + }); + + test("adds the versioned observation schema to JSON records", () => { + const lines = formatItemJson( + "error", + { timestamp: 1_700_000_000, message: "boom" }, + serverHeader + ); + + expect(JSON.parse(lines[0]).schema_version).toBe(1); + }); + + test("preserves event and envelope identities for agent correlation", () => { + const lines = formatItemJson( + "error", + { + event_id: "event-123", + timestamp: 1_700_000_000, + message: "boom", + }, + { + ...serverHeader, + // Spotlight stores its internal envelope identity as a UUID object. + __spotlight_envelope_id: { toString: () => "envelope-123" }, + } + ); + + expect(JSON.parse(lines[0])).toMatchObject({ + event_id: "event-123", + envelope_id: "envelope-123", + }); + }); + + test("formats error without stack frame", () => { + const event = { + timestamp: 1_700_000_000, + message: "Something went wrong", + }; + const lines = formatItemJson("error", event, serverHeader); + const parsed = JSON.parse(lines[0]); + expect(parsed.error_type).toBe("Error"); + expect(parsed.message).toBe("Something went wrong"); + expect(parsed.filename).toBeUndefined(); + }); + + test("formats event type as error", () => { + const event = { timestamp: 1_700_000_000, message: "boom" }; + const lines = formatItemJson("event", event, serverHeader); + const parsed = JSON.parse(lines[0]); + expect(parsed.type).toBe("error"); + }); + + test("includes trace_id for errors and transactions", () => { + const traceId = "1a2b3c4d5e6f70819a2b3c4d5e6f7081"; + const errorLines = formatItemJson( + "error", + { + timestamp: 1_700_000_000, + message: "boom", + contexts: { trace: { trace_id: traceId } }, + }, + serverHeader + ); + expect(JSON.parse(errorLines[0]).trace_id).toBe(traceId); + + const txnLines = formatItemJson( + "transaction", + { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /", + contexts: { trace: { op: "http.server", trace_id: traceId } }, + }, + serverHeader + ); + expect(JSON.parse(txnLines[0]).trace_id).toBe(traceId); + }); + + test("includes trace_id for logs from sentry.trace.trace_id attribute", () => { + const traceId = "abcdef0123456789abcdef0123456789"; + const lines = formatItemJson( + "log", + { + items: [ + { + level: "info", + body: "hello", + attributes: { + "sentry.trace.trace_id": { value: traceId }, + }, + }, + ], + }, + serverHeader + ); + expect(JSON.parse(lines[0]).trace_id).toBe(traceId); + }); + + test("formats transaction with semantic attributes", () => { + const event = { + timestamp: 1_700_000_002, + start_timestamp: 1_700_000_000, + transaction: "process_request", + contexts: { + trace: { + op: "ai.pipeline", + data: { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "gpt-4o", + }, + }, + }, + spans: [{}, {}, {}], + }; + const lines = formatItemJson("transaction", event, serverHeader); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]); + expect(parsed.type).toBe("transaction"); + expect(parsed.op).toBe("gen_ai"); + expect(parsed.label).toBe("chat gpt-4o"); + expect(parsed.duration_ms).toBe(2000); + expect(parsed.span_count).toBe(3); + expect(parsed.source).toBe("server"); + }); + + test("formats transaction without semantic attributes", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /api/users", + contexts: { trace: { op: "http.server" } }, + }; + const lines = formatItemJson("transaction", event, serverHeader); + const parsed = JSON.parse(lines[0]); + expect(parsed.label).toBe("GET /api/users"); + expect(parsed.op).toBe("http.server"); + }); + + test("formats log entries", () => { + const event = { + items: [ + { + level: "info", + body: "User logged in", + timestamp: 1_700_000_000, + attributes: { + "sentry.sdk.name": { value: "node" }, + user_id: { value: 42 }, + }, + }, + { level: "debug", body: "Cache hit" }, + ], + }; + const lines = formatItemJson("log", event, serverHeader); + expect(lines).toHaveLength(2); + + const first = JSON.parse(lines[0]); + expect(first.type).toBe("log"); + expect(first.level).toBe("info"); + expect(first.message).toBe("User logged in"); + expect(first.attributes).toEqual({ user_id: 42 }); + expect(first.attributes["sentry.sdk.name"]).toBeUndefined(); + + const second = JSON.parse(lines[1]); + expect(second.level).toBe("debug"); + expect(second.message).toBe("Cache hit"); + }); + + test("returns empty for log with no items", () => { + const lines = formatItemJson("log", { items: [] }, serverHeader); + expect(lines).toHaveLength(0); + }); + + test("formats unknown item types", () => { + const event = { timestamp: 1_700_000_000 }; + const lines = formatItemJson("attachment", event, serverHeader); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]); + expect(parsed.type).toBe("attachment"); + }); + + test("strips terminal controls from every JSON observation field", () => { + const lines = formatItemJson( + "attachment\u202e", + { timestamp: "2026-09-08\u009b", event_id: "event\u202e-123" }, + serverHeader + ); + const parsed = JSON.parse(lines[0]); + + expect(parsed).toMatchObject({ + type: "attachment", + timestamp: "2026-09-08", + event_id: "event-123", + }); + + const logLines = formatItemJson( + "log", + { + items: [ + { + body: "safe", + attributes: { + nested: { value: { child: "unsafe\u202evalue" } }, + }, + }, + ], + }, + serverHeader + ); + expect(JSON.parse(logLines[0]).attributes).toEqual({ + nested: { child: "unsafevalue" }, + }); + }); + + test("detects browser source in JSON", () => { + const event = { timestamp: 1_700_000_000, message: "error" }; + const lines = formatItemJson("error", event, browserHeader); + const parsed = JSON.parse(lines[0]); + expect(parsed.source).toBe("browser"); + }); + + test("includes grouped attributes only when requested", () => { + const event = { + timestamp: 1_700_000_000, + contexts: { trace: { op: "http.server", data: { "user.id": "42" } } }, + }; + const without = JSON.parse( + formatItemJson("transaction", event, serverHeader)[0] + ); + expect(without.attributes).toBeUndefined(); + const withAttrs = JSON.parse( + formatItemJson("transaction", event, serverHeader, true)[0] + ); + expect(withAttrs.attributes.user).toEqual({ "user.id": "42" }); + }); +}); + +describe("formatAttributeTable", () => { + test("returns no lines when the transaction has no attributes", () => { + expect(formatAttributeTable({ timestamp: 1 })).toEqual([]); + }); + + test("splits SDK-default attributes from user-custom ones", () => { + const event = { + contexts: { + trace: { + data: { + "gen_ai.request.model": "gpt-4", + "http.method": "POST", + order_id: "abc", + }, + }, + }, + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out).toContain("user attributes"); + expect(out).toContain("order_id"); + expect(out).toContain("sdk attributes"); + expect(out).toContain("gen_ai.request.model"); + expect(out).toContain("http.method"); + // user group is rendered before the sdk group + expect(out.indexOf("user attributes")).toBeLessThan( + out.indexOf("sdk attributes") + ); + }); + + test("merges child-span attributes with the trace root", () => { + const event = { + contexts: { trace: { data: { "service.name": "api" } } }, + spans: [{ data: { "gen_ai.usage.input_tokens": 10, prompt: "hi" } }], + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out).toContain("gen_ai.usage.input_tokens"); + expect(out).toContain("prompt"); + }); + + test("sorts keys alphabetically within a group", () => { + const event = { + contexts: { trace: { data: { zeta: 1, alpha: 2, mid: 3 } } }, + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out.indexOf("alpha")).toBeLessThan(out.indexOf("mid")); + expect(out.indexOf("mid")).toBeLessThan(out.indexOf("zeta")); + }); + + test("JSON-encodes object-valued attributes", () => { + const event = { + contexts: { trace: { data: { meta: { nested: true } } } }, + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out).toContain('{"nested":true}'); + }); +}); + +describe("formatItem with attributes", () => { + const serverHeader = { sdk: { name: "sentry.node" } }; + + test("appends the attribute table for transactions when enabled", () => { + const event = { + timestamp: 1_700_000_000, + transaction: "POST /api/ai/chat", + contexts: { trace: { op: "http.server", data: { tenant: "acme" } } }, + }; + const withoutAttrs = formatItem("transaction", event, serverHeader, "t"); + expect(withoutAttrs).toHaveLength(1); + const withAttrs = formatItem("transaction", event, serverHeader, "t", true); + expect(withAttrs.length).toBeGreaterThan(1); + expect(withAttrs.map(stripAnsi).join("\n")).toContain("tenant"); + }); + + test("does not append a table for errors even when enabled", () => { + const event = { timestamp: 1_700_000_000, message: "boom" }; + const lines = formatItem("error", event, serverHeader, "e", true); + expect(lines).toHaveLength(1); + }); +}); + +describe("standalone span support", () => { + const cfHeader = { sdk: { name: "sentry.javascript.cloudflare" } }; + + const aiSpan = { + trace_id: "a".repeat(32), + span_id: "b".repeat(16), + name: "chat anthropic/claude-4-sonnet", + start_timestamp: 1_700_000_000, + end_timestamp: 1_700_000_001.2, + status: "ok", + attributes: { + "gen_ai.system": { type: "string", value: "anthropic" }, + "gen_ai.request.model": { type: "string", value: "claude-4-sonnet" }, + "gen_ai.usage.input_tokens": { type: "integer", value: 512 }, + "gen_ai.usage.output_tokens": { type: "integer", value: 128 }, + "sentry.op": { type: "string", value: "gen_ai.chat" }, + }, + }; + + const mcpSpan = { + trace_id: "c".repeat(32), + span_id: "d".repeat(16), + name: "tools/call list_files", + start_timestamp: 1_700_000_000, + end_timestamp: 1_700_000_000.4, + status: "ok", + attributes: { + "mcp.method.name": { type: "string", value: "tools/call" }, + "mcp.tool.name": { type: "string", value: "list_files" }, + "sentry.op": { type: "string", value: "mcp.server" }, + }, + }; + + describe("formatSingleSpan", () => { + test("formats a gen_ai span with semantic labels", () => { + const line = stripAnsi(formatSingleSpan(aiSpan, cfHeader)); + expect(line).toContain("[gen_ai"); + expect(line).toContain("claude-4-sonnet"); + expect(line).toContain("[1200ms]"); + expect(line).toContain("[trace:aaaaaaaa]"); + expect(line).toContain("[SERVER]"); + }); + + test("formats an MCP span", () => { + const line = stripAnsi(formatSingleSpan(mcpSpan, cfHeader)); + expect(line).toContain("mcp"); + expect(line).toContain("tools/call"); + expect(line).toContain("[400ms]"); + }); + + test("handles missing name gracefully", () => { + const span = { + trace_id: "a".repeat(32), + start_timestamp: 1_700_000_000, + end_timestamp: 1_700_000_000.5, + status: "ok", + attributes: { + "http.method": { type: "string", value: "GET" }, + }, + }; + const line = stripAnsi(formatSingleSpan(span, cfHeader)); + expect(line).toContain("[TRACE]"); + expect(line).toContain("[SERVER]"); + expect(line).toContain("[500ms]"); + }); + }); + + describe("formatSpanItem", () => { + test("formats each span in the container", () => { + const payload = { items: [aiSpan, mcpSpan] }; + const lines = formatSpanItem(payload, cfHeader); + expect(lines).toHaveLength(2); + expect(stripAnsi(lines[0]!)).toContain("gen_ai"); + expect(stripAnsi(lines[1]!)).toContain("mcp"); + }); + + test("returns empty for missing items", () => { + expect(formatSpanItem({}, cfHeader)).toHaveLength(0); + expect(formatSpanItem({ items: [] }, cfHeader)).toHaveLength(0); + }); + }); + + describe("formatItem routes span type", () => { + test("routes span items to span formatter", () => { + const payload = { items: [aiSpan] }; + const lines = formatItem("span", payload, cfHeader, "span"); + expect(lines.length).toBeGreaterThanOrEqual(1); + expect(stripAnsi(lines[0]!)).toContain("gen_ai"); + }); + }); + + describe("formatItemJson routes span type", () => { + test("produces structured JSON for standalone spans", () => { + const payload = { items: [aiSpan] }; + const lines = formatItemJson("span", payload, cfHeader); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]!); + expect(parsed.type).toBe("span"); + expect(parsed.trace_id).toBe("a".repeat(32)); + expect(parsed.span_id).toBe("b".repeat(16)); + expect(parsed.op).toBe("gen_ai.chat"); + expect(parsed.duration_ms).toBe(1200); + expect(parsed.source).toBe("server"); + }); + }); + + describe("itemTypeToFilterCategory maps span to transaction", () => { + test("maps span to transaction category", () => { + expect(itemTypeToFilterCategory("span")).toBe("transaction"); + }); + }); + + describe("isItemIncluded handles standalone spans", () => { + test("ai filter matches span items with gen_ai attributes", () => { + const filters = new Set(["ai"]); + const payload = { items: [aiSpan] }; + expect(isItemIncluded("span", filters, payload)).toBe(true); + }); + + test("ai filter matches span items with mcp attributes", () => { + const filters = new Set(["ai"]); + const payload = { items: [mcpSpan] }; + expect(isItemIncluded("span", filters, payload)).toBe(true); + }); + + test("ai filter does not match span items without ai attributes", () => { + const filters = new Set(["ai"]); + const plainSpan = { + ...aiSpan, + attributes: { + "http.method": { type: "string", value: "GET" }, + }, + }; + const payload = { items: [plainSpan] }; + expect(isItemIncluded("span", filters, payload)).toBe(false); + }); + + test("transaction filter matches standalone spans", () => { + const filters = new Set(["transaction"]); + const payload = { items: [aiSpan] }; + expect(isItemIncluded("span", filters, payload)).toBe(true); + }); + }); +}); + +describe("inferSource cloudflare SDK", () => { + test("detects Cloudflare Workers SDK as server", () => { + const result = stripAnsi( + inferSource({ sdk: { name: "sentry.javascript.cloudflare" } }) + ); + expect(result).toContain("[SERVER]"); + }); +}); + +describe("warn level coloring", () => { + test("warn level gets formatted with brackets", () => { + const log = { + level: "warn", + body: "Rate limit approaching", + timestamp: 1_700_000_000, + }; + const result = formatSingleLog(log, "[SERVER] "); + expect(stripAnsi(result)).toContain("[WARN]"); + }); +}); diff --git a/packages/cli/test/lib/formatters/log.property.test.ts b/packages/cli/test/lib/formatters/log.property.test.ts new file mode 100644 index 000000000..7d4350517 --- /dev/null +++ b/packages/cli/test/lib/formatters/log.property.test.ts @@ -0,0 +1,225 @@ +/** + * Property-Based Tests for Log Formatters + * + * Uses fast-check to verify invariants of formatLogDetails() + * that should hold for any valid input. + */ + +import { + constant, + assert as fcAssert, + oneof, + option, + property, + record, + stringMatching, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { formatLogDetails } from "../../../src/lib/formatters/log.js"; +import type { DetailedSentryLog } from "../../../src/types/index.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Valid log IDs (32-char hex) */ +const logIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** Valid org slugs */ +const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Valid trace IDs (32-char hex) */ +const traceIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** ISO timestamp */ +const timestampArb = constant("2025-01-30T14:32:15Z"); + +/** Timestamp precise (nanoseconds) */ +const timestampPreciseArb = constant(1_770_060_419_044_800_300); + +/** Log severity levels */ +const severityArb = oneof( + constant("info"), + constant("warning"), + constant("error"), + constant("debug"), + constant("fatal") +); + +/** Optional string (string or null) */ +const optionalStringArb = option(stringMatching(/^[a-zA-Z0-9_.-]{1,50}$/), { + nil: null, +}); + +/** Generate DetailedSentryLog objects with various field combinations */ +function createDetailedLogArb() { + return record({ + "sentry.item_id": logIdArb, + timestamp: timestampArb, + timestamp_precise: timestampPreciseArb, + message: optionalStringArb, + severity: option(severityArb, { nil: null }), + trace: option(traceIdArb, { nil: null }), + project: optionalStringArb, + environment: optionalStringArb, + release: optionalStringArb, + "sdk.name": optionalStringArb, + "sdk.version": optionalStringArb, + span_id: optionalStringArb, + "code.function": optionalStringArb, + "code.file.path": optionalStringArb, + "code.line.number": optionalStringArb, + "sentry.otel.kind": optionalStringArb, + "sentry.otel.status_code": optionalStringArb, + "sentry.otel.instrumentation_scope.name": optionalStringArb, + }); +} + +const detailedLogArb = createDetailedLogArb(); + +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("formatLogDetails properties", () => { + test("always returns a non-empty string", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains the log ID", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = stripAnsi(formatLogDetails(log, orgSlug)); + expect(result).toContain(log["sentry.item_id"]); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains timestamp info", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = stripAnsi(formatLogDetails(log, orgSlug)); + expect(result).toContain("Timestamp"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains severity info", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = stripAnsi(formatLogDetails(log, orgSlug)); + expect(result).toContain("Severity"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("trace URL only appears when trace ID is present", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = stripAnsi(formatLogDetails(log, orgSlug)); + if (log.trace) { + expect(result).toContain("/traces/"); + expect(result).toContain(log.trace); + } else { + expect(result).not.toContain("/traces/"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("SDK section only appears when sdk.name is present", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = stripAnsi(formatLogDetails(log, orgSlug)); + if (log["sdk.name"]) { + expect(result).toContain("SDK"); + expect(result).toContain(log["sdk.name"]); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("Source Location section only appears when code fields are present", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = stripAnsi(formatLogDetails(log, orgSlug)); + const hasCodeFields = log["code.function"] || log["code.file.path"]; + if (hasCodeFields) { + expect(result).toContain("Source Location"); + } else { + expect(result).not.toContain("Source Location"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("formatting is deterministic: same input always produces same output", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result1 = formatLogDetails(log, orgSlug); + const result2 = formatLogDetails(log, orgSlug); + expect(result1).toEqual(result2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a string (not array)", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug); + expect(typeof result).toBe("string"); + expect(Array.isArray(result)).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/log.test.ts b/packages/cli/test/lib/formatters/log.test.ts new file mode 100644 index 000000000..96ede2b47 --- /dev/null +++ b/packages/cli/test/lib/formatters/log.test.ts @@ -0,0 +1,641 @@ +/** + * Tests for log formatters + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + buildLogRowCells, + createLogStreamingTable, + formatLogDetails, + formatLogRow, + formatLogsHeader, + formatLogTable, + getLogId, +} from "../../../src/lib/formatters/log.js"; +import type { + DetailedSentryLog, + SentryLog, + TraceItemAttribute, +} from "../../../src/types/index.js"; + +/** Force rendered (TTY) mode for a describe block */ +function useRenderedMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + +/** Force plain mode for a describe block */ +function usePlainMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "1"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + +function createTestLog(overrides: Partial = {}): SentryLog { + return { + "sentry.item_id": "test-id-123", + timestamp: "2025-01-30T14:32:15Z", + timestamp_precise: 1_770_060_419_044_800_300, + message: "Test log message", + severity: "info", + trace: "abc123def456", + ...overrides, + }; +} + +// Strip ANSI color codes for easier testing +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI stripping + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("formatLogRow (rendered mode)", () => { + useRenderedMode(); + + test("formats basic log entry", () => { + const log = createTestLog(); + const result = formatLogRow(log); + + // Should contain timestamp, severity, message, and trace + expect(result).toContain("Test log message"); + expect(result).toContain("[abc123de]"); // First 8 chars of trace + expect(result).toEndWith("\n"); + }); + + test("handles missing message", () => { + const log = createTestLog({ message: null }); + const result = formatLogRow(log); + + // Should not throw, just show empty message area + expect(result).toContain("INFO"); + expect(result).toEndWith("\n"); + }); + + test("handles missing severity", () => { + const log = createTestLog({ severity: null }); + const result = stripAnsi(formatLogRow(log)); + + // Should default to INFO + expect(result).toContain("INFO"); + }); + + test("handles missing trace", () => { + const log = createTestLog({ trace: null }); + const result = stripAnsi(formatLogRow(log)); + + // Should not include trace bracket + expect(result).not.toContain("["); + expect(result).toContain("Test log message"); + }); + + test("formats different severity levels", () => { + const levels = [ + "fatal", + "error", + "warning", + "warn", + "info", + "debug", + "trace", + ]; + + for (const level of levels) { + const log = createTestLog({ severity: level }); + const result = stripAnsi(formatLogRow(log)); + expect(result).toContain(level.toUpperCase().slice(0, 7)); // Max 7 chars + } + }); + + test("pads severity to consistent width", () => { + const shortLevel = createTestLog({ severity: "info" }); + const longLevel = createTestLog({ severity: "warning" }); + + const shortResult = stripAnsi(formatLogRow(shortLevel)); + const longResult = stripAnsi(formatLogRow(longLevel)); + + // Both should have severity at same position + const shortPos = shortResult.indexOf("INFO"); + const longPos = longResult.indexOf("WARNING"); + + // The position after timestamp should be consistent + expect(shortPos).toBe(longPos); + }); + + test("formats timestamp in local format", () => { + const log = createTestLog({ timestamp: "2025-01-30T14:32:15Z" }); + const result = formatLogRow(log); + + // Should have date and time format (actual values depend on timezone) + expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); + + test("handles invalid timestamp gracefully", () => { + const log = createTestLog({ timestamp: "invalid-date" }); + const result = formatLogRow(log); + + // Should return original string instead of NaN + expect(result).toContain("invalid-date"); + expect(result).not.toContain("NaN"); + }); +}); + +describe("createLogStreamingTable", () => { + test("header() contains column titles and box-drawing borders", () => { + const table = createLogStreamingTable({ maxWidth: 120 }); + const result = table.header(); + + expect(result).toContain("ID"); + expect(result).toContain("Timestamp"); + expect(result).toContain("Level"); + expect(result).toContain("Message"); + // Box-drawing border characters + expect(result).toContain("─"); + expect(result).toContain("╭"); + }); + + test("row() renders cells with side borders", () => { + const table = createLogStreamingTable({ maxWidth: 120 }); + const result = table.row([ + "ace106b2", + "2026-01-15 10:00:00", + "ERROR", + "something", + ]); + + expect(result).toContain("ace106b2"); + expect(result).toContain("2026-01-15 10:00:00"); + expect(result).toContain("ERROR"); + expect(result).toContain("something"); + // Side borders + expect(result).toContain("│"); + }); + + test("footer() renders bottom border", () => { + const table = createLogStreamingTable({ maxWidth: 120 }); + const result = table.footer(); + + expect(result).toContain("─"); + expect(result).toContain("╯"); + }); + + test("ends with newline", () => { + const table = createLogStreamingTable({ maxWidth: 120 }); + expect(table.header()).toEndWith("\n"); + expect(table.row(["a", "b", "c", "d"])).toEndWith("\n"); + expect(table.footer()).toEndWith("\n"); + }); + + test("accepts extra columns", () => { + const table = createLogStreamingTable({ maxWidth: 160 }, [ + "email", + "user_id", + ]); + const result = table.header(); + + expect(result).toContain("email"); + expect(result).toContain("user_id"); + }); +}); + +describe("formatLogRow (plain mode)", () => { + usePlainMode(); + + test("emits a markdown table row with 4 columns", () => { + const log = createTestLog(); + const result = formatLogRow(log); + // 4 columns: ID | Timestamp | Level | Message + expect(result).toMatch(/^\|.+\|.+\|.+\|.+\|\n$/); + }); + + test("contains log ID prefix, timestamp, severity, message", () => { + const log = createTestLog({ + severity: "error", + message: "connection failed", + }); + const result = formatLogRow(log); + expect(result).toContain("test-id-"); // first 8 chars of "test-id-123" + expect(result).toContain("connection failed"); + expect(result).toContain("ERROR"); + expect(result).toMatch(/\d{4}-\d{2}-\d{2}/); + }); + + test("contains trace ID as inline code", () => { + const log = createTestLog({ trace: "abc123def456" }); + const result = formatLogRow(log); + expect(result).toContain("[abc123de]"); + }); + + test("omits trace cell when trace is null", () => { + const log = createTestLog({ trace: null }); + const result = formatLogRow(log); + expect(result).not.toContain("["); + }); + + test("escapes pipe characters in message", () => { + const log = createTestLog({ message: "a|b" }); + const result = formatLogRow(log); + // Pipe in message replaced with box-drawing │ so it doesn't break the table + expect(result).toContain("a\u2502b"); + }); + + test("ends with newline", () => { + const result = formatLogRow(createTestLog()); + expect(result).toEndWith("\n"); + }); + + test("renders extra fields as additional columns", () => { + const log = { ...createTestLog(), email: "user@example.com" }; + const result = formatLogRow(log, true, ["email"]); + expect(result).toContain("user@example.com"); + }); +}); + +describe("formatLogsHeader (plain mode)", () => { + usePlainMode(); + + test("emits markdown table header and separator with ID column", () => { + const result = formatLogsHeader(); + // Plain mode produces mdTableHeader output (no bold markup), followed by separator + expect(result).toContain("| ID | Timestamp | Level | Message |"); + expect(result).toContain("| --- | --- | --- | --- |"); + }); + + test("ends with newline", () => { + expect(formatLogsHeader()).toEndWith("\n"); + }); + + test("includes extra columns when provided", () => { + const result = formatLogsHeader(["email", "user_id"]); + expect(result).toContain( + "| ID | Timestamp | Level | Message | email | user_id |" + ); + }); +}); + +function createDetailedTestLog( + overrides: Partial = {} +): DetailedSentryLog { + return { + "sentry.item_id": "test-log-id-123456789012345678901234", + timestamp: "2025-01-30T14:32:15Z", + timestamp_precise: 1_770_060_419_044_800_300, + message: "Test log message", + severity: "info", + trace: "abc123def456abc123def456abc12345", + project: "test-project", + environment: "production", + release: "1.0.0", + "sdk.name": "sentry.javascript.node", + "sdk.version": "8.0.0", + span_id: null, + "code.function": null, + "code.file.path": null, + "code.line.number": null, + "sentry.otel.kind": null, + "sentry.otel.status_code": null, + "sentry.otel.instrumentation_scope.name": null, + ...overrides, + }; +} + +describe("formatLogDetails", () => { + test("formats basic log entry with header", () => { + const log = createDetailedTestLog(); + const result = stripAnsi(formatLogDetails(log, "test-org")); + + // Header contains log ID prefix + expect(result).toContain("Log"); + expect(result).toContain("test-log-id"); + }); + + test("includes ID, timestamp, and severity", () => { + const log = createDetailedTestLog(); + const result = stripAnsi(formatLogDetails(log, "test-org")); + + expect(result).toContain("ID"); + expect(result).toContain("test-log-id-123456789012345678901234"); + expect(result).toContain("Timestamp"); + expect(result).toContain("Severity"); + expect(result).toContain("INFO"); + }); + + test("includes message when present", () => { + const log = createDetailedTestLog({ message: "Custom error message" }); + const result = formatLogDetails(log, "test-org"); + + expect(result).toContain("Message"); + expect(result).toContain("Custom error message"); + }); + + test("shows Context section when project/environment/release present", () => { + const log = createDetailedTestLog({ + project: "my-project", + environment: "staging", + release: "2.0.0", + }); + const result = stripAnsi(formatLogDetails(log, "test-org")); + + expect(result).toContain("Context"); + expect(result).toContain("Project"); + expect(result).toContain("my-project"); + expect(result).toContain("Environment"); + expect(result).toContain("staging"); + expect(result).toContain("Release"); + expect(result).toContain("2.0.0"); + }); + + test("shows SDK section when sdk.name present", () => { + const log = createDetailedTestLog({ + "sdk.name": "sentry.python", + "sdk.version": "2.0.0", + }); + const result = stripAnsi(formatLogDetails(log, "test-org")); + + expect(result).toContain("SDK"); + expect(result).toContain("sentry.python"); + expect(result).toContain("2.0.0"); + }); + + test("shows Trace section with URL when trace ID present", () => { + const log = createDetailedTestLog({ + trace: "trace123abc456def789", + span_id: "span-abc-123", + }); + const result = stripAnsi(formatLogDetails(log, "my-org")); + + expect(result).toContain("Trace"); + expect(result).toContain("Trace ID"); + expect(result).toContain("trace123abc456def789"); + expect(result).toContain("Span ID"); + expect(result).toContain("span-abc-123"); + expect(result).toContain("Link"); + expect(result).toContain("my-org.sentry.io/explore/traces/trace/"); + }); + + test("shows Source Location when code.function present", () => { + const log = createDetailedTestLog({ + "code.function": "handleRequest", + "code.file.path": "src/api/handler.ts", + "code.line.number": "42", + }); + const result = stripAnsi(formatLogDetails(log, "test-org")); + + expect(result).toContain("Source Location"); + expect(result).toContain("Function"); + expect(result).toContain("handleRequest"); + expect(result).toContain("File"); + expect(result).toContain("src/api/handler.ts:42"); + }); + + test("shows OpenTelemetry section when otel fields present", () => { + const log = createDetailedTestLog({ + "sentry.otel.kind": "server", + "sentry.otel.status_code": "OK", + "sentry.otel.instrumentation_scope.name": "express", + }); + const result = stripAnsi(formatLogDetails(log, "test-org")); + + expect(result).toContain("OpenTelemetry"); + expect(result).toContain("Kind"); + expect(result).toContain("server"); + expect(result).toContain("Status"); + expect(result).toContain("OK"); + expect(result).toContain("Scope"); + expect(result).toContain("express"); + }); + + test("handles missing optional fields gracefully", () => { + const log = createDetailedTestLog({ + message: null, + trace: null, + project: null, + environment: null, + release: null, + "sdk.name": null, + "sdk.version": null, + }); + const result = stripAnsi(formatLogDetails(log, "test-org")); + + // Should still have basic info + expect(result).toContain("ID"); + expect(result).toContain("Timestamp"); + expect(result).toContain("Severity"); + + // Should not have optional sections + expect(result).not.toContain("Context"); + expect(result).not.toContain("SDK"); + expect(result).not.toContain("Trace"); + }); + + describe("Custom Attributes section", () => { + const customAttrs: TraceItemAttribute[] = [ + { name: "user.id", type: "str", value: "u_42" }, + { name: "order.total", type: "float", value: 99.9 }, + { name: "retry.count", type: "int", value: 3 }, + { name: "is_premium", type: "bool", value: true }, + ]; + + test("renders custom attributes when allAttributes provided", () => { + const log = createDetailedTestLog(); + const result = stripAnsi(formatLogDetails(log, "test-org", customAttrs)); + + expect(result).toContain("Custom Attributes"); + expect(result).toContain("user.id"); + expect(result).toContain("u_42"); + expect(result).toContain("order.total"); + expect(result).toContain("99.9"); + expect(result).toContain("retry.count"); + expect(result).toContain("3"); + expect(result).toContain("is_premium"); + expect(result).toContain("true"); + }); + + test("filters out REDUNDANT_LOG_DETAIL_ATTRS from custom attributes", () => { + const attrsWithRedundant: TraceItemAttribute[] = [ + { name: "user.id", type: "str", value: "u_42" }, + // these are in REDUNDANT_LOG_DETAIL_ATTRS and should be suppressed + { name: "severity_number", type: "int", value: 9 }, + { name: "project.id", type: "str", value: "123" }, + ]; + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", attrsWithRedundant) + ); + + expect(result).toContain("user.id"); + expect(result).not.toContain("severity_number"); + expect(result).not.toContain("project.id"); + }); + + test("extraFields limits which custom attributes are shown", () => { + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", customAttrs, ["user.id"]) + ); + + expect(result).toContain("Custom Attributes"); + expect(result).toContain("user.id"); + expect(result).not.toContain("order.total"); + expect(result).not.toContain("retry.count"); + }); + + test("shows no Custom Attributes section when all are filtered by extraFields", () => { + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", customAttrs, ["nonexistent.field"]) + ); + + expect(result).not.toContain("Custom Attributes"); + }); + + test("fallback: shows extraFields from log when allAttributes absent", () => { + const log = createDetailedTestLog({ + "user.id": "u_99", + } as DetailedSentryLog); + const result = stripAnsi( + formatLogDetails(log, "test-org", undefined, ["user.id"]) + ); + + expect(result).toContain("Custom Attributes"); + expect(result).toContain("user.id"); + expect(result).toContain("u_99"); + }); + + test("renders array attributes with JSON.stringify instead of [object Object]", () => { + const attrsWithArray: TraceItemAttribute[] = [ + { name: "tags", type: "array", value: ["prod", "web"] }, + { name: "ids", type: "array", value: [1, 2, 3] }, + ]; + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", attrsWithArray) + ); + + expect(result).toContain('["prod","web"]'); + expect(result).toContain("[1,2,3]"); + expect(result).not.toContain("[object Object]"); + }); + + test("no Custom Attributes section when allAttributes is empty array", () => { + const log = createDetailedTestLog(); + const result = stripAnsi(formatLogDetails(log, "test-org", [])); + + expect(result).not.toContain("Custom Attributes"); + }); + }); +}); + +describe("getLogId", () => { + test("returns sentry.item_id when present", () => { + expect(getLogId({ "sentry.item_id": "abc123", timestamp: "" })).toBe( + "abc123" + ); + }); + + test("falls back to id when sentry.item_id is absent", () => { + expect(getLogId({ id: "trace-log-id", timestamp: "" })).toBe( + "trace-log-id" + ); + }); + + test("returns empty string when neither is present", () => { + expect(getLogId({ timestamp: "" })).toBe(""); + }); +}); + +describe("buildLogRowCells", () => { + test("returns 4 base cells (ID, Timestamp, Level, Message)", () => { + const log = createTestLog(); + const cells = buildLogRowCells(log); + expect(cells.length).toBe(4); + }); + + test("first cell contains short log ID", () => { + const log = createTestLog(); + const cells = buildLogRowCells(log); + // First 8 chars of "test-id-123" + expect(stripAnsi(cells[0])).toContain("test-id-"); + }); + + test("appends extra field cells when provided", () => { + const log = { ...createTestLog(), email: "user@example.com" }; + const cells = buildLogRowCells(log, true, true, ["email"]); + expect(cells.length).toBe(5); + expect(cells[4]).toContain("user@example.com"); + }); + + test("extra field shows empty for null values", () => { + const log = createTestLog(); + const cells = buildLogRowCells(log, true, true, ["nonexistent"]); + expect(cells.length).toBe(5); + expect(cells[4]).toBe(""); + }); +}); + +describe("formatLogTable", () => { + test("returns a string", () => { + const result = formatLogTable([createTestLog()]); + expect(typeof result).toBe("string"); + }); + + test("includes log ID in output", () => { + const result = stripAnsi(formatLogTable([createTestLog()])); + // First 8 chars of "test-id-123" + expect(result).toContain("test-id-"); + }); + + test("includes all log messages", () => { + const logs = [ + createTestLog({ message: "First log" }), + createTestLog({ message: "Second log" }), + ]; + const result = stripAnsi(formatLogTable(logs)); + expect(result).toContain("First log"); + expect(result).toContain("Second log"); + }); + + test("includes severity levels", () => { + const result = stripAnsi( + formatLogTable([createTestLog({ severity: "error" })]) + ); + expect(result).toContain("ERROR"); + }); + + test("includes trace IDs when present", () => { + const result = stripAnsi( + formatLogTable([createTestLog({ trace: "abcdef1234567890" })]) + ); + expect(result).toContain("abcdef12"); + }); + + test("handles empty messages", () => { + const result = formatLogTable([createTestLog({ message: "" })]); + expect(typeof result).toBe("string"); + }); + + test("includes extra field columns when provided", () => { + const log = { ...createTestLog(), email: "hi@ex.co" }; + const result = stripAnsi(formatLogTable([log], true, ["email"])); + expect(result).toContain("email"); + expect(result).toContain("hi@ex.co"); + }); +}); diff --git a/packages/cli/test/lib/formatters/markdown.test.ts b/packages/cli/test/lib/formatters/markdown.test.ts new file mode 100644 index 000000000..6c6534ce1 --- /dev/null +++ b/packages/cli/test/lib/formatters/markdown.test.ts @@ -0,0 +1,820 @@ +/** + * Tests for markdown.ts rendering mode logic. + * + * Tests cover isPlainOutput() priority chain, env var truthy/falsy + * normalization, and the gating behaviour of renderMarkdown() / + * renderInlineMarkdown(). + */ + +import { describe, expect, test } from "vitest"; +import { + colorTag, + divider, + escapeMarkdownCell, + escapeMarkdownInline, + isPlainOutput, + mdKvTable, + mdRow, + mdTableHeader, + renderInlineMarkdown, + renderMarkdown, + safeCodeSpan, +} from "../../../src/lib/formatters/markdown.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Strip ANSI escape codes for content-only assertions */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** Save and restore env vars + isTTY around each test */ +function withEnv( + vars: Partial< + Record< + "SENTRY_PLAIN_OUTPUT" | "NO_COLOR" | "FORCE_COLOR", + string | undefined + > + >, + isTTY: boolean | undefined, + fn: () => void +): void { + const savedEnv: Record = {}; + const savedTTY = process.stdout.isTTY; + + for (const [key, val] of Object.entries(vars)) { + savedEnv[key] = process.env[key]; + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } + process.stdout.isTTY = isTTY as boolean; + + try { + fn(); + } finally { + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } + process.stdout.isTTY = savedTTY; + } +} + +// --------------------------------------------------------------------------- +// isPlainOutput() +// --------------------------------------------------------------------------- + +describe("isPlainOutput", () => { + describe("SENTRY_PLAIN_OUTPUT takes highest priority", () => { + test("=1 → plain, regardless of isTTY", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + expect(isPlainOutput()).toBe(true); + }); + }); + + test("=true → plain", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "true", NO_COLOR: undefined }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("=TRUE → plain (case-insensitive)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "TRUE", NO_COLOR: undefined }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("=0 → rendered, even when not a TTY", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + + test("=false → rendered", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "false", NO_COLOR: undefined }, + false, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("=FALSE → rendered (case-insensitive)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: "FALSE", NO_COLOR: undefined }, + false, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("='' → rendered (empty string is falsy)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "", NO_COLOR: undefined }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + + test("SENTRY_PLAIN_OUTPUT=0 overrides NO_COLOR=1", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: "1" }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + + test("SENTRY_PLAIN_OUTPUT=1 overrides NO_COLOR=0", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: "0" }, true, () => { + expect(isPlainOutput()).toBe(true); + }); + }); + }); + + describe("NO_COLOR as secondary override (SENTRY_PLAIN_OUTPUT unset)", () => { + test("=1 → plain", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "1" }, true, () => { + expect(isPlainOutput()).toBe(true); + }); + }); + + test("=True → plain (any non-empty value per spec)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "True" }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + // Per no-color.org spec (updated 2026-02-09): any non-empty value disables + // color, including "0" and "false". Only empty string leaves color enabled. + test("=0 → plain (non-empty value disables color per spec)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "0" }, false, () => { + expect(isPlainOutput()).toBe(true); + }); + }); + + test("=false → plain (non-empty value disables color per spec)", () => { + withEnv( + { SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "false" }, + false, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("='' → rendered (empty string leaves color enabled)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: undefined, NO_COLOR: "" }, false, () => { + expect(isPlainOutput()).toBe(false); + }); + }); + }); + + describe("FORCE_COLOR (only applies on TTY)", () => { + test("=1 on TTY → rich output (forces color)", () => { + withEnv( + { + SENTRY_PLAIN_OUTPUT: undefined, + NO_COLOR: undefined, + FORCE_COLOR: "1", + }, + true, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("=1 on non-TTY → plain output (FORCE_COLOR ignored when piped)", () => { + withEnv( + { + SENTRY_PLAIN_OUTPUT: undefined, + NO_COLOR: undefined, + FORCE_COLOR: "1", + }, + false, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("=0 on TTY → plain output (forces no color per chalk convention)", () => { + withEnv( + { + SENTRY_PLAIN_OUTPUT: undefined, + NO_COLOR: undefined, + FORCE_COLOR: "0", + }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("NO_COLOR wins over FORCE_COLOR=1", () => { + withEnv( + { + SENTRY_PLAIN_OUTPUT: undefined, + NO_COLOR: "1", + FORCE_COLOR: "1", + }, + true, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + }); + + describe("isTTY fallback (all env vars unset)", () => { + test("non-TTY → plain", () => { + withEnv( + { + SENTRY_PLAIN_OUTPUT: undefined, + NO_COLOR: undefined, + FORCE_COLOR: undefined, + }, + false, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + + test("TTY → rendered", () => { + withEnv( + { + SENTRY_PLAIN_OUTPUT: undefined, + NO_COLOR: undefined, + FORCE_COLOR: undefined, + }, + true, + () => { + expect(isPlainOutput()).toBe(false); + } + ); + }); + + test("isTTY=undefined → plain", () => { + withEnv( + { + SENTRY_PLAIN_OUTPUT: undefined, + NO_COLOR: undefined, + FORCE_COLOR: undefined, + }, + undefined, + () => { + expect(isPlainOutput()).toBe(true); + } + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// renderMarkdown() +// --------------------------------------------------------------------------- + +describe("renderMarkdown", () => { + test("plain mode: parses markdown and strips ANSI (not raw CommonMark)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, false, () => { + const md = "## Hello\n\n| A | B |\n|---|---|\n| 1 | 2 |\n"; + const result = renderMarkdown(md); + // Heading text is present without ### prefix + expect(result).toContain("Hello"); + expect(result).not.toContain("##"); + // Table data is present in box-drawing format + expect(result).toContain("A"); + expect(result).toContain("B"); + expect(result).toContain("1"); + expect(result).toContain("2"); + }); + }); + + test("rendered mode: returns ANSI-styled output (not raw markdown)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = renderMarkdown("**bold text**"); + // Should contain ANSI codes or at minimum not be the raw markdown + // (chalk may produce no ANSI in test env — check trimEnd at minimum) + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + }); + + test("plain mode: trailing whitespace is trimmed", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, false, () => { + const result = renderMarkdown("hello\n\n\n"); + expect(result.trimEnd()).toBe(result); + expect(result).toContain("hello"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// renderInlineMarkdown() +// --------------------------------------------------------------------------- + +describe("renderInlineMarkdown", () => { + test("plain mode: strips markdown syntax", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, false, () => { + expect(renderInlineMarkdown("`trace-id`")).toBe("trace-id"); + expect(renderInlineMarkdown("**ERROR**")).toBe("ERROR"); + }); + }); + + test("rendered mode: renders code spans with padding", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = stripAnsi(renderInlineMarkdown("`trace-id`")); + expect(result).toContain("trace-id"); + // Should not contain the backtick delimiters + expect(result).not.toContain("`"); + // Code spans have space padding for the "pill" look + expect(result).toBe(" trace-id "); + }); + }); + + test("rendered mode: renders bold", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = stripAnsi(renderInlineMarkdown("**ERROR**")); + expect(result).toContain("ERROR"); + }); + }); + + test("rendered mode: does not wrap in paragraph tags", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = renderInlineMarkdown("hello world"); + // parseInline should not add paragraph wrapping + expect(result).not.toContain("

"); + expect(result.trim()).toContain("hello world"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// escapeMarkdownCell +// --------------------------------------------------------------------------- + +describe("escapeMarkdownCell", () => { + test("escapes pipe characters", () => { + expect(escapeMarkdownCell("foo|bar")).toBe("foo\\|bar"); + }); + + test("escapes backslashes before pipes", () => { + expect(escapeMarkdownCell("a\\|b")).toBe("a\\\\\\|b"); + }); + + test("returns unchanged string when no special chars", () => { + expect(escapeMarkdownCell("hello world")).toBe("hello world"); + }); + + test("handles empty string", () => { + expect(escapeMarkdownCell("")).toBe(""); + }); + + test("replaces newlines with a space to preserve row structure", () => { + expect(escapeMarkdownCell("line1\nline2")).toBe("line1 line2"); + expect(escapeMarkdownCell("a\nb\nc")).toBe("a b c"); + }); + + test("handles multiple pipes", () => { + const result = escapeMarkdownCell("a|b|c"); + expect(result).toBe("a\\|b\\|c"); + }); + + test("escapes angle brackets with backslash", () => { + expect(escapeMarkdownCell("")).toBe("\\"); + }); +}); + +// --------------------------------------------------------------------------- +// mdTableHeader +// --------------------------------------------------------------------------- + +describe("mdTableHeader", () => { + test("generates header and separator rows", () => { + const result = mdTableHeader(["Name", "Value"]); + expect(result).toBe("| Name | Value |\n| --- | --- |"); + }); + + test("right-aligns columns with : suffix", () => { + const result = mdTableHeader(["Label", "Count:"]); + expect(result).toBe("| Label | Count |\n| --- | ---: |"); + }); + + test("strips : suffix from display name", () => { + const result = mdTableHeader(["Duration:"]); + expect(result).toContain("| Duration |"); + expect(result).not.toContain("Duration:"); + }); + + test("handles single column", () => { + const result = mdTableHeader(["Only"]); + expect(result).toBe("| Only |\n| --- |"); + }); +}); + +// --------------------------------------------------------------------------- +// mdRow +// --------------------------------------------------------------------------- + +describe("mdRow", () => { + test("plain mode: strips markdown syntax from cells", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + const result = mdRow(["**bold**", "`code`"]); + expect(result).toBe("| bold | code |\n"); + }); + }); + + test("rendered mode: applies inline rendering", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = mdRow(["**bold**", "plain"]); + // Should contain ANSI codes for bold + expect(result).not.toBe("| **bold** | plain |\n"); + expect(stripAnsi(result)).toContain("bold"); + expect(stripAnsi(result)).toContain("plain"); + }); + }); + + test("produces pipe-delimited format (plain text cells)", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + const result = mdRow(["a", "b", "c"]); + expect(result).toBe("| a | b | c |\n"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// mdKvTable +// --------------------------------------------------------------------------- + +describe("mdKvTable", () => { + test("generates key-value table rows", () => { + const result = mdKvTable([ + ["Name", "Alice"], + ["Age", "30"], + ]); + expect(result).toContain("| | |"); + expect(result).toContain("|---|---|"); + expect(result).toContain("| **Name** | Alice |"); + expect(result).toContain("| **Age** | 30 |"); + }); + + test("includes heading when provided", () => { + const result = mdKvTable([["Key", "Val"]], "Details"); + expect(result).toContain("### Details"); + expect(result).toContain("| **Key** | Val |"); + }); + + test("omits heading when not provided", () => { + const result = mdKvTable([["K", "V"]]); + expect(result).not.toContain("###"); + expect(result).toContain("| **K** | V |"); + }); + + test("handles single row", () => { + const result = mdKvTable([["Only", "Row"]]); + expect(result).toContain("| **Only** | Row |"); + }); +}); + +// --------------------------------------------------------------------------- +// colorTag +// --------------------------------------------------------------------------- + +describe("colorTag", () => { + test("wraps text in HTML-style tag", () => { + expect(colorTag("red", "ERROR")).toBe("ERROR"); + }); + + test("works with all supported tags", () => { + for (const tag of [ + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "muted", + ] as const) { + const result = colorTag(tag, "text"); + expect(result).toBe(`<${tag}>text`); + } + }); + + test("rendered mode: strips color tags and preserves content", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const result = renderInlineMarkdown(colorTag("red", "ERROR")); + // Tags are consumed by the renderer (not present as raw HTML) + expect(result).not.toContain(""); + expect(result).not.toContain(""); + expect(stripAnsi(result)).toContain("ERROR"); + }); + }); + + test("plain mode: tags are stripped leaving bare text", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1", NO_COLOR: undefined }, true, () => { + const result = renderInlineMarkdown(colorTag("red", "ERROR")); + // Color tags must be stripped in plain mode — they must not leak as literal markup + expect(result).not.toContain(""); + expect(result).not.toContain(""); + expect(result).toContain("ERROR"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// escapeMarkdownInline +// --------------------------------------------------------------------------- + +describe("escapeMarkdownInline", () => { + test("escapes underscores", () => { + expect(escapeMarkdownInline("hello_world")).toBe("hello\\_world"); + }); + + test("escapes asterisks", () => { + expect(escapeMarkdownInline("*bold*")).toBe("\\*bold\\*"); + }); + + test("escapes backticks", () => { + expect(escapeMarkdownInline("`code`")).toBe("\\`code\\`"); + }); + + test("escapes square brackets", () => { + expect(escapeMarkdownInline("[link]")).toBe("\\[link\\]"); + }); + + test("escapes backslashes", () => { + expect(escapeMarkdownInline("a\\b")).toBe("a\\\\b"); + }); + + test("returns unchanged string with no special chars", () => { + expect(escapeMarkdownInline("hello world")).toBe("hello world"); + }); + + test("escapes angle brackets with backslash", () => { + expect(escapeMarkdownInline("")).toBe("\\"); + }); + + test("angle brackets survive round-trip through renderInlineMarkdown", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const escaped = escapeMarkdownInline(""); + const result = stripAnsi(renderInlineMarkdown(escaped)); + expect(result).toBe(""); + }); + }); + + test("URLs with underscores render without backslashes", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + const escaped = escapeMarkdownInline( + "https://spotlightjs.com/_astro/ui-core.js" + ); + const result = stripAnsi(renderInlineMarkdown(escaped)); + expect(result).not.toContain("\\_"); + expect(result).toContain("_astro"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// safeCodeSpan +// --------------------------------------------------------------------------- + +describe("safeCodeSpan", () => { + test("wraps value in backticks", () => { + expect(safeCodeSpan("hello")).toBe("`hello`"); + }); + + test("replaces internal backticks with modifier letter", () => { + const result = safeCodeSpan("a`b"); + expect(result).not.toContain("`b"); + expect(result.startsWith("`")).toBe(true); + expect(result.endsWith("`")).toBe(true); + }); + + test("replaces pipe with unicode vertical bar", () => { + const result = safeCodeSpan("a|b"); + expect(result).not.toContain("|"); + expect(result).toContain("\u2502"); + }); + + test("replaces newlines with spaces", () => { + const result = safeCodeSpan("line1\nline2"); + expect(result).not.toContain("\n"); + expect(result).toContain("line1 line2"); + }); +}); + +// --------------------------------------------------------------------------- +// divider +// --------------------------------------------------------------------------- + +describe("divider", () => { + test("returns horizontal rule of default width", () => { + const result = divider(); + expect(stripAnsi(result)).toBe("\u2500".repeat(80)); + }); + + test("accepts custom width", () => { + const result = divider(40); + expect(stripAnsi(result)).toBe("\u2500".repeat(40)); + }); +}); + +// --------------------------------------------------------------------------- +// renderMarkdown: block-level rendering +// --------------------------------------------------------------------------- + +describe("renderMarkdown blocks (rendered mode)", () => { + function rendered(md: string): string { + let result = ""; + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + result = renderMarkdown(md); + }); + return result; + } + + test("renders h1/h2 headings with divider bar", () => { + const result = rendered("## My Heading"); + const plain = stripAnsi(result); + expect(plain).toContain("My Heading"); + // h1/h2 have a colored divider bar (━) underneath + expect(plain).toContain("━"); + // Divider length = min(stringWidth("My Heading"), 30) = 10 + expect(plain).toContain("━".repeat(10)); + }); + + test("renders h3+ headings as bold cyan without divider", () => { + const result = rendered("### Sub Heading"); + const plain = stripAnsi(result); + expect(plain).toContain("Sub Heading"); + // h3+ should NOT have a divider bar + expect(plain).not.toContain("━"); + }); + + test("h1/h2 divider bar is clamped to 30 chars", () => { + const longHeading = + "## This Is A Very Long Heading That Exceeds Thirty Characters"; + const result = rendered(longHeading); + const plain = stripAnsi(result); + // Divider should be exactly 30 chars (clamped) + const dividerLine = plain.split("\n").find((line) => line.includes("━")); + expect(dividerLine).toBeDefined(); + expect(dividerLine!.length).toBe(30); + }); + + test("renders paragraphs", () => { + const result = rendered("Hello paragraph text."); + expect(stripAnsi(result)).toContain("Hello paragraph text."); + }); + + test("renders code blocks with language", () => { + const result = rendered("```python\nprint('hello')\n```"); + expect(stripAnsi(result)).toContain("print"); + expect(stripAnsi(result)).toContain("hello"); + }); + + test("renders code blocks without language", () => { + const result = rendered("```\nsome code\n```"); + expect(stripAnsi(result)).toContain("some code"); + }); + + test("renders blockquotes", () => { + const result = rendered("> This is a quote"); + expect(stripAnsi(result)).toContain("This is a quote"); + }); + + test("renders unordered lists", () => { + const result = rendered("- Item A\n- Item B"); + expect(stripAnsi(result)).toContain("Item A"); + expect(stripAnsi(result)).toContain("Item B"); + }); + + test("renders ordered lists", () => { + const result = rendered("1. First\n2. Second"); + expect(stripAnsi(result)).toContain("First"); + expect(stripAnsi(result)).toContain("Second"); + }); + + test("renders horizontal rules", () => { + const result = rendered("---"); + expect(result).toContain("\u2500"); + }); + + test("renders markdown tables as box tables", () => { + const result = rendered("| A | B |\n|---|---|\n| 1 | 2 |"); + expect(stripAnsi(result)).toContain("A"); + expect(stripAnsi(result)).toContain("B"); + expect(stripAnsi(result)).toContain("1"); + expect(stripAnsi(result)).toContain("2"); + }); + + test("hides empty header row in rendered KV tables", () => { + // mdKvTable without heading produces `| | |` empty headers + const md = mdKvTable([ + ["Method", "curl"], + ["Channel", "stable"], + ]); + const result = rendered(md); + const plain = stripAnsi(result); + + // Data rows should be present + expect(plain).toContain("Method"); + expect(plain).toContain("curl"); + expect(plain).toContain("Channel"); + expect(plain).toContain("stable"); + + // Count content rows (lines containing │ vertical border) + const contentLines = plain.split("\n").filter((l) => l.includes("\u2502")); + // Should have exactly 2 data rows, no empty header row + expect(contentLines).toHaveLength(2); + }); + + test("shows header row when headers are non-empty", () => { + const md = "| Key | Value |\n|---|---|\n| a | b |"; + const result = rendered(md); + const plain = stripAnsi(result); + + expect(plain).toContain("Key"); + expect(plain).toContain("Value"); + + // Content rows: header + data = 3 minimum + const contentLines = plain.split("\n").filter((l) => l.includes("\u2502")); + expect(contentLines.length).toBeGreaterThanOrEqual(2); + }); +}); + +// --------------------------------------------------------------------------- +// renderInlineMarkdown: inline token rendering +// --------------------------------------------------------------------------- + +describe("renderInlineMarkdown inline tokens (rendered mode)", () => { + function rendered(md: string): string { + let result = ""; + withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { + result = renderInlineMarkdown(md); + }); + return result; + } + + test("renders italic", () => { + const result = rendered("*italic text*"); + expect(stripAnsi(result)).toContain("italic text"); + // Should have ANSI codes + expect(result).not.toBe("*italic text*"); + }); + + test("renders links", () => { + const result = rendered("[click](https://example.com)"); + expect(stripAnsi(result)).toContain("click"); + }); + + test("renders strikethrough", () => { + const result = rendered("~~deleted~~"); + expect(stripAnsi(result)).toContain("deleted"); + }); + + test("renders color tags", () => { + const result = rendered("ERROR"); + // Tags are consumed by the renderer (not present as raw HTML) + expect(result).not.toContain(""); + expect(result).not.toContain(""); + expect(stripAnsi(result)).toContain("ERROR"); + }); + + test("unknown HTML tags are stripped", () => { + const result = rendered("fruit"); + expect(stripAnsi(result)).toContain("fruit"); + }); + + test("bare open tags are dropped", () => { + const result = rendered("before after"); + expect(stripAnsi(result)).toContain("before"); + expect(stripAnsi(result)).toContain("after"); + }); + + test("bare close tags are dropped", () => { + const result = rendered("before after"); + expect(stripAnsi(result)).toContain("before"); + expect(stripAnsi(result)).toContain("after"); + }); +}); diff --git a/packages/cli/test/lib/formatters/output.test.ts b/packages/cli/test/lib/formatters/output.test.ts new file mode 100644 index 000000000..200bd960a --- /dev/null +++ b/packages/cli/test/lib/formatters/output.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, test } from "vitest"; +import { + type OutputConfig, + renderCommandOutput, + resolveRenderer, + writeFooter, +} from "../../../src/lib/formatters/output.js"; + +/** Collect all writes for assertions (string or binary). */ +function createTestWriter() { + const chunks: Array = []; + return { + write(data: string | Uint8Array) { + chunks.push(data); + return true; + }, + chunks, + /** Full concatenated string output (binary chunks decoded as latin1). */ + get output() { + return chunks + .map((c) => + typeof c === "string" ? c : Buffer.from(c).toString("latin1") + ) + .join(""); + }, + /** Concatenated raw bytes from all writes. */ + get bytes() { + const parts = chunks.map((c) => + typeof c === "string" ? Buffer.from(c, "utf8") : Buffer.from(c) + ); + return Buffer.concat(parts); + }, + }; +} + +/** + * Test helper: calls renderCommandOutput with a fresh renderer resolved + * from the config. Mirrors the real wrapper's per-invocation resolve. + */ +function render( + w: ReturnType, + data: unknown, + config: OutputConfig, + ctx: { json: boolean; fields?: string[] } +) { + const renderer = resolveRenderer(config.human); + renderCommandOutput(w, data, config, renderer, ctx); +} + +describe("writeFooter", () => { + test("writes empty line followed by muted text", () => { + const w = createTestWriter(); + writeFooter(w, "Some hint"); + const output = w.chunks.join(""); + expect(output).toStartWith("\n"); + expect(output).toContain("Some hint"); + expect(output).toEndWith("\n"); + }); +}); + +// --------------------------------------------------------------------------- +// Return-based output (renderCommandOutput) +// --------------------------------------------------------------------------- + +describe("renderCommandOutput", () => { + test("renders JSON when json=true", () => { + const w = createTestWriter(); + const config: OutputConfig<{ id: number; name: string }> = { + human: (d) => `${d.name}`, + }; + render(w, { id: 1, name: "Alice" }, config, { json: true }); + expect(JSON.parse(w.output)).toEqual({ id: 1, name: "Alice" }); + }); + + test("renders human output when json=false", () => { + const w = createTestWriter(); + const config: OutputConfig<{ name: string }> = { + human: (d) => `Hello ${d.name}`, + }; + render(w, { name: "Alice" }, config, { json: false }); + expect(w.output).toBe("Hello Alice\n"); + }); + + test("applies fields filtering in JSON mode", () => { + const w = createTestWriter(); + const config: OutputConfig<{ id: number; name: string; secret: string }> = { + human: (_d) => "unused", + }; + render(w, { id: 1, name: "Alice", secret: "x" }, config, { + json: true, + fields: ["id", "name"], + }); + expect(JSON.parse(w.output)).toEqual({ id: 1, name: "Alice" }); + }); + + test("does not render hints (hints are rendered by the wrapper after generator completes)", () => { + const w = createTestWriter(); + const config: OutputConfig = { + human: (_d) => "Result", + }; + // renderCommandOutput only renders data — hints are handled by + // buildCommand's wrapper via the generator return value + render(w, "data", config, { json: false }); + expect(w.output).toBe("Result\n"); + }); + + test("works without hint", () => { + const w = createTestWriter(); + const config: OutputConfig<{ value: number }> = { + human: (d) => `Value: ${d.value}`, + }; + render(w, { value: 42 }, config, { json: false }); + expect(w.output).toBe("Value: 42\n"); + }); + + test("streams Uint8Array binary bodies raw with no trailing newline", () => { + const w = createTestWriter(); + const config: OutputConfig = { + human: () => "SHOULD_NOT_RUN", + }; + // Real PNG signature — proves no UTF-8 replacement and no formatter path. + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + render(w, png, config, { json: false }); + expect(w.chunks).toHaveLength(1); + expect(w.chunks[0]).toBeInstanceOf(Uint8Array); + expect(Array.from(w.bytes)).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + // No trailing newline added by the text formatter path + expect(w.bytes.length).toBe(8); + }); + + test("streams Uint8Array even when json=true (binary bypasses JSON)", () => { + const w = createTestWriter(); + const config: OutputConfig = { + human: () => "SHOULD_NOT_RUN", + }; + const bytes = new Uint8Array([0xff, 0x00, 0x80]); + render(w, bytes, config, { json: true }); + expect(Array.from(w.bytes)).toEqual([0xff, 0x00, 0x80]); + }); + + test("jsonExclude strips fields from JSON output", () => { + const w = createTestWriter(); + const config: OutputConfig<{ + id: number; + name: string; + spanTreeLines?: string[]; + }> = { + human: (d) => `${d.id}: ${d.name}`, + jsonExclude: ["spanTreeLines"], + }; + render( + w, + { id: 1, name: "Alice", spanTreeLines: ["line1", "line2"] }, + config, + { json: true } + ); + const parsed = JSON.parse(w.output); + expect(parsed).toEqual({ id: 1, name: "Alice" }); + expect(parsed).not.toHaveProperty("spanTreeLines"); + }); + + test("jsonExclude does not affect human output", () => { + const w = createTestWriter(); + const config: OutputConfig<{ + id: number; + spanTreeLines?: string[]; + }> = { + human: (d) => + `${d.id}\n${d.spanTreeLines ? d.spanTreeLines.join("\n") : ""}`, + jsonExclude: ["spanTreeLines"], + }; + render(w, { id: 1, spanTreeLines: ["line1", "line2"] }, config, { + json: false, + }); + expect(w.output).toContain("line1"); + expect(w.output).toContain("line2"); + }); + + test("jsonExclude with empty array is a no-op", () => { + const w = createTestWriter(); + const config: OutputConfig<{ id: number; extra: string }> = { + human: (d) => `${d.id}`, + jsonExclude: [], + }; + render(w, { id: 1, extra: "keep" }, config, { json: true }); + const parsed = JSON.parse(w.output); + expect(parsed).toEqual({ id: 1, extra: "keep" }); + }); + + test("jsonExclude strips fields from array elements", () => { + const w = createTestWriter(); + const config: OutputConfig = { + human: (d: { id: number; name: string }[]) => + d.map((e) => e.name).join(", "), + jsonExclude: ["detectedFrom"], + }; + render( + w, + [ + { id: 1, name: "a", detectedFrom: "dsn" }, + { id: 2, name: "b" }, + ], + config, + { json: true } + ); + const parsed = JSON.parse(w.output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]); + }); + + test("jsonTransform reshapes data for JSON output", () => { + const w = createTestWriter(); + type ListResult = { + items: { id: number; name: string }[]; + hasMore: boolean; + org: string; + }; + const config: OutputConfig = { + human: (d) => d.items.map((i) => i.name).join(", "), + jsonTransform: (data) => ({ + data: data.items, + hasMore: data.hasMore, + }), + }; + render( + w, + { items: [{ id: 1, name: "Alice" }], hasMore: true, org: "test-org" }, + config, + { json: true } + ); + const parsed = JSON.parse(w.output); + expect(parsed).toEqual({ + data: [{ id: 1, name: "Alice" }], + hasMore: true, + }); + // org should not appear (transform omits it) + expect(parsed).not.toHaveProperty("org"); + }); + + test("jsonTransform receives fields for per-element filtering", () => { + const w = createTestWriter(); + type ListResult = { + items: { id: number; name: string; secret: string }[]; + hasMore: boolean; + }; + const config: OutputConfig = { + human: (_d) => "unused", + jsonTransform: (data, fields) => ({ + data: + fields && fields.length > 0 + ? data.items.map((item) => { + const filtered: Record = {}; + for (const f of fields) { + if (f in item) { + filtered[f] = (item as Record)[f]; + } + } + return filtered; + }) + : data.items, + hasMore: data.hasMore, + }), + }; + render( + w, + { + items: [{ id: 1, name: "Alice", secret: "x" }], + hasMore: false, + }, + config, + { json: true, fields: ["id", "name"] } + ); + const parsed = JSON.parse(w.output); + expect(parsed.data[0]).toEqual({ id: 1, name: "Alice" }); + expect(parsed.data[0]).not.toHaveProperty("secret"); + }); + + test("jsonTransform is ignored in human mode", () => { + const w = createTestWriter(); + const config: OutputConfig<{ items: string[]; org: string }> = { + human: (d) => `${d.org}: ${d.items.join(", ")}`, + jsonTransform: (data) => ({ data: data.items }), + }; + render(w, { items: ["a", "b"], org: "test-org" }, config, { + json: false, + }); + expect(w.output).toBe("test-org: a, b\n"); + }); + + test("jsonTransform takes precedence over jsonExclude", () => { + const w = createTestWriter(); + const config: OutputConfig<{ id: number; name: string; extra: string }> = { + human: (_d) => "unused", + jsonExclude: ["extra"], + jsonTransform: (data) => ({ transformed: true, id: data.id }), + }; + render(w, { id: 1, name: "Alice", extra: "kept-by-transform" }, config, { + json: true, + }); + const parsed = JSON.parse(w.output); + // jsonTransform output, not jsonExclude + expect(parsed).toEqual({ transformed: true, id: 1 }); + }); + + test("human factory creates fresh renderer per resolve", () => { + const calls: number[] = []; + const config: OutputConfig<{ n: number }> = { + human: () => ({ + render: (d) => { + calls.push(d.n); + return `#${d.n}`; + }, + }), + }; + + // First resolve + render + const r1 = resolveRenderer(config.human); + r1.render({ n: 1 }); + + // Second resolve = fresh renderer + const r2 = resolveRenderer(config.human); + r2.render({ n: 2 }); + + expect(calls).toEqual([1, 2]); + }); + + test("finalize is called with hint and output is written", () => { + const w = createTestWriter(); + const config: OutputConfig<{ value: string }> = { + human: () => ({ + render: (d) => `[${d.value}]`, + finalize: (hint) => `=== END ===${hint ? `\n${hint}` : ""}`, + }), + }; + + const renderer = resolveRenderer(config.human); + renderCommandOutput(w, { value: "test" }, config, renderer, { + json: false, + }); + expect(w.output).toBe("[test]\n"); + + // Simulate finalize + const footer = renderer.finalize?.("Done."); + expect(footer).toBe("=== END ===\nDone."); + }); + + test("plain function renderer has no finalize method", () => { + const config: OutputConfig = { + human: (s) => s.toUpperCase(), + }; + const renderer = resolveRenderer(config.human); + expect(renderer.render("hello")).toBe("HELLO"); + expect(renderer.finalize).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/formatters/pixel-canvas.test.ts b/packages/cli/test/lib/formatters/pixel-canvas.test.ts new file mode 100644 index 000000000..4ff92a40b --- /dev/null +++ b/packages/cli/test/lib/formatters/pixel-canvas.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test, vi } from "vitest"; +import { + createPixelCanvas, + drawPixelText, +} from "../../../src/lib/formatters/pixel-canvas.js"; +import { getSpleenGlyph } from "../../../src/lib/formatters/spleen-font.js"; + +function opaquePixelCount(image: { data: Uint8Array }): number { + let count = 0; + for (let index = 3; index < image.data.length; index += 4) { + if (image.data[index] === 255) { + count += 1; + } + } + return count; +} + +describe("drawPixelText", () => { + test("uses Spleen's native 8x16 raster at terminal cell size", () => { + const image = createPixelCanvas({ width: 8, height: 16 }); + + drawPixelText(image, "A", { + x: 0, + y: 0, + cellWidth: 8, + cellHeight: 16, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(opaquePixelCount(image)).toBe(44); + expect(image.data[(2 * image.width + 2) * 4 + 3]).toBe(255); + expect(image.data[(0 * image.width + 1) * 4 + 3]).toBe(0); + }); + + test("keeps Spleen's native box and block glyphs for dashboard content", () => { + expect([...getSpleenGlyph("▀")]).toEqual([ + 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + expect([...getSpleenGlyph("─")]).toEqual([ + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + }); + + test("uses a question mark for unsupported text glyphs", () => { + expect([...getSpleenGlyph("\u6f22")]).toEqual([...getSpleenGlyph("?")]); + }); + + test("includes the dashboard axis, sparkline, and ellipsis glyphs", () => { + const questionMark = [...getSpleenGlyph("?")]; + for (const character of [ + "│", + "└", + "┤", + "┬", + "…", + "▁", + "▂", + "▃", + "▄", + "▅", + "▆", + "▇", + "░", + "▓", + "■", + ]) { + expect([...getSpleenGlyph(character)]).not.toEqual(questionMark); + } + }); + + test("renders accented labels and typographic punctuation legibly", () => { + const rendered = renderText("Café — 50…"); + const expected = renderText("Cafe - 50…"); + + expect(rendered.data).toEqual(expected.data); + }); + + test("does not normalize text outside its visible width", () => { + const normalize = vi.spyOn(String.prototype, "normalize"); + const image = createPixelCanvas({ width: 8, height: 16 }); + + try { + drawPixelText(image, `A${"é".repeat(100)}`, { + x: 0, + y: 0, + cellWidth: 8, + cellHeight: 16, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(normalize).not.toHaveBeenCalled(); + } finally { + normalize.mockRestore(); + } + }); + + test("fills non-integer terminal cells with proportional bitmap scaling", () => { + const image = createPixelCanvas({ width: 9, height: 18 }); + + drawPixelText(image, "A", { + x: 0, + y: 0, + cellWidth: 9, + cellHeight: 18, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(opaquePixelCount(image)).toBe(48); + expect(image.data[(2 * image.width + 3) * 4 + 3]).toBe(255); + }); + + test("uses proportional scaling for larger cells", () => { + const image = createPixelCanvas({ width: 12, height: 26 }); + + drawPixelText(image, "A", { + x: 0, + y: 0, + cellWidth: 12, + cellHeight: 26, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(opaquePixelCount(image)).toBe(106); + }); + + test("never draws outside an undersized terminal cell", () => { + const image = createPixelCanvas({ width: 16, height: 16 }); + + drawPixelText(image, "AA", { + x: 4, + y: 4, + cellWidth: 4, + cellHeight: 8, + maxColumns: 2, + color: [255, 255, 255], + }); + + for (let y = 0; y < image.height; y += 1) { + for (let x = 0; x < image.width; x += 1) { + const opaque = image.data[(y * image.width + x) * 4 + 3] === 255; + if (opaque) { + expect(x).toBeGreaterThanOrEqual(4); + expect(x).toBeLessThan(12); + expect(y).toBeGreaterThanOrEqual(4); + expect(y).toBeLessThan(12); + } + } + } + }); +}); + +function renderText(text: string) { + const image = createPixelCanvas({ width: 160, height: 16 }); + drawPixelText(image, text, { + x: 0, + y: 0, + cellWidth: 8, + cellHeight: 16, + maxColumns: 20, + color: [255, 255, 255], + }); + return image; +} diff --git a/packages/cli/test/lib/formatters/replay.test.ts b/packages/cli/test/lib/formatters/replay.test.ts new file mode 100644 index 000000000..31d4ebaa6 --- /dev/null +++ b/packages/cli/test/lib/formatters/replay.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for shape-specific Session Replay activity extraction. + */ + +import { describe, expect, test } from "vitest"; +import { extractReplayActivityEvents } from "../../../src/lib/formatters/replay.js"; + +describe("extractReplayActivityEvents", () => { + test("extracts page, click, performance, and breadcrumb shapes", () => { + const events = extractReplayActivityEvents( + [ + [ + { type: 4, timestamp: 1, data: { href: "/checkout" } }, + { + type: 5, + timestamp: 2, + data: { + tag: "breadcrumb", + payload: { category: "ui.click", message: "#pay" }, + }, + }, + { + type: 5, + timestamp: 3, + data: { + tag: "performanceSpan", + payload: { + op: "resource.fetch", + description: "GET /api/cart", + data: { duration: 42 }, + }, + }, + }, + { + type: 5, + timestamp: 4, + data: { + tag: "breadcrumb", + payload: { + category: "navigation", + message: "Visited checkout", + }, + }, + }, + ], + ], + 10 + ); + + expect(events).toEqual([ + { + timestampMs: 1, + label: "page.view", + details: ["href=/checkout"], + }, + { + timestampMs: 2, + label: "ui.click", + details: ["message=#pay"], + }, + { + timestampMs: 3, + label: "resource.fetch", + details: ["description=GET /api/cart", "duration_ms=42"], + }, + { + timestampMs: 4, + label: "navigation", + details: ["message=Visited checkout"], + }, + ]); + }); + + test("ignores unknown event fields and keeps empty click payload behavior", () => { + const events = extractReplayActivityEvents( + [ + [ + { timestamp: 1 }, + { timestamp: 2, data: null }, + { timestamp: 3, data: { href: "" } }, + { timestamp: 4, data: { tag: "click", payload: {} } }, + { + timestamp: 5, + data: { + tag: "performanceSpan", + payload: { op: "db", data: { duration: "slow" } }, + }, + }, + ], + ], + 10 + ); + + expect(events).toEqual([ + { timestampMs: 4, label: "click", details: [] }, + { timestampMs: 5, label: "db", details: [] }, + ]); + }); +}); diff --git a/packages/cli/test/lib/formatters/seer.test.ts b/packages/cli/test/lib/formatters/seer.test.ts new file mode 100644 index 000000000..588c70770 --- /dev/null +++ b/packages/cli/test/lib/formatters/seer.test.ts @@ -0,0 +1,543 @@ +/** + * Seer Formatter Tests + * + * Tests for formatting functions in src/lib/formatters/seer.ts + */ + +import { describe, expect, test } from "vitest"; +import { SeerError } from "../../../src/lib/errors.js"; +import { + createSeerError, + formatAutofixError, + formatIssueExplain, + formatProgressLine, + formatRootCauseList, + formatSolution, + getProgressMessage, + getSpinnerFrame, + handleSeerApiError, + jsonTransformIssueExplain, + truncateProgressMessage, +} from "../../../src/lib/formatters/seer.js"; +import type { + AutofixState, + RootCause, + SolutionArtifact, +} from "../../../src/types/seer.js"; + +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("getSpinnerFrame", () => { + test("returns a spinner character", () => { + const frame = getSpinnerFrame(0); + expect(typeof frame).toBe("string"); + expect(frame.length).toBeGreaterThan(0); + }); + + test("cycles through frames", () => { + const frame0 = getSpinnerFrame(0); + const frame1 = getSpinnerFrame(1); + const frame10 = getSpinnerFrame(10); + + // Frame 0 and 10 should be the same (assuming 10 frames in the cycle) + expect(frame0).toBe(frame10); + expect(frame0).not.toBe(frame1); + }); +}); + +describe("formatProgressLine", () => { + test("includes message and spinner", () => { + const line = formatProgressLine("Processing...", 0); + expect(line).toContain("Processing..."); + // Should have some character before the message (spinner) + expect(line.length).toBeGreaterThan("Processing...".length); + }); + + test("changes with different tick values", () => { + const line0 = formatProgressLine("Test", 0); + const line1 = formatProgressLine("Test", 1); + expect(line0).not.toBe(line1); + }); +}); + +describe("truncateProgressMessage", () => { + test("returns short message unchanged", () => { + const message = "Analyzing issue..."; + expect(truncateProgressMessage(message)).toBe(message); + }); + + test("returns message at exactly max length unchanged", () => { + const message = "x".repeat(300); + expect(truncateProgressMessage(message)).toBe(message); + }); + + test("truncates message exceeding max length", () => { + const message = "x".repeat(350); + const result = truncateProgressMessage(message); + expect(result.length).toBe(300); + expect(result.endsWith("...")).toBe(true); + }); + + test("preserves content before truncation point", () => { + const message = `Important prefix ${"x".repeat(350)}`; + const result = truncateProgressMessage(message); + expect(result.startsWith("Important prefix")).toBe(true); + }); +}); + +describe("getProgressMessage", () => { + test("returns progress message from steps", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + steps: [ + { + id: "step-1", + key: "analysis", + status: "PROCESSING", + title: "Analyzing", + progress: [ + { + message: "Figuring out the root cause...", + timestamp: "2025-01-01T00:00:00Z", + }, + ], + }, + ], + }; + + expect(getProgressMessage(state)).toBe("Figuring out the root cause..."); + }); + + test("returns default message when no steps", () => { + const state: AutofixState = { + run_id: 123, + status: "PROCESSING", + }; + + const message = getProgressMessage(state); + expect(message).toBeTruthy(); + expect(typeof message).toBe("string"); + }); + + test("returns appropriate message based on status", () => { + // Need empty steps array to trigger status-based fallback + const completedState: AutofixState = { + run_id: 123, + status: "COMPLETED", + steps: [], + }; + + const errorState: AutofixState = { + run_id: 123, + status: "ERROR", + steps: [], + }; + + expect(getProgressMessage(completedState)).toContain("complete"); + expect(getProgressMessage(errorState)).toContain("fail"); + }); + + test("ignores stale progress from earlier completed steps", () => { + // Reproduces #950: RCA step is COMPLETED with progress, solution step + // has no progress yet — should NOT show stale RCA messages. + const state: AutofixState = { + run_id: 456, + status: "PROCESSING", + steps: [ + { + id: "step-rca", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause Analysis", + progress: [ + { + message: "Looking at `src/mdx.ts` in `getsentry/sentry-docs`...", + timestamp: "2025-01-01T00:00:00Z", + }, + ], + }, + { + id: "step-solution", + key: "solution", + status: "PROCESSING", + title: "Solution Planning", + progress: [], + }, + ], + }; + + const message = getProgressMessage(state); + // Should NOT return the stale RCA message + expect(message).not.toContain("Looking at"); + // Should return a status-based fallback + expect(message).toBe("Analyzing issue..."); + }); + + test("returns progress from the current (last) step", () => { + const state: AutofixState = { + run_id: 789, + status: "PROCESSING", + steps: [ + { + id: "step-rca", + key: "root_cause_analysis", + status: "COMPLETED", + title: "Root Cause Analysis", + progress: [ + { + message: "Stale RCA message", + timestamp: "2025-01-01T00:00:00Z", + }, + ], + }, + { + id: "step-solution", + key: "solution", + status: "PROCESSING", + title: "Solution Planning", + progress: [ + { + message: "Generating solution plan...", + timestamp: "2025-01-01T00:01:00Z", + }, + ], + }, + ], + }; + + expect(getProgressMessage(state)).toBe("Generating solution plan..."); + }); +}); + +describe("formatRootCauseList", () => { + test("formats a basic root cause", () => { + const causes: RootCause[] = [ + { + id: 0, + description: + "Database connection timeout due to missing pool configuration", + }, + ]; + + const output = stripAnsi(formatRootCauseList(causes)); + expect(output.length).toBeGreaterThan(0); + expect(output).toContain("Database connection timeout"); + }); + + test("includes relevant repos when present", () => { + const causes: RootCause[] = [ + { + id: 0, + description: "Test cause", + relevant_repos: ["org/repo1", "org/repo2"], + }, + ]; + + const output = stripAnsi(formatRootCauseList(causes)); + expect(output).toContain("org/repo1"); + }); + + test("includes reproduction steps when present", () => { + const causes: RootCause[] = [ + { + id: 0, + description: "Test cause", + root_cause_reproduction: [ + { + title: "Step 1", + code_snippet_and_analysis: "User makes API request", + }, + { + title: "Step 2", + code_snippet_and_analysis: "Database query times out", + }, + ], + }, + ]; + + const output = stripAnsi(formatRootCauseList(causes)); + expect(output).toContain("Step 1"); + expect(output).toContain("User makes API request"); + }); + + test("formats single cause", () => { + const causes: RootCause[] = [{ id: 0, description: "Single root cause" }]; + + const output = stripAnsi(formatRootCauseList(causes)); + expect(output).toContain("Single root cause"); + }); + + test("formats multiple causes", () => { + const causes: RootCause[] = [ + { id: 0, description: "First cause" }, + { id: 1, description: "Second cause" }, + ]; + + const output = stripAnsi(formatRootCauseList(causes)); + expect(output).toContain("First cause"); + expect(output).toContain("Second cause"); + }); + + test("handles empty causes array", () => { + const output = stripAnsi(formatRootCauseList([])); + expect(output).toContain("No root causes"); + }); +}); + +describe("issue explain batch formatting", () => { + const data = { + results: [ + { + issue: "IOS-1", + org: "test-org", + issueId: "1", + rootCauses: [{ id: 0, description: "First cause" }], + }, + { + issue: "IOS-2", + org: "test-org", + issueId: "2", + rootCauses: [{ id: 0, description: "Second cause" }], + }, + ], + requestedCount: 2, + }; + + test("labels each issue in human output", () => { + const output = stripAnsi(formatIssueExplain(data)); + expect(output).toContain("IOS-1"); + expect(output).toContain("First cause"); + expect(output).toContain("─"); + expect(output).toContain("IOS-2"); + expect(output).toContain("Second cause"); + }); + + test("returns labeled envelopes in multi-issue JSON", () => { + expect(jsonTransformIssueExplain(data)).toEqual([ + expect.objectContaining({ + issue: "IOS-1", + rootCauses: [expect.objectContaining({ description: "First cause" })], + }), + expect.objectContaining({ + issue: "IOS-2", + rootCauses: [expect.objectContaining({ description: "Second cause" })], + }), + ]); + }); + + test("preserves the root-cause array for single-issue JSON", () => { + expect( + jsonTransformIssueExplain({ + results: data.results.slice(0, 1), + requestedCount: 1, + }) + ).toEqual([expect.objectContaining({ description: "First cause" })]); + }); + + test("filters root-cause fields without dropping issue labels", () => { + expect(jsonTransformIssueExplain(data, ["description"])).toEqual([ + { + issue: "IOS-1", + org: "test-org", + issueId: "1", + rootCauses: [{ description: "First cause" }], + }, + { + issue: "IOS-2", + org: "test-org", + issueId: "2", + rootCauses: [{ description: "Second cause" }], + }, + ]); + }); +}); + +describe("formatAutofixError", () => { + // Note: 402 and 403 errors are handled by SeerError via createSeerError() + // formatAutofixError only handles non-Seer errors + + test("formats 404 Not Found", () => { + const message = formatAutofixError(404); + expect(message.toLowerCase()).toContain("not found"); + }); + + test("returns detail message for unknown errors", () => { + const message = formatAutofixError(500, "Internal server error"); + expect(message).toBe("Internal server error"); + }); + + test("returns generic message when no detail", () => { + const message = formatAutofixError(500); + expect(message).toBeTruthy(); + }); +}); + +describe("createSeerError", () => { + test("returns SeerError for 402 status", () => { + const error = createSeerError(402, undefined, "my-org"); + expect(error).toBeInstanceOf(SeerError); + expect(error?.reason).toBe("no_budget"); + expect(error?.orgSlug).toBe("my-org"); + }); + + test("returns SeerError for 403 with 'not enabled' detail", () => { + const error = createSeerError(403, "Seer is not enabled", "my-org"); + expect(error).toBeInstanceOf(SeerError); + expect(error?.reason).toBe("not_enabled"); + }); + + test("returns SeerError for 403 with 'AI features' detail", () => { + const error = createSeerError(403, "AI features are disabled", "my-org"); + expect(error).toBeInstanceOf(SeerError); + expect(error?.reason).toBe("ai_disabled"); + }); + + test("returns null for unrecognized 403 errors", () => { + // Unrecognized 403 errors should return null to preserve original error detail + // (could be permission denied, rate limiting, etc.) + const error = createSeerError(403, "Some other message", "my-org"); + expect(error).toBeNull(); + }); + + test("returns null for other status codes", () => { + expect(createSeerError(404)).toBeNull(); + expect(createSeerError(500)).toBeNull(); + expect(createSeerError(200)).toBeNull(); + }); +}); + +describe("handleSeerApiError", () => { + test("returns SeerError for Seer-specific status codes", () => { + const error = handleSeerApiError(402, undefined, "my-org"); + expect(error).toBeInstanceOf(SeerError); + }); + + test("returns generic Error for non-Seer status codes", () => { + const error = handleSeerApiError(404); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(SeerError); + }); + + test("includes detail in generic error message", () => { + const error = handleSeerApiError(500, "Server exploded"); + expect(error.message).toBe("Server exploded"); + }); +}); + +describe("SeerError formatting", () => { + test("format() includes message and URL for not_enabled", () => { + const error = new SeerError("not_enabled", "my-org"); + const formatted = error.format(); + expect(formatted).toContain("Seer is not enabled"); + expect(formatted).toContain("https://my-org.sentry.io/settings/seer/"); + }); + + test("format() includes message and billing URL for no_budget", () => { + const error = new SeerError("no_budget", "my-org"); + const formatted = error.format(); + expect(formatted).toContain("Seer requires a paid plan"); + expect(formatted).toContain( + "https://my-org.sentry.io/settings/billing/overview/?product=seer" + ); + }); + + test("format() includes message and URL for ai_disabled", () => { + const error = new SeerError("ai_disabled", "my-org"); + const formatted = error.format(); + expect(formatted).toContain("AI features are disabled"); + expect(formatted).toContain( + "https://my-org.sentry.io/settings/#hideAiFeatures" + ); + }); + + test("format() uses fallback text when no orgSlug", () => { + const error = new SeerError("not_enabled"); + const formatted = error.format(); + expect(formatted).toContain("Seer is not enabled"); + expect(formatted).toContain("organization's Seer settings"); + // Should NOT contain broken URL patterns + expect(formatted).not.toContain("undefined"); + expect(formatted).not.toContain("/seer/"); + }); +}); + +describe("formatSolution", () => { + function makeSolution( + overrides: Partial = {} + ): SolutionArtifact { + return { + key: "solution", + data: { + one_line_summary: "Add null check before accessing user.name", + steps: [ + { + title: "Update the handler function", + description: "Check for null before accessing the property.", + }, + ], + ...overrides, + }, + }; + } + + test("returns a string", () => { + const result = formatSolution(makeSolution()); + expect(typeof result).toBe("string"); + }); + + test("includes summary text", () => { + const result = stripAnsi(formatSolution(makeSolution())); + expect(result).toContain("Add null check before accessing user.name"); + }); + + test("includes Solution heading", () => { + const result = stripAnsi(formatSolution(makeSolution())); + expect(result).toContain("Solution"); + }); + + test("includes step titles", () => { + const result = stripAnsi( + formatSolution( + makeSolution({ + steps: [ + { title: "Step One", description: "Do the first thing." }, + { title: "Step Two", description: "Do the second thing." }, + ], + }) + ) + ); + expect(result).toContain("Step One"); + expect(result).toContain("Step Two"); + expect(result).toContain("Do the first thing"); + expect(result).toContain("Do the second thing"); + }); + + test("handles empty steps array", () => { + const result = stripAnsi(formatSolution(makeSolution({ steps: [] }))); + expect(result).toContain("Solution"); + expect(result).toContain("Add null check"); + expect(result).not.toContain("Steps to implement"); + }); + + test("preserves markdown in step descriptions", () => { + const result = stripAnsi( + formatSolution( + makeSolution({ + steps: [ + { + title: "Fix code", + description: "Change `foo()` to `bar()`\nThen redeploy.", + }, + ], + }) + ) + ); + expect(result).toContain("Fix code"); + expect(result).toContain("foo()"); + }); +}); diff --git a/packages/cli/test/lib/formatters/semantic-display.test.ts b/packages/cli/test/lib/formatters/semantic-display.test.ts new file mode 100644 index 000000000..599252614 --- /dev/null +++ b/packages/cli/test/lib/formatters/semantic-display.test.ts @@ -0,0 +1,608 @@ +/** + * Unit tests for OTel semantic attribute rendering. + * + * Tests cover the primary semantic formatters (GenAI, MCP, HTTP, DB, process) + * and the integration helpers used by the local formatter. + */ + +import { describe, expect, test } from "vitest"; +import { + type AttributeSource, + collectSpanAttributes, + formatDisplayPart, + formatSemanticSpanDisplay, + inferSemanticOp, + mergeTransactionAttributes, +} from "../../../src/lib/formatters/semantic-display.js"; + +describe("formatSemanticSpanDisplay", () => { + describe("GenAI spans", () => { + test("renders gen_ai operation with model", () => { + const attrs: AttributeSource = { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "claude-4-sonnet", + "gen_ai.provider.name": "anthropic", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("chat anthropic/claude-4-sonnet"); + expect(result.metadata).toEqual([]); + }); + + test("renders gen_ai operation with tool name and model in metadata", () => { + const attrs: AttributeSource = { + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": "search_files", + "gen_ai.request.model": "gpt-4o", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("execute_tool search_files"); + expect(result.metadata).toContain("gpt-4o"); + }); + + test("renders gen_ai agent name", () => { + const attrs: AttributeSource = { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.name": "code-reviewer", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("invoke_agent code-reviewer"); + }); + + test("renders model with provider prefix when no slash", () => { + const attrs: AttributeSource = { + "gen_ai.request.model": "gpt-4o", + "gen_ai.provider.name": "openai", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("openai/gpt-4o"); + }); + + test("does not double-prefix model that already has slash", () => { + const attrs: AttributeSource = { + "gen_ai.request.model": "anthropic/claude-4-sonnet", + "gen_ai.provider.name": "anthropic", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("anthropic/claude-4-sonnet"); + }); + + test("prefers response model over request model", () => { + const attrs: AttributeSource = { + "gen_ai.response.model": "gpt-4o-2026-05-13", + "gen_ai.request.model": "gpt-4o", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("gpt-4o-2026-05-13"); + }); + + test("shows error type in metadata", () => { + const attrs: AttributeSource = { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "gpt-4o", + "error.type": "RateLimitError", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.metadata).toContain("RateLimitError"); + }); + + test("falls back when no gen_ai attributes present", () => { + const attrs: AttributeSource = { "some.other": "value" }; + const result = formatSemanticSpanDisplay(attrs, "my-transaction"); + expect(result.label).toBe("my-transaction"); + expect(result.metadata).toEqual([]); + }); + }); + + describe("MCP spans", () => { + test("renders MCP method with tool name", () => { + const attrs: AttributeSource = { + "mcp.method.name": "tools/call", + "gen_ai.tool.name": "search_files", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("tools/call search_files"); + }); + + test("renders MCP method with resource URI", () => { + const attrs: AttributeSource = { + "mcp.method.name": "resources/read", + "mcp.resource.uri": "file:///src/main.ts?line=42", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("resources/read file:///src/main.ts"); + }); + + test("MCP takes priority over HTTP transport attributes", () => { + const attrs: AttributeSource = { + "mcp.method.name": "tools/call", + "gen_ai.tool.name": "search_files", + "http.request.method": "POST", + "url.full": "http://localhost:3000/mcp", + }; + const result = formatSemanticSpanDisplay(attrs, "POST"); + expect(result.label).toBe("tools/call search_files"); + }); + }); + + describe("HTTP spans", () => { + test("renders HTTP method with URL target", () => { + const attrs: AttributeSource = { + "http.request.method": "get", + "url.full": "https://api.example.com/v1/users?page=1", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("GET api.example.com/v1/users"); + }); + + test("renders HTTP method with route", () => { + const attrs: AttributeSource = { + "http.request.method": "POST", + "http.route": "/api/v1/messages", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("POST /api/v1/messages"); + }); + + test("shows status code in metadata", () => { + const attrs: AttributeSource = { + "http.request.method": "GET", + "http.response.status_code": "200", + "server.address": "api.openai.com", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.metadata).toContain("200"); + }); + + test("shows server address with port", () => { + const attrs: AttributeSource = { + "http.request.method": "POST", + "server.address": "api.anthropic.com", + "server.port": "443", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("POST api.anthropic.com:443"); + }); + + test("handles numeric status code", () => { + const attrs: AttributeSource = { + "http.request.method": "GET", + "http.response.status_code": 200, + "url.full": "https://api.example.com/v1/users", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.metadata).toContain("200"); + }); + }); + + describe("Database spans", () => { + test("renders DB query summary", () => { + const attrs: AttributeSource = { + "db.system.name": "postgresql", + "db.query.summary": "SELECT users", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("SELECT users"); + expect(result.metadata).toContain("postgresql"); + }); + + test("renders DB operation with collection", () => { + const attrs: AttributeSource = { + "db.system.name": "redis", + "db.operation.name": "GET", + "db.collection.name": "sessions", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("GET sessions"); + expect(result.metadata).toContain("redis"); + }); + + test("parameterizes DB query text", () => { + const attrs: AttributeSource = { + "db.system.name": "mysql", + "db.query.text": "SELECT * FROM users WHERE id = 42 AND name = 'Alice'", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe( + "SELECT * FROM users WHERE id = ? AND name = ?" + ); + }); + }); + + describe("Process spans", () => { + test("renders process command with exit code", () => { + const attrs: AttributeSource = { + "process.executable.name": "git", + "process.exit.code": "0", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("git"); + expect(result.metadata).toContain("exit:0"); + }); + + test("renders non-zero exit code", () => { + const attrs: AttributeSource = { + "process.executable.name": "npm", + "process.exit.code": "1", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("npm"); + expect(result.metadata).toContain("exit:1"); + }); + }); + + describe("FaaS spans", () => { + test("renders FaaS invocation with coldstart", () => { + const attrs: AttributeSource = { + "faas.trigger": "http", + "faas.invoked_name": "processOrder", + "faas.invoked_provider": "aws", + "faas.coldstart": "true", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("http processOrder"); + expect(result.metadata).toContain("aws"); + expect(result.metadata).toContain("coldstart"); + }); + + test("ignores faas.coldstart=false", () => { + const attrs: AttributeSource = { + "faas.trigger": "http", + "faas.invoked_name": "handler", + "faas.coldstart": "false", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("http handler"); + expect(result.metadata).not.toContain("coldstart"); + }); + }); + + describe("GraphQL spans", () => { + test("renders operation type with name", () => { + const attrs: AttributeSource = { + "graphql.operation.type": "query", + "graphql.operation.name": "GetUser", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("query GetUser"); + }); + + test("falls back to document when no operation name", () => { + const attrs: AttributeSource = { + "graphql.document": "{ user(id: 1) { name } }", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("{ user(id: 1) { name } }"); + }); + + test("returns null for non-graphql attributes", () => { + const result = formatSemanticSpanDisplay({}, "fallback"); + expect(result.label).toBe("fallback"); + }); + }); + + describe("RPC spans", () => { + test("renders service and method", () => { + const attrs: AttributeSource = { + "rpc.system.name": "grpc", + "rpc.service": "UserService", + "rpc.method": "GetUser", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("UserService/GetUser"); + expect(result.metadata).toContain("grpc"); + }); + + test("shows region and status", () => { + const attrs: AttributeSource = { + "rpc.system.name": "aws-api", + "rpc.service": "S3", + "rpc.method": "PutObject", + "rpc.response.status_code": "200", + "cloud.region": "us-east-1", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("S3/PutObject"); + expect(result.metadata).toContain("us-east-1"); + expect(result.metadata).toContain("200"); + }); + }); + + describe("Messaging spans", () => { + test("renders operation with destination", () => { + const attrs: AttributeSource = { + "messaging.system": "kafka", + "messaging.operation.name": "publish", + "messaging.destination.name": "user-events", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("publish user-events"); + expect(result.metadata).toContain("kafka"); + }); + + test("shows batch message count", () => { + const attrs: AttributeSource = { + "messaging.system": "rabbitmq", + "messaging.operation.name": "receive", + "messaging.destination.name": "orders", + "messaging.batch.message_count": "50", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.metadata).toContain("messages:50"); + }); + }); + + describe("Object store spans", () => { + test("renders bucket and key", () => { + const attrs: AttributeSource = { + "aws.s3.bucket": "my-bucket", + "aws.s3.key": "uploads/photo.jpg", + "rpc.method": "PutObject", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("PutObject my-bucket/uploads/photo.jpg"); + }); + + test("renders bucket alone", () => { + const attrs: AttributeSource = { + "aws.s3.bucket": "my-bucket", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("my-bucket"); + }); + }); + + describe("CloudEvents spans", () => { + test("renders event type with subject", () => { + const attrs: AttributeSource = { + "cloudevents.event_type": "com.example.order.created", + "cloudevents.event_subject": "order-123", + "cloudevents.event_spec_version": "1.0", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("com.example.order.created order-123"); + expect(result.metadata).toContain("cloudevents:1.0"); + }); + }); + + describe("CICD spans", () => { + test("renders pipeline action with name", () => { + const attrs: AttributeSource = { + "cicd.pipeline.action.name": "build", + "cicd.pipeline.name": "main-ci", + "cicd.pipeline.result": "success", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("build main-ci"); + expect(result.metadata).toContain("success"); + }); + + test("renders task name as fallback", () => { + const attrs: AttributeSource = { + "cicd.pipeline.task.name": "run-tests", + "cicd.pipeline.task.run.result": "failed", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("run-tests"); + expect(result.metadata).toContain("failed"); + }); + }); + + describe("Feature flag spans", () => { + test("renders flag key with variant", () => { + const attrs: AttributeSource = { + "feature_flag.key": "new-checkout", + "feature_flag.result.variant": "enabled", + "feature_flag.provider.name": "launchdarkly", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("new-checkout enabled"); + expect(result.metadata).toContain("launchdarkly"); + }); + + test("renders flag key with value when no variant", () => { + const attrs: AttributeSource = { + "feature_flag.key": "rate-limit", + "feature_flag.result.value": "100", + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label).toBe("rate-limit 100"); + }); + }); + + describe("Error/exception spans", () => { + test("renders error type as metadata", () => { + const attrs: AttributeSource = { + "error.type": "TimeoutError", + }; + const result = formatSemanticSpanDisplay(attrs, "my-transaction"); + expect(result.label).toBe("my-transaction"); + expect(result.metadata).toContain("TimeoutError"); + }); + + test("renders exception type for unnamed spans", () => { + const attrs: AttributeSource = { + "exception.type": "ValueError", + "exception.message": "invalid input", + }; + const result = formatSemanticSpanDisplay(attrs, "unnamed"); + expect(result.label).toBe("ValueError"); + }); + + test("shows exception type as metadata for named spans", () => { + const attrs: AttributeSource = { + "exception.type": "IOError", + }; + const result = formatSemanticSpanDisplay(attrs, "read-file"); + expect(result.label).toBe("read-file"); + expect(result.metadata).toContain("IOError"); + }); + }); + + describe("fallback behavior", () => { + test("returns fallback label for empty attributes", () => { + const result = formatSemanticSpanDisplay({}, "GET /api/users"); + expect(result.label).toBe("GET /api/users"); + expect(result.metadata).toEqual([]); + }); + + test("truncates very long labels", () => { + const attrs: AttributeSource = { + "gen_ai.operation.name": "a".repeat(200), + }; + const result = formatSemanticSpanDisplay(attrs, "fallback"); + expect(result.label.length).toBeLessThanOrEqual(123); // 120 + "..." + }); + }); +}); + +describe("inferSemanticOp", () => { + test("returns gen_ai for GenAI attributes", () => { + expect(inferSemanticOp({ "gen_ai.operation.name": "chat" })).toBe("gen_ai"); + }); + + test("returns gen_ai for tool name", () => { + expect(inferSemanticOp({ "gen_ai.tool.name": "search" })).toBe("gen_ai"); + }); + + test("returns gen_ai for agent name", () => { + expect(inferSemanticOp({ "gen_ai.agent.name": "coder" })).toBe("gen_ai"); + }); + + test("returns mcp for MCP attributes", () => { + expect(inferSemanticOp({ "mcp.method.name": "tools/call" })).toBe("mcp"); + }); + + test("returns db for database attributes", () => { + expect(inferSemanticOp({ "db.system.name": "postgresql" })).toBe("db"); + }); + + test("returns undefined for HTTP (keeps original op)", () => { + expect(inferSemanticOp({ "http.request.method": "GET" })).toBeUndefined(); + }); + + test("returns undefined for no attributes", () => { + expect(inferSemanticOp({})).toBeUndefined(); + }); + + test("returns process for process attributes", () => { + expect(inferSemanticOp({ "process.executable.name": "git" })).toBe( + "process" + ); + }); + + test("returns s3 for S3 attributes (not rpc)", () => { + // S3 spans carry rpc.method but should be tagged as s3 + expect( + inferSemanticOp({ + "aws.s3.bucket": "my-bucket", + "rpc.method": "PutObject", + "rpc.service": "S3", + }) + ).toBe("s3"); + }); + + test("returns rpc for RPC attributes", () => { + expect(inferSemanticOp({ "rpc.system.name": "grpc" })).toBe("rpc"); + }); + + test("returns messaging for messaging attributes", () => { + expect(inferSemanticOp({ "messaging.system": "kafka" })).toBe("messaging"); + }); +}); + +describe("mergeTransactionAttributes", () => { + test("extracts attributes from contexts.trace.data", () => { + const event = { + contexts: { + trace: { + data: { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "gpt-4o", + }, + }, + }, + }; + const attrs = mergeTransactionAttributes(event); + expect(attrs["gen_ai.operation.name"]).toBe("chat"); + expect(attrs["gen_ai.request.model"]).toBe("gpt-4o"); + }); + + test("returns empty object when no data", () => { + expect(mergeTransactionAttributes({})).toEqual({}); + expect(mergeTransactionAttributes({ contexts: {} })).toEqual({}); + expect(mergeTransactionAttributes({ contexts: { trace: {} } })).toEqual({}); + }); +}); + +describe("collectSpanAttributes", () => { + test("collects data from each child span", () => { + const event = { + spans: [ + { data: { "http.request.method": "POST" } }, + { data: { "gen_ai.operation.name": "chat" } }, + ], + }; + const collected = collectSpanAttributes(event); + expect(collected).toHaveLength(2); + expect(inferSemanticOp(collected[1])).toBe("gen_ai"); + }); + + test("skips spans without a data object", () => { + const event = { + spans: [{}, { data: null }, { data: { "db.system.name": "postgresql" } }], + }; + const collected = collectSpanAttributes(event); + expect(collected).toHaveLength(1); + }); + + test("returns empty array when no spans", () => { + expect(collectSpanAttributes({})).toEqual([]); + expect(collectSpanAttributes({ spans: "not-an-array" })).toEqual([]); + }); +}); + +describe("formatDisplayPart", () => { + test("formats string values", () => { + expect(formatDisplayPart("hello", 64)).toBe("hello"); + }); + + test("formats numeric values", () => { + expect(formatDisplayPart(200, 64)).toBe("200"); + }); + + test("formats boolean values", () => { + expect(formatDisplayPart(true, 64)).toBe("true"); + }); + + test("returns undefined for null/undefined", () => { + expect(formatDisplayPart(null, 64)).toBeUndefined(); + expect(formatDisplayPart(undefined, 64)).toBeUndefined(); + }); + + test("returns undefined for empty string", () => { + expect(formatDisplayPart("", 64)).toBeUndefined(); + expect(formatDisplayPart(" ", 64)).toBeUndefined(); + }); + + test("truncates long values", () => { + const long = "a".repeat(100); + const result = formatDisplayPart(long, 20); + expect(result).toBe(`${"a".repeat(17)}...`); + }); + + test("collapses whitespace", () => { + expect(formatDisplayPart("hello \n world", 64)).toBe("hello world"); + }); + + test("renders single-element string arrays", () => { + expect(formatDisplayPart(["postgresql"], 64)).toBe("postgresql"); + }); + + test("ignores multi-element arrays", () => { + expect(formatDisplayPart(["a", "b"], 64)).toBeUndefined(); + }); + + test("ignores arrays with non-string elements", () => { + expect(formatDisplayPart([42], 64)).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts new file mode 100644 index 000000000..99e23a63c --- /dev/null +++ b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "vitest"; +import { + formatLegendLabel, + formatTimestamp, + renderDashboardAsSixel, +} from "../../../src/lib/formatters/sixel-dashboard.js"; +import type { DecodedImage } from "../../../src/lib/sixel-image.js"; + +describe("formatTimestamp", () => { + const timestamp = Date.UTC(2024, 0, 15, 10, 30) / 1000; + const date = new Date(timestamp * 1000); + + test("uses clock time for periods shorter than two days", () => { + expect(formatTimestamp(timestamp, 1)).toBe( + `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` + ); + }); + + test("formats the Unix epoch instead of treating it as missing", () => { + const epoch = new Date(0); + expect(formatTimestamp(0, 1)).toBe( + `${String(epoch.getHours()).padStart(2, "0")}:${String(epoch.getMinutes()).padStart(2, "0")}` + ); + expect(formatTimestamp(0, 7)).toBe( + `${String(epoch.getMonth() + 1).padStart(2, "0")}/${String(epoch.getDate()).padStart(2, "0")}` + ); + }); + + test("uses calendar dates for multi-day periods", () => { + expect(formatTimestamp(timestamp, 7)).toBe( + `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}` + ); + expect(formatTimestamp(timestamp, 31)).toBe(`Jan ${date.getDate()}`); + }); +}); + +function renderWidget(displayType: string): { + image: DecodedImage; + result: ReturnType; +} { + let captured: DecodedImage | undefined; + const result = renderDashboardAsSixel( + { + widgets: [ + { + title: "Requests", + displayType, + layout: { x: 0, y: 0, w: 6, h: 2 }, + data: { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 1 }, + { timestamp: 1_700_000_060, value: 4 }, + ], + }, + ], + }, + }, + ], + }, + { + pixelWidth: 120, + cellWidth: 10, + cellHeight: 20, + renderTextContent: () => [], + encodeImage(image) { + captured = image; + return "rendered"; + }, + } + ); + + if (!captured) { + throw new Error("Test encoder did not receive a dashboard image"); + } + return { image: captured, result }; +} + +function countPixels( + image: DecodedImage, + color: [number, number, number] +): number { + let count = 0; + for (let offset = 0; offset < image.data.length; offset += 4) { + if ( + image.data[offset] === color[0] && + image.data[offset + 1] === color[1] && + image.data[offset + 2] === color[2] + ) { + count += 1; + } + } + return count; +} + +describe("dashboard chart rendering", () => { + test("uses heatmap cells only for heatmap widgets", () => { + const heatmapCell: [number, number, number] = [80, 120, 200]; + const line = renderWidget("line"); + const heatmap = renderWidget("heatmap"); + + expect(line.result).toEqual({ output: "rendered" }); + expect(heatmap.result).toEqual({ output: "rendered" }); + expect(countPixels(line.image, heatmapCell)).toBe(0); + expect(countPixels(heatmap.image, heatmapCell)).toBeGreaterThan(0); + }); + + test("uses compact aggregate names in the graphics legend", () => { + expect(formatLegendLabel("p50(span.duration)")).toBe("p50 span.duration"); + expect(formatLegendLabel("p95(value,web.vital,distribution,none)")).toBe( + "p95 web.vital" + ); + expect( + formatLegendLabel("p95(value,backend.duration,distribution,none)") + ).toBe("p95 backend.duration"); + expect(formatLegendLabel("GET /api/projects")).toBe("GET /api/projects"); + }); + + test("centers the no-data state inside a chart widget", () => { + let captured: DecodedImage | undefined; + const result = renderDashboardAsSixel( + { + widgets: [ + { + title: "Cost by Agent", + displayType: "line", + layout: { x: 0, y: 0, w: 6, h: 3 }, + data: { type: "timeseries", series: [] }, + }, + ], + }, + { + pixelWidth: 180, + cellWidth: 10, + cellHeight: 20, + renderTextContent: () => [], + encodeImage(image) { + captured = image; + return "rendered"; + }, + } + ); + + if (!captured) { + throw new Error("Test encoder did not receive a dashboard image"); + } + + expect(result).toEqual({ output: "rendered" }); + expect( + countPixelsInRegion(captured, [128, 128, 128], { + x: 10, + y: 20, + width: 70, + height: 20, + }) + ).toBe(0); + expect( + countPixelsInRegion(captured, [128, 128, 128], { + x: 50, + y: 160, + width: 80, + height: 40, + }) + ).toBeGreaterThan(0); + }); +}); + +function countPixelsInRegion( + image: DecodedImage, + color: [number, number, number], + region: { x: number; y: number; width: number; height: number } +): number { + let count = 0; + const endX = region.x + region.width; + const endY = region.y + region.height; + for (let y = region.y; y < endY; y += 1) { + for (let x = region.x; x < endX; x += 1) { + const offset = (y * image.width + x) * 4; + if ( + image.data[offset] === color[0] && + image.data[offset + 1] === color[1] && + image.data[offset + 2] === color[2] + ) { + count += 1; + } + } + } + return count; +} diff --git a/packages/cli/test/lib/formatters/sixel-timeseries.test.ts b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts new file mode 100644 index 000000000..a68fec308 --- /dev/null +++ b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts @@ -0,0 +1,109 @@ +/** + * Timeseries → sixel renderer tests. + */ + +import { describe, expect, test } from "vitest"; +import { renderTimeseriesAsSixel } from "../../../src/lib/formatters/sixel-timeseries.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +describe("renderTimeseriesAsSixel", () => { + test("returns undefined when there are no series", () => { + const data = makeTimeseries({ series: [] }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("returns undefined when all series are empty", () => { + const data = makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("emits a DCS sixel sequence for a single series", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("emits a DCS sixel sequence for stacked multi-series", () => { + const data = makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 10 }, + ], + }, + ], + }); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("applies background fill when requested", () => { + const data = makeTimeseries(); + const transparent = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: true, + }); + const opaque = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: false, + }); + expect(transparent).toBeDefined(); + expect(opaque).toBeDefined(); + }); + + test("uses sensible defaults for missing options", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); +}); diff --git a/packages/cli/test/lib/formatters/span-tree.test.ts b/packages/cli/test/lib/formatters/span-tree.test.ts new file mode 100644 index 000000000..6c8854d07 --- /dev/null +++ b/packages/cli/test/lib/formatters/span-tree.test.ts @@ -0,0 +1,345 @@ +/** + * Tests for span tree formatting + */ + +import { describe, expect, test } from "vitest"; +import { formatSimpleSpanTree } from "../../../src/lib/formatters/human.js"; +import type { TraceSpan } from "../../../src/types/index.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Test Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Strip ANSI escape codes from a string for content assertions. + * Allows tests to verify text content without color interference. + */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** + * Create a minimal TraceSpan for testing the simple span tree format. + * TraceSpan differs from Span in that it has nested children (hierarchical structure). + * + * @param op - Operation name (e.g., "http.server", "db.query") + * @param description - Human-readable description of the span + * @param children - Nested child spans (already in tree form) + * @param overrides - Optional field overrides for timestamps, duration, etc. + */ +function makeTraceSpan( + op: string, + description: string, + children: TraceSpan[] = [], + overrides?: Partial +): TraceSpan { + return { + span_id: `span-${op}`, + op, + description, + start_timestamp: 1000.0, + timestamp: 1001.0, + children, + ...overrides, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests for formatSimpleSpanTree +// ───────────────────────────────────────────────────────────────────────────── + +describe("formatSimpleSpanTree", () => { + describe("empty and edge cases", () => { + test("returns empty array for empty spans array", () => { + const result = formatSimpleSpanTree("trace-123", []); + expect(result).toEqual([]); + }); + + test("includes trace ID in header", () => { + const spans = [makeTraceSpan("http.server", "GET /api")]; + const result = formatSimpleSpanTree("abc123def456", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("Trace —"); + expect(output).toContain("abc123def456"); + }); + }); + + describe("simple tree format", () => { + test("shows op — description format", () => { + const spans = [makeTraceSpan("http.server", "GET /api/users")]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("http.server"); + expect(output).toContain("—"); + expect(output).toContain("GET /api/users"); + }); + + test("shows duration computed from timestamps", () => { + // start=1000.0, timestamp=1001.0 -> 1000ms -> "1s" + const spans = [makeTraceSpan("db.query", "SELECT * FROM users")]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(1s)"); + }); + + test("shows duration from API-provided duration field", () => { + const spans = [ + makeTraceSpan("http.server", "GET /api", [], { duration: 245 }), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(245ms)"); + }); + + test("prefers API duration over computed duration", () => { + // API says 500ms, timestamps would give 1000ms - API wins + const spans = [ + makeTraceSpan("http.server", "GET /api", [], { + start_timestamp: 1000.0, + timestamp: 1001.0, + duration: 500, + }), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(500ms)"); + }); + + test("uses end_timestamp over timestamp for duration", () => { + // end_timestamp=1000.250 -> 250ms + const spans = [ + makeTraceSpan("db.query", "SELECT 1", [], { + start_timestamp: 1000.0, + timestamp: 1001.0, + end_timestamp: 1000.25, + }), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(250ms)"); + }); + + test("omits duration when no timestamps available", () => { + const spans: TraceSpan[] = [ + { + span_id: "1", + op: "db.query", + description: "SELECT * FROM users", + start_timestamp: 1000.0, + // no timestamp, no end_timestamp, no duration + }, + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).not.toMatch(/\(\d+ms\)/); + expect(output).not.toMatch(/\(\d+\.\d+s\)/); + }); + + test("handles missing op gracefully", () => { + const spans: TraceSpan[] = [ + { + span_id: "1", + description: "Some operation", + start_timestamp: 1000.0, + timestamp: 1001.0, + }, + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("unknown"); + expect(output).toContain("Some operation"); + }); + + test("handles missing description gracefully", () => { + const spans: TraceSpan[] = [ + { + span_id: "1", + op: "http.client", + start_timestamp: 1000.0, + timestamp: 1001.0, + }, + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("http.client"); + expect(output).toContain("(no description)"); + }); + + test("uses transaction as fallback for description", () => { + const spans: TraceSpan[] = [ + { + span_id: "1", + op: "cli", + transaction: "My CLI Command", + start_timestamp: 1000.0, + timestamp: 1001.0, + }, + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("My CLI Command"); + }); + }); + + describe("nested children", () => { + test("renders nested children with indentation", () => { + const spans = [ + makeTraceSpan("cli", "Spotlight CLI", [ + makeTraceSpan("cli.setup", "Setup Spotlight", [ + makeTraceSpan("cli.setup.assets", "Setup Server Assets"), + ]), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const lines = result.map(stripAnsi); + + // Check that all spans are present + const output = lines.join("\n"); + expect(output).toContain("cli — Spotlight CLI"); + expect(output).toContain("cli.setup — Setup Spotlight"); + expect(output).toContain("cli.setup.assets — Setup Server Assets"); + + // Check indentation increases for nested children + const cliLine = lines.find((l) => l.includes("Spotlight CLI")); + const setupLine = lines.find((l) => l.includes("Setup Spotlight")); + const assetsLine = lines.find((l) => l.includes("Setup Server Assets")); + + expect(cliLine).toBeDefined(); + expect(setupLine).toBeDefined(); + expect(assetsLine).toBeDefined(); + + // Nested lines should have more leading whitespace + const cliIndent = cliLine?.match(/^\s*/)?.[0].length ?? 0; + const setupIndent = setupLine?.match(/^\s*/)?.[0].length ?? 0; + const assetsIndent = assetsLine?.match(/^\s*/)?.[0].length ?? 0; + + expect(setupIndent).toBeGreaterThan(cliIndent); + expect(assetsIndent).toBeGreaterThan(setupIndent); + }); + + test("handles multiple root spans", () => { + const spans = [ + makeTraceSpan("http.server", "POST /api"), + makeTraceSpan("db.query", "SELECT *"), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + + expect(output).toContain("http.server — POST /api"); + expect(output).toContain("db.query — SELECT *"); + }); + + test("shows durations on nested children", () => { + const spans = [ + makeTraceSpan( + "http.server", + "GET /api", + [ + makeTraceSpan("db.query", "SELECT *", [], { + start_timestamp: 1000.0, + timestamp: 1000.05, + }), + ], + { start_timestamp: 1000.0, timestamp: 1000.5 } + ), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("GET /api"); + expect(output).toContain("(500ms)"); + expect(output).toContain("SELECT *"); + expect(output).toContain("(50ms)"); + }); + + test("handles deeply nested spans (3+ levels)", () => { + const spans = [ + makeTraceSpan("level1", "First", [ + makeTraceSpan("level2", "Second", [ + makeTraceSpan("level3", "Third", [ + makeTraceSpan("level4", "Fourth"), + ]), + ]), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + + expect(output).toContain("level1 — First"); + expect(output).toContain("level2 — Second"); + expect(output).toContain("level3 — Third"); + expect(output).toContain("level4 — Fourth"); + }); + }); + + describe("depth limiting", () => { + test("respects maxDepth parameter", () => { + const spans = [ + makeTraceSpan("level1", "First", [ + makeTraceSpan("level2", "Second", [makeTraceSpan("level3", "Third")]), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans, 2); + const output = stripAnsi(result.join("\n")); + + // Should show level 1 and 2, but not level 3 + expect(output).toContain("level1 — First"); + expect(output).toContain("level2 — Second"); + expect(output).not.toContain("level3 — Third"); + }); + + test("maxDepth 0 returns empty (disabled)", () => { + const spans = [ + makeTraceSpan("level1", "First", [ + makeTraceSpan("level2", "Second", [makeTraceSpan("level3", "Third")]), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans, 0); + expect(result).toEqual([]); + }); + + test("maxDepth Infinity shows all levels", () => { + const spans = [ + makeTraceSpan("level1", "First", [ + makeTraceSpan("level2", "Second", [makeTraceSpan("level3", "Third")]), + ]), + ]; + const result = formatSimpleSpanTree( + "trace-123", + spans, + Number.POSITIVE_INFINITY + ); + const output = stripAnsi(result.join("\n")); + + expect(output).toContain("level1 — First"); + expect(output).toContain("level2 — Second"); + expect(output).toContain("level3 — Third"); + }); + }); + + describe("tree branch characters", () => { + test("uses tree branch characters", () => { + const spans = [ + makeTraceSpan("root", "Root Span", [ + makeTraceSpan("child1", "First Child"), + makeTraceSpan("child2", "Second Child"), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = result.join("\n"); + + // Should use tree branch characters + expect(output).toContain("└─"); + expect(output).toContain("├─"); + }); + }); + + describe("section header", () => { + test("includes Span Tree section divider", () => { + const spans = [makeTraceSpan("http.server", "GET /api")]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("─── Span Tree ───"); + }); + }); +}); diff --git a/packages/cli/test/lib/formatters/sparkline.property.test.ts b/packages/cli/test/lib/formatters/sparkline.property.test.ts new file mode 100644 index 000000000..880f00725 --- /dev/null +++ b/packages/cli/test/lib/formatters/sparkline.property.test.ts @@ -0,0 +1,128 @@ +/** + * Property-based tests for the sparkline renderer. + * + * Verifies invariants that must hold for any valid input: + * output length, character set, normalization behavior, and edge cases. + */ + +import { array, assert as fcAssert, integer, nat, property } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { sparkline } from "../../../src/lib/formatters/sparkline.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** All valid sparkline characters: scan-line zero + 8 block levels. */ +const VALID_CHARS = ["⎽", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + +/** Block characters only (non-zero values). Used for ordering tests. */ +const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + +/** Arbitrary for non-negative integer arrays (typical event counts). */ +const valuesArb = array(nat({ max: 10_000 }), { minLength: 1, maxLength: 100 }); + +/** Arbitrary for sparkline width (reasonable range). */ +const widthArb = integer({ min: 1, max: 50 }); + +describe("property: sparkline", () => { + test("output length <= width", () => { + fcAssert( + property(valuesArb, widthArb, (values, width) => { + const result = sparkline(values, width); + expect(result.length).toBeLessThanOrEqual(width); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output length equals min(values.length, width)", () => { + fcAssert( + property(valuesArb, widthArb, (values, width) => { + const result = sparkline(values, width); + expect(result.length).toBe(Math.min(values.length, width)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("all characters are valid sparkline characters", () => { + fcAssert( + property(valuesArb, widthArb, (values, width) => { + const result = sparkline(values, width); + for (const char of result) { + expect(VALID_CHARS).toContain(char); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("all-zero input produces all scan-line characters", () => { + fcAssert( + property(integer({ min: 1, max: 50 }), widthArb, (len, width) => { + const values = new Array(len).fill(0); + const result = sparkline(values, width); + for (const char of result) { + expect(char).toBe("⎽"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty input returns empty string", () => { + expect(sparkline([])).toBe(""); + expect(sparkline([], 10)).toBe(""); + }); + + test("singleton positive value maps to top block", () => { + fcAssert( + property(integer({ min: 1, max: 10_000 }), (v) => { + const result = sparkline([v]); + expect(result).toBe("█"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("maximum value in array maps to top block", () => { + fcAssert( + property(valuesArb, (values) => { + const max = Math.max(...values); + if (max === 0) { + return; // all-zero case tested separately + } + // When not downsampled (width >= values.length), max position → █ + const result = sparkline(values, values.length); + const maxIdx = values.indexOf(max); + expect(result[maxIdx]).toBe("█"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("monotonically increasing input produces non-decreasing block heights", () => { + fcAssert( + property(integer({ min: 2, max: 30 }), (len) => { + // Build strictly increasing values + const values = Array.from({ length: len }, (_, i) => i + 1); + const result = sparkline(values, values.length); + for (let i = 1; i < result.length; i++) { + const prev = BLOCKS.indexOf(result[i - 1] ?? ""); + const curr = BLOCKS.indexOf(result[i] ?? ""); + expect(curr).toBeGreaterThanOrEqual(prev); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is deterministic (same input → same output)", () => { + fcAssert( + property(valuesArb, widthArb, (values, width) => { + const a = sparkline(values, width); + const b = sparkline(values, width); + expect(a).toBe(b); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/sql.property.test.ts b/packages/cli/test/lib/formatters/sql.property.test.ts new file mode 100644 index 000000000..d449cfdbe --- /dev/null +++ b/packages/cli/test/lib/formatters/sql.property.test.ts @@ -0,0 +1,185 @@ +/** + * Property-Based Tests for SQL Syntax Highlighting + * + * Verifies invariants that should hold for any valid SQL-ish input: + * - ANSI stripping preserves original text content + * - Plain mode returns input unchanged + * - Colorization is deterministic + */ + +import chalk from "chalk"; +import { + array, + constantFrom, + assert as fcAssert, + oneof, + property, + stringMatching, +} from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { stripAnsi } from "../../../src/lib/formatters/plain-detect.js"; +import { colorizeSql, isDbSpanOp } from "../../../src/lib/formatters/sql.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// Force chalk to emit ANSI codes even when stdout is not a TTY (test runner) +chalk.level = 3; + +let originalPlainOutput: string | undefined; +let originalNoColor: string | undefined; + +beforeEach(() => { + originalPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + originalNoColor = process.env.NO_COLOR; +}); + +afterEach(() => { + if (originalPlainOutput !== undefined) { + process.env.SENTRY_PLAIN_OUTPUT = originalPlainOutput; + } else { + delete process.env.SENTRY_PLAIN_OUTPUT; + } + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } +}); + +/** SQL keywords that @sentry/sqlish recognizes */ +const sqlKeywordArb = constantFrom( + "SELECT", + "FROM", + "WHERE", + "INSERT", + "INTO", + "UPDATE", + "DELETE", + "JOIN", + "LEFT", + "RIGHT", + "INNER", + "ORDER", + "BY", + "GROUP", + "LIMIT", + "OFFSET", + "VALUES", + "SET", + "AND", + "OR", + "IN", + "NOT", + "NULL", + "AS", + "ON", + "RETURNING" +); + +/** Table/column identifiers */ +const identifierArb = stringMatching(/^[a-z_][a-z0-9_]{0,15}$/); + +/** Parameter placeholders */ +const parameterArb = constantFrom("%s", "$1", "$2", "$3", "?", "%d"); + +/** Build random SQL-ish strings from tokens */ +const sqlTokenArb = oneof(sqlKeywordArb, identifierArb, parameterArb); + +const sqlStringArb = array(sqlTokenArb, { minLength: 1, maxLength: 12 }).map( + (tokens) => tokens.join(" ") +); + +/** Span op values */ +const dbOpArb = constantFrom( + "db", + "db.query", + "db.sql.query", + "db.sql.execute", + "db.redis", + "db.mongodb" +); +const nonDbOpArb = constantFrom( + "http.client", + "http.server", + "cache.get", + "browser", + "queue.process" +); + +describe("property: isDbSpanOp", () => { + test("always true for db.* ops", () => { + fcAssert( + property(dbOpArb, (op) => { + expect(isDbSpanOp(op)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always false for non-db ops", () => { + fcAssert( + property(nonDbOpArb, (op) => { + expect(isDbSpanOp(op)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: colorizeSql", () => { + test("stripping ANSI preserves original text content (case-insensitive)", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + fcAssert( + property(sqlStringArb, (sql) => { + const colorized = colorizeSql(sql); + const stripped = stripAnsi(colorized); + // Case-insensitive: @sentry/sqlish uppercases SQL keywords (e.g. "by" → "BY") + expect(stripped.toLowerCase()).toBe(sql.toLowerCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("plain mode returns input unchanged (identity)", () => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + fcAssert( + property(sqlStringArb, (sql) => { + expect(colorizeSql(sql)).toBe(sql); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic: same input always gives same output", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + fcAssert( + property(sqlStringArb, (sql) => { + expect(colorizeSql(sql)).toBe(colorizeSql(sql)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent strip: stripAnsi(colorizeSql(x)) == stripAnsi(colorizeSql(stripAnsi(colorizeSql(x))))", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + fcAssert( + property(sqlStringArb, (sql) => { + const once = stripAnsi(colorizeSql(sql)); + const twice = stripAnsi(colorizeSql(stripAnsi(colorizeSql(sql)))); + expect(once).toBe(twice); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("never returns empty for non-empty input", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + fcAssert( + property(sqlStringArb, (sql) => { + if (sql.length > 0) { + expect(colorizeSql(sql).length).toBeGreaterThan(0); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/sql.test.ts b/packages/cli/test/lib/formatters/sql.test.ts new file mode 100644 index 000000000..912028a28 --- /dev/null +++ b/packages/cli/test/lib/formatters/sql.test.ts @@ -0,0 +1,167 @@ +/** + * Unit tests for SQL syntax highlighting. + * + * Core invariants (round-trips, plain-mode identity) are tested via + * property-based tests in sql.property.test.ts. These tests focus on + * specific token coloring, edge cases, and formatSqlBlock structure. + */ + +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { stripAnsi } from "../../../src/lib/formatters/plain-detect.js"; +import { + colorizeSql, + formatSqlBlock, + isDbSpanOp, +} from "../../../src/lib/formatters/sql.js"; + +// Force chalk to emit ANSI codes even when stdout is not a TTY (test runner) +chalk.level = 3; + +let originalPlainOutput: string | undefined; +let originalNoColor: string | undefined; + +beforeEach(() => { + originalPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + originalNoColor = process.env.NO_COLOR; +}); + +afterEach(() => { + if (originalPlainOutput !== undefined) { + process.env.SENTRY_PLAIN_OUTPUT = originalPlainOutput; + } else { + delete process.env.SENTRY_PLAIN_OUTPUT; + } + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } +}); + +describe("isDbSpanOp", () => { + test('returns true for "db"', () => { + expect(isDbSpanOp("db")).toBe(true); + }); + + test('returns true for "db.query"', () => { + expect(isDbSpanOp("db.query")).toBe(true); + }); + + test('returns true for "db.sql.query"', () => { + expect(isDbSpanOp("db.sql.query")).toBe(true); + }); + + test('returns true for "db.redis"', () => { + expect(isDbSpanOp("db.redis")).toBe(true); + }); + + test('returns false for "http.client"', () => { + expect(isDbSpanOp("http.client")).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isDbSpanOp(undefined)).toBe(false); + }); + + test("returns false for null", () => { + expect(isDbSpanOp(null)).toBe(false); + }); + + test("returns false for empty string", () => { + expect(isDbSpanOp("")).toBe(false); + }); + + test('returns false for "database" (no dot separator)', () => { + expect(isDbSpanOp("database")).toBe(false); + }); +}); + +describe("colorizeSql", () => { + test("returns original text in plain mode", () => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + const sql = "SELECT id, name FROM users WHERE id = %s"; + expect(colorizeSql(sql)).toBe(sql); + }); + + test("returns string with ANSI codes in TTY mode for SQL with keywords", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const sql = "SELECT id FROM users"; + const result = colorizeSql(sql); + // Should contain ANSI escape codes + expect(result).toContain("\x1b["); + // Should still contain the original text when stripped + expect(stripAnsi(result)).toBe(sql); + }); + + test("colorizes SQL keywords", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const result = colorizeSql("SELECT * FROM users"); + // Keywords should be colored (different from plain text) + expect(result).not.toBe("SELECT * FROM users"); + expect(stripAnsi(result)).toBe("SELECT * FROM users"); + }); + + test("colorizes parameters", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const result = colorizeSql("SELECT * FROM users WHERE id = %s"); + expect(result).toContain("\x1b["); + expect(stripAnsi(result)).toBe("SELECT * FROM users WHERE id = %s"); + }); + + test("handles empty string", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const result = colorizeSql(""); + expect(result).toBe(""); + }); + + test("handles non-SQL content gracefully", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const input = "GET /api/users/123"; + const result = colorizeSql(input); + // Should not crash; stripped output should contain original text + expect(stripAnsi(result)).toContain("GET"); + }); +}); + +describe("formatSqlBlock", () => { + test("returns compact single-line with header in plain mode", () => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + const sql = "SELECT id, name FROM users WHERE id = %s"; + const result = formatSqlBlock(sql); + expect(result).toContain("─── Query ───"); + expect(result).toContain(sql); + // No ANSI codes + expect(result).not.toContain("\x1b["); + }); + + test("returns multi-line formatted SQL with header in TTY mode", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const sql = "SELECT id, name FROM users WHERE id = %s"; + const result = formatSqlBlock(sql); + expect(result).toContain("─── Query ───"); + // Should contain ANSI escape codes + expect(result).toContain("\x1b["); + // Should have newlines from pretty-printing + const stripped = stripAnsi(result); + // The pretty-printer adds newlines at SELECT, FROM, WHERE + expect(stripped).toContain("SELECT"); + expect(stripped).toContain("FROM"); + expect(stripped).toContain("WHERE"); + }); + + test("does not reformat SQL in plain mode", () => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + const sql = "SELECT id FROM users WHERE active = true"; + const result = formatSqlBlock(sql); + // The raw SQL should appear exactly as-is (not reformatted) + expect(result).toContain(sql); + }); + + test("handles simple queries", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const sql = "SELECT 1"; + const result = formatSqlBlock(sql); + expect(stripAnsi(result)).toContain("SELECT"); + }); +}); diff --git a/packages/cli/test/lib/formatters/table.test.ts b/packages/cli/test/lib/formatters/table.test.ts new file mode 100644 index 000000000..3510a06e1 --- /dev/null +++ b/packages/cli/test/lib/formatters/table.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for the generic table renderer. + * + * writeTable now renders via marked-terminal producing Unicode box-drawing + * tables. Tests verify content is present rather than exact text alignment. + */ + +import { describe, expect, test, vi } from "vitest"; +import { escapeMarkdownCell } from "../../../src/lib/formatters/markdown.js"; +import { type Column, writeTable } from "../../../src/lib/formatters/table.js"; + +type Row = { name: string; count: number; status: string }; + +const columns: Column[] = [ + { header: "NAME", value: (r) => r.name }, + { header: "COUNT", value: (r) => String(r.count), align: "right" }, + { header: "STATUS", value: (r) => r.status }, +]; + +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function capture(items: Row[], cols = columns): string { + const write = vi.fn(() => true); + writeTable({ write }, items, cols); + return stripAnsi(write.mock.calls.map((c) => c[0]).join("")); +} + +describe("writeTable", () => { + test("renders header and rows with content", () => { + const output = capture([ + { name: "alpha", count: 42, status: "active" }, + { name: "beta-longer", count: 7, status: "inactive" }, + ]); + + // Header present + expect(output).toContain("NAME"); + expect(output).toContain("COUNT"); + expect(output).toContain("STATUS"); + + // Data present + expect(output).toContain("alpha"); + expect(output).toContain("42"); + expect(output).toContain("active"); + expect(output).toContain("beta-longer"); + expect(output).toContain("7"); + expect(output).toContain("inactive"); + }); + + test("all column values appear in rendered output", () => { + const output = capture([ + { name: "a", count: 1, status: "ok" }, + { name: "b", count: 999, status: "ok" }, + ]); + + expect(output).toContain("1"); + expect(output).toContain("999"); + }); + + test("respects minWidth — values appear in output", () => { + const cols: Column[] = [ + { header: "N", value: (r) => r.name, minWidth: 10 }, + { header: "C", value: (r) => String(r.count) }, + { header: "S", value: (r) => r.status }, + ]; + + const output = capture([{ name: "x", count: 1, status: "y" }], cols); + expect(output).toContain("N"); + expect(output).toContain("x"); + expect(output).toContain("1"); + expect(output).toContain("y"); + }); + + test("handles empty items array — only headers rendered", () => { + const output = capture([]); + expect(output).toContain("NAME"); + expect(output).toContain("COUNT"); + expect(output).toContain("STATUS"); + }); + + test("column width respects header length even with short values", () => { + const cols: Column<{ v: string }>[] = [ + { header: "VERY_LONG_HEADER", value: (r) => r.v }, + ]; + const write = vi.fn(() => true); + writeTable({ write }, [{ v: "x" }], cols); + const output = stripAnsi(write.mock.calls.map((c) => c[0]).join("")); + expect(output).toContain("VERY_LONG_HEADER"); + expect(output).toContain("x"); + }); +}); + +// --------------------------------------------------------------------------- +// TTY-mode output (ANSI box-drawing tables) +// --------------------------------------------------------------------------- + +describe("writeTable (TTY mode)", () => { + const saved = { + plain: process.env.SENTRY_PLAIN_OUTPUT, + columns: process.stdout.columns, + }; + + function withTty(fn: () => void): void { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + process.stdout.columns = 120; + try { + fn(); + } finally { + if (saved.plain !== undefined) { + process.env.SENTRY_PLAIN_OUTPUT = saved.plain; + } else { + delete process.env.SENTRY_PLAIN_OUTPUT; + } + process.stdout.columns = saved.columns as number; + } + } + + test("renders Unicode box-drawing borders", () => { + withTty(() => { + const write = vi.fn(() => true); + writeTable({ write }, [{ name: "a", count: 1, status: "ok" }], columns); + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(stripAnsi(output)).toContain("│"); + expect(stripAnsi(output)).toContain("─"); + }); + }); + + test("renders content in ANSI mode", () => { + withTty(() => { + const write = vi.fn(() => true); + writeTable( + { write }, + [{ name: "alice", count: 42, status: "active" }], + columns + ); + const output = stripAnsi(write.mock.calls.map((c) => c[0]).join("")); + expect(output).toContain("NAME"); + expect(output).toContain("alice"); + expect(output).toContain("42"); + expect(output).toContain("active"); + }); + }); + + test("passes rowSeparator option to renderer", () => { + withTty(() => { + const write = vi.fn(() => true); + writeTable( + { write }, + [ + { name: "a", count: 1, status: "x" }, + { name: "b", count: 2, status: "y" }, + { name: "c", count: 3, status: "z" }, + ], + columns, + { rowSeparator: true } + ); + const raw = write.mock.calls.map((c) => c[0]).join(""); + const output = stripAnsi(raw); + // With 3 data rows, there should be 2 row separators (├─┼─┤ lines) + // plus 1 header separator = at least 3 mid-lines with ├ + const midLines = output.split("\n").filter((l) => l.includes("├")); + expect(midLines.length).toBeGreaterThanOrEqual(3); + }); + }); + + test("passes rowSeparator color string to renderer", () => { + withTty(() => { + const write = vi.fn(() => true); + const color = "\x1b[38;2;137;130;148m"; + writeTable( + { write }, + [ + { name: "a", count: 1, status: "x" }, + { name: "b", count: 2, status: "y" }, + ], + columns, + { rowSeparator: color } + ); + const raw = write.mock.calls.map((c) => c[0]).join(""); + // The color escape should appear in the output + expect(raw).toContain(color); + }); + }); + + test("passes truncate option to renderer", () => { + withTty(() => { + const cols: Column<{ v: string }>[] = [ + { header: "VAL", value: (r) => r.v, minWidth: 10 }, + ]; + const write = vi.fn(() => true); + const longText = "a".repeat(200); + writeTable({ write }, [{ v: longText }], cols, { truncate: true }); + const output = stripAnsi(write.mock.calls.map((c) => c[0]).join("")); + // Truncated text should have ellipsis + expect(output).toContain("…"); + }); + }); + + test("respects column alignment", () => { + withTty(() => { + const write = vi.fn(() => true); + writeTable({ write }, [{ name: "x", count: 42, status: "ok" }], columns); + const output = stripAnsi(write.mock.calls.map((c) => c[0]).join("")); + // Right-aligned COUNT should have leading space before 42 + const lines = output.split("\n").filter((l) => l.includes("42")); + expect(lines.length).toBeGreaterThan(0); + // The 42 should be preceded by at least one space (right-aligned) + expect(lines[0]).toMatch(/\s+42\s/); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Plain-mode output (raw markdown tables) +// --------------------------------------------------------------------------- + +describe("writeTable (plain mode)", () => { + const saved = { + plain: process.env.SENTRY_PLAIN_OUTPUT, + noColor: process.env.NO_COLOR, + }; + + function withPlain(fn: () => void): void { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + process.env.NO_COLOR = undefined; + try { + fn(); + } finally { + if (saved.plain !== undefined) { + process.env.SENTRY_PLAIN_OUTPUT = saved.plain; + } else { + delete process.env.SENTRY_PLAIN_OUTPUT; + } + if (saved.noColor !== undefined) { + process.env.NO_COLOR = saved.noColor; + } else { + delete process.env.NO_COLOR; + } + } + } + + test("emits box-drawing table with stripped markdown", () => { + withPlain(() => { + const write = vi.fn(() => true); + writeTable( + { write }, + [{ name: "alice", count: 1, status: "ok" }], + columns + ); + const output = write.mock.calls.map((c) => c[0]).join(""); + // Box-drawing table format (not raw markdown pipes) + expect(output).toContain("│"); + expect(output).toContain("NAME"); + expect(output).toContain("alice"); + }); + }); + + test("pipe characters in values are preserved in box-drawing table", () => { + withPlain(() => { + const cols: Column<{ v: string }>[] = [ + { header: "VAL", value: (r) => escapeMarkdownCell(r.v) }, + ]; + const write = vi.fn(() => true); + writeTable({ write }, [{ v: "a|b" }], cols); + const output = write.mock.calls.map((c) => c[0]).join(""); + // Content is visible in box-drawing table + expect(output).toContain("VAL"); + }); + }); +}); diff --git a/packages/cli/test/lib/formatters/text-table.test.ts b/packages/cli/test/lib/formatters/text-table.test.ts new file mode 100644 index 000000000..465e8990c --- /dev/null +++ b/packages/cli/test/lib/formatters/text-table.test.ts @@ -0,0 +1,445 @@ +/** + * Tests for the ANSI-aware text table renderer. + * + * Covers: renderTextTable, column fitting (proportional + balanced), + * cell wrapping, alignment, border styles, and edge cases. + */ + +import chalk from "chalk"; +import { describe, expect, test } from "vitest"; +import { renderTextTable } from "../../../src/lib/formatters/text-table.js"; + +// Force chalk colors even in test (non-TTY) environment +chalk.level = 3; + +describe("renderTextTable", () => { + describe("basic rendering", () => { + test("empty headers returns empty string", () => { + expect(renderTextTable([], [])).toBe(""); + }); + + test("renders single-column table", () => { + const out = renderTextTable(["Name"], [["Alice"], ["Bob"]]); + expect(out).toContain("Name"); + expect(out).toContain("Alice"); + expect(out).toContain("Bob"); + expect(out.endsWith("\n")).toBe(true); + }); + + test("renders multi-column table", () => { + const out = renderTextTable( + ["ID", "Name", "Role"], + [ + ["1", "Alice", "Admin"], + ["2", "Bob", "User"], + ] + ); + expect(out).toContain("ID"); + expect(out).toContain("Name"); + expect(out).toContain("Role"); + expect(out).toContain("Alice"); + expect(out).toContain("Admin"); + expect(out).toContain("Bob"); + expect(out).toContain("User"); + }); + + test("renders header-only table (no data rows)", () => { + const out = renderTextTable(["A", "B"], []); + expect(out).toContain("A"); + expect(out).toContain("B"); + expect(out.endsWith("\n")).toBe(true); + }); + }); + + describe("border styles", () => { + test("rounded (default) uses curved corners", () => { + const out = renderTextTable(["X"], [["1"]]); + expect(out).toContain("\u256d"); + expect(out).toContain("\u256e"); + expect(out).toContain("\u2570"); + expect(out).toContain("\u256f"); + }); + + test("single uses square corners", () => { + const out = renderTextTable(["X"], [["1"]], { borderStyle: "single" }); + expect(out).toContain("\u250c"); + expect(out).toContain("\u2510"); + expect(out).toContain("\u2514"); + expect(out).toContain("\u2518"); + }); + + test("heavy uses heavy corners", () => { + const out = renderTextTable(["X"], [["1"]], { borderStyle: "heavy" }); + expect(out).toContain("\u250f"); + expect(out).toContain("\u2513"); + expect(out).toContain("\u2517"); + expect(out).toContain("\u251b"); + }); + + test("double uses double corners", () => { + const out = renderTextTable(["X"], [["1"]], { borderStyle: "double" }); + expect(out).toContain("\u2554"); + expect(out).toContain("\u2557"); + expect(out).toContain("\u255a"); + expect(out).toContain("\u255d"); + }); + }); + + describe("header separator", () => { + test("includes separator by default when data rows present", () => { + const out = renderTextTable(["H"], [["d"]]); + expect(out).toContain("\u251c"); // ├ + expect(out).toContain("\u2524"); // ┤ + }); + + test("headerSeparator: false omits separator", () => { + const out = renderTextTable(["H"], [["d"]], { headerSeparator: false }); + expect(out).not.toContain("\u251c"); + expect(out).not.toContain("\u2524"); + }); + }); + + describe("hideHeaders", () => { + test("hideHeaders: true omits the header row from output", () => { + const out = renderTextTable(["", ""], [["Key", "Val"]], { + hideHeaders: true, + }); + // Data row should be present + expect(out).toContain("Key"); + expect(out).toContain("Val"); + // Count content lines (between top and bottom border) + const lines = out.split("\n").filter((l) => l.includes("\u2502")); // │ + // With hideHeaders, only the data row should produce content lines + expect(lines).toHaveLength(1); + }); + + test("hideHeaders: true still uses header widths for column measurement", () => { + // Headers are wide, data is short — columns should still be sized for headers + const withHide = renderTextTable( + ["LongHeader1", "LongHeader2"], + [["a", "b"]], + { hideHeaders: true, maxWidth: 80 } + ); + const withoutHide = renderTextTable( + ["LongHeader1", "LongHeader2"], + [["a", "b"]], + { hideHeaders: false, maxWidth: 80 } + ); + // Both should produce the same column widths (same top border line) + const topBorderHide = withHide.split("\n")[0]; + const topBorderShow = withoutHide.split("\n")[0]; + expect(topBorderHide).toBe(topBorderShow); + }); + + test("auto-hides headers when all cells are empty", () => { + const out = renderTextTable(["", ""], [["Key", "Val"]]); + expect(out).toContain("Key"); + expect(out).toContain("Val"); + // Should auto-hide: only 1 content line (data row), no empty header row + const lines = out.split("\n").filter((l) => l.includes("\u2502")); + expect(lines).toHaveLength(1); + }); + + test("does not auto-hide when headers have content", () => { + const out = renderTextTable(["H1", "H2"], [["d1", "d2"]]); + expect(out).toContain("H1"); + expect(out).toContain("H2"); + }); + + test("explicit hideHeaders: false overrides auto-detection", () => { + const out = renderTextTable(["", ""], [["Key", "Val"]], { + hideHeaders: false, + }); + // Empty header row should be visible (explicit override) + const lines = out.split("\n").filter((l) => l.includes("\u2502")); + // 2 content lines: empty header + data row + expect(lines).toHaveLength(2); + }); + }); + + describe("alignment", () => { + test("right-aligned column pads text on the left", () => { + const out = renderTextTable(["Amount"], [["42"]], { + alignments: ["right"], + maxWidth: 40, + }); + const lines = out.split("\n"); + const dataLine = lines.find((l) => l.includes("42")); + expect(dataLine).toBeDefined(); + // Right-aligned: spaces before the value + const cellContent = dataLine!.split("\u2502")[1] ?? ""; + const trimmed = cellContent.trimStart(); + expect(cellContent.length).toBeGreaterThan(trimmed.length); + }); + + test("center-aligned column centers text", () => { + const out = renderTextTable(["Title"], [["Hi"]], { + alignments: ["center"], + maxWidth: 40, + }); + const lines = out.split("\n"); + const dataLine = lines.find((l) => l.includes("Hi")); + expect(dataLine).toBeDefined(); + }); + + test("default alignment is left", () => { + const out = renderTextTable(["Name"], [["A"]], { maxWidth: 40 }); + expect(out).toContain("A"); + }); + }); + + describe("column fitting", () => { + test("columns that fit naturally keep intrinsic widths", () => { + const out = renderTextTable(["A", "B"], [["x", "y"]], { maxWidth: 200 }); + expect(out).toContain("A"); + expect(out).toContain("B"); + }); + + test("proportional fitter shrinks wide columns more", () => { + const out = renderTextTable( + ["Short", "This is a very long header that needs shrinking"], + [["a", "b"]], + { maxWidth: 30, columnFitter: "proportional" } + ); + // Content is present (may be wrapped) + expect(out.length).toBeGreaterThan(0); + expect(out.endsWith("\n")).toBe(true); + expect(out.endsWith("\n")).toBe(true); + }); + + test("balanced fitter distributes shrink more evenly", () => { + const out = renderTextTable( + ["Short", "This is a very long header that needs shrinking"], + [["a", "b"]], + { maxWidth: 30, columnFitter: "balanced" } + ); + // Content is present (may be wrapped) + expect(out.length).toBeGreaterThan(0); + expect(out.endsWith("\n")).toBe(true); + expect(out.endsWith("\n")).toBe(true); + }); + + test("very narrow maxWidth still produces valid table", () => { + const out = renderTextTable( + ["Header One", "Header Two", "Header Three"], + [["data1", "data2", "data3"]], + { maxWidth: 15 } + ); + expect(out.length).toBeGreaterThan(0); + expect(out.endsWith("\n")).toBe(true); + }); + + test("proportional and balanced produce different layouts", () => { + const headers = ["A", "This is a much wider column"]; + const rows = [["x", "y"]]; + const prop = renderTextTable(headers, rows, { + maxWidth: 25, + columnFitter: "proportional", + }); + const bal = renderTextTable(headers, rows, { + maxWidth: 25, + columnFitter: "balanced", + }); + // Both should be valid tables but may differ in column widths + expect(prop).toContain("A"); + expect(bal).toContain("A"); + }); + }); + + describe("cell wrapping", () => { + test("long cell values wrap to multiple lines", () => { + const out = renderTextTable( + ["Name"], + [["This is a very long cell value that should wrap"]], + { maxWidth: 20 } + ); + const dataLines = out + .split("\n") + .filter((l) => l.includes("\u2502") && !l.includes("Name")); + expect(dataLines.length).toBeGreaterThan(1); + }); + }); + + describe("ANSI-aware rendering", () => { + test("preserves ANSI codes in cell values", () => { + const colored = chalk.red("ERROR"); + const out = renderTextTable(["Status"], [[colored]], { maxWidth: 40 }); + expect(out).toContain("\x1b["); + expect(out).toContain("ERROR"); + }); + + test("column width computed from visual width not byte length", () => { + const colored = chalk.red("Hi"); + const plain = "Hi"; + const outColored = renderTextTable(["H"], [[colored]], { maxWidth: 40 }); + const outPlain = renderTextTable(["H"], [[plain]], { maxWidth: 40 }); + const hzColored = (outColored.match(/\u2500/g) ?? []).length; + const hzPlain = (outPlain.match(/\u2500/g) ?? []).length; + expect(hzColored).toBe(hzPlain); + }); + }); + + describe("cellPadding", () => { + test("cellPadding: 0 produces tighter table", () => { + const tight = renderTextTable(["A"], [["x"]], { cellPadding: 0 }); + const padded = renderTextTable(["A"], [["x"]], { cellPadding: 2 }); + const tightWidth = (tight.split("\n")[0] ?? "").length; + const paddedWidth = (padded.split("\n")[0] ?? "").length; + expect(tightWidth).toBeLessThan(paddedWidth); + }); + }); + + describe("multi-column structure", () => { + test("columns are separated by vertical border character", () => { + const out = renderTextTable(["A", "B", "C"], [["1", "2", "3"]]); + const dataLines = out + .split("\n") + .filter((l) => l.includes("1") && l.includes("2") && l.includes("3")); + expect(dataLines.length).toBeGreaterThan(0); + const pipeCount = (dataLines[0] ?? "").split("\u2502").length - 1; + expect(pipeCount).toBe(4); // 3 columns = 4 borders + }); + + test("top border has T-junctions between columns", () => { + const out = renderTextTable(["A", "B", "C"], [["1", "2", "3"]]); + const topLine = out.split("\n")[0] ?? ""; + expect(topLine).toContain("\u252c"); // ┬ + }); + + test("bottom border has inverted T-junctions", () => { + const out = renderTextTable(["A", "B", "C"], [["1", "2", "3"]]); + const lines = out.split("\n").filter((l) => l.length > 0); + const bottomLine = lines.at(-1) ?? ""; + expect(bottomLine).toContain("\u2534"); // ┴ + }); + }); +}); + +describe("truncate option", () => { + test("truncates long cells to one line with ellipsis", () => { + const out = renderTextTable( + ["Name"], + [["This is a very long cell value that should be truncated"]], + { maxWidth: 20, truncate: true } + ); + const dataLines = out + .split("\n") + .filter( + (l) => + l.includes("\u2502") && + !l.includes("Name") && + !l.includes("\u2500") && + l.trim().length > 2 + ); + // Should be exactly 1 data line (not wrapped to multiple) + expect(dataLines.length).toBe(1); + // Should contain ellipsis + expect(dataLines[0]).toContain("\u2026"); + }); + + test("does not truncate short cells", () => { + const out = renderTextTable(["Name"], [["Hi"]], { + maxWidth: 40, + truncate: true, + }); + expect(out).toContain("Hi"); + expect(out).not.toContain("\u2026"); + }); + + test("headers are never truncated", () => { + const out = renderTextTable( + ["Very Long Header Name"], + [["This is an even longer cell value that should be truncated"]], + { maxWidth: 30, truncate: true } + ); + // Header should not have ellipsis (only data rows truncate) + const headerLine = out + .split("\n") + .find((l) => l.includes("Very Long Header")); + expect(headerLine).toBeDefined(); + expect(headerLine).not.toContain("\u2026"); + }); + + test("ellipsis has right-side padding gap (not flush against border)", () => { + // Use a long string without word breaks to force hard truncation + const longUrl = `https://example.com/${"a".repeat(200)}`; + const out = renderTextTable(["URL"], [[longUrl]], { + maxWidth: 40, + truncate: true, + }); + // Find the data line with the ellipsis + const dataLine = out.split("\n").find((l) => l.includes("\u2026")); + expect(dataLine).toBeDefined(); + // The ellipsis should NOT be immediately adjacent to the right border │ + // There should be at least one space between … and │ + expect(dataLine).not.toMatch(/\u2026\u2502/); + }); + + test("ellipsis inherits ANSI color from surrounding text", () => { + const colored = `${chalk.hex("#898294")("A very long colored text that will be truncated at the boundary")}`; + const out = renderTextTable(["VAL"], [[colored]], { + maxWidth: 30, + truncate: true, + }); + const dataLine = out.split("\n").find((l) => l.includes("\u2026")); + expect(dataLine).toBeDefined(); + // The ellipsis should appear BEFORE the ANSI reset, not after it. + // i.e., the pattern should be: … not … + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection + expect(dataLine).toMatch(/\u2026\x1b\[/); + }); +}); + +describe("minWidths option", () => { + test("prevents column from shrinking below minimum", () => { + const out = renderTextTable( + ["Short", "Long Column Header"], + [["data", "other data"]], + { maxWidth: 25, minWidths: [10, 0] } + ); + // First column should maintain at least 10-char content width + // (10 + 2 padding = 12 total column width) + const dataLine = out.split("\n").find((l) => l.includes("data")); + expect(dataLine).toBeDefined(); + // The "Short" column should have enough space for "data" + padding + expect(out).toContain("data"); + }); + + test("TITLE column absorbs shrink when SHORT ID has minWidth", () => { + const out = renderTextTable( + ["ID", "TITLE"], + [["SPOTLIGHT-WEB-28", "Very long error message that gets truncated"]], + { maxWidth: 50, minWidths: [20, 0], truncate: true } + ); + expect(out).toContain("SPOTLIGHT-WEB-28"); + // TITLE should be truncated, not SHORT ID + expect(out).toContain("\u2026"); + }); +}); + +describe("shrinkable option", () => { + test("non-shrinkable column keeps intrinsic width", () => { + const out = renderTextTable( + ["FIXED", "ELASTIC"], + [["SPOTLIGHT-WEB-28", "A very long title that should absorb all shrink"]], + { maxWidth: 50, shrinkable: [false, true] } + ); + // The FIXED column should show the full value without wrapping + expect(out).toContain("SPOTLIGHT-WEB-28"); + // Check no line has SPOTLIGHT-WEB-28 split across lines + const dataLines = out.split("\n").filter((l) => l.includes("SPOTLIGHT")); + expect(dataLines.length).toBe(1); + }); + + test("elastic column absorbs all shrink", () => { + const out = renderTextTable( + ["FIXED", "ELASTIC"], + [["keep-me", "shrink this very long text value please"]], + { maxWidth: 30, shrinkable: [false, true] } + ); + // FIXED column should be intact + expect(out).toContain("keep-me"); + const fixedLines = out.split("\n").filter((l) => l.includes("keep-me")); + expect(fixedLines.length).toBe(1); + }); +}); diff --git a/packages/cli/test/lib/formatters/trace.property.test.ts b/packages/cli/test/lib/formatters/trace.property.test.ts new file mode 100644 index 000000000..585f39cb7 --- /dev/null +++ b/packages/cli/test/lib/formatters/trace.property.test.ts @@ -0,0 +1,325 @@ +/** + * Property-Based Tests for Trace Formatters + * + * Uses fast-check to verify invariants of trace formatting functions + * that should hold for any valid input. + */ + +import { + array, + constantFrom, + double, + assert as fcAssert, + option, + property, + record, + stringMatching, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + computeTraceSummary, + formatTraceDuration, + formatTraceRow, + formatTraceSummary, + formatTracesHeader, +} from "../../../src/lib/formatters/trace.js"; +import type { + TraceSpan, + TransactionListItem, +} from "../../../src/types/index.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Valid positive durations in milliseconds */ +const positiveDurationArb = double({ min: 0, max: 1e9, noNaN: true }); + +/** Invalid durations that should produce "—" */ +const invalidDurationArb = constantFrom( + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + -1, + -1000 +); + +/** 32-char hex string for trace/event IDs */ +const hexId32Arb = stringMatching(/^[a-f0-9]{32}$/); + +/** Short alphanumeric slugs */ +const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}$/); + +/** Transaction name */ +const transactionNameArb = stringMatching(/^[A-Z]{3,6} \/[a-z/]{1,40}$/); + +/** ISO timestamp string */ +const isoTimestampArb = constantFrom( + "2025-01-15T10:30:00Z", + "2024-12-01T00:00:00Z", + "2025-06-15T23:59:59Z" +); + +/** Realistic Unix timestamp in seconds (2020-2030) */ +const unixTimestampArb = double({ + min: 1_577_836_800, + max: 1_893_456_000, + noNaN: true, +}); + +/** Generate a TransactionListItem */ +const transactionItemArb = record({ + trace: hexId32Arb, + id: hexId32Arb, + transaction: transactionNameArb, + timestamp: isoTimestampArb, + "span.duration": positiveDurationArb, + project: slugArb, +}) as unknown as import("fast-check").Arbitrary; + +/** Generate a flat TraceSpan (no children) */ +function makeSpanArb(): import("fast-check").Arbitrary { + return record({ + span_id: hexId32Arb, + op: option( + constantFrom("http.server", "db.query", "cache.get", "http.client"), + { + nil: undefined, + } + ), + description: option( + constantFrom("GET /api", "SELECT *", "Redis GET", null), + { + nil: undefined, + } + ), + start_timestamp: unixTimestampArb, + timestamp: unixTimestampArb, + project_slug: option(slugArb, { nil: undefined }), + transaction: option(transactionNameArb, { nil: undefined }), + "transaction.op": option( + constantFrom("http.server", "browser", "celery_task"), + { nil: undefined } + ), + }) as unknown as import("fast-check").Arbitrary; +} + +/** Generate a list of flat TraceSpans (1-10) */ +const spanListArb = array(makeSpanArb(), { minLength: 1, maxLength: 10 }); + +describe("property: formatTraceDuration", () => { + test("positive durations always produce non-empty string without dash", () => { + fcAssert( + property(positiveDurationArb, (ms) => { + const result = formatTraceDuration(ms); + expect(result.length).toBeGreaterThan(0); + expect(result).not.toBe("—"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("invalid durations always produce dash", () => { + fcAssert( + property(invalidDurationArb, (ms) => { + expect(formatTraceDuration(ms)).toBe("—"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic: same input always gives same output", () => { + fcAssert( + property(positiveDurationArb, (ms) => { + expect(formatTraceDuration(ms)).toBe(formatTraceDuration(ms)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("larger durations never produce shorter format units", () => { + // Durations >= 60_000ms should contain 'm', durations < 1000 should contain 'ms' + fcAssert( + property(positiveDurationArb, (ms) => { + const result = formatTraceDuration(ms); + if (ms < 1000) { + expect(result).toContain("ms"); + } else if (ms >= 60_000) { + expect(result).toContain("m"); + } else { + expect(result).toContain("s"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: formatTracesHeader", () => { + test("is deterministic and always non-empty", () => { + const a = formatTracesHeader(); + const b = formatTracesHeader(); + expect(a).toBe(b); + expect(a.length).toBeGreaterThan(0); + }); +}); + +describe("property: formatTraceRow", () => { + test("always contains the trace ID", () => { + fcAssert( + property(transactionItemArb, (item) => { + const row = formatTraceRow(item); + // Trace ID is sliced to 32 chars max + expect(row).toContain(item.trace.slice(0, 32)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always ends with newline", () => { + fcAssert( + property(transactionItemArb, (item) => { + expect(formatTraceRow(item).endsWith("\n")).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic", () => { + fcAssert( + property(transactionItemArb, (item) => { + expect(formatTraceRow(item)).toBe(formatTraceRow(item)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: computeTraceSummary", () => { + test("span count equals total number of spans (flat, no children)", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + expect(summary.spanCount).toBe(spans.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("traceId is preserved in summary", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + expect(summary.traceId).toBe(traceId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("duration is non-negative or NaN (never negative finite)", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + if (Number.isFinite(summary.duration)) { + expect(summary.duration).toBeGreaterThanOrEqual(0); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("projects are deduplicated", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + const uniqueProjects = new Set(summary.projects); + expect(uniqueProjects.size).toBe(summary.projects.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const a = computeTraceSummary(traceId, spans); + const b = computeTraceSummary(traceId, spans); + expect(a).toEqual(b); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty spans array produces zero span count", () => { + fcAssert( + property(hexId32Arb, (traceId) => { + const summary = computeTraceSummary(traceId, []); + expect(summary.spanCount).toBe(0); + expect(summary.projects).toEqual([]); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +/** Strip ANSI escape codes */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("property: formatTraceSummary", () => { + test("always contains the trace ID", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain(traceId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains Duration label", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Duration"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains Spans label", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Spans"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns a string", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + const result = formatTraceSummary(summary); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic", () => { + fcAssert( + property(hexId32Arb, spanListArb, (traceId, spans) => { + const summary = computeTraceSummary(traceId, spans); + const a = formatTraceSummary(summary); + const b = formatTraceSummary(summary); + expect(a).toEqual(b); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/formatters/trace.test.ts b/packages/cli/test/lib/formatters/trace.test.ts new file mode 100644 index 000000000..5e1296bc5 --- /dev/null +++ b/packages/cli/test/lib/formatters/trace.test.ts @@ -0,0 +1,631 @@ +/** + * Unit Tests for Trace Formatters + * + * Tests for formatTraceDuration, formatTraceTable, formatTracesHeader, formatTraceRow, + * computeTraceSummary, formatTraceSummary, and translateSpanQuery. + * + * Note: Core invariants (duration formatting, trace ID containment, row newline + * termination, determinism, span counting) are tested via property-based tests + * in trace.property.test.ts. These tests focus on specific format output values, + * rendered vs plain mode behavior, header newline termination, and edge cases. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { computeSpanDurationMs } from "../../../src/lib/formatters/time-utils.js"; +import { + computeTraceSummary, + findSpanById, + formatTraceDuration, + formatTraceRow, + formatTraceSummary, + formatTracesHeader, + formatTraceTable, + spanListItemToFlatSpan, + translateSpanQuery, +} from "../../../src/lib/formatters/trace.js"; +import type { + SpanListItem, + TraceSpan, + TransactionListItem, +} from "../../../src/types/index.js"; + +/** + * Strip ANSI escape codes for content assertions. + */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** Force rendered (TTY) mode for a describe block */ +function useRenderedMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + +/** Force plain mode for a describe block */ +function usePlainMode() { + let savedPlain: string | undefined; + beforeEach(() => { + savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "1"; + }); + afterEach(() => { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + }); +} + +/** + * Create a minimal TraceSpan for testing. + */ +function makeSpan(overrides: Partial = {}): TraceSpan { + return { + span_id: "abc123", + start_timestamp: 1_700_000_000.0, + timestamp: 1_700_000_001.5, + ...overrides, + }; +} + +/** + * Create a minimal TransactionListItem for testing. + */ +function makeTransaction( + overrides: Partial = {} +): TransactionListItem { + return { + trace: "a".repeat(32), + id: "b".repeat(32), + transaction: "GET /api/users", + timestamp: "2025-01-15T10:30:00Z", + "span.duration": 1234, + project: "my-project", + ...overrides, + }; +} + +describe("formatTraceDuration", () => { + test("formats sub-second durations in milliseconds", () => { + expect(formatTraceDuration(0)).toBe("0ms"); + expect(formatTraceDuration(1)).toBe("1ms"); + expect(formatTraceDuration(245)).toBe("245ms"); + expect(formatTraceDuration(999)).toBe("999ms"); + }); + + test("formats seconds with two decimal places", () => { + expect(formatTraceDuration(1000)).toBe("1.00s"); + expect(formatTraceDuration(1240)).toBe("1.24s"); + expect(formatTraceDuration(59_995)).toBe("59.99s"); + }); + + test("formats minutes and seconds for >= 60s", () => { + expect(formatTraceDuration(60_000)).toBe("1m 0s"); + expect(formatTraceDuration(135_000)).toBe("2m 15s"); + expect(formatTraceDuration(3_600_000)).toBe("60m 0s"); + }); + + test("handles seconds rollover (never produces '60s')", () => { + expect(formatTraceDuration(119_500)).toBe("2m 0s"); + expect(formatTraceDuration(179_500)).toBe("3m 0s"); + expect(formatTraceDuration(59_500)).toBe("59.50s"); + }); + + test("returns dash for invalid values", () => { + expect(formatTraceDuration(Number.NaN)).toBe("—"); + expect(formatTraceDuration(Number.POSITIVE_INFINITY)).toBe("—"); + expect(formatTraceDuration(Number.NEGATIVE_INFINITY)).toBe("—"); + expect(formatTraceDuration(-100)).toBe("—"); + }); +}); + +describe("formatTracesHeader (rendered mode)", () => { + useRenderedMode(); + + test("contains column titles", () => { + const header = stripAnsi(formatTracesHeader()); + expect(header).toContain("Trace ID"); + expect(header).toContain("Transaction"); + expect(header).toContain("Duration"); + expect(header).toContain("When"); + }); + + test("ends with newline", () => { + // Property test only checks Row newlines, not Header + expect(formatTracesHeader().endsWith("\n")).toBe(true); + }); +}); + +describe("formatTraceRow (rendered mode)", () => { + useRenderedMode(); + + test("includes transaction name", () => { + const row = formatTraceRow( + makeTransaction({ transaction: "POST /api/data" }) + ); + expect(row).toContain("POST /api/data"); + }); + + test("includes formatted duration", () => { + const row = formatTraceRow(makeTransaction({ "span.duration": 245 })); + expect(row).toContain("245ms"); + }); + + test("includes full transaction name in markdown row", () => { + const longName = "A".repeat(50); + const row = formatTraceRow(makeTransaction({ transaction: longName })); + expect(row).toContain(longName); + }); + + test("shows 'unknown' for empty transaction", () => { + const row = formatTraceRow(makeTransaction({ transaction: "" })); + expect(row).toContain("unknown"); + }); +}); + +describe("formatTracesHeader (plain mode)", () => { + usePlainMode(); + + test("emits markdown table header and separator", () => { + const result = formatTracesHeader(); + expect(result).toContain("| Trace ID | Transaction | Duration | When |"); + expect(result).toContain("| --- | --- | ---: | --- |"); + }); + + test("ends with newline", () => { + expect(formatTracesHeader()).toEndWith("\n"); + }); +}); + +describe("formatTraceRow (plain mode)", () => { + usePlainMode(); + + test("emits a markdown table row", () => { + const row = formatTraceRow(makeTransaction()); + expect(row).toMatch(/^\|.+\|.+\|.+\|.+\|\n$/); + }); + + test("includes transaction name", () => { + const row = formatTraceRow( + makeTransaction({ transaction: "POST /api/data" }) + ); + expect(row).toContain("POST /api/data"); + }); + + test("includes formatted duration", () => { + const row = formatTraceRow(makeTransaction({ "span.duration": 245 })); + expect(row).toContain("245ms"); + }); + + test("does not truncate long transaction names (no column padding in plain mode)", () => { + const longName = "A".repeat(50); + const row = formatTraceRow(makeTransaction({ transaction: longName })); + expect(row).toContain(longName); + }); + + test("escapes pipe characters in transaction name", () => { + const row = formatTraceRow(makeTransaction({ transaction: "GET /a|b" })); + // Pipe replaced with box-drawing │ so it doesn't break the table + expect(row).toContain("GET /a\u2502b"); + }); + + test("shows 'unknown' for empty transaction", () => { + const row = formatTraceRow(makeTransaction({ transaction: "" })); + expect(row).toContain("unknown"); + }); +}); + +describe("computeTraceSummary", () => { + test("computes duration from span timestamps", () => { + const spans: TraceSpan[] = [ + makeSpan({ start_timestamp: 1000.0, timestamp: 1002.5 }), + ]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.duration).toBe(2500); + }); + + test("finds min start and max end across multiple spans", () => { + const spans: TraceSpan[] = [ + makeSpan({ start_timestamp: 1000.0, timestamp: 1001.0 }), + makeSpan({ start_timestamp: 999.5, timestamp: 1003.0 }), + ]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.duration).toBe(3500); + }); + + test("counts all spans including nested children", () => { + const spans: TraceSpan[] = [ + makeSpan({ + children: [makeSpan({ children: [makeSpan()] }), makeSpan()], + }), + ]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.spanCount).toBe(4); + }); + + test("collects unique project slugs", () => { + const spans: TraceSpan[] = [ + makeSpan({ + project_slug: "frontend", + children: [makeSpan({ project_slug: "backend" })], + }), + makeSpan({ project_slug: "frontend" }), + ]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.projects.sort()).toEqual(["backend", "frontend"]); + }); + + test("extracts root transaction name and op", () => { + const spans: TraceSpan[] = [ + makeSpan({ + transaction: "GET /api/users", + "transaction.op": "http.server", + }), + ]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.rootTransaction).toBe("GET /api/users"); + expect(summary.rootOp).toBe("http.server"); + }); + + test("uses description as fallback for root transaction", () => { + const spans: TraceSpan[] = [makeSpan({ description: "My Transaction" })]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.rootTransaction).toBe("My Transaction"); + }); + + test("handles zero timestamps gracefully (NaN duration)", () => { + const spans: TraceSpan[] = [makeSpan({ start_timestamp: 0, timestamp: 0 })]; + const summary = computeTraceSummary("trace-id", spans); + expect(Number.isNaN(summary.duration)).toBe(true); + }); + + test("returns NaN duration for empty spans array", () => { + // Property generator uses minLength:1, so empty array is never tested there + const summary = computeTraceSummary("trace-id", []); + expect(Number.isNaN(summary.duration)).toBe(true); + expect(summary.spanCount).toBe(0); + }); + + test("ignores zero timestamps in min/max calculations", () => { + const spans: TraceSpan[] = [ + makeSpan({ start_timestamp: 0, timestamp: 0 }), + makeSpan({ start_timestamp: 1000.0, timestamp: 1002.0 }), + ]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.duration).toBe(2000); + }); + + test("falls back to timestamp when end_timestamp is 0", () => { + const spans: TraceSpan[] = [ + makeSpan({ + start_timestamp: 1000.0, + end_timestamp: 0, + timestamp: 1002.5, + }), + ]; + const summary = computeTraceSummary("trace-id", spans); + expect(summary.duration).toBe(2500); + }); +}); + +describe("formatTraceSummary", () => { + test("includes trace ID in header", () => { + const summary = computeTraceSummary("abc123def456", [ + makeSpan({ start_timestamp: 1000.0, timestamp: 1001.0 }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("abc123def456"); + }); + + test("shows root transaction with op prefix", () => { + const summary = computeTraceSummary("trace-id", [ + makeSpan({ + transaction: "GET /api/users", + "transaction.op": "http.server", + }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("http.server"); + expect(output).toContain("GET /api/users"); + }); + + test("shows duration", () => { + const summary = computeTraceSummary("trace-id", [ + makeSpan({ start_timestamp: 1000.0, timestamp: 1001.24 }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Duration"); + expect(output).toContain("1.24s"); + }); + + test("shows dash for NaN duration", () => { + const summary = computeTraceSummary("trace-id", [ + makeSpan({ start_timestamp: 0, timestamp: 0 }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Duration"); + expect(output).toContain("—"); + }); + + test("shows span count", () => { + const summary = computeTraceSummary("trace-id", [ + makeSpan({ children: [makeSpan(), makeSpan()] }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Spans"); + expect(output).toContain("3"); + }); + + test("shows projects when present", () => { + const summary = computeTraceSummary("trace-id", [ + makeSpan({ project_slug: "my-app" }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Projects"); + expect(output).toContain("my-app"); + }); + + test("shows start time for valid timestamps", () => { + const summary = computeTraceSummary("trace-id", [ + makeSpan({ + start_timestamp: 1_700_000_000.0, + timestamp: 1_700_000_001.0, + }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).toContain("Started"); + }); + + test("omits start time when no valid timestamps", () => { + const summary = computeTraceSummary("trace-id", [ + makeSpan({ start_timestamp: 0, timestamp: 0 }), + ]); + const output = stripAnsi(formatTraceSummary(summary)); + expect(output).not.toContain("Started"); + }); +}); + +describe("formatTraceTable", () => { + test("includes all transaction names", () => { + const items = [ + makeTransaction({ transaction: "GET /api/users" }), + makeTransaction({ transaction: "POST /api/data" }), + ]; + const result = stripAnsi(formatTraceTable(items)); + expect(result).toContain("GET /api/users"); + expect(result).toContain("POST /api/data"); + }); + + test("includes trace IDs", () => { + const traceId = "a".repeat(32); + const result = stripAnsi( + formatTraceTable([makeTransaction({ trace: traceId })]) + ); + expect(result).toContain(traceId); + }); + + test("includes formatted durations", () => { + const result = stripAnsi( + formatTraceTable([makeTransaction({ "span.duration": 1500 })]) + ); + expect(result).toContain("1.50s"); + }); + + test("shows 'unknown' for empty transaction", () => { + const result = stripAnsi( + formatTraceTable([makeTransaction({ transaction: "" })]) + ); + expect(result).toContain("unknown"); + }); +}); + +// --------------------------------------------------------------------------- +// translateSpanQuery +// --------------------------------------------------------------------------- + +describe("translateSpanQuery", () => { + test("translates op: to span.op:", () => { + expect(translateSpanQuery("op:db")).toBe("span.op:db"); + }); + + test("translates duration: to span.duration:", () => { + expect(translateSpanQuery("duration:>100ms")).toBe("span.duration:>100ms"); + }); + + test("bare words pass through unchanged", () => { + expect(translateSpanQuery("GET users")).toBe("GET users"); + }); + + test("mixed shorthand and bare words", () => { + expect(translateSpanQuery("op:http GET duration:>50ms")).toBe( + "span.op:http GET span.duration:>50ms" + ); + }); + + test("native keys pass through unchanged", () => { + expect(translateSpanQuery("description:fetch project:backend")).toBe( + "description:fetch project:backend" + ); + }); + + test("transaction: passes through unchanged", () => { + expect(translateSpanQuery("transaction:checkout")).toBe( + "transaction:checkout" + ); + }); + + test("key translation is case-insensitive", () => { + expect(translateSpanQuery("Op:db")).toBe("span.op:db"); + expect(translateSpanQuery("DURATION:>1s")).toBe("span.duration:>1s"); + }); + + test("empty query returns empty string", () => { + expect(translateSpanQuery("")).toBe(""); + }); + + test("quoted values are preserved", () => { + expect(translateSpanQuery('description:"GET /api"')).toBe( + 'description:"GET /api"' + ); + }); + + test("negated shorthand keys are translated correctly", () => { + expect(translateSpanQuery("!op:db")).toBe("!span.op:db"); + expect(translateSpanQuery("!duration:>100ms")).toBe( + "!span.duration:>100ms" + ); + }); + + test("negated non-alias keys pass through unchanged", () => { + expect(translateSpanQuery("!description:fetch")).toBe("!description:fetch"); + }); +}); + +// --------------------------------------------------------------------------- +// findSpanById +// --------------------------------------------------------------------------- + +describe("findSpanById", () => { + test("finds root-level span", () => { + const spans = [makeSpan({ span_id: "a1b2c3d4e5f67890" })]; + const result = findSpanById(spans, "a1b2c3d4e5f67890"); + expect(result).not.toBeNull(); + expect(result?.span.span_id).toBe("a1b2c3d4e5f67890"); + expect(result?.depth).toBe(0); + expect(result?.ancestors).toEqual([]); + }); + + test("finds nested span with ancestor chain", () => { + const child = makeSpan({ span_id: "childid123456789" }); + const root = makeSpan({ + span_id: "rootid1234567890", + children: [child], + }); + const result = findSpanById([root], "childid123456789"); + expect(result).not.toBeNull(); + expect(result?.span.span_id).toBe("childid123456789"); + expect(result?.depth).toBe(1); + expect(result?.ancestors).toHaveLength(1); + expect(result?.ancestors[0]?.span_id).toBe("rootid1234567890"); + }); + + test("case-insensitive matching (API returns uppercase)", () => { + const spans = [makeSpan({ span_id: "A1B2C3D4E5F67890" })]; + const result = findSpanById(spans, "a1b2c3d4e5f67890"); + expect(result).not.toBeNull(); + expect(result?.span.span_id).toBe("A1B2C3D4E5F67890"); + }); + + test("returns null for non-existent span ID", () => { + const spans = [makeSpan({ span_id: "a1b2c3d4e5f67890" })]; + const result = findSpanById(spans, "0000000000000000"); + expect(result).toBeNull(); + }); + + test("handles span with undefined span_id gracefully", () => { + const spans = [ + { start_timestamp: 1000, children: [] } as unknown as TraceSpan, + ]; + const result = findSpanById(spans, "a1b2c3d4e5f67890"); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// computeSpanDurationMs +// --------------------------------------------------------------------------- + +describe("computeSpanDurationMs", () => { + test("returns duration when present", () => { + const span = { duration: 123.45, start_timestamp: 1000 } as TraceSpan; + expect(computeSpanDurationMs(span)).toBe(123.45); + }); + + test("falls back to timestamp arithmetic", () => { + const span = { + start_timestamp: 1000, + timestamp: 1001.5, + } as TraceSpan; + expect(computeSpanDurationMs(span)).toBe(1500); + }); + + test("prefers end_timestamp over timestamp", () => { + const span = { + start_timestamp: 1000, + end_timestamp: 1002, + timestamp: 1001, + } as TraceSpan; + expect(computeSpanDurationMs(span)).toBe(2000); + }); + + test("returns undefined when no duration data", () => { + const span = { start_timestamp: 1000 } as TraceSpan; + expect(computeSpanDurationMs(span)).toBeUndefined(); + }); + + test("returns undefined for negative duration", () => { + const span = { + start_timestamp: 1002, + timestamp: 1000, + } as TraceSpan; + expect(computeSpanDurationMs(span)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// spanListItemToFlatSpan +// --------------------------------------------------------------------------- + +describe("spanListItemToFlatSpan", () => { + test("maps all fields correctly", () => { + const item: SpanListItem = { + id: "a1b2c3d4e5f67890", + parent_span: "1234567890abcdef", + "span.op": "http.client", + description: "GET /api/users", + "span.duration": 245.5, + timestamp: "2024-01-15T10:30:00+00:00", + project: "backend", + transaction: "/api/users", + trace: "aaaa1111bbbb2222cccc3333dddd4444", + }; + + const flat = spanListItemToFlatSpan(item); + expect(flat.span_id).toBe("a1b2c3d4e5f67890"); + expect(flat.parent_span_id).toBe("1234567890abcdef"); + expect(flat.op).toBe("http.client"); + expect(flat.description).toBe("GET /api/users"); + expect(flat.duration_ms).toBe(245.5); + expect(flat.project_slug).toBe("backend"); + expect(flat.transaction).toBe("/api/users"); + }); + + test("handles missing optional fields", () => { + const item: SpanListItem = { + id: "a1b2c3d4e5f67890", + timestamp: "2024-01-15T10:30:00+00:00", + trace: "aaaa1111bbbb2222cccc3333dddd4444", + project: "backend", + }; + + const flat = spanListItemToFlatSpan(item); + expect(flat.span_id).toBe("a1b2c3d4e5f67890"); + expect(flat.parent_span_id).toBeUndefined(); + expect(flat.op).toBeUndefined(); + expect(flat.description).toBeUndefined(); + expect(flat.duration_ms).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/fuzzy.test.ts b/packages/cli/test/lib/fuzzy.test.ts new file mode 100644 index 000000000..97bb5d5d0 --- /dev/null +++ b/packages/cli/test/lib/fuzzy.test.ts @@ -0,0 +1,201 @@ +/** + * Fuzzy Matching Tests + * + * Property-based and unit tests for Levenshtein distance and fuzzyMatch. + * Core invariants are tested via properties; edge cases and specific + * output formatting are tested via unit tests. + */ + +import { array, assert as fcAssert, property, string } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { fuzzyMatch, levenshtein } from "../../src/lib/fuzzy.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Short strings to keep Levenshtein computation fast in property tests +const shortStringArb = string({ minLength: 0, maxLength: 20 }); + +describe("property: levenshtein", () => { + test("identity: distance to self is always 0", () => { + fcAssert( + property(shortStringArb, (s) => { + expect(levenshtein(s, s)).toBe(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("symmetry: levenshtein(a, b) === levenshtein(b, a)", () => { + fcAssert( + property(shortStringArb, shortStringArb, (a, b) => { + expect(levenshtein(a, b)).toBe(levenshtein(b, a)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("upper bound: distance <= max(a.length, b.length)", () => { + fcAssert( + property(shortStringArb, shortStringArb, (a, b) => { + const dist = levenshtein(a, b); + expect(dist).toBeLessThanOrEqual(Math.max(a.length, b.length)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-negative: distance is always >= 0", () => { + fcAssert( + property(shortStringArb, shortStringArb, (a, b) => { + expect(levenshtein(a, b)).toBeGreaterThanOrEqual(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty string: distance to empty is the string length", () => { + fcAssert( + property(shortStringArb, (s) => { + expect(levenshtein(s, "")).toBe(s.length); + expect(levenshtein("", s)).toBe(s.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("triangle inequality: d(a,c) <= d(a,b) + d(b,c)", () => { + fcAssert( + property(shortStringArb, shortStringArb, shortStringArb, (a, b, c) => { + const dac = levenshtein(a, c); + const dab = levenshtein(a, b); + const dbc = levenshtein(b, c); + expect(dac).toBeLessThanOrEqual(dab + dbc); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("levenshtein: unit tests", () => { + test("known distances", () => { + expect(levenshtein("kitten", "sitting")).toBe(3); + expect(levenshtein("saturday", "sunday")).toBe(3); + expect(levenshtein("", "")).toBe(0); + expect(levenshtein("a", "b")).toBe(1); + expect(levenshtein("abc", "abc")).toBe(0); + }); +}); + +describe("fuzzyMatch: unit tests", () => { + const candidates = ["sentry", "entry", "notary", "sentinel", "send", "zen"]; + + test("empty partial returns all candidates sorted", () => { + const result = fuzzyMatch("", candidates); + expect(result).toEqual([...candidates].sort()); + }); + + test("exact match ranks first", () => { + const result = fuzzyMatch("sentry", candidates); + expect(result[0]).toBe("sentry"); + }); + + test("prefix matches rank high", () => { + const result = fuzzyMatch("sen", candidates); + expect(result).toContain("sentry"); + expect(result).toContain("sentinel"); + expect(result).toContain("send"); + // "sen" is a prefix of all three + expect(result.indexOf("sentry")).toBeLessThan(result.length); + }); + + test("contains matches are included", () => { + const result = fuzzyMatch("try", candidates); + // "try" is contained in "sentry" and "entry" + expect(result).toContain("sentry"); + expect(result).toContain("entry"); + }); + + test("fuzzy match on typo: senry → sentry", () => { + // "senry" has length 5, threshold = max(2, floor(5/3)) = 2 + // levenshtein("senry", "sentry") = 1 (insert 't') + const result = fuzzyMatch("senry", candidates); + expect(result).toContain("sentry"); + }); + + test("fuzzy match on typo: entry → sentry, entry (exact + fuzzy)", () => { + // "entry" exact matches "entry" and is distance 2 from "sentry" + const result = fuzzyMatch("entry", candidates); + expect(result[0]).toBe("entry"); // exact match first + expect(result).toContain("sentry"); // fuzzy match (distance 2) + }); + + test("case-insensitive matching", () => { + const result = fuzzyMatch("SENTRY", candidates); + expect(result[0]).toBe("sentry"); + }); + + test("maxResults limits output", () => { + const result = fuzzyMatch("", candidates, { maxResults: 2 }); + expect(result.length).toBe(2); + }); + + test("no matches returns empty array", () => { + const result = fuzzyMatch("zzzzzzzzz", candidates); + expect(result).toEqual([]); + }); + + test("prefix matches come before contains matches", () => { + const items = ["prefix-match", "contains-prefix", "no-match"]; + const result = fuzzyMatch("prefix", items); + expect(result.indexOf("prefix-match")).toBeLessThan( + result.indexOf("contains-prefix") + ); + }); +}); + +describe("property: fuzzyMatch", () => { + test("exact candidate is always in the result", () => { + const candidatesArb = array(shortStringArb, { + minLength: 1, + maxLength: 10, + }); + fcAssert( + property(candidatesArb, (candidates) => { + // Pick the first candidate as partial + const partial = candidates[0]; + const result = fuzzyMatch(partial, candidates); + expect(result).toContain(partial); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty partial returns all candidates", () => { + const candidatesArb = array(shortStringArb, { + minLength: 0, + maxLength: 10, + }); + fcAssert( + property(candidatesArb, (candidates) => { + const result = fuzzyMatch("", candidates); + expect(result.length).toBe(candidates.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result is a subset of candidates", () => { + const candidatesArb = array(shortStringArb, { + minLength: 0, + maxLength: 10, + }); + fcAssert( + property(shortStringArb, candidatesArb, (partial, candidates) => { + const result = fuzzyMatch(partial, candidates); + for (const r of result) { + expect(candidates).toContain(r); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/ghcr.test.ts b/packages/cli/test/lib/ghcr.test.ts new file mode 100644 index 000000000..a55612b5a --- /dev/null +++ b/packages/cli/test/lib/ghcr.test.ts @@ -0,0 +1,773 @@ +/** + * GHCR Client Tests + * + * Unit tests for the GHCR/OCI download protocol helpers. + * All HTTP calls are mocked via globalThis.fetch to avoid network access. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; +import { UpgradeError } from "../../src/lib/errors.js"; +import { + downloadLayerBlob, + downloadNightlyBlob, + fetchManifest, + fetchNightlyManifest, + findLayerByFilename, + GHCR_REPO, + GHCR_TAG, + GhcrManifestHttpError, + getAnonymousToken, + getNightlyVersion, + listTags, + type OciManifest, +} from "../../src/lib/ghcr.js"; + +/** Store original fetch for restoration */ +let originalFetch: typeof globalThis.fetch; + +/** Helper to mock fetch without TypeScript errors about missing Bun-specific properties */ +function mockFetch( + fn: (url: string | URL | Request, init?: RequestInit) => Promise +): void { + globalThis.fetch = fn as typeof globalThis.fetch; +} + +/** Minimal valid OCI manifest for testing */ +function makeManifest(overrides: Partial = {}): OciManifest { + return { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { + digest: `sha256:${"0".repeat(64)}`, + mediaType: "application/vnd.oci.empty.v1+json", + size: 2, + }, + layers: [ + { + digest: `sha256:${"a".repeat(64)}`, + mediaType: "application/octet-stream", + size: 1000, + annotations: { + "org.opencontainers.image.title": "sentry-linux-x64.gz", + }, + }, + { + digest: `sha256:${"d".repeat(64)}`, + mediaType: "application/octet-stream", + size: 1200, + annotations: { + "org.opencontainers.image.title": "sentry-darwin-arm64.gz", + }, + }, + ], + annotations: { + version: "0.0.0-dev.1740000000", + "org.opencontainers.image.source": "https://github.com/getsentry/cli", + }, + ...overrides, + }; +} + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("getAnonymousToken", () => { + test("returns token from successful response", async () => { + mockFetch(async (url) => { + expect(String(url)).toContain( + `https://ghcr.io/token?scope=repository:${GHCR_REPO}:pull` + ); + return new Response(JSON.stringify({ token: "test-token-abc" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const token = await getAnonymousToken(); + expect(token).toBe("test-token-abc"); + }); + + test("uses the selected source's GHCR repository", async () => { + mockFetch(async (url) => { + expect(String(url)).toContain("scope=repository:getsentry/toolkit:pull"); + return new Response(JSON.stringify({ token: "toolkit-token" }), { + status: 200, + }); + }); + + await expect(getAnonymousToken(UPGRADE_SOURCES[0])).resolves.toBe( + "toolkit-token" + ); + }); + + test("throws UpgradeError on HTTP error", async () => { + mockFetch(async () => new Response("Unauthorized", { status: 401 })); + + await expect(getAnonymousToken()).rejects.toThrow(UpgradeError); + await expect(getAnonymousToken()).rejects.toThrow( + "GHCR token exchange failed: HTTP 401" + ); + }); + + test("throws UpgradeError on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + await expect(getAnonymousToken()).rejects.toThrow(UpgradeError); + await expect(getAnonymousToken()).rejects.toThrow( + "Failed to connect to GHCR: fetch failed" + ); + }); + + test("propagates caller cancellation without retrying", async () => { + const controller = new AbortController(); + let requests = 0; + mockFetch(async (_url, init) => { + requests += 1; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true } + ); + }); + }); + + const request = getAnonymousToken(undefined, controller.signal); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect(requests).toBe(1); + }); + + test("preserves a primitive caller cancellation reason without retrying", async () => { + const controller = new AbortController(); + let requests = 0; + mockFetch(async () => { + requests += 1; + controller.abort("cancelled"); + throw controller.signal.reason; + }); + + await expect(getAnonymousToken(undefined, controller.signal)).rejects.toBe( + "cancelled" + ); + expect(requests).toBe(1); + }); + + test("throws UpgradeError when response has no token field", async () => { + mockFetch( + async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(getAnonymousToken()).rejects.toThrow(UpgradeError); + await expect(getAnonymousToken()).rejects.toThrow( + "GHCR token exchange returned no token" + ); + }); + + test("rejects a non-string token", async () => { + mockFetch(async () => Response.json({ token: {} })); + + await expect(getAnonymousToken()).rejects.toThrow( + "GHCR token exchange returned no token" + ); + }); + + test.each([" ", "\ttoken"])("rejects malformed token %j", async (token) => { + mockFetch(async () => Response.json({ token })); + + await expect(getAnonymousToken()).rejects.toThrow( + "GHCR token exchange returned no token" + ); + }); + + test("preserves cancellation during token body consumption", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + mockFetch(async () => { + const response = Response.json({ token: "unused" }); + response.json = async () => { + controller.abort(reason); + throw new DOMException("aborted", "AbortError"); + }; + return response; + }); + + await expect(getAnonymousToken(undefined, controller.signal)).rejects.toBe( + reason + ); + }); +}); + +describe("fetchNightlyManifest", () => { + test("fetches manifest with correct headers", async () => { + const manifest = makeManifest(); + let capturedHeaders: Record = {}; + + mockFetch(async (url, init) => { + expect(String(url)).toContain(`/v2/${GHCR_REPO}/manifests/${GHCR_TAG}`); + capturedHeaders = Object.fromEntries( + new Headers(init?.headers as HeadersInit).entries() + ); + return new Response(JSON.stringify(manifest), { + status: 200, + headers: { + "Content-Type": "application/vnd.oci.image.manifest.v1+json", + }, + }); + }); + + const result = await fetchNightlyManifest("my-token"); + expect(result).toEqual(manifest); + expect(capturedHeaders.authorization).toBe("Bearer my-token"); + expect(capturedHeaders.accept).toBe( + "application/vnd.oci.image.manifest.v1+json" + ); + }); + + test("uses the selected source's GHCR repository", async () => { + const manifest = makeManifest(); + mockFetch(async (url) => { + expect(String(url)).toContain("/v2/getsentry/toolkit/manifests/nightly"); + return new Response(JSON.stringify(manifest), { status: 200 }); + }); + + await expect( + fetchNightlyManifest("token", undefined, UPGRADE_SOURCES[0]) + ).resolves.toEqual(manifest); + }); + + test("throws UpgradeError on HTTP error", async () => { + mockFetch(async () => new Response("Not Found", { status: 404 })); + + await expect(fetchNightlyManifest("token")).rejects.toThrow(UpgradeError); + await expect(fetchNightlyManifest("token")).rejects.toThrow( + 'Failed to fetch manifest for tag "nightly": HTTP 404' + ); + }); + + test("throws UpgradeError on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + await expect(fetchNightlyManifest("token")).rejects.toThrow(UpgradeError); + await expect(fetchNightlyManifest("token")).rejects.toThrow( + 'Failed to fetch manifest for tag "nightly": fetch failed' + ); + }); +}); + +describe("getNightlyVersion", () => { + test("extracts version from manifest annotations", () => { + const manifest = makeManifest(); + expect(getNightlyVersion(manifest)).toBe("0.0.0-dev.1740000000"); + }); + + test("throws UpgradeError when version annotation is missing", () => { + const manifest = makeManifest({ annotations: {} }); + expect(() => getNightlyVersion(manifest)).toThrow(UpgradeError); + expect(() => getNightlyVersion(manifest)).toThrow( + "Nightly manifest has no version annotation" + ); + }); + + test("throws UpgradeError when annotations object is absent", () => { + const manifest = makeManifest({ annotations: undefined }); + expect(() => getNightlyVersion(manifest)).toThrow(UpgradeError); + }); + + test.each([ + "not-semver", + "1.2.3", + "1.2.3-dev.foo", + ])("rejects invalid nightly version annotation %s", (version) => { + const manifest = makeManifest({ annotations: { version } }); + + expect(() => getNightlyVersion(manifest)).toThrow( + "Nightly manifest has invalid version annotation" + ); + }); +}); + +describe("findLayerByFilename", () => { + test("finds layer by filename annotation", () => { + const manifest = makeManifest(); + const layer = findLayerByFilename(manifest, "sentry-linux-x64.gz"); + expect(layer.digest).toBe(`sha256:${"a".repeat(64)}`); + }); + + test("finds darwin layer", () => { + const manifest = makeManifest(); + const layer = findLayerByFilename(manifest, "sentry-darwin-arm64.gz"); + expect(layer.digest).toBe(`sha256:${"d".repeat(64)}`); + }); + + test("throws UpgradeError when filename not found", () => { + const manifest = makeManifest(); + expect(() => + findLayerByFilename(manifest, "sentry-freebsd-x64.gz") + ).toThrow(UpgradeError); + expect(() => + findLayerByFilename(manifest, "sentry-freebsd-x64.gz") + ).toThrow("No nightly build found for sentry-freebsd-x64.gz"); + }); + + test("throws UpgradeError when layer has no annotations", () => { + const manifest = makeManifest({ + layers: [ + { + digest: "sha256:noannotations", + mediaType: "application/octet-stream", + size: 100, + // no annotations + }, + ], + }); + expect(() => findLayerByFilename(manifest, "sentry-linux-x64.gz")).toThrow( + UpgradeError + ); + }); +}); + +describe("downloadNightlyBlob", () => { + test("returns response directly when status is 200 (no redirect)", async () => { + const binaryContent = new Uint8Array([1, 2, 3, 4]); + mockFetch(async () => new Response(binaryContent, { status: 200 })); + + const response = await downloadNightlyBlob("token", "sha256:abc123"); + expect(response.status).toBe(200); + const body = await response.arrayBuffer(); + expect(new Uint8Array(body)).toEqual(binaryContent); + }); + + test("follows 307 redirect without auth header", async () => { + const binaryContent = new Uint8Array([5, 6, 7, 8]); + let requestCount = 0; + let secondRequestHeaders: Record = {}; + + mockFetch(async (_url, init) => { + requestCount += 1; + if (requestCount === 1) { + // First request: return 307 redirect to Azure + return Response.redirect( + "https://blob.storage.azure.com/signed?token=xyz", + 307 + ); + } + // Second request: the actual download (no auth header expected) + secondRequestHeaders = Object.fromEntries( + new Headers(init?.headers as HeadersInit).entries() + ); + return new Response(binaryContent, { status: 200 }); + }); + + const response = await downloadNightlyBlob( + "my-bearer-token", + "sha256:abc123" + ); + expect(requestCount).toBe(2); + expect(response.status).toBe(200); + // Auth header must NOT be forwarded to Azure + expect(secondRequestHeaders.authorization).toBeUndefined(); + const body = await response.arrayBuffer(); + expect(new Uint8Array(body)).toEqual(binaryContent); + }); + + test("follows 302 redirect without auth header", async () => { + let requestCount = 0; + mockFetch(async () => { + requestCount += 1; + if (requestCount === 1) { + return Response.redirect("https://example.com/blob", 302); + } + return new Response(new Uint8Array([1]), { status: 200 }); + }); + + await downloadNightlyBlob("token", "sha256:xyz"); + expect(requestCount).toBe(2); + }); + + test("throws UpgradeError when redirect has no Location header", async () => { + mockFetch(async () => new Response(null, { status: 307 })); + + await expect(downloadNightlyBlob("token", "sha256:abc")).rejects.toThrow( + UpgradeError + ); + await expect(downloadNightlyBlob("token", "sha256:abc")).rejects.toThrow( + "GHCR blob redirect (307) had no Location header" + ); + }); + + test("throws UpgradeError when blob storage download fails", async () => { + let requestCount = 0; + mockFetch(async () => { + requestCount += 1; + if (requestCount === 1) { + return Response.redirect("https://blob.storage.azure.com/file", 307); + } + return new Response("Forbidden", { status: 403 }); + }); + + // Call once and capture to avoid stateful mock issues on repeated calls + const error = await downloadNightlyBlob("token", "sha256:abc").catch( + (e) => e + ); + expect(error).toBeInstanceOf(UpgradeError); + expect(error.message).toContain("Blob storage download failed: HTTP 403"); + }); + + test("throws UpgradeError on unexpected status (not 200/redirect)", async () => { + mockFetch(async () => new Response("Server Error", { status: 500 })); + + const error = await downloadNightlyBlob("token", "sha256:abc").catch( + (e) => e + ); + expect(error).toBeInstanceOf(UpgradeError); + expect(error.message).toContain("Unexpected GHCR blob response: HTTP 500"); + }); + + test("throws UpgradeError on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + const error = await downloadNightlyBlob("token", "sha256:abc").catch( + (e) => e + ); + expect(error).toBeInstanceOf(UpgradeError); + expect(error.message).toContain("Failed to connect to GHCR: fetch failed"); + }); + + test("throws UpgradeError on network failure during redirect follow", async () => { + let requestCount = 0; + mockFetch(async () => { + requestCount += 1; + if (requestCount === 1) { + return Response.redirect("https://blob.storage.azure.com/file", 307); + } + throw new TypeError("fetch failed"); + }); + + // Call once and capture to avoid stateful mock issues on repeated calls + const error = await downloadNightlyBlob("token", "sha256:abc").catch( + (e) => e + ); + expect(error).toBeInstanceOf(UpgradeError); + expect(error.message).toContain( + "Failed to download from blob storage: fetch failed" + ); + }); + + test("preserves external cancellation during the GHCR blob request", async () => { + const controller = new AbortController(); + let requestCount = 0; + mockFetch(async () => { + requestCount += 1; + controller.abort(); + throw new DOMException("aborted", "AbortError"); + }); + + await expect( + downloadNightlyBlob("token", "sha256:abc", controller.signal) + ).rejects.toMatchObject({ name: "AbortError" }); + expect(requestCount).toBe(1); + }); + + test("preserves an arbitrary external cancellation reason", async () => { + const controller = new AbortController(); + const reason = new Error("cancelled"); + let requestCount = 0; + mockFetch(async () => { + requestCount += 1; + controller.abort(reason); + throw new TypeError("invalid_argument"); + }); + + await expect( + downloadNightlyBlob("token", "sha256:abc", controller.signal) + ).rejects.toBe(reason); + expect(requestCount).toBe(1); + }); + + test("preserves external cancellation during the redirect request", async () => { + const controller = new AbortController(); + const reason = { kind: "cancelled" }; + const headers: Headers[] = []; + mockFetch(async (_url, init) => { + headers.push(new Headers(init?.headers)); + if (headers.length === 1) { + return Response.redirect("https://blob.storage.azure.com/file", 307); + } + controller.abort(reason); + throw new TypeError("invalid_argument"); + }); + + await expect( + downloadNightlyBlob("token", "sha256:abc", controller.signal) + ).rejects.toBe(reason); + expect(headers).toHaveLength(2); + expect(headers[1]?.has("authorization")).toBe(false); + }); +}); + +// fetchManifest (generic tag variant) + +describe("fetchManifest", () => { + test.each([ + null, + [], + {}, + { schemaVersion: 2 }, + { schemaVersion: 2, layers: {} }, + { schemaVersion: 2, layers: [], annotations: ["value"] }, + { + schemaVersion: 2, + layers: [ + { + digest: `sha256:${"a".repeat(64)}`, + mediaType: "application/octet-stream", + size: 1, + annotations: ["value"], + }, + ], + }, + ])("rejects invalid OCI manifest %#", async (manifest) => { + mockFetch(async () => Response.json(manifest)); + + await expect(fetchManifest("token", "nightly")).rejects.toThrow( + 'Manifest for tag "nightly" returned invalid metadata' + ); + }); + + test("classifies manifest body termination as transport failure", async () => { + mockFetch(async () => { + const response = Response.json(makeManifest()); + response.json = async () => { + throw new TypeError("terminated"); + }; + return response; + }); + + await expect(fetchManifest("token", "nightly")).rejects.toMatchObject({ + name: "UpgradeTransportError", + reason: "network_error", + }); + }); + test("fetches manifest for an arbitrary tag", async () => { + const manifest = makeManifest(); + + mockFetch(async (url) => { + expect(String(url)).toContain(`/v2/${GHCR_REPO}/manifests/patch-0.13.0`); + return new Response(JSON.stringify(manifest), { + status: 200, + headers: { + "Content-Type": "application/vnd.oci.image.manifest.v1+json", + }, + }); + }); + + const result = await fetchManifest("token", "patch-0.13.0"); + expect(result).toEqual(manifest); + }); + + test("throws UpgradeError on HTTP 404", async () => { + mockFetch(async () => new Response("Not Found", { status: 404 })); + + const error = await fetchManifest("token", "patch-0.13.0").catch( + (reason: unknown) => reason + ); + expect(error).toBeInstanceOf(GhcrManifestHttpError); + expect(error).toMatchObject({ + name: "GhcrManifestHttpError", + status: 404, + message: 'Failed to fetch manifest for tag "patch-0.13.0": HTTP 404', + }); + }); + + test("throws UpgradeError on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + await expect(fetchManifest("token", "some-tag")).rejects.toThrow( + UpgradeError + ); + await expect(fetchManifest("token", "some-tag")).rejects.toThrow( + 'Failed to fetch manifest for tag "some-tag": fetch failed' + ); + }); +}); + +// listTags + +describe("listTags", () => { + test("rejects a repeated pagination cursor", async () => { + const tags = Array.from({ length: 100 }, (_, index) => `tag-${index}`); + let requests = 0; + mockFetch(async () => { + requests += 1; + return Response.json({ tags }); + }); + + await expect(listTags("token")).rejects.toThrow( + "GHCR tag pagination returned a repeated cursor" + ); + expect(requests).toBe(2); + }); + test("returns all tags when no prefix filter", async () => { + mockFetch(async (url) => { + expect(String(url)).toContain(`/v2/${GHCR_REPO}/tags/list`); + return new Response( + JSON.stringify({ tags: ["nightly", "patch-0.13.0", "patch-0.14.0"] }), + { status: 200 } + ); + }); + + const tags = await listTags("token"); + expect(tags).toEqual(["nightly", "patch-0.13.0", "patch-0.14.0"]); + }); + + test("filters tags by prefix", async () => { + mockFetch( + async () => + new Response( + JSON.stringify({ tags: ["nightly", "patch-0.13.0", "patch-0.14.0"] }), + { status: 200 } + ) + ); + + const tags = await listTags("token", "patch-"); + expect(tags).toEqual(["patch-0.13.0", "patch-0.14.0"]); + }); + + test("returns empty array when no tags match prefix", async () => { + mockFetch( + async () => + new Response(JSON.stringify({ tags: ["nightly", "latest"] }), { + status: 200, + }) + ); + + const tags = await listTags("token", "patch-"); + expect(tags).toEqual([]); + }); + + test("returns empty array when response has no tags field", async () => { + mockFetch(async () => new Response(JSON.stringify({}), { status: 200 })); + + const tags = await listTags("token"); + expect(tags).toEqual([]); + }); + + test("throws UpgradeError on HTTP error", async () => { + mockFetch(async () => new Response("Error", { status: 500 })); + + await expect(listTags("token")).rejects.toThrow(UpgradeError); + await expect(listTags("token")).rejects.toThrow( + "Failed to list GHCR tags: HTTP 500" + ); + }); + + test("throws UpgradeError on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + await expect(listTags("token")).rejects.toThrow(UpgradeError); + await expect(listTags("token")).rejects.toThrow( + "Failed to list GHCR tags: fetch failed" + ); + }); + + test("paginates when first page is full (100 tags)", async () => { + // Generate exactly 100 tags for page 1 (triggers pagination), then 2 for page 2 + const page1Tags = Array.from( + { length: 100 }, + (_, i) => `tag-${String(i).padStart(3, "0")}` + ); + const page2Tags = ["tag-100", "tag-101"]; + const responses = [page1Tags, page2Tags]; + let pageCall = 0; + + mockFetch(async () => { + const tags = responses[pageCall] ?? []; + pageCall += 1; + return new Response(JSON.stringify({ tags }), { status: 200 }); + }); + + const tags = await listTags("token"); + expect(tags).toHaveLength(102); + expect(tags[0]).toBe("tag-000"); + expect(tags[99]).toBe("tag-099"); + expect(tags[100]).toBe("tag-100"); + expect(tags[101]).toBe("tag-101"); + expect(pageCall).toBe(2); + }); + + test("pagination with prefix filter only returns matching tags", async () => { + // Mix of matching and non-matching tags, fewer than page size + const mixedTags = Array.from({ length: 80 }, (_, i) => { + if (i < 40) { + return `patch-${i}`; + } + return `nightly-${i}`; + }); + + mockFetch( + async () => + new Response(JSON.stringify({ tags: mixedTags }), { + status: 200, + }) + ); + + const tags = await listTags("token", "patch-"); + expect(tags).toHaveLength(40); + for (const tag of tags) { + expect(tag.startsWith("patch-")).toBe(true); + } + }); +}); + +// downloadLayerBlob + +describe("downloadLayerBlob", () => { + test("returns ArrayBuffer from blob download", async () => { + const content = new Uint8Array([1, 2, 3, 4, 5]); + let requestCount = 0; + + mockFetch(async (url) => { + requestCount += 1; + if (requestCount === 1) { + expect(String(url)).toContain(`/v2/${GHCR_REPO}/blobs/sha256:abc123`); + return Response.redirect("https://blob.storage.azure.com/file", 307); + } + return new Response(content, { status: 200 }); + }); + + const result = await downloadLayerBlob("token", "sha256:abc123"); + expect(new Uint8Array(result)).toEqual(content); + }); + + test("throws UpgradeError on download failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + await expect(downloadLayerBlob("token", "sha256:abc")).rejects.toThrow( + UpgradeError + ); + }); +}); diff --git a/packages/cli/test/lib/git-commit-log.test.ts b/packages/cli/test/lib/git-commit-log.test.ts new file mode 100644 index 000000000..c0b6b91af --- /dev/null +++ b/packages/cli/test/lib/git-commit-log.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +// Mock node:child_process so getCommitLog never shells out to a real git. +const execFileSyncMock = vi.fn(() => ""); +vi.mock("node:child_process", () => ({ + execFileSync: (...args: unknown[]) => execFileSyncMock(...args), +})); + +import { getCommitLog } from "../../src/lib/git.js"; + +/** Extract the argv passed to the mocked git invocation. */ +function lastGitArgs(): string[] { + const call = execFileSyncMock.mock.calls.at(-1); + // execFileSync(file, args, options) + return (call?.[1] ?? []) as string[]; +} + +describe("getCommitLog pathspec argv", () => { + afterEach(() => { + execFileSyncMock.mockClear(); + execFileSyncMock.mockReturnValue(""); + }); + + test("appends `--` and paths when paths provided", () => { + getCommitLog("/repo", { paths: ["apps/mobile", "packages/shared"] }); + + const args = lastGitArgs(); + expect(args).toContain("--"); + const sep = args.indexOf("--"); + expect(args.slice(sep + 1)).toEqual(["apps/mobile", "packages/shared"]); + }); + + test("omits `--` when no paths provided", () => { + getCommitLog("/repo", {}); + expect(lastGitArgs()).not.toContain("--"); + }); + + test("omits `--` for empty paths array", () => { + getCommitLog("/repo", { paths: [] }); + expect(lastGitArgs()).not.toContain("--"); + }); + + test("pathspec follows the commit range", () => { + getCommitLog("/repo", { from: "abc123", paths: ["src"] }); + + const args = lastGitArgs(); + const rangeIdx = args.indexOf("abc123..HEAD"); + const sepIdx = args.indexOf("--"); + expect(rangeIdx).toBeGreaterThanOrEqual(0); + expect(sepIdx).toBeGreaterThan(rangeIdx); + }); + + test("adds --max-count when a positive depth is given", () => { + getCommitLog("/repo", { depth: 50 }); + expect(lastGitArgs()).toContain("--max-count=50"); + }); + + // Guards callers that omit depth from accidentally walking all history. + test("defaults to --max-count=20 when depth is omitted", () => { + getCommitLog("/repo", {}); + expect(lastGitArgs()).toContain("--max-count=20"); + }); + + test("omits --max-count only when from is set with non-positive depth", () => { + getCommitLog("/repo", { from: "abc123", depth: 0 }); + const args = lastGitArgs(); + expect(args.some((a) => a.startsWith("--max-count="))).toBe(false); + expect(args).toContain("abc123..HEAD"); + }); + + test("keeps --max-count when depth is non-positive but from is absent", () => { + getCommitLog("/repo", { depth: 0 }); + expect(lastGitArgs()).toContain("--max-count=0"); + }); + + // Guards against git argument injection: a `from` like "--format=x" must be + // rejected rather than passed through as a git option. Version-independent + // (no reliance on --end-of-options, which requires git >= 2.24). + test("throws on an option-like `from` ref", () => { + expect(() => getCommitLog("/repo", { from: "--format=%H" })).toThrow( + "must be a git ref, not a CLI flag" + ); + expect(() => getCommitLog("/repo", { from: "--format=%H" })).toThrow( + "Git refs cannot start with '-'" + ); + expect(() => getCommitLog("/repo", { from: "--format=%H" })).not.toThrow( + "use the equals form" + ); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + test("throws ValidationError when the from ref is unknown", () => { + const gitError = Object.assign(new Error("Command failed"), { + stderr: + "fatal: ambiguous argument 'bogus-ref..HEAD': unknown revision or path not in the working tree.\n", + }); + execFileSyncMock.mockImplementation(() => { + throw gitError; + }); + + expect(() => getCommitLog("/repo", { from: "bogus-ref" })).toThrow( + "Unknown git ref 'bogus-ref': not found in this repository." + ); + expect(() => getCommitLog("/repo", { from: "bogus-ref" })).toThrow( + "git rev-parse bogus-ref" + ); + }); + + // Uncapped `--from` ranges can emit >1 MB, so git() must raise maxBuffer + // above execFileSync's 1 MB default to avoid crashing on large histories. + test("passes a large maxBuffer to execFileSync", () => { + getCommitLog("/repo", { from: "abc123" }); + const call = execFileSyncMock.mock.calls.at(-1); + const options = call?.[2] as { maxBuffer?: number } | undefined; + expect(options?.maxBuffer).toBeGreaterThanOrEqual(100 * 1024 * 1024); + }); + + test("parses NUL-delimited git output into commits", () => { + execFileSyncMock.mockReturnValue( + "abc\x00subject\x00Jane\x00jane@example.com\x002026-01-01T00:00:00Z" + ); + + const commits = getCommitLog("/repo", { paths: ["src"] }); + expect(commits).toEqual([ + { + id: "abc", + message: "subject", + author_name: "Jane", + author_email: "jane@example.com", + timestamp: "2026-01-01T00:00:00Z", + }, + ]); + }); +}); diff --git a/packages/cli/test/lib/git.property.test.ts b/packages/cli/test/lib/git.property.test.ts new file mode 100644 index 000000000..97e75ec25 --- /dev/null +++ b/packages/cli/test/lib/git.property.test.ts @@ -0,0 +1,110 @@ +import { array, constantFrom, assert as fcAssert, property } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { parseRemoteUrl } from "../../src/lib/git.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + +const ownerArb = array(constantFrom(...`${slugChars}-`.split("")), { + minLength: 1, + maxLength: 15, +}) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-"))); + +const repoArb = array(constantFrom(...`${slugChars}-.`.split("")), { + minLength: 1, + maxLength: 20, +}) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-") || s.startsWith("."))); + +describe("property: parseRemoteUrl", () => { + test("HTTPS URL → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `https://github.com/${owner}/${repo}.git`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("HTTPS URL without .git suffix → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `https://github.com/${owner}/${repo}`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("SSH URL → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `git@github.com:${owner}/${repo}.git`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("SSH URL without .git suffix → owner/repo", () => { + fcAssert( + property(ownerArb, repoArb, (owner, repo) => { + const url = `git@github.com:${owner}/${repo}`; + const result = parseRemoteUrl(url); + expect(result).toBe(`${owner}/${repo}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("unit: parseRemoteUrl edge cases", () => { + test("standard GitHub HTTPS", () => { + expect(parseRemoteUrl("https://github.com/getsentry/cli.git")).toBe( + "getsentry/cli" + ); + }); + + test("standard GitHub SSH", () => { + expect(parseRemoteUrl("git@github.com:getsentry/cli.git")).toBe( + "getsentry/cli" + ); + }); + + test("GitLab HTTPS", () => { + expect(parseRemoteUrl("https://gitlab.com/my-group/my-project.git")).toBe( + "my-group/my-project" + ); + }); + + test("Bitbucket SSH", () => { + expect(parseRemoteUrl("git@bitbucket.org:team/repo.git")).toBe("team/repo"); + }); + + test("SSH with port (ssh:// protocol)", () => { + expect(parseRemoteUrl("ssh://git@github.com:22/owner/repo.git")).toBe( + "owner/repo" + ); + }); + + test("SSH with port (no .git)", () => { + expect(parseRemoteUrl("ssh://git@github.com:443/owner/repo")).toBe( + "owner/repo" + ); + }); + + test("empty string returns undefined", () => { + expect(parseRemoteUrl("")).toBeUndefined(); + }); + + test("plain string returns undefined", () => { + expect(parseRemoteUrl("not-a-url")).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/global-flags.test.ts b/packages/cli/test/lib/global-flags.test.ts new file mode 100644 index 000000000..43cb2aae7 --- /dev/null +++ b/packages/cli/test/lib/global-flags.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for {@link buildTopLevelFlags}, which derives the route-scanner + * allow-list from {@link GLOBAL_FLAGS}. + * + * The end-to-end behavior (a global flag placed before the subcommand reaching + * the leaf command) is covered via the patched Stricli scanner in + * `scanner-flags.integration.test.ts`. These tests pin the derivation contract so + * the allow-list stays in sync with the flag definitions and matches the shape + * Stricli's `scanner.topLevelFlags` option expects. + */ + +import { describe, expect, test } from "vitest"; +import { + buildTopLevelFlags, + GLOBAL_FLAGS, +} from "../../src/lib/global-flags.js"; + +describe("buildTopLevelFlags", () => { + test("every boolean flag contributes --name, its short alias, and --no-name", () => { + const { booleanFlags } = buildTopLevelFlags(); + for (const flag of GLOBAL_FLAGS) { + if (flag.kind !== "boolean") { + continue; + } + expect(booleanFlags.has(`--${flag.name}`)).toBe(true); + expect(booleanFlags.has(`--no-${flag.name}`)).toBe(true); + if (flag.short !== null) { + expect(booleanFlags.has(`-${flag.short}`)).toBe(true); + } + } + }); + + test("every value flag contributes --name (and its short alias)", () => { + const { valueFlags } = buildTopLevelFlags(); + for (const flag of GLOBAL_FLAGS) { + if (flag.kind !== "value") { + continue; + } + expect(valueFlags.has(`--${flag.name}`)).toBe(true); + if (flag.short !== null) { + expect(valueFlags.has(`-${flag.short}`)).toBe(true); + } + } + }); + + test("boolean and value token sets are disjoint", () => { + const { booleanFlags, valueFlags } = buildTopLevelFlags(); + for (const token of booleanFlags) { + expect(valueFlags.has(token)).toBe(false); + } + }); + + test("value flags never carry a --no- negation", () => { + const { valueFlags } = buildTopLevelFlags(); + for (const token of valueFlags) { + expect(token.startsWith("--no-")).toBe(false); + } + }); + + test("matches the current GLOBAL_FLAGS definition", () => { + const { booleanFlags, valueFlags } = buildTopLevelFlags(); + expect([...booleanFlags].sort()).toEqual( + ["--verbose", "-v", "--no-verbose", "--json", "--no-json"].sort() + ); + expect([...valueFlags].sort()).toEqual( + ["--log-level", "--fields", "--org", "--project"].sort() + ); + }); +}); diff --git a/packages/cli/test/lib/help-json.test.ts b/packages/cli/test/lib/help-json.test.ts new file mode 100644 index 000000000..132592bf8 --- /dev/null +++ b/packages/cli/test/lib/help-json.test.ts @@ -0,0 +1,249 @@ +/** + * Unit tests for {@link renderJsonHelp}, the pluggable help renderer wired into + * Stricli's `documentation.renderHelp` hook (see `app.ts`). + * + * End-to-end behavior through the real `app` (routing + the patch) lives in + * `scanner-flags.integration.test.ts`. These tests pin the pure input→output + * contract of the renderer: which top-level-flag combinations produce JSON, + * how the route prefix maps to introspection, and `--fields` narrowing. + */ + +import { describe, expect, test } from "vitest"; +import { + isRecoverableUnknownCommand, + isVersionRequest, + renderJsonHelp, + rewriteHelpJsonToHelpCommand, +} from "../../src/lib/help.js"; + +const APP = "sentry"; + +describe("renderJsonHelp", () => { + test("returns undefined without --json so text help is used", () => { + expect(renderJsonHelp([APP], [])).toBeUndefined(); + expect(renderJsonHelp([APP, "issue", "list"], [])).toBeUndefined(); + }); + + test("bare app prefix + --json emits the full command tree", () => { + const out = renderJsonHelp([APP], ["--json"]); + expect(out).toBeDefined(); + const parsed = JSON.parse(out as string); + expect(parsed).toHaveProperty("routes"); + expect(parsed).toHaveProperty("flags"); + expect(Array.isArray(parsed.routes)).toBe(true); + }); + + test("a command prefix + --json emits that command's metadata", () => { + const out = renderJsonHelp([APP, "issue", "list"], ["--json"]); + const parsed = JSON.parse(out as string); + expect(parsed).toHaveProperty("path"); + expect(parsed.path).toContain("issue list"); + }); + + test("output is pretty-printed JSON with a trailing newline", () => { + const out = renderJsonHelp([APP, "issue", "list"], ["--json"]) as string; + expect(out.endsWith("\n")).toBe(true); + // Pretty-printed (2-space indent) — the wrapper uses JSON.stringify(_, null, 2). + expect(out).toContain("\n "); + }); + + test("--fields (spaced) narrows the JSON output", () => { + const out = renderJsonHelp( + [APP, "issue", "list"], + ["--json", "--fields", "path"] + ); + const parsed = JSON.parse(out as string); + expect(Object.keys(parsed)).toEqual(["path"]); + }); + + test("--fields= inline form narrows the JSON output", () => { + const out = renderJsonHelp( + [APP, "issue", "list"], + ["--json", "--fields=path"] + ); + const parsed = JSON.parse(out as string); + expect(Object.keys(parsed)).toEqual(["path"]); + }); + + test("--fields accepts a comma-separated list", () => { + const out = renderJsonHelp( + [APP, "issue", "list"], + ["--json", "--fields", "path,brief"] + ); + const parsed = JSON.parse(out as string); + expect(Object.keys(parsed).sort()).toEqual(["brief", "path"]); + }); + + test("a --json after a -- escape is ignored (wrapped command's flag)", () => { + expect( + renderJsonHelp([APP, "monitor", "run"], ["--", "tool", "--json"]) + ).toBeUndefined(); + }); + + test("an unknown command path yields a JSON error object", () => { + const out = renderJsonHelp([APP, "nope"], ["--json"]); + const parsed = JSON.parse(out as string); + expect(parsed).toHaveProperty("error"); + }); + + test("an unknown token forwarded in unprocessedInputs yields a JSON error", () => { + // `sentry issue nope --help --json`: the scanner forwards `nope` to the + // `issue` group's default command, so it arrives in unprocessedInputs + // rather than the prefix. It must still produce an error, not the group. + const out = renderJsonHelp([APP, "issue"], ["nope", "--json"]); + const parsed = JSON.parse(out as string); + expect(parsed).toHaveProperty("error"); + expect(parsed.error).toContain("nope"); + }); + + test("a top-level unknown token yields a JSON error", () => { + const out = renderJsonHelp([APP], ["nope", "--json"]); + const parsed = JSON.parse(out as string); + expect(parsed).toHaveProperty("error"); + }); + + test("a value-taking global flag's spaced value is not treated as a path segment", () => { + // `sentry --org acme issue list --help --json`: `--org acme` is forwarded + // in unprocessedInputs. `acme` must be consumed as --org's value, leaving + // the resolved `issue list` command rather than an `acme`-prefixed path. + const out = renderJsonHelp( + [APP, "issue", "list"], + ["--org", "acme", "--json"] + ); + const parsed = JSON.parse(out as string); + expect(parsed).toHaveProperty("path"); + expect(parsed.path).toContain("issue list"); + }); +}); + +describe("rewriteHelpJsonToHelpCommand", () => { + test("returns undefined when not a --help --json request", () => { + expect(rewriteHelpJsonToHelpCommand(["cli", "nope"])).toBeUndefined(); + expect( + rewriteHelpJsonToHelpCommand(["cli", "nope", "--help"]) + ).toBeUndefined(); + expect( + rewriteHelpJsonToHelpCommand(["cli", "nope", "--json"]) + ).toBeUndefined(); + }); + + test("rewrites an unknown-command --help --json to the help command", () => { + expect( + rewriteHelpJsonToHelpCommand(["cli", "nope", "--help", "--json"]) + ).toEqual(["help", "--json", "cli", "nope"]); + }); + + test("recognizes the -h alias and is order-insensitive", () => { + expect(rewriteHelpJsonToHelpCommand(["--json", "cli", "-h"])).toEqual([ + "help", + "--json", + "cli", + ]); + }); + + test("carries --fields through", () => { + expect( + rewriteHelpJsonToHelpCommand([ + "issue", + "list", + "--help", + "--json", + "--fields", + "path", + ]) + ).toEqual(["help", "--json", "issue", "list", "--fields", "path"]); + }); + + test("drops spaced values of value-taking global flags from the path", () => { + // `sentry --org acme --log-level debug cli nope --help --json`: the values + // `acme` and `debug` must not leak into the command path, which should be + // just `cli nope`. + expect( + rewriteHelpJsonToHelpCommand([ + "--org", + "acme", + "--log-level", + "debug", + "cli", + "nope", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "cli", "nope"]); + }); + + test("drops inline value-flag forms without consuming the next token", () => { + expect( + rewriteHelpJsonToHelpCommand([ + "--org=acme", + "cli", + "nope", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "cli", "nope"]); + }); + + test("ignores --help/--json after a -- escape", () => { + expect( + rewriteHelpJsonToHelpCommand([ + "monitor", + "run", + "--", + "tool", + "--help", + "--json", + ]) + ).toBeUndefined(); + }); +}); + +describe("isVersionRequest", () => { + test("true for a bare --version at any depth", () => { + expect(isVersionRequest(["--version"])).toBe(true); + expect(isVersionRequest(["cli", "--version"])).toBe(true); + // Unknown route token before --version: the scanner aborts before + // consuming it, so this is the case cli.ts must recover. + expect(isVersionRequest(["cli", "nope", "--version"])).toBe(true); + }); + + test("false without --version", () => { + expect(isVersionRequest(["cli", "nope"])).toBe(false); + expect(isVersionRequest([])).toBe(false); + }); + + test("does not treat -v as a version request", () => { + // -v is remapped to --verbose by the Sentry CLI (and the scanner patch + // drops Stricli's -v=version alias). + expect(isVersionRequest(["-v"])).toBe(false); + }); + + test("ignores --version after a -- escape", () => { + expect(isVersionRequest(["bash-hook", "--", "--version"])).toBe(false); + }); +}); + +describe("isRecoverableUnknownCommand", () => { + test("true for a --version request", () => { + expect(isRecoverableUnknownCommand(["cli", "nope", "--version"])).toBe( + true + ); + }); + + test("true for a --help --json request whose last token is --json", () => { + // Regression: the old guard only checked `argv.at(-1) === "help"`, so this + // shape leaked a spurious unknown_command telemetry event. + expect( + isRecoverableUnknownCommand(["cli", "nope", "--help", "--json"]) + ).toBe(true); + }); + + test("true for a trailing help token", () => { + expect(isRecoverableUnknownCommand(["dashboard", "help"])).toBe(true); + }); + + test("false for a genuine unknown command", () => { + expect(isRecoverableUnknownCommand(["cli", "nope"])).toBe(false); + expect(isRecoverableUnknownCommand(["issue", "listt"])).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/help-positional.test.ts b/packages/cli/test/lib/help-positional.test.ts new file mode 100644 index 000000000..cb141ed3b --- /dev/null +++ b/packages/cli/test/lib/help-positional.test.ts @@ -0,0 +1,232 @@ +/** + * Tests for help-as-positional-arg error recovery in buildCommand. + * + * When a command throws a CliError and a positional arg was `"help"`, + * the buildCommand wrapper recovers by showing the command's help + * instead of the confusing error. + * + * This only fires as error recovery — if a command successfully resolves + * a legitimate value like a project named "help", the recovery never runs. + * + * Tests run commands through Stricli's `run()` with `help` as a positional + * and verify help output is shown when resolution fails. + */ + +import { generateHelpTextForAllCommands, run } from "@stricli/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { app } from "../../src/app.js"; +import type { SentryContext } from "../../src/context.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("help-positional-"); + +// Silence unmocked fetch calls from the resolution cascade. +// Commands run through run(app, args) with "help" as a positional arg +// trigger real resolution (e.g., findProjectsBySlug("help") → listOrganizations) +// before the help-recovery error handler fires. A silent 404 prevents +// preload warnings while preserving the error → recovery behavior. +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }) + ); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** Captured output from a command run */ +type CapturedOutput = { + stdout: string; + stderr: string; +}; + +/** + * Build a mock context with forCommand support. + * + * Stricli calls `forCommand({ prefix })` before running the command. + * We must provide it so `commandPrefix` is set on the context, enabling + * the help recovery logic in `buildCommand`. + */ +function buildMockContext(captured: { + stdout: string; + stderr: string; +}): SentryContext & { + forCommand: (opts: { prefix: readonly string[] }) => SentryContext; +} { + const stdoutWriter = { + write(data: string | Uint8Array) { + captured.stdout += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }; + const stderrWriter = { + write(data: string | Uint8Array) { + captured.stderr += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }; + + const baseContext: SentryContext = { + process, + env: process.env, + cwd: process.cwd(), + homeDir: "/tmp", + configDir: "/tmp", + stdout: stdoutWriter, + stderr: stderrWriter, + stdin: process.stdin, + }; + + return { + ...baseContext, + forCommand: ({ prefix }: { prefix: readonly string[] }): SentryContext => ({ + ...baseContext, + commandPrefix: prefix, + }), + }; +} + +/** + * Run a command through Stricli and capture stdout/stderr. + * + * Commands that hit resolution errors with "help" as a positional arg + * will be recovered by the buildCommand wrapper, which shows help output + * instead of the error. + */ +async function runCommand(args: string[]): Promise { + const captured = { stdout: "", stderr: "" }; + const mockContext = buildMockContext(captured); + + try { + await run(app, args, mockContext); + } catch { + // Some commands may still throw (e.g., uncaught errors) + } + + return captured; +} + +describe("help recovery on ResolutionError", () => { + test("sentry issue list help → shows help for issue list", async () => { + // "help" is treated as a project slug, fails resolution → recovery shows help + const { stdout, stderr } = await runCommand(["issue", "list", "help"]); + + expect(stdout).toContain("sentry issue list"); + expect(stderr).toContain("--help"); + expect(stderr).toContain("Tip"); + }); + + test("sentry project list help → shows help for project list", async () => { + const { stdout, stderr } = await runCommand(["project", "list", "help"]); + + expect(stdout).toContain("sentry project list"); + expect(stderr).toContain("--help"); + }); + + test("sentry span list help → shows help for span list", async () => { + const { stdout, stderr } = await runCommand(["span", "list", "help"]); + + expect(stdout).toContain("sentry span list"); + expect(stderr).toContain("--help"); + }); + + test("stderr hint includes the correct command path", async () => { + const { stderr } = await runCommand(["issue", "list", "help"]); + + expect(stderr).toContain("sentry issue list --help"); + }); +}); + +describe("help recovery on ValidationError", () => { + test("sentry trace view help → shows help for trace view", async () => { + // "help" fails hex ID validation → ValidationError → recovery shows help + const { stdout, stderr } = await runCommand(["trace", "view", "help"]); + + expect(stdout).toContain("sentry trace view"); + expect(stderr).toContain("--help"); + }); + + test("sentry log view help → shows help for log view", async () => { + const { stdout, stderr } = await runCommand(["log", "view", "help"]); + + expect(stdout).toContain("sentry log view"); + expect(stderr).toContain("--help"); + }); +}); + +describe("help command unchanged", () => { + test("sentry help still shows branded help", async () => { + const { stdout, stderr } = await runCommand(["help"]); + + // Custom help command shows branded output — no recovery needed + expect(stdout).toContain("sentry"); + // Should NOT have the recovery tip + expect(stderr).not.toContain("Tip"); + }); + + test("sentry help issue list still shows introspected help", async () => { + const { stdout, stderr } = await runCommand(["help", "issue", "list"]); + + expect(stdout).toContain("sentry issue list"); + // Should NOT have the recovery tip — this is the normal help path + expect(stderr).not.toContain("Tip"); + }); + + test("sentry help project create shows the public positional syntax", async () => { + const { stdout, stderr } = await runCommand(["help", "project", "create"]); + + expect(stdout).toContain( + "sentry project create [/]:..." + ); + expect(stderr).not.toContain("Tip"); + }); + + test("project create --help shows the required paired syntax", () => { + const help = generateHelpTextForAllCommands(app).find( + ([route]) => route === "sentry project create" + )?.[1]; + + expect(help).toContain( + "sentry project create [/]:..." + ); + expect(help).not.toContain("--platform"); + expect(help).not.toContain(""); + expect(help).toContain("cannot contain whitespace"); + }); +}); + +describe("project create parser contract", () => { + test.each([ + "--platform", + "-p", + ])("rejects the removed %s flag", async (flag) => { + const captured = { stdout: "", stderr: "" }; + const mockContext = buildMockContext(captured); + const stderrWrite = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + try { + await run( + app, + ["project", "create", "my-app:node", flag, "javascript"], + mockContext + ); + + const errorOutput = stderrWrite.mock.calls + .map(([data]) => String(data)) + .join(""); + expect(errorOutput).toContain(flag); + expect(errorOutput).toMatch(/No (flag|alias) registered/); + } finally { + stderrWrite.mockRestore(); + } + }); +}); diff --git a/packages/cli/test/lib/help.test.ts b/packages/cli/test/lib/help.test.ts new file mode 100644 index 000000000..37955e7ac --- /dev/null +++ b/packages/cli/test/lib/help.test.ts @@ -0,0 +1,213 @@ +/** + * Help Output Tests + * + * Tests for the branded CLI help output including the ASCII banner, + * command generation from routes, and contextual examples. + */ + +import { describe, expect, test } from "vitest"; +import { bannerLinesForWidth, formatBanner } from "../../src/lib/banner.js"; +import { introspectAllCommands, printCustomHelp } from "../../src/lib/help.js"; +import { useTestConfigDir } from "../helpers.js"; + +/** Strip ANSI escape sequences for content assertions */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes use control chars by definition +const ANSI_RE = /\u001B\[[0-9;]*m/g; +function stripAnsi(str: string): string { + return str.replace(ANSI_RE, ""); +} + +/** Widest line (in code points) in a rendered banner, ignoring ANSI codes. */ +function maxLineWidth(banner: string): number { + return Math.max( + 0, + ...stripAnsi(banner) + .split("\n") + .map((line) => [...line].length) + ); +} + +describe("formatBanner", () => { + test("renders the full arch + wordmark on wide terminals", () => { + const banner = formatBanner(120); + expect(banner.split("\n")).toHaveLength(8); + expect(stripAnsi(banner)).toContain("███████"); + }); + + test("falls back to the wordmark on medium terminals", () => { + const banner = formatBanner(64); + expect(banner.split("\n")).toHaveLength(8); + // Wordmark still contains the block run but is narrower than the full banner. + expect(stripAnsi(banner)).toContain("███████"); + expect(maxLineWidth(formatBanner(64))).toBeLessThanOrEqual(64); + }); + + test("falls back to a compact text mark on narrow terminals", () => { + expect(stripAnsi(formatBanner(40))).toBe("sentry"); + }); + + test("renders nothing when the terminal is too narrow for any mark", () => { + expect(formatBanner(4)).toBe(""); + expect(bannerLinesForWidth(4)).toEqual([]); + }); + + // Core invariant: the banner must never exceed the terminal width (it would + // wrap into a broken layout otherwise). Regression test for split-pane widths. + test("never exceeds the terminal width", () => { + for (let columns = 6; columns <= 200; columns++) { + expect(maxLineWidth(formatBanner(columns))).toBeLessThanOrEqual(columns); + } + }); + + test("is deterministic across calls", () => { + expect(formatBanner(120)).toBe(formatBanner(120)); + }); +}); + +describe("printCustomHelp", () => { + useTestConfigDir("help-test-"); + + test("returns non-empty string", async () => { + const output = printCustomHelp(); + expect(output.length).toBeGreaterThan(0); + }); + + test("output contains the tagline", async () => { + const output = stripAnsi(printCustomHelp()); + expect(output).toContain("The command-line interface for Sentry"); + }); + + test("output contains registered commands", async () => { + const output = stripAnsi(printCustomHelp()); + + // Should include at least some core commands from routes + expect(output).toContain("sentry"); + // Route map command (exercises isRouteMap branch) + expect(output).toContain("auth"); + // Direct command with tuple positional (exercises isCommand + getPositionalPlaceholder) + expect(output).toContain("init"); + }); + + test("output contains docs URL", async () => { + const output = stripAnsi(printCustomHelp()); + expect(output).toContain("cli.sentry.dev"); + }); + + test("includes the banner only when stdout is a TTY", () => { + const savedTty = process.stdout.isTTY; + try { + process.stdout.isTTY = false; + const withoutBanner = printCustomHelp(); + // stdin stays non-TTY under vitest, so sixel opts out and the block-art + // banner is used — exercises the `sixelBanner(cols) ?? formatBanner(cols)` + // fallback inside the TTY-only banner branch. + process.stdout.isTTY = true; + const withBanner = printCustomHelp(); + expect(withBanner.length).toBeGreaterThan(withoutBanner.length); + } finally { + process.stdout.isTTY = savedTty; + } + }); + + test("shows login example when not authenticated", async () => { + // useTestConfigDir provides a clean env with no auth token + const output = stripAnsi(printCustomHelp()); + expect(output).toContain("sentry auth"); + }); + + test("includes an Environment Variables section with top-level vars", () => { + const output = stripAnsi(printCustomHelp()); + expect(output).toContain("Environment Variables:"); + // Highest-signal vars from the feedback issue must be surfaced. + expect(output).toContain("SENTRY_AUTH_TOKEN"); + expect(output).toContain("SENTRY_FORCE_ENV_TOKEN"); + expect(output).toContain("SENTRY_ORG"); + expect(output).toContain("SENTRY_PROJECT"); + expect(output).toContain("SENTRY_DSN"); + expect(output).toContain("SENTRY_HOST"); + expect(output).toContain("SENTRY_LOG_LEVEL"); + expect(output).toContain("NO_COLOR"); + }); + + test("includes a Flags section with common flags", () => { + const output = stripAnsi(printCustomHelp()); + expect(output).toContain("Flags:"); + expect(output).toContain("--json"); + expect(output).toContain("--fresh"); + expect(output).toContain("-f"); + expect(output).toContain("--verbose"); + expect(output).toContain("-v"); + expect(output).toContain("--help"); + expect(output).toContain("--version"); + }); + + test("Flags section appears before Environment Variables section", () => { + const output = stripAnsi(printCustomHelp()); + const flagsIndex = output.indexOf("Flags:"); + const envVarsIndex = output.indexOf("Environment Variables:"); + expect(flagsIndex).toBeGreaterThan(-1); + expect(envVarsIndex).toBeGreaterThan(-1); + expect(flagsIndex).toBeLessThan(envVarsIndex); + }); +}); + +describe("introspectAllCommands", () => { + useTestConfigDir("help-introspect-"); + + test("includes an envVars array with the top-level env vars", () => { + const result = introspectAllCommands(); + expect(Array.isArray(result.envVars)).toBe(true); + const names = result.envVars.map((v) => v.name); + expect(names).toContain("SENTRY_AUTH_TOKEN"); + expect(names).toContain("SENTRY_FORCE_ENV_TOKEN"); + expect(names).toContain("SENTRY_ORG"); + expect(names).toContain("SENTRY_PROJECT"); + expect(names).toContain("SENTRY_DSN"); + expect(names).toContain("SENTRY_HOST"); + expect(names).toContain("SENTRY_LOG_LEVEL"); + expect(names).toContain("NO_COLOR"); + }); + + test("each envVars entry has a brief and description", () => { + const { envVars } = introspectAllCommands(); + for (const v of envVars) { + expect(typeof v.name).toBe("string"); + expect(v.name.length).toBeGreaterThan(0); + expect(typeof v.brief).toBe("string"); + expect(v.brief.length).toBeGreaterThan(0); + expect(typeof v.description).toBe("string"); + expect(v.description.length).toBeGreaterThan(0); + } + }); + + test("includes a flags array with common flags", () => { + const result = introspectAllCommands(); + expect(Array.isArray(result.flags)).toBe(true); + const longs = result.flags.map((f) => f.long); + expect(longs).toContain("--json"); + expect(longs).toContain("--fresh"); + expect(longs).toContain("--verbose"); + expect(longs).toContain("--help"); + expect(longs).toContain("--version"); + }); + + test("each flags entry has long and description", () => { + const { flags } = introspectAllCommands(); + for (const f of flags) { + expect(typeof f.long).toBe("string"); + expect(f.long.startsWith("--")).toBe(true); + expect(typeof f.description).toBe("string"); + expect(f.description.length).toBeGreaterThan(0); + } + }); + + test("flags with short aliases have the correct format", () => { + const { flags } = introspectAllCommands(); + const fresh = flags.find((f) => f.long === "--fresh"); + expect(fresh).toBeDefined(); + expect(fresh?.short).toBe("-f"); + const verbose = flags.find((f) => f.long === "--verbose"); + expect(verbose).toBeDefined(); + expect(verbose?.short).toBe("-v"); + }); +}); diff --git a/packages/cli/test/lib/hex-id-recovery.adapters.test.ts b/packages/cli/test/lib/hex-id-recovery.adapters.test.ts new file mode 100644 index 000000000..05d1ea260 --- /dev/null +++ b/packages/cli/test/lib/hex-id-recovery.adapters.test.ts @@ -0,0 +1,316 @@ +/** + * Hex ID Recovery — adapter integration tests + * + * Exercises `ADAPTERS.{event,trace,log,span}` and `recoverHexId`'s + * cross-entity redirect path against mocked `globalThis.fetch`. These + * complement `hex-id-recovery.test.ts` (which stubs `ADAPTERS` directly) + * by verifying the adapter bodies — the API query strings, response + * shapes, and empty-context guards — actually work end-to-end. + * + * Lives in `test/lib/` (not `test/isolated/`) because no module mocking + * is needed — just a bare `globalThis.fetch` swap, same pattern as + * `api-client.coverage.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { ADAPTERS, recoverHexId } from "../../src/lib/hex-id-recovery.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-hex-recovery-adapters-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", "https://sentry.io"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** Build an Events-API style response with arbitrary data rows. */ +function eventsResponse(data: unknown[]): Response { + return new Response(JSON.stringify({ data }), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"', + }, + }); +} + +// --------------------------------------------------------------------------- +// Adapters: empty-context short-circuit +// --------------------------------------------------------------------------- + +describe("adapter context guards", () => { + test("event adapter returns [] without hitting fetch when org is empty", async () => { + let fetchCalled = false; + globalThis.fetch = mockFetch(async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }); + + const ids = await ADAPTERS.event({ org: "", project: "my-project" }); + expect(ids).toEqual([]); + expect(fetchCalled).toBe(false); + }); + + test("event adapter returns [] without hitting fetch when project is empty", async () => { + let fetchCalled = false; + globalThis.fetch = mockFetch(async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }); + + const ids = await ADAPTERS.event({ org: "test-org" }); + expect(ids).toEqual([]); + expect(fetchCalled).toBe(false); + }); + + test("span adapter returns [] when traceId is missing even with org+project", async () => { + let fetchCalled = false; + globalThis.fetch = mockFetch(async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }); + + const ids = await ADAPTERS.span({ + org: "test-org", + project: "test-project", + }); + expect(ids).toEqual([]); + expect(fetchCalled).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Adapters: query-string correctness +// --------------------------------------------------------------------------- + +describe("adapter query params", () => { + test("event adapter queries the errors dataset with project scope", async () => { + let queryUrl = ""; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + queryUrl = req.url; + return eventsResponse([ + { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + timestamp: "2026-01-01T00:00:00Z", + }, + { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + timestamp: "2026-01-01T00:00:00Z", + }, + ]); + }); + + const ids = await ADAPTERS.event({ + org: "test-org", + project: "test-project", + }); + + expect(queryUrl).toContain("dataset=errors"); + expect(queryUrl).toContain("field=id"); + expect(queryUrl).toContain("project%3Atest-project"); // project:test-project URL-encoded + expect(queryUrl).toContain("sort=-timestamp"); + expect(queryUrl).toContain("statsPeriod=90d"); + expect(ids).toEqual([ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ]); + }); + + test("trace adapter queries the spans dataset and extracts trace field", async () => { + let queryUrl = ""; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + queryUrl = req.url; + return eventsResponse([ + { + id: "span1", + trace: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + timestamp: "2026-01-01T00:00:00Z", + project: "test-project", + }, + { + id: "span2", + trace: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + timestamp: "2026-01-01T00:00:00Z", + project: "test-project", + }, + ]); + }); + + const traces = await ADAPTERS.trace({ + org: "test-org", + project: "test-project", + }); + + expect(queryUrl).toContain("dataset=spans"); + expect(queryUrl).toContain("statsPeriod=30d"); + expect(traces).toEqual([ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ]); + }); + + test("span adapter scopes query by traceId", async () => { + let queryUrl = ""; + const traceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + queryUrl = req.url; + return eventsResponse([ + { + id: "abcd1234abcd1234", + trace: traceId, + timestamp: "2026-01-01T00:00:00Z", + project: "test-project", + }, + ]); + }); + + const spanIds = await ADAPTERS.span({ + org: "test-org", + project: "test-project", + traceId, + }); + + expect(queryUrl).toContain("dataset=spans"); + expect(queryUrl).toContain(`trace%3A${traceId}`); + expect(spanIds).toEqual(["abcd1234abcd1234"]); + }); + + test("log adapter queries the logs dataset and extracts sentry.item_id", async () => { + let queryUrl = ""; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + queryUrl = req.url; + return new Response( + JSON.stringify({ + data: [ + { + "sentry.item_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + timestamp: "2026-01-01T00:00:00Z", + // Nanosecond timestamps exceed Number.MAX_SAFE_INTEGER + // when encoded literally. Use string form (the schema + // coerces it to a number for us). + timestamp_precise: "1735689600000000000", + message: "test log", + severity: "info", + }, + { + "sentry.item_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + timestamp: "2026-01-01T00:00:00Z", + timestamp_precise: "1735689600000000001", + message: "another log", + severity: "error", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }); + + const ids = await ADAPTERS.log({ + org: "test-org", + project: "test-project", + }); + + expect(queryUrl).toContain("dataset=logs"); + expect(queryUrl).toContain("statsPeriod=30d"); + expect(ids).toEqual([ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ]); + }); + + test("ctx.period override replaces the default scan window", async () => { + let queryUrl = ""; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + queryUrl = req.url; + return eventsResponse([]); + }); + + await ADAPTERS.trace({ + org: "test-org", + project: "test-project", + period: "7d", + }); + + expect(queryUrl).toContain("statsPeriod=7d"); + expect(queryUrl).not.toContain("statsPeriod=30d"); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-entity redirect: 16-hex span → parent trace +// --------------------------------------------------------------------------- + +describe("recoverHexId trace redirect via findTraceBySpanId", () => { + test("16-hex input to trace recovery queries spans with id: and returns parent trace", async () => { + const spanId = "a1b2c3d4e5f67890"; + const parentTrace = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let queryUrl = ""; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + queryUrl = req.url; + return eventsResponse([ + { + id: spanId, + trace: parentTrace, + timestamp: "2026-01-01T00:00:00Z", + project: "test-project", + }, + ]); + }); + + const result = await recoverHexId(spanId, "trace", { + org: "test-org", + project: "test-project", + }); + + expect(queryUrl).toContain(`id%3A${spanId}`); + expect(result.kind).toBe("redirect"); + if (result.kind === "redirect") { + expect(result.id).toBe(parentTrace); + expect(result.fromEntity).toBe("span"); + expect(result.toEntity).toBe("trace"); + } + }); + + test("16-hex span not found returns null from findTraceBySpanId — falls through to fuzzy", async () => { + const spanId = "a1b2c3d4e5f67890"; + // First call (id: lookup) returns empty → null trace → falls through. + // Second call (fuzzy scan) also returns empty → no-matches. + const callCount = { n: 0 }; + globalThis.fetch = mockFetch(async () => { + callCount.n += 1; + return eventsResponse([]); + }); + + const result = await recoverHexId(spanId, "trace", { + org: "test-org", + project: "test-project", + }); + + expect(callCount.n).toBeGreaterThanOrEqual(1); + // The span lookup returned null, so no redirect happened. Input has a + // valid-looking 16-hex prefix, so it proceeds to the fuzzy path and + // ultimately returns `no-matches` (or `multiple-matches` if somehow + // filtered) — here we just confirm it's a failed outcome, not a + // redirect/fuzzy-success. + expect(result.kind).toBe("failed"); + }); +}); diff --git a/packages/cli/test/lib/hex-id-recovery.property.test.ts b/packages/cli/test/lib/hex-id-recovery.property.test.ts new file mode 100644 index 000000000..58df132d1 --- /dev/null +++ b/packages/cli/test/lib/hex-id-recovery.property.test.ts @@ -0,0 +1,301 @@ +/** + * Property-based tests for hex ID recovery helpers. + * + * Focus on invariants that must hold for *any* valid input: + * - `stripTrailingNonHex` always returns hex of the expected length or null + * - `extractHexCandidate` output is always lowercase hex or undefined + * - Valid full-length hex IDs never trigger recovery (`validateHexId` accepts them) + */ + +import { + array, + constantFrom, + assert as fcAssert, + integer, + property, + string, + tuple, +} from "fast-check"; +import { describe, test } from "vitest"; + +import { + ageInDaysFromUuidV7, + decodeUuidV7Timestamp, + validateHexId, +} from "../../src/lib/hex-id.js"; +import { + extractHexCandidate, + isOverNestedPath, + looksLikeSlug, + preNormalize, + stripTrailingNonHex, +} from "../../src/lib/hex-id-recovery.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const HEX_CHARS = "0123456789abcdef".split(""); +const NON_HEX_CHARS = "ghijklmnopqrstuvwxyz!@#$%^&*()_+=".split(""); + +/** Generate a 32-char hex string */ +const hex32Arb = array(constantFrom(...HEX_CHARS), { + minLength: 32, + maxLength: 32, +}).map((chars) => chars.join("")); + +/** Generate a 16-char hex string */ +const hex16Arb = array(constantFrom(...HEX_CHARS), { + minLength: 16, + maxLength: 16, +}).map((chars) => chars.join("")); + +/** Generate a non-empty string of non-hex chars */ +const nonHexSuffixArb = array(constantFrom(...NON_HEX_CHARS), { + minLength: 1, + maxLength: 10, +}).map((chars) => chars.join("")); + +/** Generate a hex prefix of variable length (1..31 chars) */ +const shortHexArb = array(constantFrom(...HEX_CHARS), { + minLength: 1, + maxLength: 31, +}).map((chars) => chars.join("")); + +describe("property: stripTrailingNonHex", () => { + test("<32hex> + non-hex junk → strips cleanly to 32hex", () => { + fcAssert( + property(hex32Arb, nonHexSuffixArb, (hex, junk) => { + const input = `${hex}${junk}`; + const result = stripTrailingNonHex(input, 32); + if (!result) { + throw new Error(`Expected non-null for ${input}`); + } + if (result.hex !== hex) { + throw new Error(`Expected ${hex}, got ${result.hex}`); + } + if (result.stripped !== junk) { + throw new Error(`Expected stripped=${junk}, got ${result.stripped}`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("<32hex> alone (no junk) → returns null", () => { + fcAssert( + property(hex32Arb, (hex) => { + if (stripTrailingNonHex(hex, 32) !== null) { + throw new Error(`Expected null for exact-length input ${hex}`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("short hex + anything → returns null (falls through to fuzzy)", () => { + fcAssert( + property(shortHexArb, string(), (hex, anything) => { + const input = `${hex}${anything}`; + // Only interesting when input length > 32 (precondition for the + // strip function to consider it). Skip otherwise. + if (input.length <= 32) { + return; + } + const result = stripTrailingNonHex(input, 32); + // If null → good. + // If non-null → the "hex" must be exactly 32 chars AND match the input prefix. + if (result) { + if (result.hex.length !== 32) { + throw new Error( + `hex result length ${result.hex.length} !== 32 for input ${input}` + ); + } + if (!input.startsWith(result.hex)) { + throw new Error( + `input ${input} does not start with returned hex ${result.hex}` + ); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("span length: <16hex> + junk → strips to 16hex", () => { + fcAssert( + property(hex16Arb, nonHexSuffixArb, (hex, junk) => { + const result = stripTrailingNonHex(`${hex}${junk}`, 16); + if (!result || result.hex !== hex || result.stripped !== junk) { + throw new Error(`Unexpected result for ${hex}${junk}`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: extractHexCandidate", () => { + test("output prefix is always lowercase hex (no non-hex chars)", () => { + fcAssert( + property(string(), (input) => { + const result = extractHexCandidate(input); + if (!result) { + return; + } + if (result.prefix && !/^[0-9a-f]*$/.test(result.prefix)) { + throw new Error( + `prefix '${result.prefix}' contains non-hex chars (input: ${input})` + ); + } + if (result.suffix && !/^[0-9a-f]*$/.test(result.suffix)) { + throw new Error(`suffix '${result.suffix}' contains non-hex chars`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("plain hex prefix round-trip: candidate.prefix === input.match(/^[0-9a-f]+/)", () => { + fcAssert( + property(tuple(shortHexArb, nonHexSuffixArb), ([hex, suffix]) => { + const result = extractHexCandidate(`${hex}${suffix}`); + if (!result || result.prefix !== hex) { + throw new Error(`Expected prefix ${hex}, got ${result?.prefix}`); + } + if (result.suffix !== undefined) { + throw new Error(`Unexpected suffix ${result.suffix}`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: valid hex IDs never need recovery", () => { + test("valid 32-hex passes validateHexId unchanged", () => { + fcAssert( + property(hex32Arb, (hex) => { + // validateHexId returns the normalized ID; must match (already lowercase). + const result = validateHexId(hex, "test ID"); + if (result !== hex) { + throw new Error(`Expected ${hex}, got ${result}`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: preNormalize", () => { + test("output length <= input length (never grows)", () => { + fcAssert( + property(string(), (input) => { + const { cleaned } = preNormalize(input); + if (cleaned.length > input.length) { + throw new Error( + `Output grew: input=${input.length}, output=${cleaned.length}` + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent — preNormalize of preNormalize yields same cleaned value", () => { + fcAssert( + property(string(), (input) => { + const once = preNormalize(input).cleaned; + const twice = preNormalize(once).cleaned; + if (once !== twice) { + throw new Error(`Not idempotent: ${once} !== ${twice}`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: looksLikeSlug vs extractHexCandidate", () => { + test("any pure long-hex input is never classified as slug", () => { + fcAssert( + property(hex32Arb, (hex) => { + if (looksLikeSlug(hex)) { + throw new Error(`Pure hex ${hex} classified as slug`); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: UUIDv7 round-trip via decode/age", () => { + /** Build a 32-char UUIDv7 where the embedded timestamp is `ms`. */ + function buildUuidV7(ms: number): string { + const ts = ms.toString(16).padStart(12, "0"); + return `${ts}70008000000000000000`; + } + + // UUIDv7 was standardized May 2024 (RFC 9562). Restrict the arbitrary + // to plausible real timestamps: Jan 2024 through year 3000 (48-bit + // ceiling is 10889 so pre-3000 is well within range). + // 2024-01-01 ≈ 1.704e12, 3000-01-01 ≈ 3.25e13. + const msArb = integer({ min: 1_704_067_200_000, max: 32_503_680_000_000 }); + + test("decoded timestamp round-trips the original ms", () => { + fcAssert( + property(msArb, (ms) => { + const decoded = decodeUuidV7Timestamp(buildUuidV7(ms)); + if (!decoded) { + throw new Error(`Failed to decode UUIDv7 for ms=${ms}`); + } + if (decoded.createdAt.getTime() !== ms) { + throw new Error( + `Round-trip failed: ${ms} → ${decoded.createdAt.getTime()}` + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("age is monotonic: older UUIDv7 ⇒ age >= newer UUIDv7", () => { + fcAssert( + property(msArb, msArb, (msA, msB) => { + const now = new Date(Math.max(msA, msB) + 24 * 60 * 60 * 1000); + const ageA = ageInDaysFromUuidV7(buildUuidV7(msA), now); + const ageB = ageInDaysFromUuidV7(buildUuidV7(msB), now); + if (ageA === null || ageB === null) { + throw new Error("Unexpected null"); + } + // Older ms → larger age (more days before `now`). + if (msA < msB && ageA < ageB) { + throw new Error( + `Monotonicity broken: ${msA}<${msB} but ${ageA}<${ageB}` + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: isOverNestedPath", () => { + test("returns true iff there are 4+ slash-separated non-empty segments", () => { + fcAssert( + property( + array(constantFrom("a", "b", "c", "1", "2"), { + minLength: 1, + maxLength: 6, + }), + (segments) => { + const input = segments.join("/"); + const expected = segments.length >= 4; + if (isOverNestedPath(input) !== expected) { + throw new Error( + `isOverNestedPath(${input}) expected ${expected}, got ${!expected}` + ); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/hex-id-recovery.test.ts b/packages/cli/test/lib/hex-id-recovery.test.ts new file mode 100644 index 000000000..66f3c369b --- /dev/null +++ b/packages/cli/test/lib/hex-id-recovery.test.ts @@ -0,0 +1,654 @@ +/** + * Hex ID Recovery Tests — unit tests for pure helpers and the decision tree. + * + * The decision tree is driven via a stubbed adapter so we don't hit real APIs. + * Adapter integration tests that mock `globalThis.fetch` live in + * `test/isolated/lib/hex-id-recovery-adapters.test.ts` per AGENTS.md. + * + * Many table cases are drawn directly from real telemetry (see + * `.opencode/plans/*quiet-planet.md`) — e.g. the CLI-1A8 `ios` input, + * the 12-char truncated UI IDs in CLI-16G, the slug-like inputs in + * CLI-16G / CLI-M0 / CLI-197, and the over-nested path from CLI-16G. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { ResolutionError, ValidationError } from "../../src/lib/errors.js"; +import { + ADAPTERS, + extractHexCandidate, + type FuzzyLookupAdapter, + handleRecoveryResult, + isOverNestedPath, + looksLikeSlug, + MIN_FUZZY_PREFIX, + preNormalize, + recoverHexId, + stripTrailingNonHex, +} from "../../src/lib/hex-id-recovery.js"; + +const VALID_32 = "c0a5a9d4dce44358ab4231fc3bead7e9"; +const VALID_16 = "d3808597105ed493"; +const CLEAN_CTX = { org: "my-org", project: "my-project" }; + +// --------------------------------------------------------------------------- +// preNormalize +// --------------------------------------------------------------------------- + +describe("preNormalize", () => { + test("lowercases and trims", () => { + expect(preNormalize(" ABC123 ")).toEqual({ cleaned: "abc123" }); + }); + + test("detects sentinel 'null'", () => { + expect(preNormalize("null")).toEqual({ + cleaned: "null", + sentinel: "null", + }); + }); + + test("detects sentinel 'latest'", () => { + expect(preNormalize("LATEST")).toEqual({ + cleaned: "latest", + sentinel: "latest", + }); + }); + + test("detects sentinel '@latest'", () => { + expect(preNormalize("@latest")).toEqual({ + cleaned: "@latest", + sentinel: "@latest", + }); + }); + + test("strips span- URL fragment prefix", () => { + expect(preNormalize("span-abc12345")).toEqual({ cleaned: "abc12345" }); + }); + + test("detects sentinel after URL prefix stripping", () => { + // Regression: `span-null` must be classified as sentinel, not slug. + // The sentinel check has to run AFTER prefix stripping to catch + // shell variable leaks that went through URL builders. + expect(preNormalize("span-null")).toEqual({ + cleaned: "null", + sentinel: "null", + }); + expect(preNormalize("event-latest")).toEqual({ + cleaned: "latest", + sentinel: "latest", + }); + }); + + test("strips nested URL fragment prefixes (idempotency safety)", () => { + // Ensures preNormalize is idempotent even on pathological inputs — + // first pass would only peel the outer prefix without the loop. + expect(preNormalize("span-span-abc")).toEqual({ cleaned: "abc" }); + expect(preNormalize("trace-txn-deadbeef")).toEqual({ + cleaned: "deadbeef", + }); + }); + + test("strips txn- URL fragment prefix", () => { + expect(preNormalize("txn-abc123")).toEqual({ cleaned: "abc123" }); + }); + + test("strips UUID dashes on full UUID", () => { + const uuid = "c0a5a9d4-dce4-4358-ab42-31fc3bead7e9"; + expect(preNormalize(uuid)).toEqual({ cleaned: VALID_32 }); + }); + + test("strips dashes on partial UUID", () => { + expect(preNormalize("abc12345-6789")).toEqual({ + cleaned: "abc123456789", + }); + }); + + test("preserves dashes when dashless result starts with non-hex char", () => { + // `human-interfaces` has `h` at the start which is NOT a hex digit + // (hex = 0-9, a-f). Stripping would produce `humaninterfaces` which + // doesn't start with hex, so dashes are preserved. Slug detection + // happens downstream in `looksLikeSlug`. + const { cleaned } = preNormalize("human-interfaces"); + expect(cleaned).toBe("human-interfaces"); + }); + + test("strips dashes when dashless result starts with hex char", () => { + // `abc12345-6789` → dashless `abc123456789` starts with `a` (hex) → strip. + const { cleaned } = preNormalize("abc12345-6789"); + expect(cleaned).toBe("abc123456789"); + }); +}); + +// --------------------------------------------------------------------------- +// stripTrailingNonHex +// --------------------------------------------------------------------------- + +describe("stripTrailingNonHex", () => { + test("strips 'ios' suffix from 32+3 hex input", () => { + const input = `${VALID_32}ios`; + expect(stripTrailingNonHex(input, 32)).toEqual({ + hex: VALID_32, + stripped: "ios", + }); + }); + + test("returns null when input equals expectedLen", () => { + expect(stripTrailingNonHex(VALID_32, 32)).toBeNull(); + }); + + test("returns null when hex prefix is shorter than expectedLen (CLI-1A8 26+ios case)", () => { + // The exact CLI-1A8 input has only 26 hex + "ios" = falls through to fuzzy + expect(stripTrailingNonHex("c0a5a9d4dce44358ab4231fc3bios", 32)).toBeNull(); + }); + + test("returns null for leading non-hex", () => { + expect(stripTrailingNonHex(`xyz${VALID_32}`, 32)).toBeNull(); + }); + + test("strips span ID trailing junk", () => { + expect(stripTrailingNonHex(`${VALID_16}x`, 16)).toEqual({ + hex: VALID_16, + stripped: "x", + }); + }); + + test("returns null for shorter input than expected", () => { + expect(stripTrailingNonHex("abc", 32)).toBeNull(); + }); + + test("returns null when trace ID passed to span-length strip (all-hex tail)", () => { + // Regression: a 32-char trace ID mistakenly passed where a span ID is + // expected must NOT be silently truncated to 16 chars. Stripping would + // have returned `{hex: first-16, stripped: last-16}`, masking the + // wrong-entity-type error. The null return lets validateSpanId's + // targeted "looks like a trace ID" hint surface instead. + expect(stripTrailingNonHex(VALID_32, 16)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractHexCandidate +// --------------------------------------------------------------------------- + +describe("extractHexCandidate", () => { + test("plain 8-hex prefix", () => { + expect(extractHexCandidate("05d6975a")).toEqual({ prefix: "05d6975a" }); + }); + + test("12-hex prefix (common UI truncation)", () => { + expect(extractHexCandidate("05d6975ab9bb")).toEqual({ + prefix: "05d6975ab9bb", + }); + }); + + test("ASCII middle ellipsis", () => { + expect(extractHexCandidate("abc123...def456")).toEqual({ + prefix: "abc123", + suffix: "def456", + }); + }); + + test("Unicode middle ellipsis", () => { + expect(extractHexCandidate("abc123\u2026def456")).toEqual({ + prefix: "abc123", + suffix: "def456", + }); + }); + + test("suffix-only with leading ellipsis", () => { + expect(extractHexCandidate("...def456")).toEqual({ + prefix: "", + suffix: "def456", + }); + }); + + test("returns null for all non-hex input", () => { + expect(extractHexCandidate("xyz")).toBeNull(); + }); + + test("takes only leading hex run on non-hex suffix", () => { + expect(extractHexCandidate("c0a5a9d4dce44358ab4231fc3bios")).toEqual({ + prefix: "c0a5a9d4dce44358ab4231fc3b", + }); + }); +}); + +// --------------------------------------------------------------------------- +// looksLikeSlug +// --------------------------------------------------------------------------- + +describe("looksLikeSlug", () => { + test.each([ + ["human-interfaces", true], + ["apacta-2-wttd", true], + ["uts-patient-app-7d", true], + ["simpelweb-2ry", true], + ["server-xn", true], + ])("slug-like: %s → %p", (input, expected) => { + expect(looksLikeSlug(input)).toBe(expected); + }); + + test.each([ + ["abc12345", false], // pure hex, no dash + ["c0a5a9d4", false], // pure hex + ["null", false], // sentinel + ["a", false], // too short + ["abc", false], // no dash + ["a-b", false], // no alpha segment of 2+ + ])("not a slug: %s → %p", (input, expected) => { + expect(looksLikeSlug(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// isOverNestedPath +// --------------------------------------------------------------------------- + +describe("isOverNestedPath", () => { + test("2 segments: not over-nested", () => { + expect(isOverNestedPath("org/project")).toBe(false); + }); + + test("3 segments: not over-nested (org/project/id)", () => { + expect(isOverNestedPath("org/project/abcdef12")).toBe(false); + }); + + test("4 segments: over-nested", () => { + expect(isOverNestedPath("gohighlevel/highlevel-flutter/27bc063/abc")).toBe( + true + ); + }); + + test("single segment: not over-nested", () => { + expect(isOverNestedPath("abc")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// recoverHexId decision tree — driven via stubbed adapters +// --------------------------------------------------------------------------- + +describe("recoverHexId decision tree", () => { + let originalAdapters: typeof ADAPTERS; + + beforeEach(() => { + originalAdapters = { ...ADAPTERS }; + }); + + afterEach(() => { + // Restore original adapters + for (const key of Object.keys( + originalAdapters + ) as (keyof typeof ADAPTERS)[]) { + ADAPTERS[key] = originalAdapters[key]; + } + }); + + function stubAdapter(entity: keyof typeof ADAPTERS, fn: FuzzyLookupAdapter) { + ADAPTERS[entity] = fn; + } + + test("sentinel-leak short-circuits without adapter call", async () => { + let called = false; + stubAdapter("event", async () => { + called = true; + return []; + }); + const r = await recoverHexId("null", "event", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("sentinel-leak"); + expect(called).toBe(false); + }); + + test("over-nested path short-circuits", async () => { + const r = await recoverHexId( + "gohighlevel/highlevel-flutter/27bc063/abc", + "event", + CLEAN_CTX + ); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("over-nested"); + }); + + test("stripped recovery for <32hex>ios input", async () => { + let called = false; + stubAdapter("event", async () => { + called = true; + return []; + }); + const r = await recoverHexId(`${VALID_32}ios`, "event", CLEAN_CTX); + expect(r.kind).toBe("stripped"); + expect(r.kind === "stripped" && r.id).toBe(VALID_32); + expect(r.kind === "stripped" && r.stripped).toBe("ios"); + expect(called).toBe(false); + }); + + test("fuzzy with single match returns fuzzy result (12-char UI truncation)", async () => { + const fullId = `05d6975ab9bb${"0".repeat(20)}`; + stubAdapter("event", async () => [fullId]); + const r = await recoverHexId("05d6975ab9bb", "event", CLEAN_CTX); + expect(r.kind).toBe("fuzzy"); + expect(r.kind === "fuzzy" && r.id).toBe(fullId); + expect(r.kind === "fuzzy" && r.prefix).toBe("05d6975ab9bb"); + }); + + test("fuzzy with multiple matches returns multiple-matches", async () => { + const id1 = `05d6975ab9bb${"0".repeat(20)}`; + const id2 = `05d6975ab9bb${"1".repeat(20)}`; + stubAdapter("event", async () => [id1, id2]); + const r = await recoverHexId("05d6975ab9bb", "event", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("multiple-matches"); + expect(r.kind === "failed" && r.candidates).toEqual([id1, id2]); + }); + + test("fuzzy with zero matches returns no-matches with retention hint", async () => { + stubAdapter("event", async () => []); + const r = await recoverHexId("05d6975ab9bb", "event", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("no-matches"); + expect(r.kind === "failed" && r.hint).toContain("32-character ID"); + }); + + test("31-hex off-by-one falls through to fuzzy", async () => { + // CLI-16G had `14d9a67fda344df0a138a88d62e41be` (31 chars) + const full = "14d9a67fda344df0a138a88d62e41beb"; + stubAdapter("event", async () => [full]); + const r = await recoverHexId( + "14d9a67fda344df0a138a88d62e41be", + "event", + CLEAN_CTX + ); + expect(r.kind).toBe("fuzzy"); + expect(r.kind === "fuzzy" && r.id).toBe(full); + }); + + test("too-short pure hex returns too-short", async () => { + let called = false; + stubAdapter("event", async () => { + called = true; + return []; + }); + const r = await recoverHexId("abc", "event", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("too-short"); + expect(called).toBe(false); + }); + + test("looks-like-slug returns looks-like-slug (CLI-16G apacta-2-wttd)", async () => { + const r = await recoverHexId("apacta-2-wttd", "event", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("looks-like-slug"); + expect(r.kind === "failed" && r.hint).toContain("issue list"); + }); + + test("hex-starting slug (cafe-babe-app) classified as slug, not fuzzy-looked-up", async () => { + // Regression: `cafe-babe-app` is clearly a project slug. preNormalize + // strips the dashes (dashless starts with hex `c`) to `cafebabeapp`, + // which has an 8+ char hex prefix that would trigger fuzzy lookup if + // the slug check only ran in the too-short branch. The raw-input + // slug check must fire first. + let adapterCalled = false; + stubAdapter("event", async () => { + adapterCalled = true; + return []; + }); + const r = await recoverHexId("cafe-babe-app", "event", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("looks-like-slug"); + expect(adapterCalled).toBe(false); + }); + + test("looks-like-slug for trace → trace list hint (CLI-M0 frontend)", async () => { + const r = await recoverHexId("frontend-app", "trace", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("looks-like-slug"); + expect(r.kind === "failed" && r.hint).toContain("trace list"); + }); + + test("looks-like-slug for log (CLI-197 uts-patient-app-7d)", async () => { + const r = await recoverHexId("uts-patient-app-7d", "log", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("looks-like-slug"); + expect(r.kind === "failed" && r.hint).toContain("log list"); + }); + + test("adapter error returns api-error", async () => { + stubAdapter("event", async () => { + throw new Error("network fail"); + }); + const r = await recoverHexId("05d6975ab9bb", "event", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("api-error"); + }); + + test("middle-ellipsis input drives prefix+suffix lookup (prefix ≥8)", async () => { + // Prefix must be ≥ MIN_FUZZY_PREFIX (8) for adapter to be invoked. + const fullId = `abcdef12${"0".repeat(18)}def456`; + stubAdapter("log", async () => [fullId]); + const r = await recoverHexId("abcdef12...def456", "log", CLEAN_CTX); + expect(r.kind).toBe("fuzzy"); + expect(r.kind === "fuzzy" && r.suffix).toBe("def456"); + }); + + test("middle-ellipsis with short prefix and long suffix (suffix ≥8) still triggers lookup", async () => { + // Per the plan: either prefix OR suffix must be ≥ MIN_FUZZY_PREFIX. + const fullId = `abc123${"0".repeat(18)}deadbeef12`; + stubAdapter("log", async () => [fullId]); + const r = await recoverHexId("abc123...deadbeef12", "log", CLEAN_CTX); + expect(r.kind).toBe("fuzzy"); + }); + + test("URL-prefixed valid span ID returns stripped result without adapter call", async () => { + // Regression test: `span-` is a valid span ID once the + // `span-` prefix is stripped. Recovery should recognize this and return + // immediately without invoking the fuzzy adapter (which might return + // empty if the span is outside the scan window). + let adapterCalled = false; + stubAdapter("span", async () => { + adapterCalled = true; + return []; + }); + const r = await recoverHexId(`span-${VALID_16}`, "span", { + ...CLEAN_CTX, + traceId: "a".repeat(32), + }); + expect(r.kind).toBe("stripped"); + expect(r.kind === "stripped" && r.id).toBe(VALID_16); + expect(r.kind === "stripped" && r.stripped).toBe("span-"); + expect(adapterCalled).toBe(false); + }); + + test("URL-prefixed valid event ID returns stripped result without adapter call", async () => { + let adapterCalled = false; + stubAdapter("event", async () => { + adapterCalled = true; + return []; + }); + const r = await recoverHexId(`event-${VALID_32}`, "event", CLEAN_CTX); + expect(r.kind).toBe("stripped"); + expect(r.kind === "stripped" && r.id).toBe(VALID_32); + expect(adapterCalled).toBe(false); + }); + + test("URL-prefixed UUID-dashed valid event ID: stripped field describes transformation", async () => { + // When preNormalize removes BOTH the URL prefix AND UUID dashes, + // `cleaned` is no longer a literal substring of the lowercased raw + // input. The stripped field should describe the transformation + // (URL prefix + UUID dashes) rather than echoing the whole input. + const uuidDashed = "c0a5a9d4-dce4-4358-ab42-31fc3bead7e9"; + stubAdapter("event", async () => []); + const r = await recoverHexId(`event-${uuidDashed}`, "event", CLEAN_CTX); + expect(r.kind).toBe("stripped"); + expect(r.kind === "stripped" && r.id).toBe(VALID_32); + // stripped should not equal the entire input — should describe the + // parts that were removed. + if (r.kind === "stripped") { + expect(r.stripped).not.toBe(`event-${uuidDashed}`); + // Should mention the prefix and dashes were stripped. + expect(r.stripped).toContain("event-"); + } + }); + + test("8-hex prefix for trace does NOT trigger cross-entity (needs 16 hex)", async () => { + // tryCrossEntityRedirect only fires when input is exactly 16 chars + // (SPAN_ID_RE). An 8-char prefix falls through to the fuzzy adapter. + stubAdapter("trace", async () => []); + const r = await recoverHexId("abcd1234", "trace", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("no-matches"); + }); + + /** Build a 32-char UUIDv7 where the embedded timestamp is `date`. */ + function buildUuidV7(date: Date): string { + const ts = date.getTime().toString(16).padStart(12, "0"); + // 12 time + 1 version (7) + 19 variant/rand = 32 total + return `${ts}70008000000000000000`; + } + + test("stripped UUIDv7 log ID past 90-day retention returns past-retention", async () => { + // Input: . strip would normally succeed + // but the stripped ID is past retention → recovery surfaces that + // instead of returning a hex that will hit a 404 downstream. + const past = new Date(Date.now() - 730 * 24 * 60 * 60 * 1000); + const expiredLogId = buildUuidV7(past); + expect(expiredLogId.length).toBe(32); + const r = await recoverHexId(`${expiredLogId}ios`, "log", CLEAN_CTX); + expect(r.kind).toBe("failed"); + expect(r.kind === "failed" && r.reason).toBe("past-retention"); + expect(r.kind === "failed" && r.hint).toContain("90-day log retention"); + }); + + test("stripped UUIDv7 log ID within retention returns stripped result", async () => { + const recent = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + const recentLogId = buildUuidV7(recent); + const r = await recoverHexId(`${recentLogId}ios`, "log", CLEAN_CTX); + expect(r.kind).toBe("stripped"); + expect(r.kind === "stripped" && r.id).toBe(recentLogId); + }); + + test("retention check skipped for non-v7 (v4) UUIDs — traces/events lack a hard cap", async () => { + // A v4 UUID past a "retention-like" age — no hard cap on trace/event. + // Strip returns normally; retention short-circuit doesn't fire. + const v4Id = "c0a5a9d4dce44358ab4231fc3bead7e9"; + const r = await recoverHexId(`${v4Id}ios`, "trace", CLEAN_CTX); + expect(r.kind).toBe("stripped"); + }); +}); + +// --------------------------------------------------------------------------- +// handleRecoveryResult +// --------------------------------------------------------------------------- + +describe("handleRecoveryResult", () => { + const fallback = new ValidationError("Invalid event ID"); + const opts = { + entityType: "event" as const, + canonicalCommand: "sentry event view my-org/my-project/", + logTag: "test", + }; + + test("stripped returns the ID", () => { + const id = handleRecoveryResult( + { + kind: "stripped", + id: VALID_32, + original: `${VALID_32}ios`, + stripped: "ios", + }, + fallback, + opts + ); + expect(id).toBe(VALID_32); + }); + + test("fuzzy returns the ID", () => { + const id = handleRecoveryResult( + { + kind: "fuzzy", + id: VALID_32, + original: "05d6975ab9bb", + prefix: "05d6975ab9bb", + }, + fallback, + opts + ); + expect(id).toBe(VALID_32); + }); + + test("redirect returns the redirected ID", () => { + const id = handleRecoveryResult( + { + kind: "redirect", + id: VALID_32, + original: VALID_16, + fromEntity: "span", + toEntity: "trace", + }, + fallback, + { ...opts, entityType: "trace" } + ); + expect(id).toBe(VALID_32); + }); + + test("multiple-matches throws ResolutionError with candidates", () => { + const id1 = `${"a".repeat(24)}0000aaaa`; + const id2 = `${"a".repeat(24)}0000bbbb`; + try { + handleRecoveryResult( + { + kind: "failed", + original: "aaaaaaaa", + reason: "multiple-matches", + candidates: [id1, id2], + }, + fallback, + opts + ); + expect.unreachable("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ResolutionError); + expect((err as ResolutionError).message).toContain(id1); + expect((err as ResolutionError).message).toContain(id2); + } + }); + + test("sentinel-leak throws ValidationError", () => { + try { + handleRecoveryResult( + { + kind: "failed", + original: "null", + reason: "sentinel-leak", + hint: "shell variable leak", + }, + fallback, + opts + ); + expect.unreachable("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).message).toContain("shell variable"); + } + }); + + test("api-error preserves the original fallback error", () => { + try { + handleRecoveryResult( + { kind: "failed", original: "abc", reason: "api-error" }, + fallback, + opts + ); + expect.unreachable("Should have thrown"); + } catch (err) { + expect(err).toBe(fallback); + } + }); +}); + +// --------------------------------------------------------------------------- +// MIN_FUZZY_PREFIX sanity +// --------------------------------------------------------------------------- + +test("MIN_FUZZY_PREFIX matches Sentry UI getShortEventId (8 chars)", () => { + expect(MIN_FUZZY_PREFIX).toBe(8); +}); diff --git a/packages/cli/test/lib/hex-id.test.ts b/packages/cli/test/lib/hex-id.test.ts new file mode 100644 index 000000000..a75f1f8c5 --- /dev/null +++ b/packages/cli/test/lib/hex-id.test.ts @@ -0,0 +1,433 @@ +/** + * Hex ID Validation Tests + * + * Property-based and unit tests for the shared hex ID validation + * in src/lib/hex-id.ts. + * + * Regex patterns (HEX_ID_RE, UUID_DASH_RE) are covered by the property tests + * at the bottom of this file which generate random valid/invalid hex strings. + * The unit tests here focus on `validateHexId` behavior: error messages, + * whitespace handling, UUID normalization, and edge cases. + */ + +import { array, constantFrom, assert as fcAssert, property } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { ValidationError } from "../../src/lib/errors.js"; +import { + ageInDaysFromUuidV7, + decodeUuidV7Timestamp, + validateHexId, + validateSpanId, +} from "../../src/lib/hex-id.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const HEX_CHARS = "0123456789abcdefABCDEF".split(""); +const VALID_ID = "aaaa1111bbbb2222cccc3333dddd4444"; + +/** Arbitrary for valid 32-char hex strings */ +const validIdArb = array(constantFrom(...HEX_CHARS), { + minLength: 32, + maxLength: 32, +}).map((chars) => chars.join("")); + +describe("validateHexId", () => { + test("returns the ID for valid input", () => { + expect(validateHexId(VALID_ID, "test ID")).toBe(VALID_ID); + }); + + test("trims leading and trailing whitespace", () => { + expect(validateHexId(` ${VALID_ID} `, "test ID")).toBe(VALID_ID); + }); + + test("trims trailing newline", () => { + expect(validateHexId(`${VALID_ID}\n`, "test ID")).toBe(VALID_ID); + }); + + test("normalizes to lowercase", () => { + const mixedCase = "AAAA1111bbbb2222CCCC3333dddd4444"; + expect(validateHexId(mixedCase, "test ID")).toBe( + "aaaa1111bbbb2222cccc3333dddd4444" + ); + }); + + test("throws ValidationError for empty string", () => { + expect(() => validateHexId("", "test ID")).toThrow(ValidationError); + }); + + test("throws ValidationError for short hex", () => { + expect(() => validateHexId("abc123", "test ID")).toThrow(ValidationError); + }); + + test("throws ValidationError for non-hex chars", () => { + expect(() => + validateHexId("zzzz1111bbbb2222cccc3333dddd4444", "test ID") + ).toThrow(ValidationError); + }); + + test("throws ValidationError for 33-char hex", () => { + expect(() => validateHexId(`${VALID_ID}a`, "test ID")).toThrow( + ValidationError + ); + }); + + test("error message includes the label", () => { + try { + validateHexId("bad", "log ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain("log ID"); + } + }); + + test("error message includes the invalid value", () => { + try { + validateHexId("bad-id", "test ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain("bad-id"); + } + }); + + test("error message truncates long invalid values", () => { + const longId = "a".repeat(100); + try { + validateHexId(longId, "test ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("..."); + // Should not contain the full 100-char string + expect(msg).not.toContain(longId); + } + }); + + test("error message includes format hint", () => { + try { + validateHexId("short", "test ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain( + "32-character hexadecimal" + ); + } + }); + + test("error hints span ID when 16-char hex is passed as trace ID", () => { + try { + validateHexId("a1b2c3d4e5f67890", "trace ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("trace ID"); + expect(msg).toContain("looks like a span ID"); + expect(msg).toContain("sentry span view"); + } + }); + + test("error hints non-hex input when slug is passed as trace ID", () => { + try { + validateHexId("my-project", "trace ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("doesn't look like a hex ID"); + expect(msg).toContain("project"); + } + }); + + test("error hints help flag for --h input", () => { + try { + validateHexId("--h", "event ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("looks like a help flag"); + expect(msg).toContain("--help or -h"); + } + }); + + test("error hints help flag for -help input", () => { + try { + validateHexId("-help", "trace ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("looks like a help flag"); + } + }); + + test("error hints generic flag for --verbose input", () => { + try { + validateHexId("--verbose", "log ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("looks like a CLI flag"); + expect(msg).toContain("--help"); + } + }); + + test("flag-like detection takes precedence over slug hint", () => { + try { + validateHexId("--my-flag", "event ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("looks like a CLI flag"); + // Should NOT suggest project slug + expect(msg).not.toContain("doesn't look like a hex ID"); + } + }); + + test("no extra hint for random-length hex (not a span ID)", () => { + try { + validateHexId("abc123", "log ID"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).not.toContain("span ID"); + expect(msg).not.toContain("project"); + } + }); + + test("throws for newline-separated IDs (not a single valid ID)", () => { + const multiLine = `${VALID_ID}\n${"bbbb1111cccc2222dddd3333eeee4444"}`; + expect(() => validateHexId(multiLine, "test ID")).toThrow(ValidationError); + }); + + test("strips dashes from UUID format and returns 32-char hex", () => { + expect( + validateHexId("aaaa1111-bbbb-2222-cccc-3333dddd4444", "test ID") + ).toBe(VALID_ID); + }); + + test("strips dashes from real user UUID (CLI-7Z)", () => { + expect( + validateHexId("ed29abc8-71c4-475b-9675-4655ef1a02d0", "test ID") + ).toBe("ed29abc871c4475b96754655ef1a02d0"); + }); + + test("strips dashes from uppercase UUID and normalizes to lowercase", () => { + expect( + validateHexId("AAAA1111-BBBB-2222-CCCC-3333DDDD4444", "test ID") + ).toBe(VALID_ID); + }); + + test("strips dashes from UUID with whitespace padding", () => { + expect( + validateHexId(" aaaa1111-bbbb-2222-cccc-3333dddd4444 ", "test ID") + ).toBe(VALID_ID); + }); + + test("UUID validation is idempotent — validated UUID validates again unchanged", () => { + const first = validateHexId( + "aaaa1111-bbbb-2222-cccc-3333dddd4444", + "test ID" + ); + const second = validateHexId(first, "test ID"); + expect(second).toBe(first); + }); + + test("rejects non-UUID dash patterns (random dashes)", () => { + expect(() => validateHexId("abc-def", "test ID")).toThrow(ValidationError); + }); + + test("rejects dashes in wrong positions (not 8-4-4-4-12)", () => { + expect(() => + validateHexId("aaaa-1111bbbb-2222cccc-3333dddd-4444", "test ID") + ).toThrow(ValidationError); + }); +}); + +describe("validateSpanId", () => { + test("returns the span ID for valid input", () => { + expect(validateSpanId("a1b2c3d4e5f67890")).toBe("a1b2c3d4e5f67890"); + }); + + test("normalizes to lowercase", () => { + expect(validateSpanId("A1B2C3D4E5F67890")).toBe("a1b2c3d4e5f67890"); + }); + + test("throws for 32-char hex with trace ID hint", () => { + try { + validateSpanId("aaaa1111bbbb2222cccc3333dddd4444"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("span ID"); + expect(msg).toContain("looks like a trace ID"); + } + }); + + test("throws for short hex without trace ID hint", () => { + try { + validateSpanId("abc123"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("span ID"); + expect(msg).not.toContain("trace ID"); + } + }); +}); + +describe("property: validateHexId", () => { + test("accepts any 32-char hex string and normalizes to lowercase", () => { + fcAssert( + property(validIdArb, (id) => { + expect(validateHexId(id, "test ID")).toBe(id.toLowerCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is idempotent — validating twice returns the same value", () => { + fcAssert( + property(validIdArb, (id) => { + const first = validateHexId(id, "test ID"); + const second = validateHexId(first, "test ID"); + expect(second).toBe(first); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("accepts whitespace-padded valid IDs after trim", () => { + fcAssert( + property(validIdArb, (id) => { + const expected = id.toLowerCase(); + expect(validateHexId(` ${id} `, "test ID")).toBe(expected); + expect(validateHexId(`\t${id}\n`, "test ID")).toBe(expected); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + /** Arbitrary for hex strings that are NOT exactly 32 chars */ + const wrongLengthHexArb = array(constantFrom(...HEX_CHARS), { + minLength: 0, + maxLength: 64, + }) + .filter((chars) => chars.length !== 32) + .map((chars) => chars.join("")); + + test("rejects hex strings with wrong length", () => { + fcAssert( + property(wrongLengthHexArb, (id) => { + expect(() => validateHexId(id, "test ID")).toThrow(ValidationError); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + /** + * Insert dashes at UUID positions (8-4-4-4-12) into a 32-char hex string. + */ + function toUuidFormat(hex: string): string { + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + test("UUID format with dashes produces same result as plain hex", () => { + fcAssert( + property(validIdArb, (id) => { + const expected = id.toLowerCase(); + const uuid = toUuidFormat(id); + expect(validateHexId(uuid, "test ID")).toBe(expected); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("UUID validation round-trips: validateHexId(uuid) === validateHexId(plain)", () => { + fcAssert( + property(validIdArb, (id) => { + const fromPlain = validateHexId(id, "test ID"); + const fromUuid = validateHexId(toUuidFormat(id), "test ID"); + expect(fromUuid).toBe(fromPlain); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("decodeUuidV7Timestamp", () => { + // Real Sentry log IDs observed in CLI project telemetry. + // First 12 hex chars = milliseconds since Unix epoch. + test("decodes real Sentry log ID (April 2026)", () => { + const result = decodeUuidV7Timestamp("019da223817478d8a98508aaedbafdc1"); + expect(result).not.toBeNull(); + expect(result?.createdAt.toISOString().slice(0, 10)).toBe("2026-04-18"); + }); + + test("decodes real Sentry log ID (February 2026)", () => { + const result = decodeUuidV7Timestamp("019c6d2ca9ec7cc5bd02f9190d77debe"); + expect(result).not.toBeNull(); + expect(result?.createdAt.toISOString().slice(0, 10)).toBe("2026-02-17"); + }); + + test("accepts dash-separated UUIDv7", () => { + const dashed = "019da223-8174-78d8-a985-08aaedbafdc1"; + const result = decodeUuidV7Timestamp(dashed); + expect(result).not.toBeNull(); + expect(result?.createdAt.toISOString().slice(0, 10)).toBe("2026-04-18"); + }); + + test("returns null for non-v7 (v4) UUID", () => { + // Random UUID v4 — version char at position 12 is "4" + const result = decodeUuidV7Timestamp("c0a5a9d4dce44358ab4231fc3bead7e9"); + expect(result).toBeNull(); + }); + + test("returns null for invalid hex input", () => { + expect(decodeUuidV7Timestamp("not-a-uuid")).toBeNull(); + expect(decodeUuidV7Timestamp("")).toBeNull(); + expect(decodeUuidV7Timestamp("019d")).toBeNull(); + }); + + test("returns null for 32-char hex with version char outside v7", () => { + // Swap position 12 to "8" → not v7 + expect( + decodeUuidV7Timestamp("019da22381848d8a98508aaedbafdc11") + ).toBeNull(); + }); +}); + +describe("ageInDaysFromUuidV7", () => { + /** Build a 32-char UUIDv7 where the embedded timestamp is `date`. */ + function buildUuidV7(date: Date): string { + const ts = date.getTime().toString(16).padStart(12, "0"); + // 12 time + 1 version + 19 rand/variant = 32 total + return `${ts}70008000000000000000`; + } + + test("returns age in days relative to `now`", () => { + const now = new Date("2026-08-01T00:00:00Z"); + const past = new Date(now.getTime() - 100 * 24 * 60 * 60 * 1000); + const age = ageInDaysFromUuidV7(buildUuidV7(past), now); + expect(age).toBeCloseTo(100, 5); + }); + + test("returns null for non-v7 input", () => { + expect(ageInDaysFromUuidV7("c0a5a9d4dce44358ab4231fc3bead7e9")).toBeNull(); + }); + + test("returns negative age for future timestamps (no clamping)", () => { + const now = new Date("2020-01-01T00:00:00Z"); + const future = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000); + const age = ageInDaysFromUuidV7(buildUuidV7(future), now); + expect(age).toBeCloseTo(-10, 5); + }); +}); diff --git a/packages/cli/test/lib/index.test.ts b/packages/cli/test/lib/index.test.ts new file mode 100644 index 000000000..62dd8ceeb --- /dev/null +++ b/packages/cli/test/lib/index.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import createSentrySDK, { SentryError } from "../../src/index.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("sdk-library-"); + +describe("createSentrySDK() library API", () => { + // Silence unmocked fetch calls from resolution cascade. + // SDK tests that call commands like "issue list" or "org list" trigger + // the org/project resolution cascade which hits real API endpoints. + // A silent 404 prevents preload warnings while preserving error behavior. + let originalFetch: typeof globalThis.fetch; + let authorizationHeaders: string[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + authorizationHeaders = []; + // Return empty successes rather than 404s so the resolution cascade + // terminates cleanly without triggering follow-up requests that could + // outlive the test and spill into later test files. + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + authorizationHeaders.push(request.headers.get("Authorization") ?? ""); + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + url = new Request(input).url; + } + if (url.includes("/regions/")) { + return new Response(JSON.stringify({ regions: [] }), { status: 200 }); + } + if (url.includes("/organizations")) { + return new Response(JSON.stringify([]), { status: 200 }); + } + // Return empty 200 for all other endpoints (projects, issues, etc.) + // to prevent follow-up requests from outliving the test. + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("sdk.run returns version string for --version", async () => { + const sdk = createSentrySDK(); + const result = await sdk.run("--version"); + expect(typeof result).toBe("string"); + // Version output is a semver string like "0.0.0-dev" or "0.21.0" + expect(result as string).toMatch(/\d+\.\d+\.\d+/); + }); + + test("sdk.run returns parsed object for help command in JSON mode", async () => { + const sdk = createSentrySDK(); + const result = await sdk.run("help"); + // help --json returns a parsed object with routes + expect(typeof result).toBe("object"); + expect(result).toHaveProperty("routes"); + }); + + test("sdk.run throws when auth is required but missing", async () => { + // Clear the preload's test auth token so the auth guard fires. + const savedToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + try { + // Use cwd:/tmp to prevent DSN scanning of the repo root which finds + // real DSNs and triggers async project resolution that can outlive the test. + const sdk = createSentrySDK({ cwd: "/tmp" }); + try { + // issue list requires auth — with no token and isolated config, it should fail + await sdk.run("issue", "list"); + expect.unreachable("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + // The error should have an exitCode (either SentryError or CliError subclass) + expect((err as { exitCode?: number }).exitCode).toBeGreaterThan(0); + } + } finally { + // Restore the preload token for other tests + if (savedToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedToken; + } + } + }); + + test("process.env is unchanged after successful call", async () => { + const sdk = createSentrySDK(); + const envBefore = { ...process.env }; + await sdk.run("--version"); + // Check that no new SENTRY_OUTPUT_FORMAT key leaked + expect(process.env.SENTRY_OUTPUT_FORMAT).toBe( + envBefore.SENTRY_OUTPUT_FORMAT + ); + expect(process.env.SENTRY_AUTH_TOKEN).toBe(envBefore.SENTRY_AUTH_TOKEN); + }); + + test("process.env is unchanged after failed call", async () => { + const sdk = createSentrySDK({ cwd: "/tmp" }); + const envBefore = { ...process.env }; + try { + await sdk.run("issue", "list"); + } catch { + // expected + } + expect(process.env.SENTRY_OUTPUT_FORMAT).toBe( + envBefore.SENTRY_OUTPUT_FORMAT + ); + }); + + test("{ text: true } returns string for help command", async () => { + const sdk = createSentrySDK({ text: true }); + const result = await sdk.run("help"); + expect(typeof result).toBe("string"); + expect(result as string).toContain("sentry"); + }); + + test("accepts cwd option without error", async () => { + const sdk = createSentrySDK({ cwd: "/tmp" }); + const result = await sdk.run("--version"); + expect(typeof result).toBe("string"); + }); + + test("nested namespaces (dashboard.widget)", () => { + const sdk = createSentrySDK(); + expect(sdk.dashboard.widget).toBeDefined(); + expect(typeof sdk.dashboard.widget.add).toBe("function"); + }); + + test("token option is plumbed through", { timeout: 15_000 }, async () => { + const sdk = createSentrySDK({ token: "invalid-token" }); + try { + await sdk.org.list(); + expect.unreachable("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(SentryError); + } + }); + + test("explicit token overrides stored OAuth credentials", async () => { + setAuthToken("stored-oauth-token", 3600); + const sdk = createSentrySDK({ token: "explicit-sdk-token" }); + + await sdk.auth.status(); + + expect(authorizationHeaders).toContain("Bearer explicit-sdk-token"); + expect(authorizationHeaders).not.toContain("Bearer stored-oauth-token"); + }); + + test("sdk.run returns AsyncIterable for streaming flag --follow", () => { + const sdk = createSentrySDK(); + const result = sdk.run("log", "list", "--follow"); + // Streaming flags return an AsyncIterable, not a Promise + expect(Symbol.asyncIterator in (result as object)).toBe(true); + }); + + test("sdk.run returns AsyncIterable for streaming flag --refresh", () => { + const sdk = createSentrySDK(); + const result = sdk.run("issue", "list", "--refresh"); + expect(Symbol.asyncIterator in (result as object)).toBe(true); + }); + + test("sdk.run returns AsyncIterable for streaming short flag -f", () => { + const sdk = createSentrySDK(); + const result = sdk.run("log", "list", "-f"); + expect(Symbol.asyncIterator in (result as object)).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/ini.property.test.ts b/packages/cli/test/lib/ini.property.test.ts new file mode 100644 index 000000000..549fe8422 --- /dev/null +++ b/packages/cli/test/lib/ini.property.test.ts @@ -0,0 +1,229 @@ +/** + * Property-Based Tests for INI Parser + * + * Uses fast-check to verify properties that should always hold true + * for parseIni/serializeIni, regardless of input. + */ + +import { + array, + constantFrom, + dictionary, + assert as fcAssert, + property, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { type IniData, parseIni } from "../../src/lib/ini.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** + * Serialize IniData to INI string for round-trip testing. + * Global (empty-key) section is emitted first without a header. + */ +function serializeIni(data: IniData): string { + const lines: string[] = []; + const sections = Object.keys(data).sort((a, b) => { + if (a === "") { + return -1; + } + if (b === "") { + return 1; + } + return a.localeCompare(b); + }); + + for (const section of sections) { + const entries = data[section] ?? {}; + if (section !== "") { + if (lines.length > 0) { + lines.push(""); + } + lines.push(`[${section}]`); + } + for (const [key, value] of Object.entries(entries)) { + lines.push(`${key} = ${value}`); + } + } + + return lines.join("\n"); +} + +// Arbitraries + +/** Valid INI section names: lowercase alpha + digits + hyphens/underscores */ +const sectionNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-_"; +const sectionNameArb = array(constantFrom(...sectionNameChars.split("")), { + minLength: 1, + maxLength: 20, +}).map((chars) => chars.join("")); + +/** Valid INI key names: lowercase alpha + digits + underscores/hyphens */ +const keyChars = "abcdefghijklmnopqrstuvwxyz0123456789_-"; +const keyNameArb = array(constantFrom(...keyChars.split("")), { + minLength: 1, + maxLength: 20, +}).map((chars) => chars.join("")); + +/** + * Valid INI values: printable characters that don't start with quotes + * and don't contain newlines. Avoids leading `"` or `'` to prevent + * round-trip issues with quote stripping. + */ +const safeValueChars = + "abcdefghijklmnopqrstuvwxyz0123456789 !@#$%^&*()-_=+[]{}|:/<>.?~`"; +const valueArb = array(constantFrom(...safeValueChars.split("")), { + minLength: 0, + maxLength: 50, +}).map((chars) => chars.join("").trim()); + +/** Generate a section as a dict of key→value pairs */ +const sectionArb = dictionary(keyNameArb, valueArb, { + minKeys: 0, + maxKeys: 5, +}); + +/** Generate valid IniData (only named sections, no global) for round-trip */ +const iniDataArb = dictionary(sectionNameArb, sectionArb, { + minKeys: 1, + maxKeys: 5, +}); + +/** Generate a comment line */ +const commentArb = constantFrom( + "# this is a comment", + "; another comment", + "# key = value", + "; [section]" +); + +describe("property: parseIni", () => { + test("round-trip: serialize then parse recovers all data", () => { + fcAssert( + property(iniDataArb, (data) => { + const serialized = serializeIni(data); + const parsed = parseIni(serialized); + + // All sections and keys from input should be in output + for (const [section, entries] of Object.entries(data)) { + for (const [key, value] of Object.entries(entries)) { + // Keys and sections are lowercased by parseIni, serializeIni outputs lowercase + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + expect(parsed[normalizedSection]?.[normalizedKey]).toBe(value); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotency: parse(serialize(parse(x))) === parse(x)", () => { + fcAssert( + property(iniDataArb, (data) => { + const serialized1 = serializeIni(data); + const parsed1 = parseIni(serialized1); + const serialized2 = serializeIni(parsed1); + const parsed2 = parseIni(serialized2); + + // Remove empty global section for comparison + const clean1 = { ...parsed1 }; + const clean2 = { ...parsed2 }; + if (clean1[""] && Object.keys(clean1[""]).length === 0) { + delete clean1[""]; + } + if (clean2[""] && Object.keys(clean2[""]).length === 0) { + delete clean2[""]; + } + + expect(clean2).toEqual(clean1); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("inserting comment lines does not change parsed values", () => { + fcAssert( + property(iniDataArb, commentArb, (data, comment) => { + const serialized = serializeIni(data); + const lines = serialized.split("\n"); + + // Insert comment at random positions + const withComments = lines.flatMap((line) => [comment, line]); + const withCommentsStr = withComments.join("\n"); + + const original = parseIni(serialized); + const withCommentsData = parseIni(withCommentsStr); + + // All non-empty-global sections should match + for (const [section, entries] of Object.entries(original)) { + if (section === "" && Object.keys(entries).length === 0) { + continue; + } + for (const [key, value] of Object.entries(entries)) { + expect(withCommentsData[section]?.[key]).toBe(value); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("key names are case-insensitive", () => { + fcAssert( + property(sectionNameArb, keyNameArb, valueArb, (section, key, value) => { + const upper = `[${section}]\n${key.toUpperCase()} = ${value}`; + const lower = `[${section}]\n${key.toLowerCase()} = ${value}`; + + const parsedUpper = parseIni(upper); + const parsedLower = parseIni(lower); + + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + + expect(parsedUpper[normalizedSection]?.[normalizedKey]).toBe( + parsedLower[normalizedSection]?.[normalizedKey] + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("section names are case-insensitive", () => { + fcAssert( + property(sectionNameArb, keyNameArb, valueArb, (section, key, value) => { + const upper = `[${section.toUpperCase()}]\n${key} = ${value}`; + const lower = `[${section.toLowerCase()}]\n${key} = ${value}`; + + const parsedUpper = parseIni(upper); + const parsedLower = parseIni(lower); + + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + + expect(parsedUpper[normalizedSection]?.[normalizedKey]).toBe( + parsedLower[normalizedSection]?.[normalizedKey] + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("last-write-wins for duplicate keys in same section", () => { + fcAssert( + property( + sectionNameArb, + keyNameArb, + valueArb, + valueArb, + (section, key, value1, value2) => { + const content = `[${section}]\n${key} = ${value1}\n${key} = ${value2}`; + const parsed = parseIni(content); + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + expect(parsed[normalizedSection]?.[normalizedKey]).toBe(value2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/ini.test.ts b/packages/cli/test/lib/ini.test.ts new file mode 100644 index 000000000..d6b50f32f --- /dev/null +++ b/packages/cli/test/lib/ini.test.ts @@ -0,0 +1,172 @@ +/** + * Unit Tests for INI Parser + * + * Note: Core invariants (round-trips, case insensitivity, duplicate handling) + * are tested via property-based tests in ini.property.test.ts. These tests + * focus on edge cases and specific formatting not covered by property generators. + */ + +import { describe, expect, test } from "vitest"; +import { parseIni } from "../../src/lib/ini.js"; + +describe("parseIni", () => { + test("empty string returns empty global section", () => { + const result = parseIni(""); + expect(result).toEqual({ "": {} }); + }); + + test("comments-only file returns empty global section", () => { + const result = parseIni("# comment\n; another comment\n"); + expect(result).toEqual({ "": {} }); + }); + + test("strips UTF-8 BOM", () => { + const result = parseIni("\uFEFF[defaults]\norg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("handles Windows line endings (CRLF)", () => { + const result = parseIni( + "[defaults]\r\norg = my-org\r\nproject = my-proj\r\n" + ); + expect(result.defaults?.org).toBe("my-org"); + expect(result.defaults?.project).toBe("my-proj"); + }); + + test("global keys (before any section header)", () => { + const result = parseIni("key = value\n[section]\nother = data"); + expect(result[""]?.key).toBe("value"); + expect(result.section?.other).toBe("data"); + }); + + test("section names are lowercased", () => { + const result = parseIni("[Defaults]\nOrg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + expect(result.Defaults).toBeUndefined(); + }); + + test("key names are lowercased", () => { + const result = parseIni("[auth]\nToken = secret123"); + expect(result.auth?.token).toBe("secret123"); + expect(result.auth?.Token).toBeUndefined(); + }); + + test("strips matching double quotes from values", () => { + const result = parseIni('[auth]\ntoken = "my-secret-token"'); + expect(result.auth?.token).toBe("my-secret-token"); + }); + + test("strips matching single quotes from values", () => { + const result = parseIni("[auth]\ntoken = 'my-secret-token'"); + expect(result.auth?.token).toBe("my-secret-token"); + }); + + test("does not strip mismatched quotes", () => { + const result = parseIni("[auth]\ntoken = \"my-secret-token'"); + expect(result.auth?.token).toBe("\"my-secret-token'"); + }); + + test("preserves quotes in middle of value", () => { + const result = parseIni( + '[defaults]\nurl = https://example.com/path?foo="bar"' + ); + expect(result.defaults?.url).toBe('https://example.com/path?foo="bar"'); + }); + + test("empty value after = sign", () => { + const result = parseIni("[defaults]\norg =\n"); + expect(result.defaults?.org).toBe(""); + }); + + test("value with leading/trailing spaces is trimmed", () => { + const result = parseIni("[defaults]\norg = my-org "); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("duplicate keys: last value wins", () => { + const result = parseIni("[defaults]\norg = first\norg = second"); + expect(result.defaults?.org).toBe("second"); + }); + + test("duplicate sections: merged", () => { + const result = parseIni( + "[defaults]\norg = my-org\n[defaults]\nproject = my-proj" + ); + expect(result.defaults?.org).toBe("my-org"); + expect(result.defaults?.project).toBe("my-proj"); + }); + + test("duplicate section with duplicate key: last wins", () => { + const result = parseIni( + "[defaults]\norg = first\n[defaults]\norg = second" + ); + expect(result.defaults?.org).toBe("second"); + }); + + test("malformed line (no = sign) is skipped", () => { + const result = parseIni("[defaults]\nthis is not valid\norg = works"); + expect(result.defaults?.org).toBe("works"); + expect(Object.keys(result.defaults ?? {})).toEqual(["org"]); + }); + + test("line with = but empty key is skipped", () => { + const result = parseIni("[defaults]\n = value\norg = works"); + expect(result.defaults?.org).toBe("works"); + expect(Object.keys(result.defaults ?? {})).toEqual(["org"]); + }); + + test("unclosed section header is skipped", () => { + const result = parseIni("[defaults\norg = my-org"); + // Falls through as a malformed line, org goes into global section + expect(result[""]?.org).toBe("my-org"); + }); + + test("section header with whitespace inside", () => { + const result = parseIni("[ defaults ]\norg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("inline # in value is preserved (not treated as comment)", () => { + const result = parseIni("[auth]\ntoken = abc#def"); + expect(result.auth?.token).toBe("abc#def"); + }); + + test("inline ; in value is preserved (not treated as comment)", () => { + const result = parseIni("[auth]\ntoken = abc;def"); + expect(result.auth?.token).toBe("abc;def"); + }); + + test("real-world .sentryclirc from old sentry-cli", () => { + const content = `[defaults] +url = https://sentry.io/ +org = my-org +project = my-project + +[auth] +token = sntrys_eyJpYXQiOjE3MTkzODM1MjQuNjQ0OTgyfQ==_abc123 +`; + + const result = parseIni(content); + expect(result.defaults?.url).toBe("https://sentry.io/"); + expect(result.defaults?.org).toBe("my-org"); + expect(result.defaults?.project).toBe("my-project"); + expect(result.auth?.token).toBe( + "sntrys_eyJpYXQiOjE3MTkzODM1MjQuNjQ0OTgyfQ==_abc123" + ); + }); + + test("key with no spaces around = sign", () => { + const result = parseIni("[defaults]\norg=my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("key with extra spaces around = sign", () => { + const result = parseIni("[defaults]\norg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("value containing = sign", () => { + const result = parseIni("[auth]\ntoken = abc=def=ghi"); + expect(result.auth?.token).toBe("abc=def=ghi"); + }); +}); diff --git a/packages/cli/test/lib/init/clack-plain.test.ts b/packages/cli/test/lib/init/clack-plain.test.ts new file mode 100644 index 000000000..567897932 --- /dev/null +++ b/packages/cli/test/lib/init/clack-plain.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for the clack-plain adapter. + * + * Verifies that clack output functions produce clean plain text when + * isPlainOutput() is true, and delegate to real @clack/prompts when false. + */ + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as clack from "@clack/prompts"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + cancel, + confirm, + intro, + isCancel, + log, + multiselect, + outro, + select, +} from "../../../src/lib/init/clack-plain.js"; + +let savedPlainOutput: string | undefined; +let stdoutSpy: ReturnType; + +beforeEach(() => { + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); +}); + +afterEach(() => { + stdoutSpy.mockRestore(); + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } +}); + +describe("plain mode (SENTRY_PLAIN_OUTPUT=1)", () => { + beforeEach(() => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; + }); + + test("intro writes clean text", () => { + intro("sentry init"); + + expect(stdoutSpy).toHaveBeenCalledWith("sentry init\n"); + }); + + test("intro does nothing when no title", () => { + intro(); + + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + test("outro writes clean text", () => { + outro("Done!"); + + expect(stdoutSpy).toHaveBeenCalledWith("Done!\n"); + }); + + test("cancel writes clean text", () => { + cancel("Setup cancelled."); + + expect(stdoutSpy).toHaveBeenCalledWith("Setup cancelled.\n"); + }); + + test("log.info writes clean text", () => { + log.info("Hello world"); + + expect(stdoutSpy).toHaveBeenCalledWith("Hello world\n"); + }); + + test("log.warn prefixes with warning symbol", () => { + log.warn("Watch out"); + + expect(stdoutSpy).toHaveBeenCalledWith("⚠ Watch out\n"); + }); + + test("log.error prefixes with error symbol", () => { + log.error("Something broke"); + + expect(stdoutSpy).toHaveBeenCalledWith("✗ Something broke\n"); + }); + + test("log.success prefixes with success symbol", () => { + log.success("All good"); + + expect(stdoutSpy).toHaveBeenCalledWith("✓ All good\n"); + }); + + test("log.message writes clean text", () => { + log.message("Just a message"); + + expect(stdoutSpy).toHaveBeenCalledWith("Just a message\n"); + }); + + test("log.step writes clean text", () => { + log.step("Step 1"); + + expect(stdoutSpy).toHaveBeenCalledWith("Step 1\n"); + }); + + test("log.info strips ANSI escape sequences", () => { + const ansiText = "\x1b[32mGreen\x1b[0m text"; + log.info(ansiText); + + expect(stdoutSpy).toHaveBeenCalledWith("Green text\n"); + }); +}); + +describe("rich mode (SENTRY_PLAIN_OUTPUT=0)", () => { + let introSpy: ReturnType; + let outroSpy: ReturnType; + let cancelSpy: ReturnType; + let logInfoSpy: ReturnType; + + const noop = () => { + /* suppress clack output */ + }; + + beforeEach(() => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + introSpy = vi.spyOn(clack, "intro").mockImplementation(noop); + outroSpy = vi.spyOn(clack, "outro").mockImplementation(noop); + cancelSpy = vi.spyOn(clack, "cancel").mockImplementation(noop); + logInfoSpy = vi.spyOn(clack.log, "info").mockImplementation(noop); + }); + + afterEach(() => { + introSpy.mockRestore(); + outroSpy.mockRestore(); + cancelSpy.mockRestore(); + logInfoSpy.mockRestore(); + }); + + test("intro delegates to clack", () => { + intro("sentry init"); + + expect(introSpy).toHaveBeenCalledWith("sentry init"); + }); + + test("outro delegates to clack", () => { + outro("Done!"); + + expect(outroSpy).toHaveBeenCalledWith("Done!"); + }); + + test("cancel delegates to clack", () => { + cancel("Setup cancelled."); + + expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled."); + }); + + test("log.info delegates to clack", () => { + log.info("Hello"); + + expect(logInfoSpy).toHaveBeenCalledWith("Hello"); + }); +}); + +describe("pass-through functions", () => { + test("isCancel delegates to real clack.isCancel", () => { + // clack uses a module-private symbol, so we can only test non-cancel values + expect(isCancel("not-cancel")).toBe(false); + expect(isCancel(42)).toBe(false); + expect(isCancel(null)).toBe(false); + }); + + test("select delegates to clack.select", () => { + const selectSpy = vi.spyOn(clack, "select").mockResolvedValue("choice"); + + const result = select({ + message: "Pick one", + options: [{ value: "choice", label: "Choice" }], + }); + + expect(selectSpy).toHaveBeenCalled(); + selectSpy.mockRestore(); + return result; + }); + + test("confirm delegates to clack.confirm", () => { + const confirmSpy = vi.spyOn(clack, "confirm").mockResolvedValue(true); + + const result = confirm({ message: "Continue?" }); + + expect(confirmSpy).toHaveBeenCalled(); + confirmSpy.mockRestore(); + return result; + }); + + test("multiselect delegates to clack.multiselect", () => { + const multiselectSpy = vi.spyOn(clack, "multiselect").mockResolvedValue([]); + + const result = multiselect({ + message: "Pick some", + options: [{ value: "a", label: "A" }], + }); + + expect(multiselectSpy).toHaveBeenCalled(); + multiselectSpy.mockRestore(); + return result; + }); +}); diff --git a/packages/cli/test/lib/init/clack-utils.test.ts b/packages/cli/test/lib/init/clack-utils.test.ts new file mode 100644 index 000000000..82935cdaa --- /dev/null +++ b/packages/cli/test/lib/init/clack-utils.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for clack-utils: cancellation helpers and feature display metadata. + * + * These are pure utility functions that don't require module mocking. + */ + +import { describe, expect, test } from "vitest"; +import { + abortIfCancelled, + featureDescription, + featureLabel, + PROGRESS_ROTATE_INTERVAL_MS, + STEP_ACTIVE_LABELS, + STEP_PROGRESS_MESSAGES, + sortFeatures, + WizardCancelledError, +} from "../../../src/lib/init/clack-utils.js"; + +describe("WizardCancelledError", () => { + test("has correct message", () => { + const err = new WizardCancelledError(); + expect(err.message).toBe("Setup cancelled."); + }); + + test("has correct name", () => { + const err = new WizardCancelledError(); + expect(err.name).toBe("WizardCancelledError"); + }); + + test("is an instance of Error", () => { + const err = new WizardCancelledError(); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("abortIfCancelled", () => { + test("passes through non-cancel values", () => { + expect(abortIfCancelled("hello")).toBe("hello"); + expect(abortIfCancelled(42)).toBe(42); + expect(abortIfCancelled(null)).toBeNull(); + }); + + test("passes through object values", () => { + const obj = { key: "value" }; + expect(abortIfCancelled(obj)).toBe(obj); + }); +}); + +describe("featureLabel", () => { + test("returns label for known feature", () => { + expect(featureLabel("errorMonitoring")).toBe("Error Monitoring"); + expect(featureLabel("performanceMonitoring")).toBe("Tracing"); + expect(featureLabel("logs")).toBe("Logs"); + expect(featureLabel("crons")).toBe("Crons & Uptime Monitors"); + expect(featureLabel("attachments")).toBe("Attachments"); + expect(featureLabel("aiMonitoring")).toBe("Agent Tracing"); + expect(featureLabel("mcpObservability")).toBe("MCP Observability"); + expect(featureLabel("userFeedback")).toBe("User Feedback"); + }); + + test("returns id as passthrough for unknown feature", () => { + expect(featureLabel("unknownFeature")).toBe("unknownFeature"); + }); +}); + +describe("featureDescription", () => { + test("returns description for known feature", () => { + expect(featureDescription("errorMonitoring")).toBe( + "Automatically capture exceptions and stack traces" + ); + expect(featureDescription("performanceMonitoring")).toBe( + "Find bottlenecks, broken requests, and understand application flow end-to-end" + ); + expect(featureDescription("sessionReplay")).toBe( + "Watch real user sessions to see what went wrong" + ); + expect(featureDescription("profiling")).toBe( + "Pinpoint the functions and lines of code responsible for performance issues" + ); + expect(featureDescription("logs")).toBe( + "See logs in context with errors and performance issues" + ); + expect(featureDescription("metrics")).toBe( + "Track application performance and usage over time with custom metrics" + ); + expect(featureDescription("sourceMaps")).toBe( + "Turn minified production stack traces back into your original source code" + ); + expect(featureDescription("crons")).toBe( + "Detect failed, missed, or delayed scheduled jobs" + ); + expect(featureDescription("attachments")).toBe( + "Link user-supplied data to captured events" + ); + expect(featureDescription("aiMonitoring")).toBe( + "Understand AI calls, latency, token usage, cost, and failures" + ); + expect(featureDescription("mcpObservability")).toBe( + "Trace MCP tool calls and understand failures across agent workflows" + ); + expect(featureDescription("userFeedback")).toBe( + "Collect user reports with the error and session context needed to investigate" + ); + expect(featureDescription("reactFeatures")).toBe( + "Capture React-specific errors with component and rendering context" + ); + }); + + test("returns undefined for unknown feature", () => { + expect(featureDescription("unknownFeature")).toBeUndefined(); + }); +}); + +describe("sortFeatures", () => { + test("orders known features by canonical display order", () => { + expect( + sortFeatures([ + "userFeedback", + "logs", + "errorMonitoring", + "sourceMaps", + "crons", + "attachments", + "aiMonitoring", + "mcpObservability", + ]) + ).toEqual([ + "errorMonitoring", + "logs", + "aiMonitoring", + "attachments", + "crons", + "mcpObservability", + "sourceMaps", + "userFeedback", + ]); + }); + + test("keeps unknown features after known ones", () => { + expect(sortFeatures(["unknown", "metrics", "another"])).toEqual([ + "metrics", + "unknown", + "another", + ]); + }); +}); + +describe("STEP_PROGRESS_MESSAGES", () => { + test("plan-codemods has multiple rotating messages", () => { + const messages = STEP_PROGRESS_MESSAGES["plan-codemods"]; + expect(messages).toBeDefined(); + expect(messages!.length).toBeGreaterThanOrEqual(3); + for (const msg of messages!) { + expect(msg).toMatch(/\.\.\.$/); + } + }); + + test("detect-platform has multiple rotating messages", () => { + const messages = STEP_PROGRESS_MESSAGES["detect-platform"]; + expect(messages).toBeDefined(); + expect(messages!.length).toBeGreaterThanOrEqual(2); + }); + + test("every step with progress messages also has an active label", () => { + for (const stepId of Object.keys(STEP_PROGRESS_MESSAGES)) { + expect(STEP_ACTIVE_LABELS[stepId]).toBeDefined(); + } + }); + + test("rotation interval is a positive number", () => { + expect(PROGRESS_ROTATE_INTERVAL_MS).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/test/lib/init/feedback.test.ts b/packages/cli/test/lib/init/feedback.test.ts new file mode 100644 index 000000000..5f02c42dc --- /dev/null +++ b/packages/cli/test/lib/init/feedback.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { formatFeedbackHint } from "../../../src/lib/init/feedback.js"; + +describe("formatFeedbackHint", () => { + test("maps init outcomes to copy-paste feedback commands", () => { + expect(formatFeedbackHint("success")).toBe( + [ + "Tell us what felt great or rough:", + '$ sentry cli feedback "sentry init worked well"', + ].join("\n") + ); + expect(formatFeedbackHint("cancelled")).toBe( + [ + "Sad to see setup stop. Was something going sideways?", + "Tell us so we can fix it:", + '$ sentry cli feedback "sentry init was cancelled"', + ].join("\n") + ); + expect(formatFeedbackHint("failed")).toBe( + [ + "Setup hit a wall.", + "Tell us what happened so we can fix it:", + '$ sentry cli feedback "sentry init failed"', + ].join("\n") + ); + }); +}); diff --git a/packages/cli/test/lib/init/formatters.test.ts b/packages/cli/test/lib/init/formatters.test.ts new file mode 100644 index 000000000..a6d3d2768 --- /dev/null +++ b/packages/cli/test/lib/init/formatters.test.ts @@ -0,0 +1,744 @@ +/** + * Formatters Tests + * + * Tests for the init wizard output formatters. Uses `MockUI` to capture + * every UI call. + * + * The previous implementation pushed pre-rendered markdown through + * `ui.log.message`; these tests asserted on raw markdown strings. The + * new implementation hands a structured `WizardSummary` to + * `ui.summary()`, so the assertions look at fields and changedFiles + * instead of rendered markup. + */ + +import { describe, expect, test } from "vitest"; +import { formatError, formatResult } from "../../../src/lib/init/formatters.js"; +import type { WizardSummary } from "../../../src/lib/init/ui/types.js"; +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; + +function summaryCall(calls: MockCall[]): WizardSummary | undefined { + const call = calls.find((c) => c.kind === "summary"); + return call?.kind === "summary" ? call.summary : undefined; +} + +function warnMessages(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "log.warn" + ) + .map((c) => c.message); +} + +function errorMessages(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => + c.kind === "log.error" + ) + .map((c) => c.message); +} + +function infoMessages(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "log.info" + ) + .map((c) => c.message); +} + +function feedbackOutcomes(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "feedback" + ) + .map((c) => c.outcome); +} + +describe("formatResult", () => { + test("emits a structured summary with all fields and the changed-files list", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + projectDir: "/app", + features: ["errorMonitoring", "performanceMonitoring"], + commands: ["npm install @sentry/nextjs"], + sentryProjectUrl: "https://sentry.io/project", + docsUrl: "https://docs.sentry.io", + changedFiles: [ + { action: "modify", path: "next.config.js" }, + { action: "create", path: "src/app/instrumentation-client.ts" }, + { action: "modify", path: "src/app/layout.tsx" }, + { action: "delete", path: "src/old-sentry.js" }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary).toBeDefined(); + if (!summary) { + throw new Error("expected summary call"); + } + + // Field order matches the source iteration in `buildSummary`. + expect(summary.fields).toEqual([ + { label: "Platform", value: "Next.js" }, + { label: "Directory", value: "/app" }, + { + label: "Features", + value: "Error Monitoring, Tracing", + }, + { label: "Commands", value: "npm install @sentry/nextjs" }, + { label: "Project", value: "https://sentry.io/project" }, + { label: "Docs", value: "https://docs.sentry.io" }, + ]); + expect(summary.changedFiles).toEqual([ + { action: "modify", path: "next.config.js" }, + { action: "create", path: "src/app/instrumentation-client.ts" }, + { action: "modify", path: "src/app/layout.tsx" }, + { action: "delete", path: "src/old-sentry.js" }, + ]); + expect(feedbackOutcomes(calls)).toEqual(["success"]); + expect( + infoMessages(calls).some((message) => + message.includes("one of the first") + ) + ).toBe(false); + }); + + test("skips the summary call when result has no summary-worthy fields", () => { + const { ui, calls } = createMockUI(); + formatResult({ status: "success" }, ui); + + expect(summaryCall(calls)).toBeUndefined(); + expect(calls.some((c) => c.kind === "outro")).toBe(true); + }); + + test("displays warnings when present", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + warnings: ["Source maps not configured", "Missing DSN"], + }, + }, + ui + ); + + const warns = warnMessages(calls); + expect(warns).toContain("Source maps not configured"); + expect(warns).toContain("Missing DSN"); + }); + + test("unwraps nested result property", () => { + const { ui, calls } = createMockUI(); + formatResult({ status: "success", result: { platform: "React" } }, ui); + + const summary = summaryCall(calls); + expect(summary?.fields).toContainEqual({ + label: "Platform", + value: "React", + }); + }); + + test("omits changedFiles when empty", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "React", + changedFiles: [], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary).toBeDefined(); + expect(summary?.changedFiles).toBeUndefined(); + }); + + test("emits a summary with only changedFiles when no fields are populated", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + changedFiles: [{ action: "create", path: "instrument.ts" }], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.fields).toEqual([]); + expect(summary?.changedFiles).toEqual([ + { action: "create", path: "instrument.ts" }, + ]); + // The structured completion payload is always attached for the InkUI + // completion screen; its internals are covered by dedicated tests. + expect(summary?.completion).toBeDefined(); + }); +}); + +describe("formatResult completion payload", () => { + test("builds the Issues-stream URL, MCP endpoint, and next-step metadata", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "next.js", + projectDir: "/work/my-app", + features: ["errorMonitoring", "performanceMonitoring"], + commands: ["pnpm add @sentry/nextjs"], + orgSlug: "acme", + projectSlug: "my-app", + projectId: "4507", + docsUrl: "https://docs.sentry.io/platforms/javascript/guides/nextjs/", + changedFiles: [{ action: "create", path: "instrument.ts" }], + }, + }, + ui + ); + + const completion = summaryCall(calls)?.completion; + expect(completion).toBeDefined(); + // Primary CTA points at the project-scoped Issues stream, not settings. + expect(completion?.issuesUrl).toContain("/issues/?project=4507"); + expect(completion?.issuesUrl).toContain("acme"); + expect(completion?.projectName).toBe("my-app"); + expect(completion?.features).toHaveLength(2); + expect(completion?.changedFileCount).toBe(1); + expect(completion?.agentInstallCommand).toBe("npx @sentry/ai install"); + expect(completion?.startCommand).toBe("pnpm dev"); + expect(completion?.verification).toEqual({ received: false }); + }); + + test("a verified event deep-links the exact event", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { orgSlug: "acme", projectSlug: "my-app", projectId: "4507" }, + }, + ui, + { verified: true, kind: "envelope", eventId: "abc123def" } + ); + + const completion = summaryCall(calls)?.completion; + expect(completion?.verification.received).toBe(true); + expect(completion?.verification.eventUrl).toContain("event.id:abc123def"); + }); + + test("recovers the org from the settings URL when the server omits slugs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "python", + // Older server: only the settings URL, no orgSlug. + sentryProjectUrl: "https://acme.sentry.io/settings/projects/api/", + }, + }, + ui + ); + + const completion = summaryCall(calls)?.completion; + // Issues stream is recovered from the settings URL's org. + expect(completion?.issuesUrl).toBe("https://acme.sentry.io/issues/"); + }); + + test("keeps the raw project URL only when the org can't be recovered", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { platform: "python", sentryProjectUrl: "not-a-url" }, + }, + ui + ); + + const completion = summaryCall(calls)?.completion; + expect(completion?.issuesUrl).toBe("not-a-url"); + }); +}); + +describe("formatResult with locally-captured project identity", () => { + // The CLI creates the Sentry project itself, so it captures the identity from + // the create-sentry-project tool result and passes it as the 4th arg. This is + // now the primary source of org/project/projectId in prod — the server no + // longer echoes them back (cli-init-api#239 closed). + const identity = { + orgSlug: "acme", + projectSlug: "my-app", + projectId: "4507", + dsn: "https://k@o0.ingest.sentry.io/4507", + url: "https://acme.sentry.io/settings/projects/my-app/", + }; + + test("builds the Issues link from the captured identity when the server sends no slugs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { platform: "next.js", commands: ["pnpm add @sentry/nextjs"] }, + }, + ui, + undefined, + identity + ); + + const completion = summaryCall(calls)?.completion; + expect(completion?.issuesUrl).toBe( + "https://acme.sentry.io/issues/?project=4507" + ); + expect(completion?.projectName).toBe("my-app"); + }); + + test("captured identity takes precedence over stale server-echoed slugs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "next.js", + orgSlug: "stale-org", + projectSlug: "stale-project", + projectId: "9999", + }, + }, + ui, + undefined, + identity + ); + + const completion = summaryCall(calls)?.completion; + expect(completion?.projectName).toBe("my-app"); + expect(completion?.issuesUrl).toContain("acme"); + expect(completion?.issuesUrl).toContain("project=4507"); + expect(completion?.issuesUrl).not.toContain("stale"); + }); + + test("backfills the summary Project field from the captured identity URL", () => { + const { ui, calls } = createMockUI(); + formatResult( + { status: "success", result: { platform: "next.js" } }, + ui, + undefined, + identity + ); + + const summary = summaryCall(calls); + expect(summary?.fields).toContainEqual({ + label: "Project", + value: "https://acme.sentry.io/settings/projects/my-app/", + }); + }); + + test("drops the dry-run sentinel projectId instead of leaking ?project=(dry-run)", () => { + const { ui, calls } = createMockUI(); + formatResult( + { status: "success", result: { platform: "next.js" } }, + ui, + undefined, + { + orgSlug: "acme", + projectSlug: "my-app", + projectId: "(dry-run)", + url: "https://sentry.io/dry-run", + } + ); + + const completion = summaryCall(calls)?.completion; + // Non-numeric id is dropped → org-wide, unfiltered Issues stream. + expect(completion?.issuesUrl).toBe("https://acme.sentry.io/issues/"); + expect(completion?.issuesUrl).not.toContain("(dry-run)"); + }); +}); + +describe("formatResult with featureBlurbs", () => { + test("populates featureBlurbs from output.featureBlurbs paired positionally with output.features", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + projectDir: "/app", + features: ["errorMonitoring", "performanceMonitoring"], + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures exceptions." }, + { feature: "performanceMonitoring", blurb: "Traces requests." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs).toEqual([ + { label: "Error Monitoring", blurb: "Captures exceptions." }, + { label: "Tracing", blurb: "Traces requests." }, + ]); + }); + + test("suppresses the Features row when featureBlurbs are present", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures exceptions." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.fields.some((f) => f.label === "Features")).toBe(false); + }); + + test("shows the Features row when featureBlurbs are absent", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring", "sessionReplay"], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.fields.some((f) => f.label === "Features")).toBe(true); + }); + + test("regression: all blurbs render when server returns canonical feature IDs", () => { + // Regression for the 'only Tracing' bug: the LLM was echoing human-readable + // labels ("Error Monitoring") in the feature field instead of canonical IDs + // ("errorMonitoring"). The server-side fix now maps blurbs positionally so + // canonical IDs are always in the feature field. This test verifies the CLI + // renders all blurbs when the server returns canonical IDs correctly — not + // just the one feature that happened to have a matching ID. + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures exceptions." }, + { feature: "performanceMonitoring", blurb: "Traces requests." }, + { feature: "sessionReplay", blurb: "Records sessions." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs).toHaveLength(3); + expect(summary?.featureBlurbs?.map((b) => b.label)).toEqual([ + "Error Monitoring", + "Session Replay", + "Tracing", + ]); + }); + + test("labels use canonical feature IDs — agent echoing wrong IDs omits the blurb rather than mislabelling", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring", "sessionReplay"], + // Agent echoed back wrong IDs — neither matches a canonical feature + featureBlurbs: [ + { feature: "error_monitoring", blurb: "Blurb A." }, + { feature: "session-replay", blurb: "Blurb B." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + // Wrong IDs → no match → blurbs omitted entirely; safe fallback + expect(summary?.featureBlurbs).toBeUndefined(); + }); + + test("drops blurb for feature the agent omitted — remaining blurbs stay correctly labelled", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + // Agent returned 2 of 3; skipped performanceMonitoring + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures." }, + { feature: "sessionReplay", blurb: "Records." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs).toHaveLength(2); + expect(summary?.featureBlurbs?.map((b) => b.label)).toEqual([ + "Error Monitoring", + "Session Replay", + ]); + }); + + test("stripAnsi strips SGR colour codes from server-supplied blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "\x1b[31mCaptures.\x1b[0m" }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips CSI sequences with intermediate bytes (e.g. soft reset, cursor style)", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1b[!p = Soft Terminal Reset (intermediate byte !) + // \x1b[1 q = Set Cursor Style (intermediate byte space) + { + feature: "errorMonitoring", + blurb: "\x1b[!pCaptures.\x1b[1 q", + }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips arbitrary OSC sequences (e.g. window title change) from blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1b]0;title\x07 = set window title — OSC command, not OSC 8 + { + feature: "errorMonitoring", + blurb: "\x1b]0;injected title\x07Captures.", + }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips single-char C1 ESC sequences (e.g. terminal reset) from blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1bc = RIS (Reset to Initial State) — two-char ESC sequence + { feature: "errorMonitoring", blurb: "\x1bcCaptures." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips non-SGR CSI sequences (cursor movement, screen-clear) from blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1b[2J = clear screen, \x1b[1A = cursor up — non-SGR CSI + { + feature: "errorMonitoring", + blurb: "\x1b[2JCaptures.\x1b[1A", + }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("sorts featureBlurbs by canonical display order", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + // Server returned performanceMonitoring before errorMonitoring + features: ["performanceMonitoring", "errorMonitoring"], + featureBlurbs: [ + { feature: "performanceMonitoring", blurb: "Traces." }, + { feature: "errorMonitoring", blurb: "Captures." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + // errorMonitoring comes before performanceMonitoring in FEATURE_DISPLAY_ORDER + expect(summary?.featureBlurbs?.map((b) => b.label)).toEqual([ + "Error Monitoring", + "Tracing", + ]); + }); +}); + +describe("formatError", () => { + test("logs the error message", () => { + const { ui, calls } = createMockUI(); + formatError({ status: "failed", error: "Connection timed out" }, ui); + + expect(errorMessages(calls)).toContain("Connection timed out"); + const cancel = calls.find((c) => c.kind === "cancel"); + expect(cancel?.kind === "cancel" && cancel.message).toBe("Setup failed"); + expect(feedbackOutcomes(calls)).toEqual(["failed"]); + }); + + test("extracts message from nested result.message", () => { + const { ui, calls } = createMockUI(); + formatError({ status: "failed", result: { message: "Inner failure" } }, ui); + + expect(errorMessages(calls)).toContain("Inner failure"); + }); + + test("falls back to unknown error when no message available", () => { + const { ui, calls } = createMockUI(); + formatError({ status: "failed" }, ui); + + expect(errorMessages(calls)).toContain( + "Wizard failed with an unknown error" + ); + }); + + test("shows platform hint for detection failure exit code (20)", () => { + const { ui, calls } = createMockUI(); + formatError({ status: "failed", result: { exitCode: 20 } }, ui); + + expect(warnMessages(calls).some((m) => m.includes("platform"))).toBe(true); + }); + + test("shows manual install commands for dependency failure (30)", () => { + const { ui, calls } = createMockUI(); + formatError( + { + status: "failed", + result: { + exitCode: 30, + commands: ["npm install @sentry/node"], + }, + }, + ui + ); + + expect( + warnMessages(calls).some((m) => m.includes("$ npm install @sentry/node")) + ).toBe(true); + }); + + test("shows verification hint for exit code 50", () => { + const { ui, calls } = createMockUI(); + formatError({ status: "failed", result: { exitCode: 50 } }, ui); + + expect(warnMessages(calls).some((m) => m.includes("verification"))).toBe( + true + ); + }); + + test("shows docs URL when present", () => { + const { ui, calls } = createMockUI(); + const docsUrl = "https://docs.sentry.io/platforms/react/"; + formatError( + { + status: "failed", + result: { docsUrl }, + }, + ui + ); + + // Pull every URL out of the info messages and check the docs URL + // is among them. The previous `String.prototype.includes` form + // tripped CodeQL's "incomplete URL substring sanitization" rule — + // this regex-based extraction makes the URL-matching intent + // explicit (and silences the false positive). + const urlRe = /https?:\/\/[^\s)]+/g; + const seenUrls = infoMessages(calls).flatMap( + (msg) => msg.match(urlRe) ?? [] + ); + expect(seenUrls).toContain(docsUrl); + }); +}); diff --git a/packages/cli/test/lib/init/git.test.ts b/packages/cli/test/lib/init/git.test.ts new file mode 100644 index 000000000..e423873bd --- /dev/null +++ b/packages/cli/test/lib/init/git.test.ts @@ -0,0 +1,185 @@ +/** + * Tests for `checkGitStatus`. Stubs the low-level git probes from + * `src/lib/git.ts` and uses `MockUI` to record/replay all UI traffic. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as gitLib from "../../../src/lib/git.js"; +import { + checkGitStatus, + getUncommittedOrUntrackedFiles, + isInsideGitWorkTree, +} from "../../../src/lib/init/git.js"; +import { CANCELLED } from "../../../src/lib/init/ui/types.js"; +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; + +let isInsideWorkTreeSpy: ReturnType; +let getUncommittedFilesSpy: ReturnType; + +beforeEach(() => { + isInsideWorkTreeSpy = vi.spyOn(gitLib, "isInsideGitWorkTree"); + getUncommittedFilesSpy = vi.spyOn(gitLib, "getUncommittedFiles"); +}); + +afterEach(() => { + isInsideWorkTreeSpy.mockRestore(); + getUncommittedFilesSpy.mockRestore(); +}); + +function lastWarn(calls: MockCall[]): string | undefined { + for (let i = calls.length - 1; i >= 0; i--) { + const call = calls[i]; + if (call?.kind === "log.warn") { + return call.message; + } + } + return; +} + +describe("isInsideGitWorkTree", () => { + test("returns true when inside git work tree", () => { + isInsideWorkTreeSpy.mockReturnValue(true); + + expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(true); + expect(isInsideWorkTreeSpy).toHaveBeenCalledWith("/tmp"); + }); + + test("returns false when not in git repo", () => { + isInsideWorkTreeSpy.mockReturnValue(false); + + expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(false); + }); +}); + +describe("getUncommittedOrUntrackedFiles", () => { + test("returns formatted file list from lib/git", () => { + getUncommittedFilesSpy.mockReturnValue([ + "- M src/index.ts", + "- ?? new-file.ts", + ]); + + const files = getUncommittedOrUntrackedFiles({ cwd: "/tmp" }); + + expect(files).toEqual(["- M src/index.ts", "- ?? new-file.ts"]); + expect(getUncommittedFilesSpy).toHaveBeenCalledWith("/tmp"); + }); + + test("returns empty array for clean repo", () => { + getUncommittedFilesSpy.mockReturnValue([]); + + expect(getUncommittedOrUntrackedFiles({ cwd: "/tmp" })).toEqual([]); + }); +}); + +describe("checkGitStatus", () => { + test("returns true silently for clean git repo", async () => { + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue([]); + + const { ui, calls } = createMockUI(); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); + + expect(result).toBe(true); + expect(calls.some((c) => c.kind === "confirm")).toBe(false); + expect(calls.some((c) => c.kind === "log.warn")).toBe(false); + }); + + test("prompts when not in git repo (interactive) and returns true on confirm", async () => { + isInsideWorkTreeSpy.mockReturnValue(false); + const { ui, calls, respond } = createMockUI(); + respond.confirm(true); + + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); + + expect(result).toBe(true); + const confirmCall = calls.find((c) => c.kind === "confirm"); + expect(confirmCall?.kind === "confirm" && confirmCall.message).toContain( + "not inside a git repository" + ); + }); + + test("prompts when not in git repo (interactive) and returns false on decline", async () => { + isInsideWorkTreeSpy.mockReturnValue(false); + const { ui, respond } = createMockUI(); + respond.confirm(false); + + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); + + expect(result).toBe(false); + }); + + test("returns false without throwing when user cancels not-in-git-repo prompt", async () => { + isInsideWorkTreeSpy.mockReturnValue(false); + const { ui, respond } = createMockUI(); + respond.confirm(CANCELLED); + + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); + + expect(result).toBe(false); + }); + + test("warns and auto-continues when not in git repo with --yes", async () => { + isInsideWorkTreeSpy.mockReturnValue(false); + const { ui, calls } = createMockUI(); + + const result = await checkGitStatus({ cwd: "/tmp", yes: true, ui }); + + expect(result).toBe(true); + expect(lastWarn(calls)).toContain("not inside a git repository"); + expect(calls.some((c) => c.kind === "confirm")).toBe(false); + }); + + test("shows files and prompts for dirty tree (interactive), returns true on confirm", async () => { + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); + const { ui, calls, respond } = createMockUI(); + respond.confirm(true); + + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); + + expect(result).toBe(true); + expect(lastWarn(calls)).toContain("uncommitted"); + const confirmCall = calls.find((c) => c.kind === "confirm"); + expect(confirmCall?.kind === "confirm" && confirmCall.message).toContain( + "uncommitted changes" + ); + }); + + test("shows files and prompts for dirty tree (interactive), returns false on decline", async () => { + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); + const { ui, respond } = createMockUI(); + respond.confirm(false); + + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); + + expect(result).toBe(false); + }); + + test("returns false without throwing when user cancels dirty-tree prompt", async () => { + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); + const { ui, respond } = createMockUI(); + respond.confirm(CANCELLED); + + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); + + expect(result).toBe(false); + }); + + test("warns with file list and auto-continues for dirty tree with --yes", async () => { + isInsideWorkTreeSpy.mockReturnValue(true); + getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts", "- ?? new.ts"]); + const { ui, calls } = createMockUI(); + + const result = await checkGitStatus({ cwd: "/tmp", yes: true, ui }); + + expect(result).toBe(true); + const warn = lastWarn(calls); + expect(warn).toBeDefined(); + expect(warn).toContain("uncommitted"); + expect(warn).toContain("M dirty.ts"); + expect(calls.some((c) => c.kind === "confirm")).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/init/init-service-auth.test.ts b/packages/cli/test/lib/init/init-service-auth.test.ts new file mode 100644 index 000000000..d7af0d44e --- /dev/null +++ b/packages/cli/test/lib/init/init-service-auth.test.ts @@ -0,0 +1,83 @@ +import { MastraClientError } from "@mastra/client-js"; +import { describe, expect, test, vi } from "vitest"; +import { ApiError } from "../../../src/lib/errors.js"; +import { withInitServiceAuthClassification } from "../../../src/lib/init/init-service-auth.js"; + +const ENDPOINT = "/api/workflows/sentry-wizard/resume-async"; + +function authFailure( + code: string, + safeToRetry = true, + status = 503 +): MastraClientError { + return new MastraClientError(status, "Service Unavailable", "auth failed", { + code, + safeToRetry, + }); +} + +describe("withInitServiceAuthClassification", () => { + test.each([ + "AUTH_UPSTREAM_TIMEOUT", + "AUTH_UPSTREAM_UNAVAILABLE", + ])("retries %s once", async (code) => { + const failure = authFailure(code); + const operation = vi + .fn<() => Promise>() + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce("ok"); + + await expect( + withInitServiceAuthClassification(operation, ENDPOINT) + ).resolves.toBe("ok"); + expect(operation).toHaveBeenCalledTimes(2); + }); + + test("stops after one retry when the auth service remains unavailable", async () => { + const failure = authFailure("AUTH_UPSTREAM_TIMEOUT"); + const operation = vi.fn<() => Promise>().mockRejectedValue(failure); + + await expect( + withInitServiceAuthClassification(operation, ENDPOINT) + ).rejects.toBe(failure); + expect(operation).toHaveBeenCalledTimes(2); + }); + + test("classifies a 401 returned by the second attempt", async () => { + const operation = vi + .fn<() => Promise>() + .mockRejectedValueOnce(authFailure("AUTH_UPSTREAM_TIMEOUT")) + .mockRejectedValueOnce( + new Error( + 'HTTP error! status: 401 - {"error":"Unauthorized: invalid token"}' + ) + ); + + const error = await withInitServiceAuthClassification( + operation, + ENDPOINT + ).catch((caught) => caught); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 401, endpoint: ENDPOINT }); + expect(operation).toHaveBeenCalledTimes(2); + }); + + test.each([ + authFailure("AUTH_UPSTREAM_RATE_LIMITED", false), + authFailure("UNKNOWN_CODE"), + authFailure("AUTH_UPSTREAM_TIMEOUT", true, 502), + new TypeError("fetch failed"), + Object.assign(new Error("auth failed"), { + status: 503, + body: { code: "AUTH_UPSTREAM_TIMEOUT", safeToRetry: true }, + }), + ])("does not retry an unauthorized failure contract", async (failure) => { + const operation = vi.fn<() => Promise>().mockRejectedValue(failure); + + await expect( + withInitServiceAuthClassification(operation, ENDPOINT) + ).rejects.toBe(failure); + expect(operation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/test/lib/init/interactive.test.ts b/packages/cli/test/lib/init/interactive.test.ts new file mode 100644 index 000000000..108b810c3 --- /dev/null +++ b/packages/cli/test/lib/init/interactive.test.ts @@ -0,0 +1,729 @@ +/** + * Interactive Dispatcher Tests + * + * Tests for the init wizard interactive prompt handlers. Uses a + * `MockUI` that records calls and replays canned prompt responses, so + * the dispatcher can be exercised without touching clack or any real + * terminal. + */ + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { WizardError } from "../../../src/lib/errors.js"; +import { handleInteractive } from "../../../src/lib/init/interactive.js"; +import type { InteractiveContext } from "../../../src/lib/init/types.js"; +import { CANCELLED } from "../../../src/lib/init/ui/types.js"; +import { createMockUI } from "./ui/mock-ui.js"; + +function makeOptions( + overrides?: Partial +): InteractiveContext { + return { + yes: false, + dryRun: false, + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("handleInteractive dispatcher", () => { + test("throws WizardError for unknown kind", async () => { + const { ui } = createMockUI(); + await expect( + handleInteractive( + { type: "interactive", prompt: "test", kind: "unknown" as "select" }, + makeOptions(), + ui + ) + ).rejects.toBeInstanceOf(WizardError); + }); +}); + +describe("handleSelect", () => { + test("auto-selects single option with --yes", async () => { + const { ui, calls } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["my-app"], + }, + makeOptions({ yes: true }), + ui + ); + + expect(result).toEqual({ selectedApp: "my-app" }); + expect( + calls.some((c) => c.kind === "log.info" && c.message.includes("my-app")) + ).toBe(true); + }); + + test("throws WizardError with app list when --yes and multiple apps", async () => { + const { ui, calls } = createMockUI(); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + apps: [ + { name: "react", path: "/repo/apps/react" }, + { name: "vue", path: "/repo/apps/vue" }, + ], + }, + makeOptions({ yes: true }), + ui + ) + ).rejects.toBeInstanceOf(WizardError); + expect(calls.some((c) => c.kind === "log.error")).toBe(true); + }); + + test("falls through to ui.select when --yes and non-monorepo select", async () => { + // --yes must not throw the monorepo error for select prompts that have + // no payload.apps — only app-selection prompts provide that array. + const { ui, respond } = createMockUI(); + respond.select("create"); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Found an existing project.", + kind: "select", + options: ["existing", "create"], + }, + makeOptions({ yes: true }), + ui + ); + expect(result).toEqual({ selectedApp: "create" }); + }); + + test("throws WizardError when options list is empty", async () => { + const { ui } = createMockUI(); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: [], + }, + makeOptions(), + ui + ) + ).rejects.toBeInstanceOf(WizardError); + }); + + test("uses apps array names when options not provided", async () => { + const { ui } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + apps: [{ name: "express-app", path: "/app", framework: "Express" }], + }, + makeOptions({ yes: true }), + ui + ); + + expect(result).toEqual({ selectedApp: "express-app" }); + }); + + test("calls ui.select in interactive mode", async () => { + const { ui, calls, respond } = createMockUI(); + respond.select("vue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["react", "vue"], + }, + makeOptions({ yes: false }), + ui + ); + + expect(result).toEqual({ selectedApp: "vue" }); + expect(calls.some((c) => c.kind === "select")).toBe(true); + }); + + test("throws WizardCancelledError on user cancellation", async () => { + const { ui, respond } = createMockUI(); + respond.select(CANCELLED); + + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["react", "vue"], + }, + makeOptions({ yes: false }), + ui + ) + ).rejects.toThrow("Setup cancelled"); + }); +}); + +describe("handleSelect with --app flag", () => { + test("selects matching app by name", async () => { + const { ui, calls } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [ + { name: "web", path: "/repo/apps/web", framework: "Next.js" }, + { name: "api", path: "/repo/apps/api", framework: "Express" }, + ], + }, + makeOptions({ yes: true, app: "web" }), + ui + ); + + expect(result).toEqual({ selectedApp: "web" }); + expect( + calls.some((c) => c.kind === "log.info" && c.message.includes("web")) + ).toBe(true); + }); + + test("matches --app case-insensitively", async () => { + const { ui } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [{ name: "Web", path: "/repo/apps/web" }], + }, + makeOptions({ app: "WEB" }), + ui + ); + + expect(result).toEqual({ selectedApp: "Web" }); + }); + + test("throws WizardError when --app name is not found", async () => { + const { ui, calls } = createMockUI(); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [ + { name: "web", path: "/repo/apps/web" }, + { name: "api", path: "/repo/apps/api" }, + ], + }, + makeOptions({ yes: true, app: "missing" }), + ui + ) + ).rejects.toBeInstanceOf(WizardError); + const errorCall = calls.find((c) => c.kind === "log.error"); + expect(errorCall?.message).toContain("missing"); + expect(errorCall?.message).toContain("web"); + }); + + test("ignores --app when payload has no apps array", async () => { + // --app only activates for monorepo app-selection prompts (payload.apps present). + // For other select prompts it must fall through to the normal interactive pick. + const { ui, respond } = createMockUI(); + respond.select("existing"); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Found an existing project.", + kind: "select", + options: ["existing", "create"], + }, + makeOptions({ app: "web" }), + ui + ); + + expect(result).toEqual({ selectedApp: "existing" }); + }); + + test("error message for --yes with multiple apps includes app names and --app hint", async () => { + const { ui, calls } = createMockUI(); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [ + { name: "web", path: "/repo/apps/web", framework: "Next.js" }, + { name: "api", path: "/repo/apps/api" }, + ], + }, + makeOptions({ yes: true }), + ui + ) + ).rejects.toBeInstanceOf(WizardError); + const errorCall = calls.find((c) => c.kind === "log.error"); + expect(errorCall?.message).toContain("web"); + expect(errorCall?.message).toContain("api"); + expect(errorCall?.message).toContain("--app"); + }); +}); + +describe("handleMultiSelect", () => { + test("records offered and selected features for interactive prompts", async () => { + const setTagSpy = vi.spyOn(Sentry, "setTag"); + const { ui, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + respond.select("continue"); + + await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + "userFeedback", + ], + }, + makeOptions(), + ui + ); + + expect(setTagSpy).toHaveBeenCalledWith( + "wizard.features.offered", + "errorMonitoring,performanceMonitoring,sessionReplay" + ); + expect(setTagSpy).toHaveBeenCalledWith( + "wizard.features.selected", + "errorMonitoring,sessionReplay" + ); + }); + + test("auto-selects all features with --yes", async () => { + const { ui } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + "userFeedback", + ], + }, + makeOptions({ yes: true }), + ui + ); + + expect(result.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ]); + }); + + test("returns error monitoring when no features are provided", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect([]); + respond.select("continue"); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [], + }, + makeOptions(), + ui + ); + + expect(result).toEqual({ features: ["errorMonitoring"] }); + expect(calls.some((call) => call.kind === "multiselect")).toBe(true); + expect(calls.some((call) => call.kind === "select")).toBe(true); + }); + + test("injects error monitoring when the server omits the baseline", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["sessionReplay", "performanceMonitoring"], + }, + makeOptions(), + ui + ); + + expect(result).toEqual({ + features: ["errorMonitoring", "sessionReplay"], + }); + const multiselectCall = calls.find((call) => call.kind === "multiselect"); + expect(multiselectCall?.options).toContain("errorMonitoring"); + }); + + test("prepends errorMonitoring when available but not user-selected", async () => { + // User selects only sessionReplay, but errorMonitoring is available (required) + const { ui, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + }, + makeOptions({ yes: false }), + ui + ); + + const features = result.features as string[]; + expect(features[0]).toBe("errorMonitoring"); + expect(features).toContain("sessionReplay"); + }); + + test("throws WizardCancelledError when user cancels multi-select", async () => { + const setTagSpy = vi.spyOn(Sentry, "setTag"); + const { ui, respond } = createMockUI(); + respond.multiselect(CANCELLED); + + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["errorMonitoring", "performanceMonitoring"], + }, + makeOptions({ yes: false }), + ui + ) + ).rejects.toThrow("Setup cancelled"); + expect(setTagSpy).toHaveBeenCalledWith( + "wizard.features.offered", + "errorMonitoring,performanceMonitoring" + ); + expect(setTagSpy).not.toHaveBeenCalledWith( + "wizard.features.selected", + expect.anything() + ); + }); + + test("shows selection and review when only errorMonitoring is available", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect([]); + respond.select("continue"); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["errorMonitoring"], + }, + makeOptions({ yes: false }), + ui + ); + + expect(result).toEqual({ features: ["errorMonitoring"] }); + const multiselectCall = calls.find((call) => call.kind === "multiselect"); + expect(multiselectCall?.options).toEqual(["errorMonitoring"]); + expect(multiselectCall?.initialValues).toEqual(["errorMonitoring"]); + const reviewCall = calls.find((call) => call.kind === "select"); + expect(reviewCall?.details?.map((detail) => detail.text)).toContain( + "✓ Error Monitoring" + ); + }); + + test("shows errorMonitoring as a locked selected option", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["performanceMonitoring"]); + respond.select("continue"); + + await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["errorMonitoring", "performanceMonitoring"], + }, + makeOptions({ yes: false }), + ui + ); + + const multiselectCall = calls.find((c) => c.kind === "multiselect") as + | Extract<(typeof calls)[number], { kind: "multiselect" }> + | undefined; + expect(multiselectCall).toBeDefined(); + expect(multiselectCall?.options).toContain("errorMonitoring"); + expect(multiselectCall?.options).toContain("performanceMonitoring"); + expect(multiselectCall?.initialValues).toEqual([ + "errorMonitoring", + "performanceMonitoring", + ]); + expect( + multiselectCall?.optionDetails.find( + (option) => option.value === "errorMonitoring" + ) + ).toMatchObject({ + description: "Automatically capture exceptions and stack traces", + locked: true, + }); + }); + + test("shows defaults first, sorts optional features, and omits unsupported features", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "sourceMaps", + "profiling", + "performanceMonitoring", + "errorMonitoring", + "metrics", + "sessionReplay", + "logs", + "crons", + "attachments", + "aiMonitoring", + "mcpObservability", + "userFeedback", + ], + }, + makeOptions({ yes: false }), + ui + ); + + expect(result.features).toEqual(["errorMonitoring", "sessionReplay"]); + + const multiselectCall = calls.find((c) => c.kind === "multiselect") as + | Extract<(typeof calls)[number], { kind: "multiselect" }> + | undefined; + expect(multiselectCall?.options).toEqual([ + "errorMonitoring", + "logs", + "sessionReplay", + "performanceMonitoring", + "aiMonitoring", + "crons", + "mcpObservability", + "profiling", + "sourceMaps", + ]); + expect(multiselectCall?.initialValues).toEqual([ + "errorMonitoring", + "logs", + "sessionReplay", + "performanceMonitoring", + ]); + expect(multiselectCall?.details).toEqual([ + { + text: "Based on your project, these features are available to set up.", + }, + ]); + expect(multiselectCall?.options).not.toContain("metrics"); + expect(multiselectCall?.options).not.toContain("attachments"); + expect(multiselectCall?.options).not.toContain("userFeedback"); + + const reviewCall = calls.find((call) => call.kind === "select"); + expect(reviewCall?.details?.[0]).toEqual({ + text: "We'll add these features:", + }); + expect(reviewCall?.footer).toEqual({ + text: "We'll modify project files for this Sentry setup.", + }); + }); + + test("can go back from review and preserves the explicit selection", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + respond.select("back"); + respond.multiselect(["performanceMonitoring"]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + }, + makeOptions(), + ui + ); + + expect(result).toEqual({ + features: ["errorMonitoring", "performanceMonitoring"], + }); + const multiselectCalls = calls.filter( + (call) => call.kind === "multiselect" + ); + expect(multiselectCalls).toHaveLength(2); + expect(multiselectCalls[1]?.initialValues).toEqual([ + "errorMonitoring", + "sessionReplay", + ]); + const reviewCalls = calls.filter((call) => call.kind === "select"); + expect(reviewCalls[0]?.details?.map((detail) => detail.text)).toContain( + "✓ Session Replay" + ); + expect(reviewCalls[0]?.details?.map((detail) => detail.text)).not.toContain( + "✓ Tracing" + ); + expect( + reviewCalls[0]?.details + ?.map((detail) => detail.text) + .filter((line) => line.startsWith("✓ ")) + ).toEqual(["✓ Error Monitoring", "✓ Session Replay"]); + expect(reviewCalls[1]?.details?.map((detail) => detail.text)).toContain( + "✓ Tracing" + ); + expect(reviewCalls[1]?.options).toEqual(["continue", "back"]); + }); + + test.each([ + ["aiMonitoring", "Agent Tracing"], + ["mcpObservability", "MCP Observability"], + ["profiling", "Profiling"], + ])("review includes tracing when %s enables it implicitly", async (dependencyFeature, dependencyLabel) => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect([dependencyFeature]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + dependencyFeature, + ], + }, + makeOptions(), + ui + ); + + expect(result).toEqual({ + features: ["errorMonitoring", "performanceMonitoring", dependencyFeature], + }); + const reviewCall = calls.find((call) => call.kind === "select"); + const reviewDetails = reviewCall?.details?.map((detail) => detail.text); + expect(reviewDetails).toContain("✓ Error Monitoring"); + expect(reviewDetails).toContain("✓ Tracing"); + expect(reviewDetails).toContain(`✓ ${dependencyLabel}`); + }); + + test("Back restores the normalized AI selection including Tracing", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["aiMonitoring"]); + respond.select("back"); + respond.multiselect(["aiMonitoring", "performanceMonitoring"]); + respond.select("continue"); + + await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "aiMonitoring", + ], + }, + makeOptions(), + ui + ); + + const multiselectCalls = calls.filter( + (call) => call.kind === "multiselect" + ); + expect(multiselectCalls[1]?.initialValues).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "aiMonitoring", + ]); + }); +}); + +describe("handleConfirm", () => { + test("auto-confirms with action: continue for non-example prompts with --yes", async () => { + const { ui } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Continue with setup?", + kind: "confirm", + }, + makeOptions({ yes: true }), + ui + ); + + expect(result).toEqual({ action: "continue" }); + }); + + test("throws WizardCancelledError when user cancels confirm", async () => { + const { ui, respond } = createMockUI(); + respond.confirm(CANCELLED); + + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Continue with setup?", + kind: "confirm", + }, + makeOptions({ yes: false }), + ui + ) + ).rejects.toThrow("Setup cancelled"); + }); + + test("returns action: stop when user declines non-example prompt", async () => { + const { ui, respond } = createMockUI(); + respond.confirm(false); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Continue with setup?", + kind: "confirm", + }, + makeOptions({ yes: false }), + ui + ); + + expect(result).toEqual({ action: "stop" }); + }); +}); diff --git a/packages/cli/test/lib/init/preflight.test.ts b/packages/cli/test/lib/init/preflight.test.ts new file mode 100644 index 000000000..b471a84b1 --- /dev/null +++ b/packages/cli/test/lib/init/preflight.test.ts @@ -0,0 +1,917 @@ +/** + * Tests for `resolveInitContext`. Stubs API and DSN-detection layers + * with `spyOn` and uses `MockUI` to drive prompts deterministically. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +vi.mock("../../../src/lib/scope-recovery.js", () => ({ + captureOAuthScopeRecoveryGate: vi.fn(), +})); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/db/auth.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as auth from "../../../src/lib/db/auth.js"; + +vi.mock("../../../src/lib/dsn/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as dsnIndex from "../../../src/lib/dsn/index.js"; +import { + ApiError, + AuthError, + HostScopeError, + WizardError, +} from "../../../src/lib/errors.js"; + +vi.mock("../../../src/lib/init/org-prefetch.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/lib/init/org-prefetch.js") + >(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as prefetch from "../../../src/lib/init/org-prefetch.js"; +import { resolveInitContext } from "../../../src/lib/init/preflight.js"; +import type { WizardOptions } from "../../../src/lib/init/types.js"; +import { CANCELLED } from "../../../src/lib/init/ui/types.js"; +// biome-ignore lint/performance/noNamespaceImport: scope decision is mocked at the module boundary +import * as scopeRecovery from "../../../src/lib/scope-recovery.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +vi.mock("../../../src/lib/resolve-team.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTeam from "../../../src/lib/resolve-team.js"; +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + yes: true, + dryRun: false, + ...overrides, + }; +} + +function feedbackOutcomes(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "feedback" + ) + .map((c) => c.outcome); +} + +let resolveOrgPrefetchedSpy: ReturnType; +let listOrganizationsSpy: ReturnType; +let listTeamsSpy: ReturnType; +let getOrganizationSpy: ReturnType; +let getProjectSpy: ReturnType; +let tryGetPrimaryDsnSpy: ReturnType; +let getAuthTokenSpy: ReturnType; +let resolveOrCreateTeamSpy: ReturnType; +let detectDsnSpy: ReturnType; +let resolveDsnByPublicKeySpy: ReturnType; +let shouldDelegateScopeRecovery: ReturnType; + +beforeEach(() => { + shouldDelegateScopeRecovery = vi.fn().mockResolvedValue(false); + vi.mocked(scopeRecovery.captureOAuthScopeRecoveryGate).mockReturnValue({ + shouldDelegate: shouldDelegateScopeRecovery, + }); + resolveOrgPrefetchedSpy = vi + .spyOn(prefetch, "resolveOrgPrefetched") + .mockResolvedValue({ org: "acme" }); + listOrganizationsSpy = vi + .spyOn(apiClient, "listOrganizations") + .mockResolvedValue([{ id: "1", slug: "acme", name: "Acme" }]); + listTeamsSpy = vi.spyOn(apiClient, "listTeams").mockResolvedValue([ + { + id: "1", + slug: "platform", + name: "Platform", + access: ["team:admin"], + isMember: true, + } as any, + ]); + getOrganizationSpy = vi + .spyOn(apiClient, "getOrganization") + .mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read"], + allowMemberProjectCreation: true, + } as any); + getProjectSpy = vi.spyOn(apiClient, "getProject").mockResolvedValue({ + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any); + tryGetPrimaryDsnSpy = vi + .spyOn(apiClient, "tryGetPrimaryDsn") + .mockResolvedValue("https://abc@o1.ingest.sentry.io/42"); + getAuthTokenSpy = vi + .spyOn(auth, "getAuthToken") + .mockReturnValue("sntrys_test"); + resolveOrCreateTeamSpy = vi + .spyOn(resolveTeam, "resolveOrCreateTeam") + .mockResolvedValue({ + slug: "platform", + source: "auto-selected", + }); + detectDsnSpy = vi.spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); + resolveDsnByPublicKeySpy = vi + .spyOn(resolveTarget, "resolveDsnByPublicKey") + .mockResolvedValue(null); +}); + +afterEach(() => { + resolveOrgPrefetchedSpy.mockRestore(); + listOrganizationsSpy.mockRestore(); + listTeamsSpy.mockRestore(); + getOrganizationSpy.mockRestore(); + getProjectSpy.mockRestore(); + tryGetPrimaryDsnSpy.mockRestore(); + getAuthTokenSpy.mockRestore(); + resolveOrCreateTeamSpy.mockRestore(); + detectDsnSpy.mockRestore(); + resolveDsnByPublicKeySpy.mockRestore(); + vi.mocked(scopeRecovery.captureOAuthScopeRecoveryGate).mockReset(); + process.exitCode = 0; +}); + +describe("resolveInitContext", () => { + test("uses an existing detected project in --yes mode", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + }); + resolveDsnByPublicKeySpy.mockResolvedValue({ + org: "acme", + project: "my-app", + }); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context).toEqual( + expect.objectContaining({ + org: "acme", + project: "my-app", + team: "platform", + authToken: "sntrys_test", + existingProject: expect.objectContaining({ + orgSlug: "acme", + projectSlug: "my-app", + }), + }) + ); + expect(getProjectSpy).toHaveBeenCalledTimes(1); + expect(tryGetPrimaryDsnSpy).toHaveBeenCalledTimes(1); + }); + + test("keeps a detected DSN project even when project enrichment fails", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + }); + resolveDsnByPublicKeySpy.mockResolvedValue({ + org: "acme", + project: "my-app", + }); + getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context).toEqual( + expect.objectContaining({ + org: "acme", + project: "my-app", + team: "platform", + }) + ); + expect(context?.existingProject).toBeUndefined(); + }); + + test("retries detected project enrichment during project selection when the first lookup yields no metadata", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + }); + resolveDsnByPublicKeySpy.mockResolvedValue({ + org: "acme", + project: "my-app", + }); + getProjectSpy + .mockRejectedValueOnce(new ApiError("not found", 404)) + .mockResolvedValue({ + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.existingProject).toEqual( + expect.objectContaining({ + orgSlug: "acme", + projectSlug: "my-app", + }) + ); + expect(getProjectSpy).toHaveBeenCalledTimes(2); + expect(tryGetPrimaryDsnSpy).toHaveBeenCalledTimes(1); + }); + + test("falls back to listing organizations when prefetch misses", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "solo-org", name: "Solo Org" }, + ]); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions({ yes: false }), ui); + + expect(context?.org).toBe("solo-org"); + }); + + test("lets the user choose an existing bare-slug project", async () => { + const { ui, respond } = createMockUI(); + respond.select("existing"); + + const context = await resolveInitContext( + makeOptions({ yes: false, project: "my-app" }), + ui + ); + + expect(context?.project).toBe("my-app"); + expect(context?.existingProject?.projectSlug).toBe("my-app"); + }); + + test("keeps the bare slug when the existence lookup fails", async () => { + getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); + + const { ui } = createMockUI(); + const context = await resolveInitContext( + makeOptions({ yes: false, project: "my-app" }), + ui + ); + + expect(context?.project).toBe("my-app"); + expect(context?.existingProject).toBeUndefined(); + }); + + test("uses org-scoped creation when no Team Admin team is available", async () => { + listTeamsSpy.mockResolvedValueOnce([ + { + id: "1", + slug: "frontend", + name: "Frontend", + access: ["team:read"], + isMember: true, + } as any, + ]); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.team).toBeUndefined(); + expect(getOrganizationSpy).toHaveBeenCalledWith("acme"); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + }); + + test("clears the project when the user chooses to create new", async () => { + const { ui, respond } = createMockUI(); + respond.select("create"); + + const context = await resolveInitContext( + makeOptions({ yes: false, project: "my-app" }), + ui + ); + + expect(context?.project).toBeUndefined(); + expect(context?.existingProject).toBeUndefined(); + }); + + test("resolves an explicit team during preflight", async () => { + resolveOrCreateTeamSpy.mockImplementation(async (_org, options) => ({ + slug: options.team ?? "platform", + source: options.team ? "explicit" : "auto-selected", + })); + + const { ui } = createMockUI(); + const context = await resolveInitContext( + makeOptions({ team: "backend", yes: false }), + ui + ); + + expect(context?.team).toBe("backend"); + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( + "acme", + expect.objectContaining({ + team: "backend", + deferAutoCreateOnEmptyOrg: true, + }) + ); + }); + + test("selects a Team Admin team over non-admin member teams", async () => { + listTeamsSpy.mockResolvedValueOnce([ + { + id: "1", + slug: "frontend", + name: "Frontend", + access: ["team:read"], + isMember: true, + } as any, + { + id: "2", + slug: "platform", + name: "Platform", + access: ["team:admin"], + isMember: true, + } as any, + ]); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.team).toBe("platform"); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + }); + + test("prompts when multiple Team Admin teams are available", async () => { + const { ui, calls, respond } = createMockUI(); + respond.select("mobile"); + listTeamsSpy.mockResolvedValueOnce([ + { + id: "1", + slug: "platform", + name: "Platform", + access: ["team:admin"], + isMember: true, + } as any, + { + id: "2", + slug: "mobile", + name: "Mobile", + access: ["team:admin"], + isMember: true, + } as any, + ]); + + const context = await resolveInitContext(makeOptions({ yes: false }), ui); + + expect(context?.team).toBe("mobile"); + const selectCall = calls.find((call) => call.kind === "select"); + expect(selectCall?.options).toEqual(["mobile", "platform"]); + }); + + test("selects the first alphabetical Team Admin team in --yes mode", async () => { + listTeamsSpy.mockResolvedValueOnce([ + { + id: "1", + slug: "platform", + name: "Platform", + access: ["team:admin"], + isMember: true, + } as any, + { + id: "2", + slug: "mobile", + name: "Mobile", + access: ["team:admin"], + isMember: true, + } as any, + ]); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions({ yes: true }), ui); + + expect(context?.team).toBe("mobile"); + }); + + test("sorts organization options alphabetically by name", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "z-org", name: "Alpha" }, + { id: "2", slug: "beta", name: "Beta" }, + { id: "3", slug: "a-org", name: "Alpha" }, + ]); + + const { ui, calls, respond } = createMockUI(); + respond.select("a-org"); + + const context = await resolveInitContext(makeOptions({ yes: false }), ui); + + expect(context?.org).toBe("a-org"); + const selectCall = calls.find((call) => call.kind === "select"); + expect(selectCall?.options).toEqual(["a-org", "z-org", "beta"]); + }); + + test("sorts organizations in the --yes error", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "z-org", name: "Zulu" }, + { id: "2", slug: "a-org", name: "Alpha" }, + ]); + + const { ui } = createMockUI(); + + await expect( + resolveInitContext(makeOptions({ yes: true }), ui) + ).rejects.toThrow("Multiple organizations found (a-org, z-org)."); + }); + + test("returns null when the user cancels an org selection", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "acme", name: "Acme" }, + { id: "2", slug: "beta", name: "Beta" }, + ]); + + const { ui, calls, respond } = createMockUI(); + respond.select(CANCELLED); + + const context = await resolveInitContext(makeOptions({ yes: false }), ui); + + expect(context).toBeNull(); + const cancelCall = calls.find((c) => c.kind === "cancel"); + expect(cancelCall?.kind === "cancel" && cancelCall.message).toBe( + "Setup cancelled." + ); + expect(feedbackOutcomes(calls)).toEqual(["cancelled"]); + }); + + test("surfaces 403 guidance when listOrganizations is forbidden", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockRejectedValueOnce( + new ApiError( + "Failed to list organizations", + 403, + "You do not have permission." + ) + ); + + const { ui, calls } = createMockUI(); + await expect( + resolveInitContext(makeOptions({ yes: true }), ui) + ).rejects.toThrow("403 Forbidden"); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("403 Forbidden"); + expect(errorCall?.message).toContain("sentry init /"); + }); + + test("preserves an organization-list 403 when OAuth recovery can run", async () => { + const error = new ApiError( + "Failed to list organizations", + 403, + "Missing org:read" + ); + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockRejectedValueOnce(error); + shouldDelegateScopeRecovery.mockResolvedValueOnce(true); + + const { ui } = createMockUI(); + + await expect( + resolveInitContext(makeOptions({ yes: false }), ui) + ).rejects.toBe(error); + }); + + test("surfaces 401 guidance when listOrganizations is unauthorized", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockRejectedValueOnce( + new ApiError("Failed to list organizations", 401, "Token expired") + ); + + const { ui, calls } = createMockUI(); + await expect( + resolveInitContext(makeOptions({ yes: true }), ui) + ).rejects.toThrow("401 Unauthorized"); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("401 Unauthorized"); + expect(errorCall?.message).toContain("Token expired"); + }); + + test("includes the auth token in the resolved context", async () => { + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.authToken).toBe("sntrys_test"); + }); + + test("sets isExplicitTeam:true when --team flag is provided", async () => { + resolveOrCreateTeamSpy.mockResolvedValue({ + slug: "backend", + source: "explicit", + } as any); + + const { ui } = createMockUI(); + const context = await resolveInitContext( + makeOptions({ team: "backend" }), + ui + ); + + expect(context?.isExplicitTeam).toBe(true); + expect(context?.team).toBe("backend"); + }); + + test("sets isExplicitTeam:false when no --team flag is provided", async () => { + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.isExplicitTeam).toBe(false); + }); + + test("keeps the org-scoped fallback for an unattended explicit-team 403", async () => { + resolveOrCreateTeamSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No team:admin access") + ); + + const { ui } = createMockUI(); + const context = await resolveInitContext( + makeOptions({ team: "backend", yes: true }), + ui + ); + + expect(context?.team).toBeUndefined(); + }); + + test("preserves an explicit-team 403 when OAuth recovery can run", async () => { + const error = new ApiError("Forbidden", 403, "No team:admin access"); + resolveOrCreateTeamSpy.mockRejectedValueOnce(error); + shouldDelegateScopeRecovery.mockResolvedValueOnce(true); + + const { ui } = createMockUI(); + + await expect( + resolveInitContext(makeOptions({ team: "backend", yes: false }), ui) + ).rejects.toBe(error); + }); + + test("swallows 403 from listTeams and resolves context with team:undefined", async () => { + listTeamsSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No team:read access") + ); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + // 403 is swallowed so the wizard can proceed to the org-scoped fallback + expect(context).not.toBeNull(); + expect(context?.team).toBeUndefined(); + expect(getOrganizationSpy).toHaveBeenCalledWith("acme"); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + }); + + test("preserves a 403 when OAuth scope recovery can run", async () => { + const error = new ApiError("Forbidden", 403, "No team:read access"); + listTeamsSpy.mockRejectedValueOnce(error); + shouldDelegateScopeRecovery.mockResolvedValueOnce(true); + + const { ui } = createMockUI(); + + await expect( + resolveInitContext(makeOptions({ yes: false }), ui) + ).rejects.toBe(error); + expect(shouldDelegateScopeRecovery).toHaveBeenCalledWith(error, { + unattended: false, + }); + }); + + test("preserves a recoverable 401 from team lookup", async () => { + const error = new ApiError("Unauthorized", 401, "Invalid token"); + listTeamsSpy.mockRejectedValueOnce(error); + shouldDelegateScopeRecovery.mockResolvedValueOnce(true); + + const { ui } = createMockUI(); + + await expect( + resolveInitContext(makeOptions({ yes: false }), ui) + ).rejects.toBe(error); + }); + + test("keeps the org-scoped fallback when OAuth recovery is unattended", async () => { + listTeamsSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No team:read access") + ); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions({ yes: true }), ui); + + expect(context?.team).toBeUndefined(); + expect(shouldDelegateScopeRecovery).toHaveBeenCalledWith( + expect.any(ApiError), + { + unattended: true, + } + ); + }); + + test("preserves rich org-not-found guidance when implicit team lookup returns 404", async () => { + resolveOrgPrefetchedSpy.mockResolvedValueOnce({ org: "missing-org" }); + listOrganizationsSpy.mockResolvedValueOnce([ + { id: "1", slug: "acme", name: "Acme" }, + { id: "2", slug: "beta", name: "Beta" }, + ]); + listTeamsSpy.mockRejectedValueOnce( + new ApiError("Not found", 404, "Organization not found") + ); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( + "Organization 'missing-org'" + ); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("Your organizations:"); + expect(errorCall?.message).toContain("acme"); + expect(errorCall?.message).toContain("beta"); + }); + + test("surfaces the enriched detail when implicit listTeams returns 401", async () => { + // member-disabled-over-limit: a 401 from listTeams must reach the user with + // its actionable detail, not a bare "Failed to list teams" + status line. + listTeamsSpy.mockRejectedValueOnce( + new ApiError( + "Failed to list teams", + 401, + "Your account is disabled in this organization because it is over its member limit." + ) + ); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow(); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("over its member limit"); + }); + + test("surfaces the enriched detail when explicit --team listTeams returns 401", async () => { + resolveOrCreateTeamSpy.mockRejectedValueOnce( + new ApiError( + "Failed to list teams", + 401, + "Your account is disabled in this organization because it is over its member limit." + ) + ); + + const { ui, calls } = createMockUI(); + await expect( + resolveInitContext(makeOptions({ team: "backend" }), ui) + ).rejects.toThrow(); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("over its member limit"); + }); + + test("passes a pre-rendered WizardError through team resolution unchanged", async () => { + listTeamsSpy.mockRejectedValueOnce( + new WizardError("custom preflight failure") + ); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( + "custom preflight failure" + ); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toBe("custom preflight failure"); + }); + + test.each([ + ["AuthError", new AuthError("expired")], + ["HostScopeError", new HostScopeError("host mismatch")], + ])("propagates %s without turning it into a wizard failure", async (_, error) => { + listTeamsSpy.mockRejectedValueOnce(error); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toBe(error); + + expect(calls.some((call) => call.kind === "log.error")).toBe(false); + expect(calls.some((call) => call.kind === "cancel")).toBe(false); + expect(calls.some((call) => call.kind === "feedback")).toBe(false); + }); + + test("surfaces a non-API error message from implicit team resolution", async () => { + listTeamsSpy.mockRejectedValueOnce(new Error("network down")); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( + "network down" + ); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("network down"); + }); + + test("fails early when listTeams is forbidden and member project creation is disabled", async () => { + listTeamsSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No team:read access") + ); + getOrganizationSpy.mockResolvedValueOnce({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read"], + allowMemberProjectCreation: false, + } as any); + + const { ui } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( + "Project creation is disabled for members" + ); + }); + + test("fails early when member project creation is disabled and no Team Admin team exists", async () => { + listTeamsSpy.mockResolvedValueOnce([]); + getOrganizationSpy.mockResolvedValueOnce({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read"], + allowMemberProjectCreation: false, + } as any); + + const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( + "Project creation is disabled for members" + ); + + const errorCall = calls.find( + (c): c is Extract => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("sentry init acme/"); + }); + + test("allows org-scoped creation when member creation is disabled but token has org:write", async () => { + listTeamsSpy.mockResolvedValueOnce([]); + getOrganizationSpy.mockResolvedValueOnce({ + id: "1", + slug: "acme", + name: "Acme", + access: ["org:write"], + allowMemberProjectCreation: false, + } as any); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.team).toBeUndefined(); + }); + + test("allows org-scoped creation when member creation is disabled but user is an admin (project:admin scope)", async () => { + listTeamsSpy.mockResolvedValueOnce([]); + getOrganizationSpy.mockResolvedValueOnce({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read", "project:write", "project:admin", "team:admin"], + allowMemberProjectCreation: false, + } as any); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.team).toBeUndefined(); + }); + + test("allows org-scoped creation when member creation is disabled but user has project:write scope", async () => { + listTeamsSpy.mockResolvedValueOnce([]); + getOrganizationSpy.mockResolvedValueOnce({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read", "project:write"], + allowMemberProjectCreation: false, + } as any); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.team).toBeUndefined(); + }); + + test("allows org-scoped creation when listTeams returns 403 and user has project:admin scope", async () => { + listTeamsSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No team:read access") + ); + getOrganizationSpy.mockResolvedValueOnce({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read", "project:write", "project:admin"], + allowMemberProjectCreation: false, + } as any); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.team).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/init/readiness.test.ts b/packages/cli/test/lib/init/readiness.test.ts new file mode 100644 index 000000000..f8261c83d --- /dev/null +++ b/packages/cli/test/lib/init/readiness.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { checkReadiness } from "../../../src/lib/init/readiness.js"; +import type { WizardUI } from "../../../src/lib/init/ui/types.js"; + +function makeUI(): { ui: WizardUI; errors: string[]; warns: string[] } { + const errors: string[] = []; + const warns: string[] = []; + const ui: WizardUI = { + intro: () => { + /* noop */ + }, + outro: () => { + /* noop */ + }, + cancel: () => { + /* noop */ + }, + feedback: () => { + /* noop */ + }, + banner: () => { + /* noop */ + }, + summary: () => { + /* noop */ + }, + log: { + info: () => { + /* noop */ + }, + warn: (m) => warns.push(m), + error: (m) => errors.push(m), + success: () => { + /* noop */ + }, + message: () => { + /* noop */ + }, + }, + spinner: () => ({ + start: () => { + /* noop */ + }, + message: () => { + /* noop */ + }, + stop: () => { + /* noop */ + }, + }), + select: () => Promise.reject(new Error("noop")), + multiselect: () => Promise.reject(new Error("noop")), + confirm: () => Promise.reject(new Error("noop")), + [Symbol.asyncDispose]: () => Promise.resolve(), + }; + return { ui, errors, warns }; +} + +const OK_RESPONSE = new Response(null, { status: 200 }); +const ERR_RESPONSE = new Response(null, { status: 503 }); + +let fetchSpy: ReturnType; + +beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); +}); + +afterEach(() => { + fetchSpy.mockRestore(); +}); + +describe("checkReadiness", () => { + test("resolves without error when the setup service is reachable", async () => { + fetchSpy.mockResolvedValue(OK_RESPONSE.clone()); + const { ui, errors, warns } = makeUI(); + await expect(checkReadiness(ui)).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); + expect(warns).toHaveLength(0); + }); + + test("resolves but logs a warning when the setup service is unreachable", async () => { + fetchSpy.mockRejectedValue(new Error("network failure")); + const { ui, errors, warns } = makeUI(); + await expect(checkReadiness(ui)).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); + expect(warns.length).toBeGreaterThanOrEqual(1); + }); + + test("resolves but logs a warning when the setup service returns non-ok status", async () => { + fetchSpy.mockResolvedValue(ERR_RESPONSE.clone()); + const { ui, errors, warns } = makeUI(); + await expect(checkReadiness(ui)).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); + expect(warns.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/cli/test/lib/init/spinner.test.ts b/packages/cli/test/lib/init/spinner.test.ts new file mode 100644 index 000000000..b2efcb0c0 --- /dev/null +++ b/packages/cli/test/lib/init/spinner.test.ts @@ -0,0 +1,74 @@ +import { Writable } from "node:stream"; +import { describe, expect, test } from "vitest"; +import { createWizardSpinner } from "../../../src/lib/init/spinner.js"; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in rendered terminal output +const ANSI_CSI_RE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in rendered terminal output +const ANSI_OSC_RE = /\u001B\][^\u0007]*\u0007/g; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI clear-screen escape sequence in rendered terminal output +const ANSI_CLEAR_RE = /\u001B\[(?:0)?J/; + +class CaptureStream extends Writable { + readonly chunks: string[] = []; + readonly isTTY = true; + readonly columns: number; + + constructor(columns = 80) { + super(); + this.columns = columns; + } + + _write( + chunk: string | Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + this.chunks.push(String(chunk)); + callback(); + } + + output(): string { + return this.chunks.join(""); + } +} + +function stripAnsi(value: string): string { + return value.replace(ANSI_CSI_RE, "").replace(ANSI_OSC_RE, ""); +} + +describe("createWizardSpinner", () => { + test("clears upward when repainting a multiline status block", () => { + const output = new CaptureStream(); + const spin = createWizardSpinner(output as unknown as NodeJS.WriteStream); + + spin.start("Reading files...\n├─ `settings.py`\n└─ `urls.py`"); + spin.message("Analyzing files...\n├─ `settings.py`\n└─ `urls.py`"); + spin.stop("Done"); + + const rendered = output.output(); + expect(rendered).toContain("\u001B[?25l"); + expect(rendered).toContain("\u001B[2A"); + expect(rendered).toMatch(ANSI_CLEAR_RE); + expect(rendered).toContain("\u001B[?25h"); + + const plain = stripAnsi(rendered); + expect(plain).toContain("Reading files..."); + expect(plain).toContain("│ ├─ settings.py"); + expect(plain).toContain("Analyzing files..."); + }); + + test("does not print a stale message when stopped with an empty string", () => { + const output = new CaptureStream(); + const spin = createWizardSpinner(output as unknown as NodeJS.WriteStream); + + spin.start("Selecting features"); + spin.stop(""); + + const plain = stripAnsi(output.output()); + expect(plain).toContain("Selecting features"); + expect(plain).not.toContain("◆ Selecting features"); + expect(plain).not.toContain("■ Selecting features"); + expect(plain).not.toContain("▲ Selecting features"); + }); +}); diff --git a/packages/cli/test/lib/init/stdin-reopen.test.ts b/packages/cli/test/lib/init/stdin-reopen.test.ts new file mode 100644 index 000000000..094550034 --- /dev/null +++ b/packages/cli/test/lib/init/stdin-reopen.test.ts @@ -0,0 +1,452 @@ +/** + * Tests for the `/dev/tty` forwarding install→teardown lifecycle. + * + * The production code opens a real `/dev/tty` fd via `openSync` and checks + * `isatty(0)` for the environment gate. To exercise the state transitions + * deterministically we pass `TtyDeps` that: + * + * - override `isTty` to return `true` (bun test runs with piped stdin, so + * the default predicate short-circuits the install path), + * - override `openTty` to return a `/dev/ptmx` fd — a pseudo-TTY master + * that `new ReadStream(fd)` accepts, with no keyboard side effects. + * + * `/dev/ptmx` is Linux-specific. Tests skip gracefully on platforms where it + * isn't available. + */ + +import { existsSync, openSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + closeFreshTtyForwarding, + forwardFreshTtyToStdin, +} from "../../../src/lib/init/stdin-reopen.js"; + +const HAS_PTMX = existsSync("/dev/ptmx"); + +/** + * Open a fresh `/dev/ptmx` fd for use as a pseudo-TTY fixture. The returned + * fd is owned by the `ReadStream` that the test attaches it to — + * `fresh.destroy()` inside teardown closes it. No explicit close needed + * from the test body. + */ +function makePtmxFd(): { fd: number } { + const fd = openSync("/dev/ptmx", "r+"); + return { fd }; +} + +/** + * Assign `process.stdin.isTTY` via `Object.defineProperty` so the test + * works regardless of whether the runtime defined `isTTY` as writable or + * readonly. On CI (Node/Bun with piped stdin), `isTTY` is a non-writable + * property and bare assignment throws `TypeError: Attempted to assign to + * readonly property`. `defineProperty` overrides the descriptor in-place. + */ +function setIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, "isTTY", { + value, + writable: true, + configurable: true, + }); +} + +/** + * `bun test` runs with piped stdin, so `process.stdin.setRawMode` is + * typically `undefined` (the method only exists on real TTY streams). + * The production code captures these methods at install time via + * `stdinHandle.setRawMode` etc., so we stub them on `process.stdin` before + * each test and restore after. This matches what the real Bun binary would + * provide when stdin is actually a TTY. + */ +type StdinStubState = { + restore: () => void; +}; + +function stubStdinTtyMethods(): StdinStubState { + const stdin = process.stdin as unknown as Record; + const keys = ["setRawMode", "pause", "resume", "_read"] as const; + const originals: Record = {}; + const hadKey: Record = {}; + for (const key of keys) { + hadKey[key] = key in stdin; + originals[key] = stdin[key]; + } + // Install test stubs. Each stub returns process.stdin so chainable-style + // callers (clack's setRawMode, etc.) behave sensibly. + stdin.setRawMode = (_mode: boolean) => process.stdin; + stdin.pause = () => process.stdin; + stdin.resume = () => process.stdin; + stdin._read = (_size: number) => { + // intentionally empty — test stub + }; + + return { + restore: () => { + for (const key of keys) { + if (hadKey[key]) { + stdin[key] = originals[key]; + } else { + delete stdin[key]; + } + } + }, + }; +} + +let stdinStub: StdinStubState | undefined; + +beforeEach(() => { + stdinStub = stubStdinTtyMethods(); +}); + +afterEach(() => { + // Defense-in-depth: if a test installs forwarding and throws before + // tearing down, reset the module state before the next test runs. + closeFreshTtyForwarding(); + stdinStub?.restore(); + stdinStub = undefined; +}); + +describe("closeFreshTtyForwarding (null-state paths)", () => { + test("is a no-op when forwarding was never installed", () => { + expect(() => closeFreshTtyForwarding()).not.toThrow(); + }); + + test("is idempotent across repeated calls", () => { + expect(() => { + closeFreshTtyForwarding(); + closeFreshTtyForwarding(); + closeFreshTtyForwarding(); + }).not.toThrow(); + }); +}); + +describe("forwardFreshTtyToStdin no-install paths", () => { + test("does not patch stdin methods when isTty predicate is false", () => { + const originalSetRawMode = process.stdin.setRawMode; + const handle = forwardFreshTtyToStdin({ isTty: () => false }); + // Handle is a Disposable but install was skipped — stdin untouched. + expect(handle).toBeDefined(); + expect(process.stdin.setRawMode).toBe(originalSetRawMode); + // Disposing the no-install handle is a safe no-op. + expect(() => handle[Symbol.dispose]()).not.toThrow(); + expect(process.stdin.setRawMode).toBe(originalSetRawMode); + }); + + test("does not patch stdin methods when the openTty factory throws", () => { + const originalSetRawMode = process.stdin.setRawMode; + const openTty = vi.fn(() => { + throw new Error("fake /dev/tty unavailable"); + }); + const handle = forwardFreshTtyToStdin({ isTty: () => true, openTty }); + expect(handle).toBeDefined(); + expect(openTty).toHaveBeenCalledTimes(1); + expect(process.stdin.setRawMode).toBe(originalSetRawMode); + }); +}); + +describe("forwardFreshTtyToStdin → closeFreshTtyForwarding round trip", () => { + if (!HAS_PTMX) { + test("skipped: /dev/ptmx unavailable on this platform", () => { + expect(HAS_PTMX).toBe(false); + }); + return; + } + + test("install captures and teardown restores stdin methods", () => { + const { fd } = makePtmxFd(); + const openTty = vi.fn(() => fd); + + const originalSetRawMode = process.stdin.setRawMode; + const originalPause = process.stdin.pause; + const originalResume = process.stdin.resume; + + const handle = forwardFreshTtyToStdin({ isTty: () => true, openTty }); + expect(handle).toBeDefined(); + expect(openTty).toHaveBeenCalledTimes(1); + + // After install, process.stdin methods are patched — they no longer + // match the pre-install references. + expect(process.stdin.setRawMode).not.toBe(originalSetRawMode); + expect(process.stdin.pause).not.toBe(originalPause); + expect(process.stdin.resume).not.toBe(originalResume); + + closeFreshTtyForwarding(); + + // After teardown the originals are restored by reference equality. + expect(process.stdin.setRawMode).toBe(originalSetRawMode); + expect(process.stdin.pause).toBe(originalPause); + expect(process.stdin.resume).toBe(originalResume); + }); + + test("isTTY restored to its pre-install value (backfill branch)", () => { + const { fd } = makePtmxFd(); + const stdin = process.stdin as { isTTY?: boolean }; + const previousIsTty = stdin.isTTY; + + // Force the backfill branch by clearing isTTY up-front. + setIsTTY(undefined); + + try { + const handle = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(handle).toBeDefined(); + // Install backfilled isTTY to true. + expect(stdin.isTTY).toBe(true); + + closeFreshTtyForwarding(); + // Teardown restored to the pre-install value (undefined). + expect(stdin.isTTY).toBeUndefined(); + } finally { + setIsTTY(previousIsTty); + } + }); + + test("isTTY untouched when already set (no-backfill branch)", () => { + const { fd } = makePtmxFd(); + const stdin = process.stdin as { isTTY?: boolean }; + const previousIsTty = stdin.isTTY; + setIsTTY(true); + + try { + const handle = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(handle).toBeDefined(); + expect(stdin.isTTY).toBe(true); + + closeFreshTtyForwarding(); + // backfilledIsTty was false, so teardown must not touch isTTY. + expect(stdin.isTTY).toBe(true); + } finally { + setIsTTY(previousIsTty); + } + }); + + test("re-install after teardown succeeds with fresh state", () => { + const first = makePtmxFd(); + const second = makePtmxFd(); + + const h1 = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => first.fd, + }); + expect(h1).toBeDefined(); + closeFreshTtyForwarding(); + + // Second install observes the newly-restored original methods as its + // capture target, so the cycle completes cleanly. + const h2 = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => second.fd, + }); + expect(h2).toBeDefined(); + closeFreshTtyForwarding(); + }); + + test("secondary forward call returns a no-op disposable", () => { + const { fd } = makePtmxFd(); + const stdin = process.stdin as { isTTY?: boolean }; + const previousIsTty = stdin.isTTY; + + try { + const h1 = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(h1).toBeDefined(); + + // Second call — already installed. Factory NOT called again. + const secondaryFactory = vi.fn(() => { + throw new Error("should not be invoked"); + }); + const h2 = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: secondaryFactory, + }); + expect(h2).toBeDefined(); + expect(secondaryFactory).not.toHaveBeenCalled(); + + // Disposing the secondary handle must NOT tear down the primary's + // state. stdin methods stay patched until the PRIMARY disposable fires. + const patchedSetRawMode = process.stdin.setRawMode; + h2[Symbol.dispose](); + expect(process.stdin.setRawMode).toBe(patchedSetRawMode); + + // Primary disposal now actually tears down. + h1[Symbol.dispose](); + } finally { + setIsTTY(previousIsTty); + } + }); + + test("teardown tolerates double-close of the same install", () => { + const { fd } = makePtmxFd(); + + const handle = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(handle).toBeDefined(); + + // First close tears down; second must be a no-op guarded by the + // installedState === null check at the top of closeFreshTtyForwarding. + closeFreshTtyForwarding(); + expect(() => closeFreshTtyForwarding()).not.toThrow(); + }); + + test("teardown tolerates raw mode being set before close", () => { + const { fd } = makePtmxFd(); + + const handle = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(handle).toBeDefined(); + + // Simulate clack calling setRawMode(true) mid-prompt, then the + // wizard throwing before clack's matching setRawMode(false) fires. + // The patched setRawMode routes to fresh.setRawMode on the real + // ptmx fd — exercising the ioctl path. + process.stdin.setRawMode(true); + + // Teardown should call fresh.setRawMode(false) before destroy. + // We can't observe the call directly without a deeper seam, but we + // verify teardown completes without throwing even after raw mode + // was set (covers the termios-restore try/catch). + expect(() => closeFreshTtyForwarding()).not.toThrow(); + }); + + test("teardown invokes restored pause to release stdin handle", () => { + // Regression for CLI-1DD: post-wizard `sentry init` hang. Our no-op + // pause patch (installed to dodge Bun's fd-0 EINVAL) silently + // swallowed clack's `rl.close() → input.pause()` call, leaving stdin + // ref'd. Teardown must invoke the restored original so the libuv + // event loop can drain. + const { fd } = makePtmxFd(); + + // Replace the beforeEach stub with a counting spy BEFORE install so + // the install captures it as `original.pause`. + let pauseCalls = 0; + const pauseSpy = (): NodeJS.ReadStream => { + pauseCalls += 1; + return process.stdin; + }; + (process.stdin as unknown as { pause: () => NodeJS.ReadStream }).pause = + pauseSpy; + + const handle = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(handle).toBeDefined(); + + // During install, process.stdin.pause is the patched no-op — clack's + // internal `rl.close() → input.pause()` would hit the no-op and the + // spy would never fire. Simulate that: + expect(process.stdin.pause).not.toBe(pauseSpy); + process.stdin.pause(); + expect(pauseCalls).toBe(0); + + closeFreshTtyForwarding(); + + // After teardown: pause is restored to our spy AND teardown invoked + // it exactly once to release the libuv handle. + expect(process.stdin.pause).toBe(pauseSpy); + expect(pauseCalls).toBe(1); + }); + + test("stdin is not flowing after teardown (releases event loop)", () => { + // Integration regression for CLI-1DD. Observes the actual stream + // state transition that blocks the event loop. Without the fix, + // `readableFlowing` stays `true` post-teardown and the process hangs + // until a keypress. + const { fd } = makePtmxFd(); + + // Route install's `original.pause` capture at the real + // `Readable.prototype.pause`, not the beforeEach no-op stub — so + // that invoking it actually transitions `readableFlowing: true → false`. + // Same for `.resume()`: used below to simulate clack's implicit flow. + const { Readable } = require("node:stream") as typeof import("node:stream"); + const stdinMut = process.stdin as unknown as { + pause: () => NodeJS.ReadStream; + resume: () => NodeJS.ReadStream; + }; + stdinMut.pause = Readable.prototype.pause as () => NodeJS.ReadStream; + stdinMut.resume = Readable.prototype.resume as () => NodeJS.ReadStream; + + // Put stdin into flowing mode. This is effectively what clack does + // indirectly via `readline.createInterface()` → `input.resume()`. + process.stdin.resume(); + + try { + expect( + (process.stdin as { readableFlowing?: boolean | null }).readableFlowing + ).toBe(true); + + const handle = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(handle).toBeDefined(); + + closeFreshTtyForwarding(); + + // `readableFlowing` values: null (initial), true (flowing), false + // (paused). After teardown we must be NOT true — otherwise the + // libuv event loop stays alive. + expect( + (process.stdin as { readableFlowing?: boolean | null }).readableFlowing + ).not.toBe(true); + } finally { + // Defensive: ensure we don't leak a flowing-mode stdin into the + // next test even if an expectation above threw. + Readable.prototype.pause.call(process.stdin); + } + }); +}); + +describe("using-declaration semantics", () => { + if (!HAS_PTMX) { + test("skipped: /dev/ptmx unavailable on this platform", () => { + expect(HAS_PTMX).toBe(false); + }); + return; + } + + test("`using` scope releases forwarding when the block exits", () => { + const { fd } = makePtmxFd(); + const originalSetRawMode = process.stdin.setRawMode; + + { + using tty = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(tty).toBeDefined(); + // Inside the block, setRawMode is patched. + expect(process.stdin.setRawMode).not.toBe(originalSetRawMode); + } + // Block exited → disposable fired → originals restored. + expect(process.stdin.setRawMode).toBe(originalSetRawMode); + }); + + test("`using` teardown fires even when the block throws", () => { + const { fd } = makePtmxFd(); + const originalSetRawMode = process.stdin.setRawMode; + + const run = (): void => { + using tty = forwardFreshTtyToStdin({ + isTty: () => true, + openTty: () => fd, + }); + expect(tty).toBeDefined(); + throw new Error("boom"); + }; + expect(run).toThrow("boom"); + // Throw unwound the `using` scope → disposable fired. + expect(process.stdin.setRawMode).toBe(originalSetRawMode); + }); +}); diff --git a/packages/cli/test/lib/init/tools/create-sentry-project.test.ts b/packages/cli/test/lib/init/tools/create-sentry-project.test.ts new file mode 100644 index 000000000..9f898ca4d --- /dev/null +++ b/packages/cli/test/lib/init/tools/create-sentry-project.test.ts @@ -0,0 +1,501 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +vi.mock("../../../../src/lib/scope-recovery.js", () => ({ + captureOAuthScopeRecoveryGate: vi.fn(), +})); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ApiError } from "../../../../src/lib/errors.js"; +import { + createSentryProject, + createSentryProjectTool, +} from "../../../../src/lib/init/tools/create-sentry-project.js"; +import { executeTool } from "../../../../src/lib/init/tools/registry.js"; +import type { + CreateSentryProjectPayload, + EnsureSentryProjectPayload, +} from "../../../../src/lib/init/types.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as scopeRecovery from "../../../../src/lib/scope-recovery.js"; + +vi.mock("../../../../src/lib/resolve-team.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../../src/lib/resolve-team.js") + >(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTeam from "../../../../src/lib/resolve-team.js"; + +function makePayload( + overrides?: Partial, + operation: CreateSentryProjectPayload["operation"] = "create-sentry-project" +): CreateSentryProjectPayload { + return { + type: "tool", + operation, + cwd: "/tmp/test", + params: { + name: "my-app", + platform: "javascript-react", + ...overrides, + }, + }; +} + +function makeEnsurePayload( + overrides?: Partial +): EnsureSentryProjectPayload { + return { + ...makePayload(overrides), + operation: "ensure-sentry-project", + }; +} + +const sampleAutoTeamResult = { + project: { + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any, + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + team_slug: "team-testuser", +}; + +let createProjectWithDsnSpy: ReturnType; +let createProjectWithAutoTeamSpy: ReturnType; +let getProjectSpy: ReturnType; +let tryGetPrimaryDsnSpy: ReturnType; +let resolveOrCreateTeamSpy: ReturnType; +let shouldDelegateScopeRecovery: ReturnType; + +beforeEach(() => { + shouldDelegateScopeRecovery = vi.fn().mockResolvedValue(false); + vi.mocked(scopeRecovery.captureOAuthScopeRecoveryGate).mockReturnValue({ + shouldDelegate: shouldDelegateScopeRecovery, + }); + createProjectWithDsnSpy = vi + .spyOn(apiClient, "createProjectWithDsn") + .mockResolvedValue({ + project: { + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any, + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + }); + createProjectWithAutoTeamSpy = vi + .spyOn(apiClient, "createProjectWithAutoTeam") + .mockResolvedValue(sampleAutoTeamResult); + getProjectSpy = vi.spyOn(apiClient, "getProject").mockResolvedValue({ + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any); + tryGetPrimaryDsnSpy = vi + .spyOn(apiClient, "tryGetPrimaryDsn") + .mockResolvedValue("https://abc@o1.ingest.sentry.io/42"); + resolveOrCreateTeamSpy = vi + .spyOn(resolveTeam, "resolveOrCreateTeam") + .mockResolvedValue({ + slug: "generated-team", + source: "auto-created", + } as any); +}); + +afterEach(() => { + createProjectWithDsnSpy.mockRestore(); + createProjectWithAutoTeamSpy.mockRestore(); + getProjectSpy.mockRestore(); + tryGetPrimaryDsnSpy.mockRestore(); + resolveOrCreateTeamSpy.mockRestore(); + vi.mocked(scopeRecovery.captureOAuthScopeRecoveryGate).mockReset(); +}); + +describe("createSentryProject", () => { + test("returns the pre-resolved existing project without creating", async () => { + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: undefined, + project: "my-app", + existingProject: { + orgSlug: "acme", + projectSlug: "my-app", + projectId: "42", + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + }, + }); + + expect(result.ok).toBe(true); + expect(result.message).toContain("Using existing project"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + }); + + test("accepts the legacy ensure-sentry-project alias", async () => { + const result = await createSentryProject(makeEnsurePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: undefined, + project: "my-app", + existingProject: { + orgSlug: "acme", + projectSlug: "my-app", + projectId: "42", + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + }, + }); + + expect(result.ok).toBe(true); + expect(result.message).toContain("Using existing project"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + test("returns error when project name produces an empty slug", async () => { + const result = await createSentryProject(makePayload({ name: "---" }), { + dryRun: false, + yes: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("produces an empty slug"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + test("creates a new project with the pre-resolved org and team", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme", + "platform", + expect.objectContaining({ + name: "my-app", + platform: "javascript-react", + }) + ); + }); + + test("re-checks for an existing project before creating when the slug is known", async () => { + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(result.message).toContain("Using existing project"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + }); + + test("surfaces lookup failures before creating when a known slug cannot be verified", async () => { + getProjectSpy.mockRejectedValueOnce(new Error("temporary failure")); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("temporary failure"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + test("returns dry-run placeholder project data", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + + const result = await createSentryProject(makePayload(), { + dryRun: true, + yes: true, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + orgSlug: "acme", + projectId: "(dry-run)", + }) + ); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + test("uses org-scoped auto-team creation when preflight did not resolve a team", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme", { + name: "my-app", + platform: "javascript-react", + }); + }); + + test("returns clear error with sentry-init guidance when org disables member creation", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError( + "Failed to create project: 403 Forbidden", + 403, + "Your organization has disabled this feature for members.", + undefined, + true + ) + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("disabled for members"); + expect(result.error).toContain("sentry init acme/"); + expect(result.error).not.toContain("Re-authenticate"); + }); + + test("tool describe uses payload.detail when provided", () => { + const payload = { ...makePayload(), detail: "Setting up my-app..." }; + expect(createSentryProjectTool.describe(payload)).toBe( + "Setting up my-app..." + ); + }); + + test("tool describe falls back to project name and platform", () => { + expect(createSentryProjectTool.describe(makePayload())).toContain("my-app"); + }); + + test("falls back to org-scoped endpoint on 403 from team-based creation", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme", { + name: "my-app", + platform: "javascript-react", + }); + }); + + test("suppresses fallback when team was set via --team (isExplicitTeam)", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: "backend", + isExplicitTeam: true, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + }); + + test("lets a recoverable 403 escape the real tool registry", async () => { + shouldDelegateScopeRecovery.mockResolvedValueOnce(true); + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const error = await executeTool(makePayload(), { + directory: "/tmp/test", + yes: false, + dryRun: false, + org: "acme", + team: undefined, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(403); + expect(shouldDelegateScopeRecovery).toHaveBeenCalledWith( + expect.any(ApiError), + { + unattended: false, + } + ); + }); + + test("keeps the tool fallback when OAuth recovery is unattended", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: true, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(shouldDelegateScopeRecovery).toHaveBeenCalledWith( + expect.any(ApiError), + { + unattended: true, + } + ); + }); + + test("keeps the policy-specific error when OAuth scopes are stale", async () => { + shouldDelegateScopeRecovery.mockResolvedValueOnce(true); + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError( + "Forbidden", + 403, + "Your organization has disabled this feature for members." + ) + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("disabled for members"); + expect(shouldDelegateScopeRecovery).not.toHaveBeenCalled(); + }); + + test("does not fall back on team-scoped policy 403", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError( + "Forbidden", + 403, + "Your organization has disabled this feature for members." + ) + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + expect(result.error).toContain("disabled for members"); + }); + + test("surfaces friendly 409 error when fallback project already exists", async () => { + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError("Conflict", 409, "Slug already in use") + ); + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + yes: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("already exists"); + }); + + // ── dry-run ────────────────────────────────────────────────────────────── + + test("does not resolve a team for org-scoped dry-run mode", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + + const result = await createSentryProject(makePayload(), { + dryRun: true, + yes: true, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/init/tools/file-changes.test.ts b/packages/cli/test/lib/init/tools/file-changes.test.ts new file mode 100644 index 000000000..eba19d30d --- /dev/null +++ b/packages/cli/test/lib/init/tools/file-changes.test.ts @@ -0,0 +1,693 @@ +import { + chmodSync, + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { applyPatchset } from "../../../../src/lib/init/tools/apply-patchset.js"; +import { applyPreparedFileChanges } from "../../../../src/lib/init/tools/file-changes/apply.js"; +import { prepareFileChanges } from "../../../../src/lib/init/tools/file-changes/prepare.js"; + +const AUTH_TOKEN = "sntrys_test_token_123"; + +function request(cwd: string, patches: Record[]): unknown { + return { + cwd, + operation: "apply-patchset", + params: { patches }, + type: "tool", + }; +} + +describe("apply file changes", () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), "init-file-changes-")); + }); + + afterEach(() => { + rmSync(directory, { force: true, recursive: true }); + }); + + test("prepares the entire batch before writing any file", async () => { + const result = await applyPatchset( + request(directory, [ + { action: "create", patch: "created\n", path: "created.txt" }, + { + action: "modify", + edits: [{ newString: "new", oldString: "old" }], + path: "missing.txt", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { + applied: [], + failed: { code: "missing_file", path: "missing.txt" }, + }, + ok: false, + }); + expect(() => readFileSync(path.join(directory, "created.txt"))).toThrow(); + }); + + test("never overwrites an existing create target", async () => { + const target = path.join(directory, "existing.txt"); + writeFileSync(target, "original\n"); + + const result = await applyPatchset( + request(directory, [ + { action: "create", patch: "replacement\n", path: "existing.txt" }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { failed: { code: "already_exists" } }, + ok: false, + }); + expect(readFileSync(target, "utf-8")).toBe("original\n"); + }); + + test("dry-run performs real preparation without writing", async () => { + const valid = await applyPatchset( + request(directory, [ + { action: "create", patch: "preview\n", path: "preview.txt" }, + ]), + { authToken: undefined, dryRun: true } + ); + const invalid = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "new", oldString: "old" }], + path: "missing.txt", + }, + ]), + { authToken: undefined, dryRun: true } + ); + + expect(valid).toMatchObject({ data: { dryRun: true }, ok: true }); + expect(invalid).toMatchObject({ + data: { failed: { code: "missing_file" } }, + ok: false, + }); + expect(() => readFileSync(path.join(directory, "preview.txt"))).toThrow(); + }); + + test("rejects fuzzy anchor matches instead of replacing different code", async () => { + const target = path.join(directory, "setup.ts"); + const original = [ + "function setup() {", + " const actual = initializeProduction();", + " return actual;", + "}", + ].join("\n"); + writeFileSync(target, original); + + const result = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [ + { + newString: "replacement();", + oldString: [ + "function setup() {", + " const guessed = initializeTest();", + " return guessed;", + "}", + ].join("\n"), + }, + ], + path: "setup.ts", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { failed: { code: "edit_not_found" } }, + ok: false, + }); + expect(readFileSync(target, "utf-8")).toBe(original); + }); + + test("preserves CRLF and a UTF-8 BOM", async () => { + const target = path.join(directory, "config.ts"); + writeFileSync(target, "\uFEFFfirst\r\nsecond\r\nthird\r\n"); + + const result = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "changed\n", oldString: "second\n" }], + path: "config.ts", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(readFileSync(target, "utf-8")).toBe( + "\uFEFFfirst\r\nchanged\r\nthird\r\n" + ); + }); + + test("rejects a stale modify prepared from older content", async () => { + const target = path.join(directory, "config.ts"); + writeFileSync(target, "const value = 1;\n"); + const prepared = await prepareFileChanges(directory, [ + { + action: "modify", + edits: [ + { newString: "const value = 2;", oldString: "const value = 1;" }, + ], + path: "config.ts", + }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + writeFileSync(target, "const value = 3;\n"); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { failed: { code: "stale_content" } }, + ok: false, + }); + expect(readFileSync(target, "utf-8")).toBe("const value = 3;\n"); + }); + + test("rejects a same-content file replaced after preparation", async () => { + const target = path.join(directory, "config.ts"); + const original = path.join(directory, "config.original.ts"); + writeFileSync(target, "const value = 1;\n"); + const prepared = await prepareFileChanges(directory, [ + { + action: "modify", + edits: [ + { newString: "const value = 2;", oldString: "const value = 1;" }, + ], + path: "config.ts", + }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + renameSync(target, original); + writeFileSync(target, "const value = 1;\n"); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { failed: { code: "stale_content" } }, + ok: false, + }); + expect(readFileSync(target, "utf-8")).toBe("const value = 1;\n"); + expect(readFileSync(original, "utf-8")).toBe("const value = 1;\n"); + }); + + test("reports unavoidable write-time partial application", async () => { + const prepared = await prepareFileChanges(directory, [ + { action: "create", content: "first\n", path: "first.txt" }, + { + action: "create", + content: "second\n", + path: "blocked/second.txt", + }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + writeFileSync(path.join(directory, "blocked"), "not a directory\n"); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { + applied: [{ action: "create", path: "first.txt" }], + failed: { code: "stale_content", path: "blocked/second.txt" }, + }, + ok: false, + }); + expect(readFileSync(path.join(directory, "first.txt"), "utf-8")).toBe( + "first\n" + ); + }); + + test.skipIf(process.platform === "win32")( + "maps an I/O failure after an applied change to write_failed", + async () => { + const blockedDirectory = path.join(directory, "blocked"); + mkdirSync(blockedDirectory); + const prepared = await prepareFileChanges(directory, [ + { action: "create", content: "first\n", path: "first.txt" }, + { + action: "create", + content: "second\n", + path: "blocked/second.txt", + }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + chmodSync(blockedDirectory, 0o500); + + const result = await applyPreparedFileChanges( + prepared.changes, + false + ).finally(() => chmodSync(blockedDirectory, 0o700)); + + expect(result).toMatchObject({ + data: { + applied: [{ action: "create", path: "first.txt" }], + failed: { code: "write_failed", path: "blocked/second.txt" }, + }, + ok: false, + }); + expect(existsSync(path.join(blockedDirectory, "second.txt"))).toBe(false); + } + ); + + test("injects auth locally without returning it in tool data", async () => { + const result = await applyPatchset( + request(directory, [ + { + action: "create", + patch: "SENTRY_AUTH_TOKEN=\n", + path: ".env.sentry-build-plugin", + }, + ]), + { authToken: AUTH_TOKEN, dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(JSON.stringify(result)).not.toContain(AUTH_TOKEN); + expect( + readFileSync(path.join(directory, ".env.sentry-build-plugin"), "utf-8") + ).toContain(AUTH_TOKEN); + }); + + test("rejects malformed and duplicate file-change requests", async () => { + const malformed = await applyPatchset( + request(directory, [{ action: "modify", edits: [], path: "config.ts" }]), + { authToken: undefined, dryRun: false } + ); + const duplicate = await applyPatchset( + request(directory, [ + { action: "create", patch: "first", path: "same.txt" }, + { action: "create", patch: "second", path: "same.txt" }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(malformed).toMatchObject({ ok: false }); + expect(malformed.error).toContain("edits must not be empty"); + expect(duplicate).toMatchObject({ + data: { failed: { code: "duplicate_target" } }, + ok: false, + }); + }); + + test("preserves an existing file mode on modify", async () => { + const target = path.join(directory, "script.sh"); + writeFileSync(target, "echo old\n"); + chmodSync(target, 0o755); + + const result = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "echo new", oldString: "echo old" }], + path: "script.sh", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(readFileSync(target, "utf-8")).toBe("echo new\n"); + expect(statSync(target).mode % 0o1000).toBe(0o755); + }); + + test("creates nested directories only during the apply phase", async () => { + const prepared = await prepareFileChanges(directory, [ + { action: "create", content: "content\n", path: "nested/file.txt" }, + ]); + expect(prepared.ok).toBe(true); + expect(existsSync(path.join(directory, "nested"))).toBe(false); + if (!prepared.ok) { + return; + } + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result.ok).toBe(true); + expect(readFileSync(path.join(directory, "nested/file.txt"), "utf-8")).toBe( + "content\n" + ); + }); + + test("keeps delete idempotent but refuses to delete a new target", async () => { + const prepared = await prepareFileChanges(directory, [ + { action: "delete", path: "missing.txt" }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + writeFileSync(path.join(directory, "missing.txt"), "appeared\n"); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { failed: { code: "stale_content" } }, + ok: false, + }); + expect(readFileSync(path.join(directory, "missing.txt"), "utf-8")).toBe( + "appeared\n" + ); + }); + + test("rejects ambiguous exact edits", async () => { + const target = path.join(directory, "duplicate.txt"); + writeFileSync(target, "same\nsame\n"); + + const result = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "changed", oldString: "same" }], + path: "duplicate.txt", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { failed: { code: "edit_ambiguous" } }, + ok: false, + }); + expect(readFileSync(target, "utf-8")).toBe("same\nsame\n"); + }); + + test("rejects overlapping and invalid no-op matches", async () => { + writeFileSync(path.join(directory, "overlap.txt"), "aaa"); + + const overlapping = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "b", oldString: "aa" }], + path: "overlap.txt", + }, + ]), + { authToken: undefined, dryRun: false } + ); + const absentNoOp = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "missing", oldString: "missing" }], + path: "overlap.txt", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(overlapping).toMatchObject({ + data: { failed: { code: "edit_ambiguous" } }, + ok: false, + }); + expect(absentNoOp).toMatchObject({ + data: { failed: { code: "edit_not_found" } }, + ok: false, + }); + expect(readFileSync(path.join(directory, "overlap.txt"), "utf-8")).toBe( + "aaa" + ); + }); + + test("rejects ancestor and descendant targets before writing", async () => { + const result = await applyPatchset( + request(directory, [ + { action: "create", patch: "file", path: "nested" }, + { action: "create", patch: "child", path: "nested/child.txt" }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { applied: [], failed: { code: "path_conflict" } }, + ok: false, + }); + expect(existsSync(path.join(directory, "nested"))).toBe(false); + }); + + test("composes repeated modifies in their original order", async () => { + const target = path.join(directory, "config.ts"); + writeFileSync(target, "const value = 1;\n"); + + const result = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [ + { newString: "const value = 2;", oldString: "const value = 1;" }, + ], + path: "config.ts", + }, + { + action: "modify", + edits: [ + { newString: "const value = 3;", oldString: "const value = 2;" }, + ], + path: "config.ts", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(readFileSync(target, "utf-8")).toBe("const value = 3;\n"); + }); + + test("revalidates path containment immediately before writing", async () => { + const outside = mkdtempSync(path.join(tmpdir(), "init-file-outside-")); + const prepared = await prepareFileChanges(directory, [ + { action: "create", content: "blocked\n", path: "nested/file.txt" }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + symlinkSync(outside, path.join(directory, "nested")); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { failed: { code: "stale_content" } }, + ok: false, + }); + expect(existsSync(path.join(outside, "file.txt"))).toBe(false); + rmSync(outside, { force: true, recursive: true }); + }); + + test("rejects a project root symlink retargeted after preparation", async () => { + const originalRoot = path.join(directory, "original"); + const outside = mkdtempSync(path.join(tmpdir(), "init-root-outside-")); + const rootLink = path.join(directory, "project"); + mkdirSync(originalRoot); + symlinkSync(originalRoot, rootLink); + const prepared = await prepareFileChanges(rootLink, [ + { action: "create", content: "blocked\n", path: "file.txt" }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + rmSync(rootLink); + symlinkSync(outside, rootLink); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { failed: { code: "stale_content" } }, + ok: false, + }); + expect(existsSync(path.join(outside, "file.txt"))).toBe(false); + rmSync(outside, { force: true, recursive: true }); + }); + + test("rejects filesystem aliases that target the same file", async () => { + const realDirectory = path.join(directory, "real"); + mkdirSync(realDirectory); + symlinkSync(realDirectory, path.join(directory, "alias")); + + const result = await applyPatchset( + request(directory, [ + { action: "create", patch: "first\n", path: "real/file.txt" }, + { action: "create", patch: "second\n", path: "alias/file.txt" }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { applied: [], failed: { code: "path_conflict" } }, + ok: false, + }); + expect(existsSync(path.join(realDirectory, "file.txt"))).toBe(false); + }); + + test.skipIf(process.platform === "win32")( + "rejects hard-link aliases before writing", + async () => { + const first = path.join(directory, "first.txt"); + const second = path.join(directory, "second.txt"); + writeFileSync(first, "old\n"); + linkSync(first, second); + + const result = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "first", oldString: "old" }], + path: "first.txt", + }, + { + action: "modify", + edits: [{ newString: "second", oldString: "old" }], + path: "second.txt", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { applied: [], failed: { code: "path_conflict" } }, + ok: false, + }); + expect(readFileSync(first, "utf-8")).toBe("old\n"); + expect(readFileSync(second, "utf-8")).toBe("old\n"); + } + ); + + test("uses exclusive create at apply time", async () => { + const prepared = await prepareFileChanges(directory, [ + { action: "create", content: "new\n", path: "created-later.txt" }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + const target = path.join(directory, "created-later.txt"); + writeFileSync(target, "external\n"); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { failed: { code: "stale_content" } }, + ok: false, + }); + expect(readFileSync(target, "utf-8")).toBe("external\n"); + }); + + test("applies a successful create, modify, and delete batch", async () => { + writeFileSync(path.join(directory, "modify.txt"), "old\n"); + writeFileSync(path.join(directory, "delete.txt"), "remove\n"); + + const result = await applyPatchset( + request(directory, [ + { action: "create", patch: "created\n", path: "create.txt" }, + { + action: "modify", + edits: [{ newString: "new", oldString: "old" }], + path: "modify.txt", + }, + { action: "delete", path: "delete.txt" }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(readFileSync(path.join(directory, "create.txt"), "utf-8")).toBe( + "created\n" + ); + expect(readFileSync(path.join(directory, "modify.txt"), "utf-8")).toBe( + "new\n" + ); + expect(existsSync(path.join(directory, "delete.txt"))).toBe(false); + }); + + test("rejects deleting content changed after preparation", async () => { + const target = path.join(directory, "delete.txt"); + writeFileSync(target, "original\n"); + const prepared = await prepareFileChanges(directory, [ + { action: "delete", path: "delete.txt" }, + ]); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + writeFileSync(target, "changed\n"); + + const result = await applyPreparedFileChanges(prepared.changes, false); + + expect(result).toMatchObject({ + data: { failed: { code: "stale_content" } }, + ok: false, + }); + expect(readFileSync(target, "utf-8")).toBe("changed\n"); + }); + + test("does not expose source content in an edit failure", async () => { + const secret = "PRIVATE_VALUE_NOT_FOR_TELEMETRY"; + writeFileSync(path.join(directory, "config.ts"), `${secret}\n`); + + const result = await applyPatchset( + request(directory, [ + { + action: "modify", + edits: [{ newString: "replacement", oldString: "missing" }], + path: "config.ts", + }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result.ok).toBe(false); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + test("rejects paths that escape the project", async () => { + const result = await applyPatchset( + request(directory, [ + { action: "create", patch: "outside", path: "../outside.txt" }, + ]), + { authToken: undefined, dryRun: false } + ); + + expect(result).toMatchObject({ + data: { failed: { code: "invalid_path" } }, + ok: false, + }); + }); +}); diff --git a/packages/cli/test/lib/init/tools/filesystem-tools.test.ts b/packages/cli/test/lib/init/tools/filesystem-tools.test.ts new file mode 100644 index 000000000..feb0ee416 --- /dev/null +++ b/packages/cli/test/lib/init/tools/filesystem-tools.test.ts @@ -0,0 +1,1306 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as dsnIndex from "../../../../src/lib/dsn/index.js"; +import { executeTool } from "../../../../src/lib/init/tools/registry.js"; +import type { + ResolvedInitContext, + ToolPayload, +} from "../../../../src/lib/init/types.js"; +import { precomputeDirListing } from "../../../../src/lib/init/workflow-inputs.js"; + +function makeContext(directory: string): ResolvedInitContext { + return { + directory, + yes: true, + dryRun: false, + org: "acme", + team: "platform", + authToken: "sntrys_test_token_123", + }; +} + +let testDir: string; +let detectDsnSpy: ReturnType; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join("/tmp", "init-tools-")); + detectDsnSpy = vi.spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); +}); + +afterEach(() => { + detectDsnSpy.mockRestore(); + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +describe("filesystem tools", () => { + test("rejects tool execution when cwd escapes the project directory", async () => { + const payload = { + type: "tool", + operation: "list-dir", + cwd: "/", + params: { path: "." }, + } as ToolPayload; + + const result = await executeTool(payload, makeContext(testDir)); + + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); + + test("rejects an external cwd symlink for every filesystem and shell tool", async () => { + const outsideDir = fs.mkdtempSync( + path.join(path.dirname(testDir), "init-tools-outside-") + ); + const escapedCwd = path.join(testDir, "escape"); + fs.writeFileSync(path.join(outsideDir, "sentinel.txt"), "OUTSIDE\n"); + fs.symlinkSync(outsideDir, escapedCwd, "dir"); + const payloads: ToolPayload[] = [ + { + type: "tool", + operation: "list-dir", + cwd: escapedCwd, + params: { path: "." }, + }, + { + type: "tool", + operation: "read-files", + cwd: escapedCwd, + params: { paths: ["sentinel.txt"], resultVersion: 2 }, + }, + { + type: "tool", + operation: "file-exists-batch", + cwd: escapedCwd, + params: { paths: ["sentinel.txt"] }, + }, + { + type: "tool", + operation: "grep", + cwd: escapedCwd, + params: { searches: [{ pattern: "OUTSIDE" }] }, + }, + { + type: "tool", + operation: "glob", + cwd: escapedCwd, + params: { patterns: ["**/*"] }, + }, + { + type: "tool", + operation: "run-commands", + cwd: escapedCwd, + params: { commands: ["node --version"] }, + }, + { + type: "tool", + operation: "apply-patchset", + cwd: escapedCwd, + params: { + patches: [ + { + action: "create", + path: "created-by-tool.txt", + patch: "created outside the project\n", + }, + ], + }, + }, + ]; + + try { + for (const payload of payloads) { + const result = await executeTool(payload, makeContext(testDir)); + expect(result.ok, payload.operation).toBe(false); + expect(result.error, payload.operation).toContain( + "outside project directory" + ); + } + expect(fs.existsSync(path.join(outsideDir, "created-by-tool.txt"))).toBe( + false + ); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + test("allows a cwd symlink that resolves inside the selected project", async () => { + fs.mkdirSync(path.join(testDir, "real", "nested"), { recursive: true }); + fs.writeFileSync( + path.join(testDir, "real", "nested", "inside.txt"), + "inside\n" + ); + fs.symlinkSync( + path.join(testDir, "real"), + path.join(testDir, "alias"), + "dir" + ); + + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: path.join(testDir, "alias"), + params: { paths: ["nested/inside.txt"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "nested/inside.txt": { + content: "inside\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + }); + + test("rejects sibling-prefix and missing cwd values", async () => { + const siblingDir = `${testDir}-sibling`; + fs.mkdirSync(siblingDir); + try { + for (const cwd of [siblingDir, path.join(testDir, "missing")]) { + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd, + params: { path: "." }, + }, + makeContext(testDir) + ); + expect(result.ok, cwd).toBe(false); + expect(result.error, cwd).toContain("outside project directory"); + } + } finally { + fs.rmSync(siblingDir, { recursive: true, force: true }); + } + }); + + test("lists and precomputes directory contents", async () => { + fs.writeFileSync(path.join(testDir, "index.ts"), "export {};\n"); + fs.mkdirSync(path.join(testDir, "src")); + fs.writeFileSync( + path.join(testDir, "src", "app.ts"), + "console.log('x');\n" + ); + + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 3 }, + }, + makeContext(testDir) + ); + const entries = (result.data as { entries: Array<{ path: string }> }) + .entries; + const precomputed = await precomputeDirListing(testDir); + + expect(result.ok).toBe(true); + expect(entries.map((entry) => entry.path)).toContain("src/app.ts"); + expect(entries.find((entry) => entry.path === "src/app.ts")).toMatchObject({ + size: 18, + type: "file", + }); + expect(precomputed.map((entry) => entry.path)).toContain("src/app.ts"); + }); + + test("reports when a bounded recursive listing is incomplete", async () => { + fs.mkdirSync(path.join(testDir, "nested", "deeper"), { recursive: true }); + fs.writeFileSync(path.join(testDir, "nested", "deeper", "app.ts"), "x"); + fs.writeFileSync(path.join(testDir, "root.ts"), "x"); + + const byDepth = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 0, maxEntries: 100 }, + }, + makeContext(testDir) + ); + const byEntries = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 10, maxEntries: 1 }, + }, + makeContext(testDir) + ); + + expect(byDepth.data).toMatchObject({ truncated: true }); + expect(byEntries.data).toMatchObject({ truncated: true }); + expect((byEntries.data as { entries: unknown[] }).entries).toHaveLength(1); + }); + + test("reports an unreadable directory as an incomplete listing", async () => { + const readdirSpy = vi + .spyOn(fs.promises, "readdir") + .mockRejectedValueOnce(new Error("permission denied")); + + try { + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true }, + }, + makeContext(testDir) + ); + + expect(result).toMatchObject({ + data: { entries: [], truncated: true }, + ok: true, + }); + } finally { + readdirSpy.mockRestore(); + } + }); + + test.runIf(process.platform !== "win32")( + "lists special files without opening them or attaching a size", + async () => { + const fifoPath = path.join(testDir, "stream.pipe"); + execFileSync("mkfifo", [fifoPath]); + fs.writeFileSync(path.join(testDir, "regular.ts"), "export {};\n"); + const openSpy = vi.spyOn(fs, "openSync"); + const readSpy = vi.spyOn(fs, "readSync"); + + try { + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: "." }, + }, + makeContext(testDir) + ); + const entry = ( + result.data as { entries: Record[] } + ).entries.find(({ path: entryPath }) => entryPath === "stream.pipe"); + const regular = ( + result.data as { entries: Record[] } + ).entries.find(({ path: entryPath }) => entryPath === "regular.ts"); + + expect(entry).toEqual({ + name: "stream.pipe", + path: "stream.pipe", + type: "file", + }); + expect(regular).toMatchObject({ size: 11, type: "file" }); + expect(openSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + readSpy.mockRestore(); + } + } + ); + + test("reads files and checks existence in batches", async () => { + fs.writeFileSync(path.join(testDir, "exists.txt"), "hello"); + + const readResult = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["exists.txt", "missing.txt"], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + const existsResult = await executeTool( + { + type: "tool", + operation: "file-exists-batch", + cwd: testDir, + params: { paths: ["exists.txt", "missing.txt"] }, + }, + makeContext(testDir) + ); + + expect((readResult.data as any).files["exists.txt"]).toEqual({ + content: "hello", + status: "ok", + truncated: false, + }); + expect((readResult.data as any).files["missing.txt"]).toEqual({ + error: "not-found", + status: "error", + }); + expect((existsResult.data as any).exists["exists.txt"]).toBe(true); + expect((existsResult.data as any).exists["missing.txt"]).toBe(false); + }); + + test("returns independent bounded V2 line ranges", async () => { + const lines = Array.from( + { length: 8 }, + (_, index) => `line-${index + 1}:${"x".repeat(19_990)}\n` + ); + fs.writeFileSync(path.join(testDir, "large.txt"), lines.join("")); + + const first = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["large.txt"], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + const later = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["large.txt"], + resultVersion: 2, + startLine: 5, + }, + }, + makeContext(testDir) + ); + + expect(first.data).toEqual({ + files: { + "large.txt": { + content: lines.slice(0, 2).join(""), + status: "ok", + truncated: true, + }, + }, + version: 2, + }); + expect(later.data).toEqual({ + files: { + "large.txt": { + content: lines.slice(4, 6).join(""), + status: "ok", + truncated: true, + }, + }, + version: 2, + }); + }); + + test("preserves exact line endings in V2 snapshots", async () => { + const firstLine = `${"x".repeat(39_997)}\r\n`; + fs.writeFileSync( + path.join(testDir, "windows.txt"), + `${firstLine}second\r\n` + ); + + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["windows.txt"], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "windows.txt": { + content: firstLine, + status: "ok", + truncated: true, + }, + }, + version: 2, + }); + }); + + test("finds a continuation and fills its window after short descriptor reads", async () => { + const filePath = path.join(testDir, "short-reads.txt"); + const content = "first\nsecond\nthird\n"; + fs.writeFileSync(filePath, content); + const actualHandle = await fs.promises.open(filePath, "r"); + const shortRead = vi.fn( + ( + buffer: Buffer, + offset: number, + length: number, + position: number | null + ) => actualHandle.read(buffer, offset, Math.min(length, 3), position) + ); + const openSpy = vi.spyOn(fs.promises, "open").mockResolvedValue({ + close: vi.fn().mockResolvedValue(undefined), + read: shortRead, + stat: () => actualHandle.stat(), + } as unknown as fs.promises.FileHandle); + + try { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["short-reads.txt"], + resultVersion: 2, + startLine: 2, + }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "short-reads.txt": { + content: "second\nthird\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + expect(shortRead.mock.calls.length).toBeGreaterThan(1); + } finally { + openSpy.mockRestore(); + await actualHandle.close(); + } + }); + + test("rejects malformed V2 read requests before local I/O", async () => { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: undefined, + } as unknown as ToolPayload, + makeContext(testDir) + ); + + expect(result).toEqual({ + error: "read-files params must be an object", + ok: false, + }); + }); + + test("rejects read requests without the V2 result contract", async () => { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: ["package.json"] }, + } as unknown as ToolPayload, + makeContext(testDir) + ); + + expect(result).toEqual({ + error: "read-files requires resultVersion 2", + ok: false, + }); + }); + + test("canonicalizes slash styles while preserving ordinary spaces", async () => { + fs.mkdirSync(path.join(testDir, "dir with spaces", "nested dir"), { + recursive: true, + }); + fs.mkdirSync(path.join(testDir, "posix")); + fs.writeFileSync( + path.join(testDir, "dir with spaces", "nested dir", "file name.ts"), + "mixed separators\n" + ); + fs.writeFileSync(path.join(testDir, "posix", "file.ts"), "posix path\n"); + + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["dir with spaces\\nested dir/file name.ts", "posix/file.ts"], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "dir with spaces/nested dir/file name.ts": { + content: "mixed separators\n", + status: "ok", + truncated: false, + }, + "posix/file.ts": { + content: "posix path\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + }); + + test("rejects non-portable read paths before opening requested files", async () => { + const invalidPaths = [ + "/etc/passwd", + "\\etc\\passwd", + "C:/repo/package.json", + "C:\\repo\\package.json", + "C:repo/package.json", + "\\\\server\\share\\package.json", + "//server/share/package.json", + "../outside.txt", + "apps/../../outside.txt", + "./package.json", + "apps/./package.json", + "apps//package.json", + "apps\\\\package.json", + "apps/", + "package\0.json", + "package\n.json", + "package\u007f.json", + "folder /package.json", + "package.json.", + "CON", + "con.txt", + "apps/NUL.json", + "COM1", + "lpt9.log", + "package.json:secret", + ]; + const openSpy = vi.spyOn(fs.promises, "open"); + + try { + for (const filePath of invalidPaths) { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: [filePath], resultVersion: 2 }, + }, + makeContext(testDir) + ); + + expect(result, filePath).toEqual({ + error: + "read-files paths must be portable filesystem-root-relative paths without aliases", + ok: false, + }); + } + expect(openSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + } + }); + + test("rejects duplicate aliases after path normalization", async () => { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["apps/web/package.json", "apps\\web\\package.json"], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + + expect(result).toEqual({ + error: "read-files paths must be unique after path normalization", + ok: false, + }); + }); + + test("reads regular metadata files while preserving sandbox and text bounds", async () => { + fs.writeFileSync(path.join(testDir, ".env.local"), "SECRET=value\n"); + fs.writeFileSync( + path.join(testDir, ".netrc"), + "machine example.test login user password secret\n" + ); + fs.writeFileSync(path.join(testDir, ".pypirc"), "token=secret\n"); + fs.writeFileSync( + path.join(testDir, ".npmrc"), + "//registry/:_authToken=secret\n" + ); + fs.writeFileSync(path.join(testDir, "binary.txt"), Buffer.from([0, 1, 2])); + + const environment = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: [".env.local"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + const binary = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: ["binary.txt"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + const packageMetadata = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: [".pypirc"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + const netrc = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: [".netrc"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + const npmConfig = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: [".npmrc"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + expect(environment.data).toEqual({ + files: { + ".env.local": { + content: "SECRET=value\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + expect(packageMetadata.data).toEqual({ + files: { + ".pypirc": { + content: "token=secret\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + expect(netrc.data).toEqual({ + files: { + ".netrc": { + content: "machine example.test login user password secret\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + expect(npmConfig.data).toEqual({ + files: { + ".npmrc": { + content: "//registry/:_authToken=secret\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + expect(binary.data).toEqual({ + files: { "binary.txt": { error: "not-text", status: "error" } }, + version: 2, + }); + }); + + test("rejects aliases to sensitive files", async () => { + fs.writeFileSync( + path.join(testDir, ".netrc"), + "machine example.test login user password secret\n" + ); + fs.symlinkSync(".netrc", path.join(testDir, "credentials.txt")); + fs.symlinkSync(".netrc", path.join(testDir, ".pypirc")); + + const alias = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: ["credentials.txt"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + const sensitiveNameAlias = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: [".pypirc"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + + expect(alias.data).toEqual({ + files: { + "credentials.txt": { error: "unreadable", status: "error" }, + }, + version: 2, + }); + expect(sensitiveNameAlias.data).toEqual({ + files: { ".pypirc": { error: "unreadable", status: "error" } }, + version: 2, + }); + }); + + test("rejects regular-file symlinks that escape the project root", async () => { + const outsideDir = fs.mkdtempSync(path.join("/tmp", "init-tools-outside-")); + const sentinel = "OUTSIDE_PROJECT_SENTINEL"; + try { + const outsideFile = path.join(outsideDir, "outside.txt"); + fs.writeFileSync(outsideFile, sentinel); + fs.symlinkSync(outsideFile, path.join(testDir, "inside.txt")); + + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: ["inside.txt"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { "inside.txt": { error: "unreadable", status: "error" } }, + version: 2, + }); + expect(JSON.stringify(result)).not.toContain(sentinel); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + test("finds later lines without exposing a cursor protocol", async () => { + fs.writeFileSync( + path.join(testDir, "deep.txt"), + `${"x".repeat(270_000)}\ntarget\n` + ); + + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["deep.txt"], + resultVersion: 2, + startLine: 2, + }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "deep.txt": { + content: "target\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + }); + + test("allows 20 normal V2 paths, bounds their content, and rejects 21", async () => { + const paths = Array.from({ length: 20 }, (_, index) => `file-${index}.txt`); + for (const filePath of paths) { + fs.writeFileSync(path.join(testDir, filePath), "x\n".repeat(5000)); + } + + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths, resultVersion: 2 }, + }, + makeContext(testDir) + ); + const overLimit = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: [...paths, "file-20.txt"], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + const files = ( + result.data as { + files: Record; + } + ).files; + + expect(result.ok).toBe(true); + expect( + Object.values(files).reduce( + (total, file) => total + Buffer.byteLength(file.content), + 0 + ) + ).toBe(40_000); + expect(Object.values(files).every((file) => file.truncated)).toBe(true); + expect(overLimit).toEqual({ + error: "read-files requires between 1 and 20 paths", + ok: false, + }); + }); + + test("rejects a continuation request with more than one path", async () => { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["first.txt", "second.txt"], + resultVersion: 2, + startLine: 2, + }, + }, + makeContext(testDir) + ); + + expect(result).toEqual({ + error: "read-files range reads require exactly one path", + ok: false, + }); + }); + + test("does not impose a separate line limit on V2 reads", async () => { + const content = "x\n".repeat(1500); + fs.writeFileSync(path.join(testDir, "many-lines.txt"), content); + + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: ["many-lines.txt"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "many-lines.txt": { content, status: "ok", truncated: false }, + }, + version: 2, + }); + }); + + test("rejects byte-budget knobs instead of enabling another profile", async () => { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + maxBytes: 262_144, + paths: ["large.txt"], + resultVersion: 2, + }, + } as ToolPayload, + makeContext(testDir) + ); + + expect(result).toEqual({ + error: "read-files params include unsupported fields", + ok: false, + }); + }); + + test("streams a continuation beyond 4 MiB with bounded read buffers", async () => { + const filePath = path.join(testDir, "deep-continuation.txt"); + const splitUtf8Prefix = `${"x".repeat(5 * 1024 * 1024 - 1)}é\n`; + fs.writeFileSync(filePath, `${splitUtf8Prefix}target\n`); + const actualHandle = await fs.promises.open(filePath, "r"); + const requestedLengths: number[] = []; + const streamedRead = vi.fn( + ( + buffer: Buffer, + offset: number, + length: number, + position: number | null + ) => { + requestedLengths.push(length); + return actualHandle.read(buffer, offset, length, position); + } + ); + const openSpy = vi.spyOn(fs.promises, "open").mockResolvedValue({ + close: vi.fn().mockResolvedValue(undefined), + read: streamedRead, + stat: () => actualHandle.stat(), + } as unknown as fs.promises.FileHandle); + + try { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["deep-continuation.txt"], + resultVersion: 2, + startLine: 2, + }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "deep-continuation.txt": { + content: "target\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + expect(Math.max(...requestedLengths)).toBeLessThanOrEqual(64 * 1024); + expect(streamedRead.mock.calls.length).toBeGreaterThan(64); + } finally { + openSpy.mockRestore(); + await actualHandle.close(); + } + }); + + test("stops reading an enormous first line at the output budget", async () => { + const filePath = path.join(testDir, "enormous-line.txt"); + fs.writeFileSync(filePath, `${"x".repeat(8 * 1024 * 1024)}\n`); + const actualHandle = await fs.promises.open(filePath, "r"); + let totalBytesRead = 0; + const boundedRead = vi.fn( + async ( + buffer: Buffer, + offset: number, + length: number, + position: number | null + ) => { + const result = await actualHandle.read( + buffer, + offset, + length, + position + ); + totalBytesRead += result.bytesRead; + return result; + } + ); + const openSpy = vi.spyOn(fs.promises, "open").mockResolvedValue({ + close: vi.fn().mockResolvedValue(undefined), + read: boundedRead, + stat: () => actualHandle.stat(), + } as unknown as fs.promises.FileHandle); + + try { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: ["enormous-line.txt"], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { + "enormous-line.txt": { error: "line-too-long", status: "error" }, + }, + version: 2, + }); + expect(totalBytesRead).toBe(40_000); + } finally { + openSpy.mockRestore(); + await actualHandle.close(); + } + }); + + test("rejects invalid UTF-8 and incomplete multibyte lines", async () => { + fs.writeFileSync( + path.join(testDir, "invalid-utf8.txt"), + Buffer.from([0x61, 0x62, 0x63, 0xff]) + ); + fs.writeFileSync( + path.join(testDir, "multibyte.txt"), + `${"é".repeat(20_001)}\n` + ); + + const read = (filePath: string) => + executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { + paths: [filePath], + resultVersion: 2, + }, + }, + makeContext(testDir) + ); + + expect((await read("invalid-utf8.txt")).data).toEqual({ + files: { + "invalid-utf8.txt": { error: "not-text", status: "error" }, + }, + version: 2, + }); + expect((await read("multibyte.txt")).data).toEqual({ + files: { + "multibyte.txt": { error: "line-too-long", status: "error" }, + }, + version: 2, + }); + }); + + test("preserves not-file when descriptor cleanup fails", async () => { + const close = vi.fn().mockRejectedValue(new Error("close failed")); + const open = vi.spyOn(fs.promises, "open").mockResolvedValue({ + close, + stat: vi.fn().mockResolvedValue({ isFile: () => false }), + } as unknown as fs.promises.FileHandle); + + try { + const result = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: ["directory"], resultVersion: 2 }, + }, + makeContext(testDir) + ); + + expect(result.data).toEqual({ + files: { directory: { error: "not-file", status: "error" } }, + version: 2, + }); + expect(close).toHaveBeenCalledOnce(); + } finally { + open.mockRestore(); + } + }); + + test("applies patchsets and injects auth tokens into env files", async () => { + const result = await executeTool( + { + type: "tool", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: ".env.sentry-build-plugin", + action: "create", + patch: "SENTRY_AUTH_TOKEN=\n", + }, + ], + }, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + expect( + fs.readFileSync(path.join(testDir, ".env.sentry-build-plugin"), "utf-8") + ).toContain("sntrys_test_token_123"); + }); + + test("rejects unsafe apply-patchset paths before writing", async () => { + const unsafePaths: unknown[] = [ + null, + "../../outside.txt", + "/tmp/outside.txt", + "C:/repo/package.json", + "C:\\repo\\package.json", + "apps/../package.json", + "./package.json", + "apps//package.json", + ]; + + for (const unsafePath of unsafePaths) { + const result = await executeTool( + { + type: "tool", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: unsafePath as never, + action: "create", + patch: "should not be written\n", + }, + ], + }, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatch( + /Invalid (?:file change path|file changes request)/ + ); + } + }); + + test("applies patchsets to nested project-relative files", async () => { + fs.mkdirSync(path.join(testDir, "Cary.ConversionFunnels.API")); + fs.writeFileSync( + path.join( + testDir, + "Cary.ConversionFunnels.API", + "Cary.ConversionFunnels.API.csproj" + ), + "\n" + ); + + const result = await executeTool( + { + type: "tool", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "Directory.Packages.props", + action: "create", + patch: "\n", + }, + { + path: "Cary.ConversionFunnels.API/Cary.ConversionFunnels.API.csproj", + action: "modify", + edits: [ + { + oldString: "", + newString: + '', + }, + ], + }, + ], + }, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + expect( + fs.readFileSync(path.join(testDir, "Directory.Packages.props"), "utf-8") + ).toContain(""); + expect( + fs.readFileSync( + path.join( + testDir, + "Cary.ConversionFunnels.API", + "Cary.ConversionFunnels.API.csproj" + ), + "utf-8" + ) + ).toContain("Sentry.AspNetCore"); + }); + + test("greps and globs files inside the project", async () => { + fs.mkdirSync(path.join(testDir, "src")); + fs.writeFileSync( + path.join(testDir, "src", "app.ts"), + "Sentry.captureException(error);\n" + ); + + const grepResult = await executeTool( + { + type: "tool", + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "captureException" }] }, + }, + makeContext(testDir) + ); + const globResult = await executeTool( + { + type: "tool", + operation: "glob", + cwd: testDir, + params: { patterns: ["**/*.ts"] }, + }, + makeContext(testDir) + ); + + expect((grepResult.data as any).results[0].matches[0].path).toBe( + "src/app.ts" + ); + expect((globResult.data as any).results[0].files).toContain("src/app.ts"); + }); + + test("reports installed Sentry signals when a DSN is detected", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + sourcePath: ".env", + }); + + const result = await executeTool( + { + type: "tool", + operation: "detect-sentry", + cwd: testDir, + params: {}, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + status: "installed", + dsn: "https://abc@o1.ingest.sentry.io/42", + }) + ); + }); +}); diff --git a/packages/cli/test/lib/init/tools/list-dir.test.ts b/packages/cli/test/lib/init/tools/list-dir.test.ts new file mode 100644 index 000000000..86b11bdf1 --- /dev/null +++ b/packages/cli/test/lib/init/tools/list-dir.test.ts @@ -0,0 +1,303 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { listDir } from "../../../../src/lib/init/tools/list-dir.js"; +import type { + DirEntry, + ListDirPayload, +} from "../../../../src/lib/init/types.js"; + +function makePayload( + cwd: string, + params: ListDirPayload["params"] +): ListDirPayload { + return { + type: "tool", + operation: "list-dir", + cwd, + params, + }; +} + +function entriesOf(result: Awaited>): DirEntry[] { + if (!result.ok) { + throw new Error(`expected listDir to succeed, got: ${result.error}`); + } + return (result.data as { entries: DirEntry[] }).entries; +} + +describe("listDir", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "sentry-listdir-")); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("lists top-level files + dirs with the right shape", async () => { + writeFileSync(join(testDir, "index.ts"), ""); + mkdirSync(join(testDir, "src")); + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: "." })) + ); + // Order depends on `readdir` behavior (FS-dependent — e.g., ext4 + // returns entries in insertion order while tmpfs and macOS APFS + // do not). Sort by name for a stable assertion. + const sorted = entries + .map((e) => ({ name: e.name, type: e.type })) + .sort((a, b) => a.name.localeCompare(b.name)); + expect(sorted).toEqual([ + { name: "index.ts", type: "file" }, + { name: "src", type: "directory" }, + ]); + }); + + test("non-recursive mode does not descend into subdirectories", async () => { + writeFileSync(join(testDir, "top.ts"), ""); + mkdirSync(join(testDir, "src")); + writeFileSync(join(testDir, "src", "deep.ts"), ""); + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: ".", recursive: false })) + ); + const paths = entries.map((e) => e.path).sort(); + expect(paths).toEqual(["src", "top.ts"]); + }); + + test("recursive mode descends into subdirectories up to maxDepth", async () => { + mkdirSync(join(testDir, "a", "b", "c", "d"), { recursive: true }); + writeFileSync(join(testDir, "a", "level1.ts"), ""); + writeFileSync(join(testDir, "a", "b", "level2.ts"), ""); + writeFileSync(join(testDir, "a", "b", "c", "level3.ts"), ""); + writeFileSync(join(testDir, "a", "b", "c", "d", "level4.ts"), ""); + + const entries = entriesOf( + await listDir( + makePayload(testDir, { path: ".", recursive: true, maxDepth: 2 }) + ) + ); + const files = entries.filter((e) => e.type === "file").map((e) => e.path); + // With maxDepth: 2, we enter `a/` (depth 1) and `a/b/` (depth 2), see + // their files, but we do NOT descend into `a/b/c/`. + expect(files).toContain("a/level1.ts"); + expect(files).toContain("a/b/level2.ts"); + expect(files).not.toContain("a/b/c/level3.ts"); + expect(files).not.toContain("a/b/c/d/level4.ts"); + }); + + test("emits POSIX-separator paths even on nested trees", async () => { + mkdirSync(join(testDir, "src", "nested"), { recursive: true }); + writeFileSync(join(testDir, "src", "nested", "deep.ts"), ""); + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: ".", recursive: true })) + ); + for (const entry of entries) { + expect(entry.path).not.toContain("\\"); + } + const paths = entries.map((e) => e.path).sort(); + expect(paths).toContain("src/nested/deep.ts"); + }); + + test("includes hidden entries and node_modules at the surface level", async () => { + writeFileSync(join(testDir, ".env"), ""); + mkdirSync(join(testDir, ".cache")); + mkdirSync(join(testDir, "node_modules")); + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: ".", recursive: true })) + ); + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual([".cache", ".env", "node_modules"]); + }); + + test("does NOT recurse into hidden dirs or node_modules", async () => { + mkdirSync(join(testDir, ".cache")); + writeFileSync(join(testDir, ".cache", "inside.ts"), ""); + mkdirSync(join(testDir, "node_modules", "pkg"), { recursive: true }); + writeFileSync(join(testDir, "node_modules", "pkg", "index.js"), ""); + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: ".", recursive: true })) + ); + const paths = entries.map((e) => e.path); + expect(paths).toContain(".cache"); + expect(paths).toContain("node_modules"); + expect(paths).not.toContain(".cache/inside.ts"); + expect(paths).not.toContain("node_modules/pkg"); + }); + + test("surfaces artifact directories but does not recurse into them", async () => { + const artifactDirs = [ + "dist", + "build", + ".next", + ".nuxt", + ".output", + "target", + "coverage", + ".cache", + ".turbo", + "out", + ".parcel-cache", + ".svelte-kit", + ".angular", + "tmp", + "temp", + "bin", + "obj", + "CMakeFiles", + "cmake-build-debug", + "cmake-build-release", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".tox", + ".nox", + "htmlcov", + ".pnpm-store", + "bower_components", + ".build", + "DerivedData", + ".dart_tool", + ".serverless", + "Pods", + ]; + for (const dir of artifactDirs) { + mkdirSync(join(testDir, dir, "nested"), { recursive: true }); + writeFileSync(join(testDir, dir, "nested", "generated.js"), ""); + } + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: ".", recursive: true })) + ); + const paths = entries.map((e) => e.path); + + for (const dir of artifactDirs) { + expect(entries.find((e) => e.path === dir)).toMatchObject({ + path: dir, + type: "directory", + }); + expect(paths).not.toContain(`${dir}/nested`); + expect(paths).not.toContain(`${dir}/nested/generated.js`); + } + }); + + test("keeps source/config files when artifact dirs contain hundreds of generated assets", async () => { + mkdirSync(join(testDir, "build", "client", "assets"), { recursive: true }); + mkdirSync(join(testDir, "dist", "client", "assets"), { recursive: true }); + for (let i = 0; i < 300; i += 1) { + writeFileSync( + join(testDir, "build", "client", "assets", `chunk-${i}.js`), + "" + ); + writeFileSync( + join(testDir, "dist", "client", "assets", `chunk-${i}.js`), + "" + ); + } + writeFileSync(join(testDir, "package.json"), "{}"); + writeFileSync(join(testDir, "vite.config.ts"), "export default {};\n"); + mkdirSync(join(testDir, "app", "routes"), { recursive: true }); + writeFileSync(join(testDir, "app", "router.tsx"), "export {};\n"); + writeFileSync(join(testDir, "app", "routes", "__root.tsx"), "export {};\n"); + + const entries = entriesOf( + await listDir( + makePayload(testDir, { + path: ".", + recursive: true, + maxEntries: 50, + }) + ) + ); + const paths = entries.map((e) => e.path); + + expect(paths).toContain("package.json"); + expect(paths).toContain("vite.config.ts"); + expect(paths).toContain("app/router.tsx"); + expect(paths).toContain("app/routes/__root.tsx"); + expect(paths).toContain("build"); + expect(paths).toContain("dist"); + expect(paths).not.toContain("build/client"); + expect(paths).not.toContain("dist/client"); + expect(paths.some((p) => p.startsWith("build/client/assets/"))).toBe(false); + expect(paths.some((p) => p.startsWith("dist/client/assets/"))).toBe(false); + }); + + test("applies maxEntries as a hard cap", async () => { + for (let i = 0; i < 20; i += 1) { + writeFileSync(join(testDir, `f${i.toString().padStart(2, "0")}.ts`), ""); + } + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: ".", maxEntries: 5 })) + ); + expect(entries).toHaveLength(5); + }); + + test("throws on sandbox escape via `..`", async () => { + await expect( + listDir(makePayload(testDir, { path: "../../etc" })) + ).rejects.toThrow(/outside project directory/); + }); + + test("includes safe symlinks (pointing inside the sandbox)", async () => { + const realPath = join(testDir, "real.ts"); + const linkPath = join(testDir, "link.ts"); + writeFileSync(realPath, ""); + try { + symlinkSync(realPath, linkPath, "file"); + } catch { + // Symlinks may not be creatable (Windows without dev mode) — skip. + return; + } + + const entries = entriesOf( + await listDir(makePayload(testDir, { path: "." })) + ); + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["link.ts", "real.ts"]); + }); + + test("skips symlinks that escape the sandbox", async () => { + writeFileSync(join(testDir, "real.ts"), ""); + const outside = mkdtempSync(join(tmpdir(), "outside-")); + const linkPath = join(testDir, "escape"); + try { + symlinkSync(outside, linkPath, "dir"); + } catch { + rmSync(outside, { recursive: true, force: true }); + return; + } + + try { + const entries = entriesOf( + await listDir(makePayload(testDir, { path: "." })) + ); + expect(entries.map((e) => e.name)).not.toContain("escape"); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + test("returns empty entries for a missing subpath", async () => { + // `safePath` allows nonexistent paths under the sandbox; `readdir` + // throws, which the walker swallows. + const entries = entriesOf( + await listDir(makePayload(testDir, { path: "does-not-exist" })) + ); + expect(entries).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/init/tools/registry.test.ts b/packages/cli/test/lib/init/tools/registry.test.ts new file mode 100644 index 000000000..1d8689a43 --- /dev/null +++ b/packages/cli/test/lib/init/tools/registry.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import { + describeTool, + executeTool, +} from "../../../../src/lib/init/tools/registry.js"; +import type { + ResolvedInitContext, + ToolPayload, +} from "../../../../src/lib/init/types.js"; + +function makeContext(): ResolvedInitContext { + return { + directory: "/tmp/test", + yes: true, + dryRun: true, + org: "acme", + team: "platform", + }; +} + +describe("tool registry", () => { + test("acknowledges an agent checkpoint without local work", async () => { + const payload: ToolPayload = { + cwd: "/outside/project", + detail: "Reviewing official Sentry feature support...", + operation: "agent-checkpoint", + params: {}, + type: "tool", + }; + + expect(describeTool(payload)).toBe( + "Reviewing official Sentry feature support..." + ); + await expect(executeTool(payload, makeContext())).resolves.toEqual({ + data: { acknowledged: true }, + ok: true, + }); + }); + + test("describes tool payloads via the registered definition", () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + + expect(describeTool(payload)).toBe("Running `npm install @sentry/node`..."); + }); + + test("returns an error for unknown operations", async () => { + const payload = { + type: "tool", + operation: "teleport", + cwd: "/tmp/test", + params: {}, + } as unknown as ToolPayload; + + const result = await executeTool(payload, makeContext()); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Unknown operation"); + }); + + test("describes malformed file-change payloads without throwing", () => { + const payload = { + cwd: "/tmp/test", + operation: "apply-patchset", + type: "tool", + } as unknown as ToolPayload; + + expect(describeTool(payload)).toBe("Applying file changes..."); + }); +}); diff --git a/packages/cli/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/packages/cli/test/lib/init/tools/run-commands-spawn.mocked.test.ts new file mode 100644 index 000000000..e59e09c73 --- /dev/null +++ b/packages/cli/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -0,0 +1,214 @@ +/** + * Unit tests for run-commands spawn options. + * + * Kept separate because node:child_process must be mocked before importing + * the tool module. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; + +const originalComSpec = process.env.ComSpec; +const { spawnCalls } = vi.hoisted(() => ({ + spawnCalls: [] as Array<{ + command: string; + args: string[]; + options: { shell?: boolean; windowsVerbatimArguments?: boolean }; + }>, +})); + +vi.mock("node:child_process", async () => { + const { EventEmitter } = await import("node:events"); + const { Readable } = await import("node:stream"); + + return { + execFileSync: (_file: string, args: string[]) => { + const command = args.at(-1); + if (process.platform !== "win32") { + return `/usr/local/bin/${command}\n`; + } + return command === "pnpm" + ? "C:\\Tools\\pnpm.CMD\r\n" + : `C:\\Tools\\${command}.exe\r\n`; + }, + spawn: ( + command: string, + args: string[], + options: { shell?: boolean; windowsVerbatimArguments?: boolean } + ) => { + spawnCalls.push({ command, args, options }); + const child = new EventEmitter() as any; + child.stdout = Readable.from(["10.0.0\n"]); + child.stderr = Readable.from([]); + child.kill = vi.fn(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }, + }; +}); + +vi.mock("@sentry/node-core/light", () => ({ + addBreadcrumb: vi.fn(), +})); + +import { runCommands } from "../../../../src/lib/init/tools/run-commands.js"; + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +function makePayload(command: string): RunCommandsPayload { + return { + type: "tool", + operation: "run-commands", + cwd: "/tmp", + params: { commands: [command] }, + }; +} + +beforeEach(() => { + spawnCalls.splice(0); + delete process.env.ComSpec; +}); + +afterEach(() => { + setPlatform(originalPlatform); + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = originalComSpec; + } +}); + +describe("runCommands spawn options", () => { + test("uses cmd.exe for package-manager .cmd shims", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("pnpm --version"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + args: ["/d", "/s", "/c", '""C:\\Tools\\pnpm.CMD" "--version""'], + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + + test("quotes Windows .cmd shim arguments with spaces", async () => { + setPlatform("win32"); + + const result = await runCommands( + makePayload('pnpm --filter "./apps/web app" add @sentry/nextjs@^8.0.0'), + { dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + args: [ + "/d", + "/s", + "/c", + '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^8.0.0""', + ], + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + + test("doubles trailing backslashes for Windows .cmd shim arguments", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("pnpm add C:\\some\\path\\"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + args: [ + "/d", + "/s", + "/c", + '""C:\\Tools\\pnpm.CMD" "add" "C:\\some\\path\\\\""', + ], + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + + test("doubles embedded quotes for Windows .cmd shim arguments", async () => { + setPlatform("win32"); + + const result = await runCommands( + makePayload('pnpm add "value \\"quoted\\""'), + { dryRun: false } + ); + + const commandLine = spawnCalls[0]?.args.at(-1) ?? ""; + + expect(result.ok).toBe(true); + expect(commandLine).toContain('"value ""quoted"""'); + expect(commandLine).not.toContain('\\"'); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + + test("doubles backslashes before embedded quotes for Windows .cmd shim arguments", async () => { + setPlatform("win32"); + + const result = await runCommands( + makePayload(String.raw`pnpm add "path\\\"name"`), + { dryRun: false } + ); + + const commandLine = spawnCalls[0]?.args.at(-1) ?? ""; + + expect(result.ok).toBe(true); + expect(commandLine).toContain(String.raw`"path\\""name"`); + expect(commandLine).not.toContain(String.raw`"path\""name"`); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + options: { shell: false, windowsVerbatimArguments: true }, + }); + }); + + test("keeps Windows .exe commands shell-free", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("dotnet --info"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "C:\\Tools\\dotnet.exe", + args: ["--info"], + options: { shell: false }, + }); + expect(spawnCalls[0]?.options.windowsVerbatimArguments).toBeUndefined(); + }); + + test("keeps POSIX command execution shell-free", async () => { + setPlatform("darwin"); + + const result = await runCommands(makePayload("pnpm --version"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "/usr/local/bin/pnpm", + args: ["--version"], + options: { shell: false }, + }); + expect(spawnCalls[0]?.options.windowsVerbatimArguments).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/init/tools/run-commands.test.ts b/packages/cli/test/lib/init/tools/run-commands.test.ts new file mode 100644 index 000000000..600a79f00 --- /dev/null +++ b/packages/cli/test/lib/init/tools/run-commands.test.ts @@ -0,0 +1,221 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { validateCommand } from "../../../../src/lib/init/tools/command-utils.js"; +import { runCommands } from "../../../../src/lib/init/tools/run-commands.js"; +import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; + +let testDir: string; +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join("/tmp", "run-commands-")); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + setPlatform(originalPlatform); +}); + +function makePayload(commands: string[]): RunCommandsPayload { + return { + type: "tool", + operation: "run-commands", + cwd: testDir, + params: { commands }, + }; +} + +describe("validateCommand", () => { + test("allows quoted package specifiers", () => { + expect(validateCommand('pip install "sentry-sdk[django]"')).toBeUndefined(); + expect(validateCommand("npm install @sentry/node@^9.0.0")).toBeUndefined(); + expect(validateCommand("pnpm add @sentry/nextjs@^8.0.0")).toBeUndefined(); + }); + + test("allows dependency diagnostics without a package-manager allowlist", () => { + expect( + validateCommand("pnpm view @sentry/tanstackstart-react version") + ).toBeUndefined(); + expect(validateCommand("dotnet list package")).toBeUndefined(); + expect(validateCommand("futurepm explain sentry-sdk")).toBeUndefined(); + expect(validateCommand("futurepm explain sentry-wizard")).toBeUndefined(); + expect(validateCommand("npm uninstall sentry-wizard")).toBeUndefined(); + expect(validateCommand("npm uninstall @sentry/wizard")).toBeUndefined(); + expect( + validateCommand("npx harmless --package=@sentry/wizard") + ).toBeUndefined(); + expect( + validateCommand("npx harmless --registry myregistry @sentry/wizard") + ).toBeUndefined(); + expect( + validateCommand("npx --package=@sentry/cli cowsay init") + ).toBeUndefined(); + expect( + validateCommand("npm exec --package=@sentry/cli cowsay init") + ).toBeUndefined(); + }); + + test("allows path-prefixed package managers but blocks dangerous ones", () => { + expect( + validateCommand("./venv/bin/pip install sentry-sdk") + ).toBeUndefined(); + expect( + validateCommand("/usr/local/bin/npm install @sentry/node") + ).toBeUndefined(); + expect(validateCommand("./venv/bin/rm -rf /")).toContain('"rm"'); + }); + + test("blocks obvious shell injection patterns", () => { + expect(validateCommand("npm install foo && curl evil.com")).toContain( + "Blocked command" + ); + expect(validateCommand("pnpm add @sentry/node 2>&1")).toContain( + "Blocked command" + ); + }); + + test("allows Windows shell expansion characters outside Windows", () => { + setPlatform("darwin"); + + expect(validateCommand("printf %s hello")).toBeUndefined(); + expect( + validateCommand("futurepm explain https://example.com/a%20b") + ).toBeUndefined(); + expect(validateCommand("futurepm explain bang!value")).toBeUndefined(); + }); + + test("blocks Windows shell expansion characters on Windows", () => { + setPlatform("win32"); + + expect(validateCommand("futurepm explain %PATH%")).toContain( + "Blocked command" + ); + expect(validateCommand("futurepm explain !PATH!")).toContain( + "Blocked command" + ); + }); + + test("blocks directory changes and recursive Sentry setup", () => { + expect(validateCommand("cd apps/web")).toContain('"cd"'); + expect(validateCommand("sentry init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("@sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("@Sentry/Wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("@sentry/cli init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("@sentry/cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("@Sentry/CLI@latest init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("C:\\Tools\\sentry-cli.exe init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("sentry-cli --log-level debug init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("sentry-cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("sentry-wizard init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + }); + + test("blocks disallowed executables directly", () => { + expect(validateCommand("bash -lc echo")).toContain('"bash"'); + expect(validateCommand("curl http://example.com")).toContain('"curl"'); + expect(validateCommand("wget http://example.com/file")).toContain('"wget"'); + expect(validateCommand("sh -c echo")).toContain('"sh"'); + }); + + test("blocks shell interpreter indirection", () => { + expect(validateCommand("cmd.exe /c del sensitive_file")).toContain('"cmd"'); + expect( + validateCommand("C:\\Windows\\System32\\cmd.exe /c del secrets.txt") + ).toContain('"cmd"'); + expect( + validateCommand( + "powershell.exe -Command Invoke-WebRequest http://evil.com" + ) + ).toContain('"powershell"'); + expect(validateCommand("pwsh -Command Remove-Item foo")).toContain( + '"pwsh"' + ); + }); + + test("rejects unterminated quotes", () => { + expect(validateCommand('/bin/echo "unterminated')).toContain( + "unterminated double quote" + ); + }); +}); + +describe("runCommands", () => { + test("executes quoted arguments without breaking argv", async () => { + const result = await runCommands(makePayload(['/bin/echo "hello world"']), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect((result.data as any).results[0].stdout.trim()).toBe("hello world"); + }); + + test("stops on the first failing command", async () => { + const result = await runCommands( + makePayload(["/usr/bin/false", "/bin/echo should-not-run"]), + { dryRun: false } + ); + + expect(result.ok).toBe(false); + expect((result.data as any).results).toHaveLength(1); + }); + + test("validates but skips execution during dry-run", async () => { + const result = await runCommands( + makePayload(["npm install @sentry/node"]), + { dryRun: true } + ); + + expect(result.ok).toBe(true); + expect((result.data as any).results[0].stdout).toBe("(dry-run: skipped)"); + }); + + test("rejects the full batch before execution when any command is blocked", async () => { + const result = await runCommands( + makePayload(["/bin/echo hello", "rm -rf /"]), + { dryRun: false } + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Blocked command"); + expect(result.data).toBeUndefined(); + }); + + test("still validates commands during dry-run", async () => { + const result = await runCommands(makePayload(["rm -rf /"]), { + dryRun: true, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Blocked command"); + }); +}); diff --git a/packages/cli/test/lib/init/tools/search-tools.test.ts b/packages/cli/test/lib/init/tools/search-tools.test.ts new file mode 100644 index 000000000..850f92fc8 --- /dev/null +++ b/packages/cli/test/lib/init/tools/search-tools.test.ts @@ -0,0 +1,224 @@ +/** + * Wire-contract tests for the init-wizard `grep` / `glob` tools. + * + * Scope after PR #791/#PR-4: the tools are thin adapters over the + * pure-TS `collectGrep`/`collectGlob` from `src/lib/scan/`. The scan + * module has extensive unit + property coverage of its own; this file + * pins the adapter-level contract: + * + * - The Mastra wire shape (field names, nesting, per-pattern rows). + * - Subpath + include filter behavior — delegated to scan but + * exercised end-to-end via the `executeTool` entry point so a + * regression at the adapter layer surfaces here. + * - The sandbox guard — `safePath` at the adapter boundary must + * reject paths that escape the project root. + * - The `absolutePath` field MUST NOT appear on wire results — the + * adapter's job is to strip it before returning. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { executeTool } from "../../../../src/lib/init/tools/registry.js"; +import type { + ResolvedInitContext, + ToolPayload, +} from "../../../../src/lib/init/types.js"; + +function makeContext(directory: string): ResolvedInitContext { + return { + directory, + yes: true, + dryRun: false, + org: "acme", + team: "platform", + }; +} + +function makeToolPayload(payload: Omit): ToolPayload { + return { + type: "tool", + ...payload, + } as ToolPayload; +} + +let testDir: string; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join("/tmp", "init-search-")); + + fs.writeFileSync( + path.join(testDir, "app.ts"), + 'import * as Sentry from "@sentry/node";\nSentry.init({ dsn: "..." });\n' + ); + fs.writeFileSync( + path.join(testDir, "utils.ts"), + "export function helper() { return 1; }\n" + ); + fs.writeFileSync(path.join(testDir, "config.json"), "{}\n"); + fs.mkdirSync(path.join(testDir, "src")); + fs.writeFileSync( + path.join(testDir, "src", "index.ts"), + 'import { helper } from "./utils";\nSentry.init({});\n' + ); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +describe("search tools", () => { + test("supports old grep include filters and subdirectory-relative paths", async () => { + const grepWithInclude = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "Sentry", include: "app.*" }] }, + }), + makeContext(testDir) + ); + const grepSubdir = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "helper", path: "src" }] }, + }), + makeContext(testDir) + ); + + expect(grepWithInclude.ok).toBe(true); + for (const match of (grepWithInclude.data as any).results[0].matches) { + expect(match.path).toContain("app"); + } + + expect(grepSubdir.ok).toBe(true); + for (const match of (grepSubdir.data as any).results[0].matches) { + expect(match.path).toMatch(/^src\//); + } + }); + + test("supports old glob multi-pattern and empty-result behavior", async () => { + const matches = await executeTool( + makeToolPayload({ + operation: "glob", + cwd: testDir, + params: { patterns: ["*.ts", "*.json"] }, + }), + makeContext(testDir) + ); + const empty = await executeTool( + makeToolPayload({ + operation: "glob", + cwd: testDir, + params: { patterns: ["*.xyz"] }, + }), + makeContext(testDir) + ); + + expect(matches.ok).toBe(true); + expect((matches.data as any).results).toHaveLength(2); + expect( + (matches.data as any).results[0].files.length + ).toBeGreaterThanOrEqual(2); + expect( + (matches.data as any).results[1].files.length + ).toBeGreaterThanOrEqual(1); + + expect(empty.ok).toBe(true); + expect((empty.data as any).results[0].files).toHaveLength(0); + }); + + test("rejects grep paths outside the init sandbox", async () => { + const result = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "test", path: "../../etc" }] }, + }), + makeContext(testDir) + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); + + test("grep result matches MUST NOT include absolutePath", async () => { + // The Mastra wire contract has never exposed `absolutePath`; the + // underlying `collectGrep` does return it on each `GrepMatch` + // (for local callers that want to re-open the file). The adapter + // is responsible for stripping it. A regression here would leak + // real filesystem paths into the agent's context window. + const result = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "Sentry" }] }, + }), + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + const [firstRow] = (result.data as any).results; + expect(firstRow.matches.length).toBeGreaterThan(0); + for (const match of firstRow.matches) { + expect(Object.keys(match).sort()).toEqual(["line", "lineNum", "path"]); + expect("absolutePath" in match).toBe(false); + } + }); + + test("grep bad regex yields empty matches without crashing the payload", async () => { + // The adapter catches `ValidationError` from `compilePattern` + // and surfaces it as an empty per-search row, letting the + // agent retry with a corrected pattern instead of the whole + // payload failing. If this behavior regressed, the Mastra + // server would see `{ok: false, error: "Invalid grep pattern…"}` + // and almost certainly stop the wizard. + const result = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + // Unclosed paren — `new RegExp("(unclosed")` throws. + params: { searches: [{ pattern: "(unclosed" }] }, + }), + makeContext(testDir) + ); + expect(result.ok).toBe(true); + expect((result.data as any).results[0]).toEqual({ + pattern: "(unclosed", + matches: [], + truncated: false, + }); + }); + + test("grep caseInsensitive flag enables case-insensitive matching", async () => { + // Regression test for the new wire field added in PR-4. The + // default is case-sensitive (matches rg), so "SENTRY" (all-caps) + // would normally return zero hits. With `caseInsensitive: true` + // the search picks up `Sentry` in the sandboxed fixture. + const caseSensitive = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "SENTRY" }] }, + }), + makeContext(testDir) + ); + const caseInsensitive = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { + searches: [{ pattern: "SENTRY", caseInsensitive: true }], + }, + }), + makeContext(testDir) + ); + + expect(caseSensitive.ok).toBe(true); + expect((caseSensitive.data as any).results[0].matches).toHaveLength(0); + expect(caseInsensitive.ok).toBe(true); + expect( + (caseInsensitive.data as any).results[0].matches.length + ).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/test/lib/init/ui/factory.test.ts b/packages/cli/test/lib/init/ui/factory.test.ts new file mode 100644 index 000000000..70cdee1c2 --- /dev/null +++ b/packages/cli/test/lib/init/ui/factory.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for getUIAsync() — verifies the runtime-detection rules pick + * the right WizardUI implementation. + * + * The factory's selection logic depends on five signals: + * - `SENTRY_INIT_TUI` env var + * - `--yes` flag (passed in via opts) + * - `--no-tui` (mapped to `forceLegacy`) + * - stdin/stdout TTY state + * - whether the runtime is the Bun-compiled binary (Ink is + * gated to Bun because its top-level-await usage doesn't + * bundle into our CJS npm distribution). + * + * We patch the env and `process.stdin.isTTY` / `process.stdout.isTTY` + * around each test so the assertions are deterministic. To keep tests + * fast and TTY-independent we use the `forceLegacy` / non-TTY / `--yes` + * paths to assert `LoggingUI` is returned without ever spinning up a + * real renderer. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + getUIAsync, + isInteractiveTerminal, +} from "../../../../src/lib/init/ui/factory.js"; +import { LoggingUI } from "../../../../src/lib/init/ui/logging-ui.js"; + +/** + * Snapshot of the process state we mutate per test. Restored in + * afterEach so the test runner's own TTY/env is left untouched. + */ +type TerminalSnapshot = { + stdinTTY: boolean | undefined; + stdoutTTY: boolean | undefined; + envValue: string | undefined; +}; + +const ENV_KEY = "SENTRY_INIT_TUI"; + +function snapshot(): TerminalSnapshot { + return { + stdinTTY: process.stdin.isTTY, + stdoutTTY: process.stdout.isTTY, + envValue: process.env[ENV_KEY], + }; +} + +function restore(snap: TerminalSnapshot): void { + // Direct property writes match how Node exposes these flags. + (process.stdin as { isTTY: boolean | undefined }).isTTY = snap.stdinTTY; + (process.stdout as { isTTY: boolean | undefined }).isTTY = snap.stdoutTTY; + if (snap.envValue === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = snap.envValue; + } +} + +let saved: TerminalSnapshot; + +beforeEach(() => { + saved = snapshot(); + delete process.env[ENV_KEY]; +}); + +afterEach(() => { + restore(saved); +}); + +describe("isInteractiveTerminal", () => { + test("returns true when both stdin and stdout are TTYs", () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; + expect(isInteractiveTerminal()).toBe(true); + }); + + test("returns false when stdin is not a TTY", () => { + (process.stdin as { isTTY: boolean }).isTTY = false; + (process.stdout as { isTTY: boolean }).isTTY = true; + expect(isInteractiveTerminal()).toBe(false); + }); + + test("returns false when stdout is not a TTY", () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = false; + expect(isInteractiveTerminal()).toBe(false); + }); +}); + +describe("getUIAsync selection", () => { + test("returns LoggingUI when --yes is set, even on a TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; + const ui = await getUIAsync({ yes: true }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns LoggingUI when stdin is not a TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = false; + (process.stdout as { isTTY: boolean }).isTTY = true; + const ui = await getUIAsync({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns LoggingUI when stdout is not a TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = false; + const ui = await getUIAsync({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns LoggingUI when SENTRY_INIT_TUI=0 even on interactive TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; + process.env[ENV_KEY] = "0"; + const ui = await getUIAsync({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns LoggingUI when forceLegacy is set on interactive TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; + const ui = await getUIAsync({ yes: false, forceLegacy: true }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("forceLegacy preserves the LoggingUI choice in non-interactive contexts too", async () => { + (process.stdin as { isTTY: boolean }).isTTY = false; + (process.stdout as { isTTY: boolean }).isTTY = false; + const ui = await getUIAsync({ yes: false, forceLegacy: true }); + expect(ui).toBeInstanceOf(LoggingUI); + }); +}); diff --git a/packages/cli/test/lib/init/ui/file-tree.test.ts b/packages/cli/test/lib/init/ui/file-tree.test.ts new file mode 100644 index 000000000..ae3fe379e --- /dev/null +++ b/packages/cli/test/lib/init/ui/file-tree.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for the shared file-tree builder used by both the OpenTUI + * sidebar (read-files panel + changed-files summary) and the + * post-dispose stderr report. + * + * Two builders share `flattenTree`: + * - `buildFileTree(changed)` — sorts directories first, then alpha + * - `buildReadTree(reads)` — preserves insertion order so the + * OpenTUI scrollbox's sticky-bottom tracking feels right + * + * The tests below exercise the second builder explicitly since it's + * new in this PR; the changed-files builder already has implicit + * coverage via the existing `formatters.test.ts` snapshot tests. + */ + +import { describe, expect, test } from "vitest"; +import { + buildFileTree, + buildReadTree, + flattenTree, +} from "../../../../src/lib/init/ui/file-tree.js"; + +describe("buildReadTree", () => { + test("returns empty tree for empty input", () => { + const tree = buildReadTree([]); + expect(tree.children).toHaveLength(0); + }); + + test("nests files under their parent directories", () => { + const tree = buildReadTree([ + { path: "src/index.ts", status: "analyzed" }, + { path: "src/lib/foo.ts", status: "reading" }, + { path: "package.json", status: "analyzed" }, + ]); + + const rows = flattenTree(tree); + const labels = rows.map((row) => `${row.kind}:${row.label}`); + // Directory rows have trailing slash, files don't. + expect(labels).toContain("directory:src/"); + expect(labels).toContain("directory:lib/"); + expect(labels).toContain("file:index.ts"); + expect(labels).toContain("file:foo.ts"); + expect(labels).toContain("file:package.json"); + }); + + test("propagates status onto leaf rows", () => { + const tree = buildReadTree([ + { path: "a.ts", status: "reading" }, + { path: "b.ts", status: "analyzed" }, + ]); + const fileRows = flattenTree(tree).filter((row) => row.kind === "file"); + expect(fileRows.find((row) => row.label === "a.ts")?.status).toBe( + "reading" + ); + expect(fileRows.find((row) => row.label === "b.ts")?.status).toBe( + "analyzed" + ); + }); + + test("preserves insertion order (no sort)", () => { + // Sorting would put `aa.ts` before `bb.ts`. We deliberately + // insert in reverse-alphabetical order to verify that the + // builder doesn't reorder — sticky-bottom scrollbox tracking + // depends on newly-added files always landing at the end. + const tree = buildReadTree([ + { path: "src/zz.ts", status: "analyzed" }, + { path: "src/aa.ts", status: "analyzed" }, + { path: "src/mm.ts", status: "analyzed" }, + ]); + const fileLabels = flattenTree(tree) + .filter((row) => row.kind === "file") + .map((row) => row.label); + expect(fileLabels).toEqual(["zz.ts", "aa.ts", "mm.ts"]); + }); + + test("does not collide with the sorted changed-files tree", () => { + // Sanity-check: feeding the same paths through `buildFileTree` + // sorts alphabetically. The two builders must stay independent. + const sorted = buildFileTree([ + { action: "modify", path: "src/zz.ts" }, + { action: "modify", path: "src/aa.ts" }, + ]); + const sortedLabels = flattenTree(sorted) + .filter((row) => row.kind === "file") + .map((row) => row.label); + expect(sortedLabels).toEqual(["aa.ts", "zz.ts"]); + }); + + test("does not duplicate intermediate directories", () => { + const tree = buildReadTree([ + { path: "src/a/foo.ts", status: "analyzed" }, + { path: "src/a/bar.ts", status: "analyzed" }, + { path: "src/b/baz.ts", status: "analyzed" }, + ]); + const dirLabels = flattenTree(tree) + .filter((row) => row.kind === "directory") + .map((row) => row.label); + // `src/` should appear once, not three times. + expect(dirLabels.filter((label) => label === "src/")).toHaveLength(1); + expect(dirLabels.filter((label) => label === "a/")).toHaveLength(1); + expect(dirLabels.filter((label) => label === "b/")).toHaveLength(1); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-app.property.test.ts b/packages/cli/test/lib/init/ui/ink-app.property.test.ts new file mode 100644 index 000000000..5f6d5d47b --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-app.property.test.ts @@ -0,0 +1,42 @@ +/** + * Property tests for terminal-height option windowing in the Ink prompt UI. + */ + +import { assert as fcAssert, integer, property } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { getOptionWindow } from "../../../../src/lib/init/ui/ink-app.js"; +import { DEFAULT_NUM_RUNS } from "../../../model-based/helpers.js"; + +describe("property: Ink prompt option window", () => { + test("stays bounded and always contains the highlighted option", () => { + fcAssert( + property( + integer({ min: 0, max: 500 }), + integer({ min: -1000, max: 1000 }), + integer({ min: -50, max: 200 }), + (totalCount, highlighted, maxVisible) => { + const [start, end] = getOptionWindow( + totalCount, + highlighted, + maxVisible + ); + const expectedSize = Math.min(totalCount, Math.max(1, maxVisible)); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeLessThanOrEqual(totalCount); + expect(end - start).toBe(expectedSize); + + if (totalCount > 0) { + const normalizedHighlight = Math.min( + totalCount - 1, + Math.max(0, highlighted) + ); + expect(start).toBeLessThanOrEqual(normalizedHighlight); + expect(end).toBeGreaterThan(normalizedHighlight); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx new file mode 100644 index 000000000..964477d1e --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -0,0 +1,1384 @@ +/** + * Smoke-test the Ink App by mounting it with mocked stdin/stdout + * inside `bun test`. Verifies the full-screen layout (tabbed + * content and keyboard hints) without needing a real TTY. + * + * Note: The first Ink render() in a bun test CI worker can hang + * indefinitely (Ink's internal reconciler keeps the event loop + * alive in non-TTY). Tests that call renderApp() rely on a 500ms + * timeout race to prevent blocking. + */ + +import { Readable, Writable } from "node:stream"; +import { setTimeout as sleep } from "node:timers/promises"; +import chalk from "chalk"; +import { render } from "ink"; +import { createElement } from "react"; +import { describe, expect, test, vi } from "vitest"; +import { + bannerLinesWidth, + FULL_BANNER_LINES, +} from "../../../../src/lib/banner.js"; +import { + App, + formatFeedbackBanner, +} from "../../../../src/lib/init/ui/ink-app.js"; +import { WizardStore } from "../../../../src/lib/init/ui/wizard-store.js"; + +const LEARN_HEADER_RE = /How Sentry Works/; +const TASKS_HEADER_RE = /Tasks\b/; +const STATUS_TAB_RE = /Status/; +const FILES_TAB_RE = /Files/; +const FILES_HEADER_PINNED_RE = /Files analyzed\s+\d+\/\d+/; +const FILES_HEADER_UNPINNED_RE = /Files analyzed\s+\u2191\s+\d+\/\d+/; +const KEYBOARD_HINT_RE = /switch tab/; +const SPACE_TOGGLE_HINT_RE = /space\s+toggle/; +const A_ALL_HINT_RE = /a\s+all/; +const ENTER_CONTINUE_HINT_RE = /enter\s+continue/; +const ESC_CANCEL_HINT_RE = /esc\s+cancel/; +const COMPLETED_SELECTING_FEATURES_RE = /✔\s+Selecting features/; +const ANSI_ESCAPE_PREFIX = "\u001B["; +const CURSOR_TO_LINE_START = "\u001B[G"; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in captured Ink output +const ANSI_CSI_RE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in captured Ink output +const ANSI_OSC_RE = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g; +const LINE_SPLIT_RE = /\r?\n/; +const DOWN_ARROW = "\u001B[B"; +const PAGE_DOWN = "\u001B[6~"; +const PAGE_UP = "\u001B[5~"; +const RIGHT_ARROW = "\u001B[C"; +const FEEDBACK_BANNER_TEXT = '$ sentry cli feedback "what worked or broke"'; + +const FRAME_SETTLE_MS = 80; +const TEST_BANNER_ROWS = [ + { content: " ███████╗███████╗███╗ ██╗", color: "#B4A4DE" }, + { content: " ╚══════╝╚══════╝╚═╝ ╚═══╝", color: "#432B8A" }, +]; + +class CaptureStream extends Writable { + frames: string[] = []; + settledOutput = ""; + columns: number; + rows: number; + isTTY = true; + constructor(columns = 120, rows = 40) { + super(); + this.columns = columns; + this.rows = rows; + } + _write(chunk: Buffer, _enc: string, cb: () => void): void { + this.frames.push(chunk.toString()); + cb(); + } + allOutput(): string { + return this.frames.join(""); + } + latestFrame(): string { + const output = this.settledOutput || this.allOutput(); + const redrawStart = output.lastIndexOf(CURSOR_TO_LINE_START); + return redrawStart === -1 + ? output + : output.slice(redrawStart + CURSOR_TO_LINE_START.length); + } +} + +function makeStdin(): Readable { + const s = new Readable({ + read() { + // No keystrokes in tests — Ink reads from this stream but + // we never push data. + }, + }); + const shim = s as Readable & { + isTTY: boolean; + setRawMode: (v: boolean) => Readable; + resume: () => Readable; + pause: () => Readable; + ref: () => Readable; + unref: () => Readable; + }; + shim.isTTY = true; + shim.setRawMode = () => s; + shim.resume = () => s; + shim.pause = () => s; + shim.ref = () => s; + shim.unref = () => s; + return s; +} + +async function renderApp( + store: WizardStore, + columns: number, + options: { rows?: number; input?: string[] } = {} +): Promise { + const out = new CaptureStream(columns, options.rows ?? 40); + const stdin = makeStdin(); + const instance = render(createElement(App, { store }), { + stdout: out as unknown as NodeJS.WriteStream, + stderr: out as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }); + for (const input of options.input ?? []) { + stdin.push(input); + await sleep(20); + } + await sleep(FRAME_SETTLE_MS); + out.settledOutput = out.allOutput(); + instance.unmount(); + // waitUntilExit() hangs in CI — race with a short unref'd timeout. + await Promise.race([ + instance.waitUntilExit().catch(() => { + // Ink may reject on unmount — ignore. + }), + new Promise((r) => { + const t = setTimeout(r, 500); + if (typeof t === "object" && "unref" in t) { + t.unref(); + } + }), + ]); + return out; +} + +async function renderActiveTaskFrameAfter(elapsedMs: number): Promise { + const out = new CaptureStream(120, 40); + const stdin = makeStdin(); + const store = new WizardStore(); + store.setStepStatus("detect-platform", "in_progress"); + const instance = render(createElement(App, { store }), { + stdout: out as unknown as NodeJS.WriteStream, + stderr: out as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }); + + try { + await vi.advanceTimersByTimeAsync(elapsedMs); + } finally { + instance.unmount(); + } + return stripAnsi(out.allOutput()); +} + +function hasForcedWhiteForeground(output: string): boolean { + return ( + output.includes(`${ANSI_ESCAPE_PREFIX}37m`) || + output.includes(`${ANSI_ESCAPE_PREFIX}97m`) || + output.includes(`${ANSI_ESCAPE_PREFIX}38;2;255;255;255m`) + ); +} + +function withoutFeedbackBanner(output: string): string { + return output + .split(LINE_SPLIT_RE) + .filter((line) => !line.includes(FEEDBACK_BANNER_TEXT)) + .join("\n"); +} + +function stripFinalLineBreak(output: string): string { + return output.endsWith("\n") ? output.slice(0, -1) : output; +} + +function stripAnsi(output: string): string { + return output.replace(ANSI_CSI_RE, "").replace(ANSI_OSC_RE, ""); +} + +function firstLogoLineIndex(output: string): number { + return stripAnsi(output) + .split(LINE_SPLIT_RE) + .findIndex((line) => line.includes("███████╗███████╗")); +} + +function ignorePromptResolution(): void { + // Snapshot tests render the prompt but never submit it. +} + +function setWelcomePrompt(store: WizardStore): void { + store.setPrompt({ + kind: "welcome", + options: { + title: "Sentry Init", + body: [ + "We'll use AI to inspect this project and configure Sentry.", + "You'll choose the setup before local files change.", + ], + punchline: "Continue to let Sentry use AI for setup.", + }, + resolve: ignorePromptResolution, + }); +} + +function makeReadFiles(count: number): string[] { + return Array.from( + { length: count }, + (_value, index) => `src/file-${String(index + 1).padStart(2, "0")}.ts` + ); +} + +describe("Ink App snapshot", () => { + test("feedback banner reserves padding on both edges", () => { + const banner = formatFeedbackBanner(120, "0.32.0-test.0"); + + expect(banner.length).toBe(120); + expect(banner.startsWith(" Sentry v0.32.0-test.0")).toBe(true); + expect(banner).toContain(FEEDBACK_BANNER_TEXT); + expect(banner.endsWith(" ")).toBe(true); + }); + + test("renders full-screen layout at 120 cols", async () => { + const store = new WizardStore(); + store.appendLog("info", "Hello world"); + store.appendLog("success", "Working\u2026"); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toMatch(LEARN_HEADER_RE); + expect(frame).toContain("App → SDK → Sentry → Issue"); + expect(frame).toContain("The SDK runs in your app."); + expect(frame).toContain("become issues with the clues"); + expect(frame).toContain("1/7"); + expect(frame).toMatch(TASKS_HEADER_RE); + expect(frame).toContain("Hello world"); + expect(frame).toContain("Working\u2026"); + expect(frame).toMatch(STATUS_TAB_RE); + expect(frame).toMatch(FILES_TAB_RE); + expect(frame).toMatch(KEYBOARD_HINT_RE); + }); + + test("renders the second learn card about debugging context", async () => { + const store = new WizardStore({ + learnState: { blockIndex: 1, lineIndex: 0, complete: false }, + }); + store.appendLog("info", "Reading project context"); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("Debug With Context"); + expect(frame).toContain("Issue → Trace → Replay → Fix"); + expect(frame).toContain("That context points to the fix."); + expect(frame).toContain("2/7"); + }); + + test("keeps tasks above rotating tips", async () => { + const store = new WizardStore({ + learnState: { blockIndex: 0, lineIndex: 0, complete: true }, + }); + + const frame = stripAnsi((await renderApp(store, 120)).allOutput()); + expect(frame).toContain("Did you know?"); + expect(frame.indexOf("Tasks")).toBeLessThan(frame.indexOf("Did you know?")); + }); + + test("pulses the active task arrow without changing its width", async () => { + vi.useFakeTimers(); + try { + const initialFrame = await renderActiveTaskFrameAfter(1); + expect(initialFrame).toContain("▶ Checking Sentry support"); + + const pulsedFrame = await renderActiveTaskFrameAfter(601); + expect(pulsedFrame).toContain("▷ Checking Sentry support"); + } finally { + vi.useRealTimers(); + } + }); + + test("renders single-column layout at narrow width", async () => { + const store = new WizardStore(); + store.appendLog("info", "Narrow terminal"); + + const frame = (await renderApp(store, 60)).allOutput(); + expect(frame).toContain("Narrow terminal"); + expect(frame).toMatch(STATUS_TAB_RE); + }); + + test("workflow screen does not repeat status messages in the footer", async () => { + const store = new WizardStore(); + store.appendStatus("Analyzing project..."); + store.appendStatus("Reading package.json"); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).not.toContain("Analyzing project..."); + expect(frame).not.toContain("Reading package.json"); + }); + + test("status history shortcut is not shown", async () => { + const store = new WizardStore(); + store.appendStatus("Analyzing project..."); + store.appendStatus("Reading package.json"); + store.appendStatus("Installing SDK"); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).not.toContain("toggle status"); + }); + + test("focused prompt text inherits terminal foreground", async () => { + const store = new WizardStore({ bannerRows: [], layout: "intro" }); + store.setPrompt({ + kind: "select", + message: "Choose a feature", + options: [ + { value: "errors", label: "Error Monitoring" }, + { value: "tracing", label: "Tracing" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("Choose a feature"); + expect(frame).toContain("Error Monitoring"); + expect(hasForcedWhiteForeground(withoutFeedbackBanner(frame))).toBe(false); + }); + + test("centered select options share aligned label and hint columns", async () => { + const options = [ + { value: "short", label: "Short", hint: "short-slug" }, + { value: "empower", label: "Empower Plant", hint: "demo" }, + { value: "sdks", label: "Sentry SDKs", hint: "sentry-sdks" }, + ]; + const store = new WizardStore({ bannerRows: [], layout: "intro" }); + store.setPrompt({ + kind: "select", + message: "Choose a project", + options, + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const frame = stripAnsi((await renderApp(store, 80)).allOutput()); + const lines = frame.split(LINE_SPLIT_RE); + const renderedOptions = options.map(({ label, hint }) => { + const line = lines.find((candidate) => candidate.includes(label)); + if (!line) { + throw new Error(`Missing rendered option: ${label}`); + } + const hintColumn = line.indexOf(hint); + if (hintColumn < 0) { + throw new Error(`Missing rendered hint: ${hint}`); + } + return { + hintColumn, + labelColumn: line.indexOf(label), + }; + }); + + expect(renderedOptions.map(({ labelColumn }) => labelColumn)).toEqual([ + renderedOptions[0]?.labelColumn, + renderedOptions[0]?.labelColumn, + renderedOptions[0]?.labelColumn, + ]); + expect(renderedOptions.map(({ hintColumn }) => hintColumn)).toEqual([ + renderedOptions[0]?.hintColumn, + renderedOptions[0]?.hintColumn, + renderedOptions[0]?.hintColumn, + ]); + }); + + test("workflow screen hides logo and shows feedback banner", async () => { + const store = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + cliVersion: "0.32.0-test.0", + }); + store.appendLog("info", "Checking project..."); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).not.toContain("███████╗███████╗"); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + expect(frame).toContain("Sentry v0.32.0-test.0"); + + const plainFrame = stripAnsi(frame); + const bannerLine = plainFrame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Sentry v") && line.includes("feedback")); + expect(bannerLine).toBeDefined(); + expect(bannerLine?.indexOf("Sentry v0.32.0-test.0")).toBeLessThan( + bannerLine?.indexOf("$ sentry cli feedback") ?? 0 + ); + }); + + test("welcome screen is centered and standalone", async () => { + const store = new WizardStore({ bannerRows: TEST_BANNER_ROWS }); + setWelcomePrompt(store); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("███████╗███████╗"); + expect(frame).not.toContain("Sentry Init"); + expect(frame).toContain("We'll use AI to inspect this project"); + expect(frame).toContain("Continue to let Sentry use AI for setup."); + expect(frame).toContain("Continue"); + expect(frame).toContain("Cancel"); + expect(frame).not.toMatch(LEARN_HEADER_RE); + expect(frame).not.toMatch(TASKS_HEADER_RE); + expect(frame).not.toMatch(STATUS_TAB_RE); + expect(frame).not.toMatch(FILES_TAB_RE); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + expect(hasForcedWhiteForeground(withoutFeedbackBanner(frame))).toBe(false); + }); + + test("welcome banner preserves row alignment while centering the art", async () => { + const store = new WizardStore({ bannerRows: FULL_BANNER_LINES }); + setWelcomePrompt(store); + + const terminalColumns = 120; + const frame = stripAnsi( + (await renderApp(store, terminalColumns)).allOutput() + ); + const lines = frame.split(LINE_SPLIT_RE); + const bannerOrigin = Math.floor( + (terminalColumns - bannerLinesWidth(FULL_BANNER_LINES)) / 2 + ); + + for (const { content } of FULL_BANNER_LINES) { + const visibleContent = content.trimStart(); + const leadingSpaces = content.length - visibleContent.length; + const renderedRow = lines.find((line) => line.includes(visibleContent)); + + expect(renderedRow).toBeDefined(); + expect(renderedRow?.indexOf(visibleContent)).toBe( + bannerOrigin + leadingSpaces + ); + } + }); + + test("intro banner shrinks to fit narrow terminals (never wraps)", async () => { + // A banner wider than the narrow terminal; distinctive marker so we can tell + // whether it was rendered verbatim or replaced by a fitting variant. + const wideRow = "Z".repeat(78); + const makeStore = () => + new WizardStore({ + bannerRows: [{ content: wideRow, color: "#B4A4DE" }], + layout: "intro", + }); + + // Wide terminal: the provided rows fit, so they render as-is. + const wide = (await renderApp(makeStore(), 120)).allOutput(); + expect(wide).toContain("Z".repeat(40)); + + // Narrow terminal (e.g. split pane): the too-wide rows are replaced by the + // widest fitting variant (the block wordmark), so nothing wraps. + const narrow = (await renderApp(makeStore(), 60)).allOutput(); + expect(narrow).not.toContain("Z".repeat(20)); + expect(narrow).toContain("████"); + }); + + test("intro preflight prompts stay centered and standalone", async () => { + const store = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + layout: "intro", + }); + store.appendLog("warn", "You have uncommitted or untracked files."); + store.appendLog("success", "Prerequisites OK"); + store.setPrompt({ + kind: "confirm", + message: "Continue with uncommitted changes?", + initialValue: true, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("███████╗███████╗"); + expect(frame).not.toContain("Sentry Init"); + expect(frame).not.toContain("uncommitted or untracked files"); + expect(frame).not.toContain("Prerequisites OK"); + expect(frame).toContain("Continue with uncommitted changes?"); + expect(frame).not.toContain("We'll use AI to inspect this project"); + expect(frame).not.toContain("Continue to let Sentry use AI for setup."); + expect(frame).not.toContain("◇ Continue with uncommitted changes?"); + expect(frame).not.toMatch(LEARN_HEADER_RE); + expect(frame).not.toMatch(TASKS_HEADER_RE); + expect(frame).not.toMatch(STATUS_TAB_RE); + expect(frame).not.toMatch(FILES_TAB_RE); + expect(frame).not.toContain("switch tab"); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + expect(hasForcedWhiteForeground(withoutFeedbackBanner(frame))).toBe(false); + }); + + test("intro logo row stays fixed across prompt heights", async () => { + const shortPrompt = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + layout: "intro", + }); + shortPrompt.setPrompt({ + kind: "confirm", + message: "Continue with setup?", + initialValue: true, + resolve: ignorePromptResolution, + }); + + const longPrompt = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + layout: "intro", + }); + longPrompt.setPrompt({ + kind: "select", + message: + "Choose the Sentry project and team context to use for this initialization before setup continues.", + options: [ + { value: "recommended", label: "Use the detected project" }, + { value: "existing", label: "Choose an existing project" }, + { value: "create", label: "Create a new project" }, + { value: "team", label: "Change team first" }, + { value: "cancel", label: "Cancel setup" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const shortFrame = ( + await renderApp(shortPrompt, 120, { rows: 24 }) + ).allOutput(); + const longFrame = ( + await renderApp(longPrompt, 120, { rows: 24 }) + ).allOutput(); + + const shortLogoLine = firstLogoLineIndex(shortFrame); + const longLogoLine = firstLogoLineIndex(longFrame); + expect(shortLogoLine).toBeGreaterThanOrEqual(0); + expect(longLogoLine).toBe(shortLogoLine); + }); + + test("feature multiselect shows descriptions and the included baseline", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features to enable", + details: [ + { + text: "Based on your project, these features are available to set up.", + }, + ], + options: [ + { + value: "errorMonitoring", + label: "Error Monitoring", + description: "Automatically capture exceptions and stack traces", + locked: true, + }, + { + value: "logs", + label: "Logging", + description: "See logs in context with errors and performance issues", + }, + { + value: "sessionReplay", + label: "Session Replay", + description: "Watch real user sessions to see what went wrong", + }, + { + value: "performanceMonitoring", + label: "Tracing", + description: + "Find bottlenecks, broken requests, and understand application flow end-to-end", + }, + { + value: "sourceMaps", + label: "Source Maps", + description: + "Turn minified production stack traces back into your original source code", + }, + ], + initialSelected: [ + "errorMonitoring", + "logs", + "sessionReplay", + "performanceMonitoring", + ], + required: false, + resolve: ignorePromptResolution, + }); + + const previousColorLevel = chalk.level; + chalk.level = 3; + let frame: string; + try { + frame = (await renderApp(store, 120)).latestFrame(); + } finally { + chalk.level = previousColorLevel; + } + const plainFrame = stripAnsi(frame); + expect(frame).toContain("Select features to enable"); + expect(frame).toContain("Based on your project, these features are"); + expect(frame).toContain("available to set up."); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain( + "Automatically capture exceptions and stack traces" + ); + expect(frame).toContain("Session Replay"); + expect(frame).toContain("Watch real user sessions to see what went wrong"); + expect(frame).toContain("Tracing"); + expect(frame).toContain("Find bottlenecks, broken requests"); + expect(frame).toContain("Source Maps"); + expect(plainFrame).toContain("4/5"); + expect(plainFrame).toContain( + "↑↓ move • space toggle • a all • enter continue" + ); + expect(plainFrame).toMatch(SPACE_TOGGLE_HINT_RE); + expect(plainFrame).toMatch(A_ALL_HINT_RE); + expect(plainFrame).toMatch(ENTER_CONTINUE_HINT_RE); + expect(plainFrame).toMatch(ESC_CANCEL_HINT_RE); + expect(plainFrame).not.toContain("required"); + expect(plainFrame).toContain("Error Monitoring (always included)"); + const errorMonitoringRow = frame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Error Monitoring")); + expect(errorMonitoringRow).toContain( + `${ANSI_ESCAPE_PREFIX}38;2;131;218;144m◼ ` + ); + expect(frame).not.toContain("Recommended setup"); + expect(frame).not.toContain("Apply recommended setup"); + expect(plainFrame.indexOf("Error Monitoring")).toBeLessThan( + plainFrame.indexOf("Logging") + ); + expect(plainFrame.indexOf("Logging")).toBeLessThan( + plainFrame.indexOf("Session Replay") + ); + expect(plainFrame.indexOf("Session Replay")).toBeLessThan( + plainFrame.indexOf("Tracing") + ); + expect(plainFrame.indexOf("Tracing")).toBeLessThan( + plainFrame.indexOf("Source Maps") + ); + const lines = plainFrame.split(LINE_SPLIT_RE); + const shortcutLine = lines.findIndex((line) => + line.includes("↑↓ move • space toggle • a all • enter continue") + ); + const contextLastLine = lines.findIndex((line) => + line.includes("available to set up.") + ); + const firstFeatureLine = lines.findIndex((line) => + line.includes("Error Monitoring") + ); + const lastFeatureLine = lines.findIndex((line) => + line.includes("Source Maps") + ); + expect(contextLastLine).toBeGreaterThan(0); + expect(firstFeatureLine - contextLastLine).toBeGreaterThan(1); + expect(shortcutLine).toBeGreaterThan(0); + expect(lastFeatureLine).toBeGreaterThan(0); + expect(shortcutLine - lastFeatureLine).toBeGreaterThan(2); + expect(plainFrame.indexOf("Tracing")).toBeLessThan( + plainFrame.indexOf("↑↓ move • space toggle • a all • enter continue") + ); + }); + + test("multiselect hints render and locked options survive toggle all", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + options: [ + { + value: "errors", + label: "Error Monitoring", + hint: "captured by default", + locked: true, + }, + { value: "replay", label: "Session Replay" }, + ], + initialSelected: ["errors", "replay"], + required: false, + resolve, + }); + + const rendered = await renderApp(store, 120, { input: ["a", "\r"] }); + expect(stripAnsi(rendered.allOutput())).toContain("captured by default"); + expect(resolve).toHaveBeenCalledWith(["errors"]); + }); + + test("the multiselect cursor starts on the first unselected option", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + options: [ + { value: "errors", label: "Error Monitoring", locked: true }, + { value: "logs", label: "Logging" }, + { value: "replay", label: "Session Replay" }, + { value: "profiling", label: "Profiling" }, + ], + initialSelected: ["errors", "logs", "replay"], + required: false, + resolve, + }); + + await renderApp(store, 120, { input: [" ", "\r"] }); + expect(resolve).toHaveBeenCalledWith([ + "errors", + "logs", + "replay", + "profiling", + ]); + }); + + test("feature descriptions fit short terminals and keep the baseline pinned", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features to enable", + details: [ + { + text: "Based on your project, these features are available to set up.", + }, + ], + options: [ + { + value: "errors", + label: "Error Monitoring", + description: "Automatically capture exceptions and stack traces", + locked: true, + }, + ...Array.from({ length: 5 }, (_value, index) => ({ + value: `feature-${index + 1}`, + label: `Feature ${index + 1}`, + description: + "A longer explanation that wraps cleanly and still leaves enough room for navigation", + })), + ], + initialSelected: ["errors"], + required: false, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 60, { + input: [DOWN_ARROW, DOWN_ARROW, DOWN_ARROW], + rows: 16, + }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Feature 4"); + expect(frame).toContain("↑↓ move • space toggle • a all • enter continue"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + }); + + test("feature selection stays usable at 30 columns", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features to enable", + details: [ + { + text: "Based on your project, these features are available to set up.", + }, + ], + options: [ + { + value: "errors", + label: "Error Monitoring", + description: "Automatically capture exceptions and stack traces", + locked: true, + }, + { + value: "logs", + label: "Logging", + description: "See logs in context with errors and performance issues", + }, + { + value: "replay", + label: "Session Replay", + description: "Watch real user sessions to see what went wrong", + }, + { + value: "tracing", + label: "Tracing", + description: + "Find bottlenecks, broken requests, and understand application flow end-to-end", + }, + { + value: "profiling", + label: "Profiling", + description: + "Pinpoint the functions and lines of code responsible for performance issues", + }, + ], + initialSelected: ["errors", "logs", "replay", "tracing"], + required: false, + resolve, + }); + + const rendered = await renderApp(store, 30, { + input: [DOWN_ARROW, DOWN_ARROW, " ", "\r"], + rows: 16, + }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Session Replay"); + expect(frame).toContain("space toggle"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + expect(resolve).toHaveBeenCalledWith(["errors", "logs", "tracing"]); + }); + + test("feature review shows the selected setup and a way back", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + { text: "✓ Error Monitoring", tone: "success" }, + { text: "✓ Session Replay", tone: "success" }, + { text: "✓ Tracing", tone: "success" }, + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve, + }); + + const frame = stripAnsi((await renderApp(store, 120)).latestFrame()); + expect(frame).toContain("Review your Sentry setup"); + expect(frame).toContain("We'll add these features:"); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Tracing"); + expect(frame).toContain("Session Replay"); + expect(frame).toContain("Continue"); + expect(frame).toContain("Back"); + expect(frame).not.toContain("Change features"); + expect(frame).toContain( + "We'll modify project files for this Sentry setup." + ); + expect(frame.indexOf("✓ Tracing")).toBeLessThan( + frame.indexOf("We'll modify project files for this Sentry setup.") + ); + expect( + frame.indexOf("We'll modify project files for this Sentry setup.") + ).toBeLessThan(frame.indexOf("Continue")); + const reviewLines = frame + .split(LINE_SPLIT_RE) + .filter((line) => line.includes("✓ ")); + expect(reviewLines).toHaveLength(3); + expect( + reviewLines.every((line) => (line.match(/✓/g) ?? []).length === 1) + ).toBe(true); + + await renderApp(store, 120, { input: [DOWN_ARROW, "\r"] }); + expect(resolve).toHaveBeenCalledWith("back"); + }); + + test("feature review keeps the workflow content anchored for the next step", async () => { + const reviewStore = new WizardStore({ bannerRows: [] }); + reviewStore.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + { text: "✓ Error Monitoring", tone: "success" }, + { text: "✓ Logging", tone: "success" }, + { text: "✓ Tracing", tone: "success" }, + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + const planStore = new WizardStore({ bannerRows: [] }); + planStore.startSpinner("Planning Sentry changes"); + + const reviewFrame = stripAnsi( + (await renderApp(reviewStore, 120)).latestFrame() + ); + const planFrame = stripAnsi( + (await renderApp(planStore, 120)).latestFrame() + ); + const reviewLine = reviewFrame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Review your Sentry setup")); + const planLine = planFrame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Planning Sentry changes")); + + expect(reviewLine).toBeDefined(); + expect(planLine).toBeDefined(); + expect(reviewLine?.indexOf("Review your Sentry setup")).toBe( + planLine?.indexOf("Planning Sentry changes") + ); + }); + + test.each([ + 120, 60, 30, + ])("review prompts keep their warning and actions visible at %i columns", async (columns) => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog("warn", "Review warning remains visible"); + store.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + ...[ + "Agent Tracing", + "Application Metrics", + "Crons", + "Error Monitoring", + "Logging", + "MCP Observability", + "Profiling", + "Session Replay", + "Source Maps", + "Tracing", + ].map((feature) => ({ + text: `✓ ${feature}`, + tone: "success" as const, + })), + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, columns, { + input: Array.from({ length: 12 }, () => PAGE_DOWN), + rows: 16, + }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + const normalizedFrame = frame.replace(/\s+/g, " "); + expect(frame).toContain("Review your Sentry setup"); + expect(frame).toContain("Review warning remains"); + expect(frame).toContain("10-10/10 · pgup/pgdn"); + expect(frame).toContain("Tracing"); + expect(normalizedFrame).toContain( + "We'll modify project files for this Sentry setup." + ); + expect(frame).toContain("Continue"); + expect(frame).toContain("Back"); + expect(frame).not.toContain("Change features"); + expect(frame).toContain("Status"); + expect(frame).toContain("Files"); + expect(frame).toContain("↑↓ navigate"); + expect(frame).toContain("enter confirm"); + expect(frame).toContain("Sentry"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + }); + + test("narrow review paging remains reversible when warnings accumulate", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog("warn", "An older review warning"); + store.appendLog("error", "The latest review warning remains visible"); + store.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + ...Array.from({ length: 10 }, (_value, index) => ({ + text: `✓ Feature ${index + 1}`, + tone: "success" as const, + })), + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const firstFrame = stripAnsi( + (await renderApp(store, 30, { rows: 16 })).latestFrame() + ); + expect(firstFrame).not.toContain("An older review warning"); + expect(firstFrame).toContain("latest review warning"); + expect(firstFrame).toContain("✓ Feature 1"); + expect(firstFrame).toContain("1-1/10 · pgup/pgdn"); + + const nextFrame = stripAnsi( + ( + await renderApp(store, 30, { input: [PAGE_DOWN], rows: 16 }) + ).latestFrame() + ); + expect(nextFrame).toContain("✓ Feature 2"); + expect(nextFrame).toContain("2-2/10 · pgup/pgdn"); + + const previousFrame = stripAnsi( + ( + await renderApp(store, 30, { + input: [PAGE_DOWN, PAGE_UP], + rows: 16, + }) + ).latestFrame() + ); + expect(previousFrame).toContain("✓ Feature 1"); + expect(previousFrame).toContain("1-1/10 · pgup/pgdn"); + expect(previousFrame).toContain("Continue"); + expect(previousFrame).toContain("Back"); + expect(previousFrame).toContain("Status"); + expect(previousFrame).toContain("Files"); + }); + + test("workflow prompts hide routine logs but keep warnings and tasks", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog( + "success", + 'Using existing project "nextjs-sentry-test" in bete-dev' + ); + store.appendLog("success", "Selecting features"); + store.appendLog("info", "Routine context loaded"); + store.appendLog("message", "Internal progress detail"); + store.appendLog("warn", "Heads up before choosing features"); + store.appendLog("error", "Something needs attention"); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + options: [ + { value: "sessionReplay", label: "Session Replay" }, + { value: "profiling", label: "Profiling" }, + ], + initialSelected: [], + required: false, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + const plainFrame = stripAnsi(frame); + expect(frame).not.toContain("Using existing project"); + expect(plainFrame).not.toMatch(COMPLETED_SELECTING_FEATURES_RE); + expect(frame).not.toContain("Routine context loaded"); + expect(frame).not.toContain("Internal progress detail"); + expect(frame).toContain("Heads up before choosing features"); + expect(frame).toContain("Something needs attention"); + expect(frame).toContain("Select features"); + expect(frame).toMatch(TASKS_HEADER_RE); + }); + + test("prompt shortcuts replace app shortcuts while prompt is active", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "select", + message: "Choose a feature", + options: [ + { value: "errors", label: "Error Monitoring" }, + { value: "tracing", label: "Tracing" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("navigate"); + expect(frame).toContain("confirm"); + expect(frame).toContain("cancel"); + expect(frame).not.toContain("switch tab"); + }); + + test("long select prompts only render options that fit the terminal", async () => { + const store = new WizardStore({ + bannerRows: FULL_BANNER_LINES, + layout: "intro", + }); + store.setPrompt({ + kind: "select", + message: "Which team should own this project?", + options: Array.from({ length: 20 }, (_value, index) => ({ + value: `team-${index + 1}`, + label: `Team ${index + 1}`, + })), + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 120, { rows: 24 }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Which team should own this project?"); + expect(frame).toContain("(1/20)"); + expect(frame).toContain("Team 4"); + expect(frame).not.toContain("Team 5"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(24); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + + const scrolledFrame = stripAnsi( + ( + await renderApp(store, 120, { + input: Array.from({ length: 7 }, () => DOWN_ARROW), + rows: 24, + }) + ).allOutput() + ); + expect(scrolledFrame).toContain("(8/20)"); + expect(scrolledFrame).toContain("Team 8"); + }); + + test("long multiselect prompts only render options that fit the terminal", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog( + "warn", + "A warning remains visible while choosing features" + ); + store.appendLog( + "error", + "An error remains visible while choosing features" + ); + store.appendLog("warn", "A second warning also remains visible"); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + details: [{ text: "Review the monitoring choices" }], + options: Array.from({ length: 20 }, (_value, index) => ({ + value: `feature-${index + 1}`, + label: `Feature ${index + 1}`, + })), + initialSelected: [], + required: false, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 120, { rows: 16 }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("0/20 selected • 1/20"); + expect(frame).toContain("Review the monitoring choices"); + expect(frame).toContain("A second warning also remains visible"); + expect(frame).toContain("Feature 1"); + expect(frame).not.toContain("Feature 2"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + + const scrolledFrame = stripFinalLineBreak( + stripAnsi( + ( + await renderApp(store, 120, { + input: Array.from({ length: 19 }, () => DOWN_ARROW), + rows: 16, + }) + ).latestFrame() + ) + ); + expect(scrolledFrame).toContain("0/20 selected • 20/20"); + expect(scrolledFrame).toContain("Feature 20"); + expect(scrolledFrame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + expect(scrolledFrame).toContain(FEEDBACK_BANNER_TEXT); + }); + + test("centered multiselect prompts fit with the full banner", async () => { + const store = new WizardStore({ + bannerRows: FULL_BANNER_LINES, + layout: "intro", + }); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + details: [{ text: "Review the monitoring choices" }], + options: Array.from({ length: 20 }, (_value, index) => ({ + value: `feature-${index + 1}`, + label: `Feature ${index + 1}`, + })), + initialSelected: [], + required: false, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 120, { rows: 30 }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Review the monitoring choices"); + expect(frame).toContain("Feature 5"); + expect(frame).not.toContain("Feature 6"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(30); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + }); + + test("long option hints stay on one terminal row", async () => { + const store = new WizardStore({ bannerRows: [], layout: "intro" }); + store.setPrompt({ + kind: "select", + message: "Choose a team", + options: [ + { + value: "team-1", + label: "A", + hint: "This deliberately long team name would wrap onto another row UNIQUE_TAIL", + }, + { value: "team-2", label: "Team 2" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const frame = stripAnsi( + (await renderApp(store, 40, { rows: 24 })).allOutput() + ); + expect(frame).toContain("A"); + expect(frame).not.toContain("UNIQUE_TAIL"); + }); + + test("file scroll shortcut appears only when the file tree overflows", async () => { + const shortTree = new WizardStore({ bannerRows: [] }); + shortTree.recordFilesReading(["src/app.ts"]); + shortTree.markFilesAnalyzed(["src/app.ts"]); + + const shortFrame = ( + await renderApp(shortTree, 120, { + input: [RIGHT_ARROW], + rows: 16, + }) + ).allOutput(); + expect(shortFrame).toMatch(FILES_HEADER_PINNED_RE); + expect(shortFrame).not.toContain("scroll"); + + const tallTree = new WizardStore({ bannerRows: [] }); + const readFiles = makeReadFiles(12); + tallTree.recordFilesReading(readFiles); + tallTree.markFilesAnalyzed(readFiles); + + const tallFrame = ( + await renderApp(tallTree, 120, { + input: [RIGHT_ARROW], + rows: 16, + }) + ).allOutput(); + expect(tallFrame).toMatch(FILES_HEADER_PINNED_RE); + expect(tallFrame).toContain("scroll"); + }); + + test("Status screen shows logs and banner, not file tree", async () => { + const store = new WizardStore(); + store.appendLog("info", "Checking project..."); + store.recordFilesReading(["package.json", "src/index.ts"]); + store.markFilesAnalyzed(["package.json"]); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("Checking project..."); + expect(frame).not.toMatch(FILES_HEADER_PINNED_RE); + expect(frame).not.toMatch(FILES_HEADER_UNPINNED_RE); + }); + + test("SummaryPanel renders featureBlurbs as Here's what we set up section", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setSummary({ + fields: [{ label: "Platform", value: "javascript.nextjs" }], + featureBlurbs: [ + { + label: "Error Monitoring", + blurb: "Captures every unhandled exception.", + }, + { label: "Tracing", blurb: "Traces requests end-to-end." }, + ], + }); + + const frame = stripAnsi((await renderApp(store, 120)).allOutput()); + expect(frame).toContain("Here's what we set up"); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Captures every unhandled exception."); + expect(frame).toContain("Tracing"); + expect(frame).toContain("Traces requests end-to-end."); + }); + + test("Ctrl+C path uses requestCancel via store, never bare process.exit", () => { + let cancels = 0; + const store = new WizardStore(); + store.setRequestCancel(() => { + cancels += 1; + }); + expect(store.getSnapshot().requestCancel).toBeDefined(); + store.getSnapshot().requestCancel?.(); + expect(cancels).toBe(1); + store.setRequestCancel(undefined); + expect(store.getSnapshot().requestCancel).toBeUndefined(); + }); +}); + +describe("completion screen", () => { + function completionStore(verified: boolean): WizardStore { + const completion = { + projectName: "my-app", + features: ["Errors", "Tracing"], + featureBlurbs: [ + { label: "Error Monitoring", blurb: "Captures unhandled exceptions." }, + { label: "Tracing", blurb: "Measures request performance." }, + ], + changedFileCount: 4, + issuesUrl: "https://acme.sentry.io/issues/?project=4507", + verification: verified + ? { + received: true, + eventUrl: "https://acme.sentry.io/issues/?query=event.id:abc123", + } + : { received: false }, + agentInstallCommand: "npx @sentry/ai install", + startCommand: "pnpm dev", + }; + return new WizardStore({ + layout: "workflow", + cliVersion: "9.9.9", + summary: { fields: [], completion }, + outroState: { + kind: "success", + dismiss: () => { + // no-op in tests + }, + actions: { + openUrl: () => { + // no-op in tests + }, + track: () => { + // no-op in tests + }, + }, + }, + }); + } + + test("guides the user to their first error when unverified", async () => { + const text = stripAnsi( + (await renderApp(completionStore(false), 100)).allOutput() + ); + // Header flows into the per-feature project info via ", with:". + expect(text).toContain("Sentry is set up in my-app"); + expect(text).toContain(", with:"); + expect(text).toContain("Error Monitoring"); + expect(text).toContain("Captures unhandled exceptions."); + // Just the file count — no platform key in this footnote. + expect(text).toContain("4 files changed"); + expect(text).toContain("See your first error"); + expect(text).toContain("pnpm dev"); + expect(text).toContain("/issues/?project=4507"); + // The link carries an inline "(o) to open" shortcut hint. + expect(text).toContain("(o) to open"); + expect(text).toContain("Open my Issues feed"); + expect(text).toContain("Install the Sentry agent plugin"); + // The raw MCP-config option was dropped — the plugin covers it. + expect(text).not.toContain("Set up the Sentry MCP"); + expect(text).toContain("Finish"); + // "Open the setup docs" was removed from the next steps. + expect(text).not.toContain("Open the setup docs"); + }); + + test("celebrates and deep-links the first event when verified", async () => { + const text = stripAnsi( + (await renderApp(completionStore(true), 100)).allOutput() + ); + expect(text).toContain("Sentry is set up in my-app"); + expect(text).toContain("First event received"); + expect(text).toContain("View my first event"); + expect(text).toContain("event.id:abc123"); + }); + + test("pressing o opens the first-error link", async () => { + const text = stripAnsi( + ( + await renderApp(completionStore(false), 100, { input: ["o"] }) + ).allOutput() + ); + // The `o` handler fires and confirms via a note. + expect(text).toContain("Opened Sentry in your browser."); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-frame.test.ts b/packages/cli/test/lib/init/ui/ink-frame.test.ts new file mode 100644 index 000000000..54da8ceaf --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-frame.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; +import { + getInkFrameMargin, + getInkFrameWidth, +} from "../../../../src/lib/init/ui/ink-frame.js"; + +describe("getInkFrameWidth", () => { + test("returns terminal width when below the 80-column minimum", () => { + expect(getInkFrameWidth(60)).toBe(60); + expect(getInkFrameWidth(40)).toBe(40); + }); + + test("returns 80 at the minimum boundary", () => { + expect(getInkFrameWidth(80)).toBe(80); + }); + + test("passes through widths between 80 and 120", () => { + expect(getInkFrameWidth(100)).toBe(100); + expect(getInkFrameWidth(119)).toBe(119); + }); + + test("caps at 120 columns", () => { + expect(getInkFrameWidth(120)).toBe(120); + expect(getInkFrameWidth(200)).toBe(120); + expect(getInkFrameWidth(999)).toBe(120); + }); +}); + +describe("getInkFrameMargin", () => { + test("centers the frame when terminal is wider than the frame", () => { + // (140 - 120) / 2 = 10 + expect(getInkFrameMargin(140, 120)).toBe(10); + // (130 - 100) / 2 = 15 + expect(getInkFrameMargin(130, 100)).toBe(15); + }); + + test("floors the margin when the difference is odd", () => { + // (121 - 120) / 2 = 0.5 → floor → 0 + expect(getInkFrameMargin(121, 120)).toBe(0); + // (123 - 120) / 2 = 1.5 → floor → 1 + expect(getInkFrameMargin(123, 120)).toBe(1); + }); + + test("returns 0 when terminal equals frame width", () => { + expect(getInkFrameMargin(120, 120)).toBe(0); + }); + + test("returns 0 when frame is wider than terminal (never negative)", () => { + expect(getInkFrameMargin(60, 80)).toBe(0); + expect(getInkFrameMargin(79, 120)).toBe(0); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-report.test.ts b/packages/cli/test/lib/init/ui/ink-report.test.ts new file mode 100644 index 000000000..2598e2036 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-report.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, test } from "vitest"; +import { stripAnsi } from "../../../../src/lib/formatters/plain-detect.js"; +import { + formatFailureReport, + formatSuccessExitLine, + formatSuccessReport, +} from "../../../../src/lib/init/ui/ink-report.js"; + +describe("formatSuccessReport with summary fields", () => { + test("renders each field label and value", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { + fields: [ + { label: "Project", value: "my-app" }, + { label: "Org", value: "acme" }, + ], + }) + ); + expect(output).toContain("Project"); + expect(output).toContain("my-app"); + expect(output).toContain("Org"); + expect(output).toContain("acme"); + }); + + test("labels are right-padded to the same width", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { + fields: [ + { label: "Short", value: "x" }, + { label: "LongerLabel", value: "y" }, + ], + }) + ); + const lines = output + .split("\n") + .filter((l) => l.includes(" x") || l.includes(" y")); + // Both lines should start with the same indentation + label block + const shortLine = lines.find((l) => l.includes("x")); + const longLine = lines.find((l) => l.includes("y")); + // The value "x" should be padded so the value column aligns with "y" + expect(shortLine?.indexOf("x")).toBe(longLine?.indexOf("y")); + }); + + test("empty fields list produces no extra blank section", () => { + const plain = stripAnsi(formatSuccessReport("Done!", { fields: [] })); + // Only the success icon + message; no indented field block + const nonEmpty = plain.split("\n").filter(Boolean); + expect(nonEmpty.length).toBe(1); + expect(nonEmpty[0]).toContain("Done!"); + }); +}); + +describe("formatSuccessReport with featureBlurbs", () => { + test("renders Here's what we set up heading and blurb content", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { + fields: [], + featureBlurbs: [ + { label: "Error Monitoring", blurb: "Captures exceptions." }, + { label: "Tracing", blurb: "Traces requests end-to-end." }, + ], + }) + ); + expect(output).toContain("Here's what we set up"); + expect(output).toContain("Error Monitoring"); + expect(output).toContain("Captures exceptions."); + expect(output).toContain("Tracing"); + expect(output).toContain("Traces requests end-to-end."); + }); + + test("no blurbs section when featureBlurbs is absent", () => { + const output = stripAnsi(formatSuccessReport("Done!", { fields: [] })); + expect(output).not.toContain("Here's what we set up"); + }); + + test("no blurbs section when featureBlurbs is empty", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { fields: [], featureBlurbs: [] }) + ); + expect(output).not.toContain("Here's what we set up"); + }); +}); + +describe("formatSuccessReport with changedFiles", () => { + test("shows Changed files heading and file paths", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { + fields: [], + changedFiles: [ + { path: "src/index.ts", action: "modify" }, + { path: "sentry.config.ts", action: "create" }, + ], + }) + ); + expect(output).toContain("Changed files"); + expect(output).toContain("index.ts"); + expect(output).toContain("sentry.config.ts"); + }); + + test("no Changed files heading when list is empty", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { fields: [], changedFiles: [] }) + ); + expect(output).not.toContain("Changed files"); + }); + + test("no Changed files heading when changedFiles is absent", () => { + const output = stripAnsi(formatSuccessReport("Done!", { fields: [] })); + expect(output).not.toContain("Changed files"); + }); +}); + +describe("formatFailureReport with error log entries", () => { + test("error entries appear in output", () => { + const output = stripAnsi( + formatFailureReport("Setup failed", [ + { severity: "error", text: "Could not reach Sentry" }, + { severity: "error", text: "Auth token missing" }, + ]) + ); + expect(output).toContain("Could not reach Sentry"); + expect(output).toContain("Auth token missing"); + }); + + test("only the last 5 error entries are shown", () => { + const logs = Array.from({ length: 8 }, (_, i) => ({ + severity: "error" as const, + text: `Error ${String(i + 1)}`, + })); + const output = stripAnsi(formatFailureReport("Setup failed", logs)); + expect(output).not.toContain("Error 1"); + expect(output).not.toContain("Error 2"); + expect(output).not.toContain("Error 3"); + expect(output).toContain("Error 4"); + expect(output).toContain("Error 8"); + }); + + test("entry whose text equals the top-level message is excluded", () => { + const output = stripAnsi( + formatFailureReport("Setup failed", [ + { severity: "error", text: "Setup failed" }, + { severity: "error", text: "Underlying cause" }, + ]) + ); + const lines = output.split("\n"); + // "Setup failed" appears once (the heading), not a second time as a log entry + const matches = lines.filter((l) => l.includes("Setup failed")); + expect(matches.length).toBe(1); + expect(output).toContain("Underlying cause"); + }); + + test("colon-separated entry splits label from detail onto separate lines", () => { + const output = stripAnsi( + formatFailureReport("Setup failed", [ + { severity: "error", text: "Network error: connection refused" }, + ]) + ); + const lines = output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const labelLine = lines.find((l) => l === "Network error"); + const detailLine = lines.find((l) => l === "connection refused"); + expect(labelLine).toBeDefined(); + expect(detailLine).toBeDefined(); + }); + + test("non-error log entries are not included", () => { + const output = stripAnsi( + formatFailureReport("Setup failed", [ + { severity: "info", text: "Info message" }, + { severity: "warn", text: "Warn message" }, + { severity: "error", text: "Real error" }, + ]) + ); + expect(output).not.toContain("Info message"); + expect(output).not.toContain("Warn message"); + expect(output).toContain("Real error"); + }); +}); + +describe("Ink post-dispose feedback reports", () => { + test("success report includes the success feedback command", () => { + const output = stripAnsi( + formatSuccessReport( + "Sentry SDK installed successfully!", + undefined, + [ + "Nice, setup made it through.", + "Tell us what felt great or rough:", + '$ sentry cli feedback "sentry init worked well"', + ].join("\n") + ) + ); + + expect(output).toContain("Sentry SDK installed successfully!"); + expect(output).toContain( + [ + "Nice, setup made it through.", + " Tell us what felt great or rough:", + ' $ sentry cli feedback "sentry init worked well"', + "", + ].join("\n") + ); + expect(output.endsWith("\n")).toBe(true); + }); + + test("failure report includes the failed feedback command", () => { + const output = stripAnsi( + formatFailureReport( + "Setup failed", + [], + [ + "Setup hit a wall.", + "Tell us what happened so we can fix it:", + '$ sentry cli feedback "sentry init failed"', + ].join("\n") + ) + ); + + expect(output).toContain("Setup failed"); + expect(output).toContain( + [ + "Setup hit a wall.", + " Tell us what happened so we can fix it:", + ' $ sentry cli feedback "sentry init failed"', + "", + ].join("\n") + ); + expect(output.endsWith("\n")).toBe(true); + }); + + test("cancel report includes the cancelled feedback command", () => { + const output = stripAnsi( + formatFailureReport( + "Setup cancelled.", + [], + [ + "Sad to see setup stop. Was something going sideways?", + "Tell us so we can fix it:", + '$ sentry cli feedback "sentry init was cancelled"', + ].join("\n") + ) + ); + + expect(output).toContain("Setup cancelled."); + expect(output).toContain( + [ + "Sad to see setup stop. Was something going sideways?", + " Tell us so we can fix it:", + ' $ sentry cli feedback "sentry init was cancelled"', + "", + ].join("\n") + ); + expect(output.endsWith("\n")).toBe(true); + }); +}); + +describe("formatSuccessExitLine", () => { + const baseCompletion = { + projectName: "my-app", + features: [], + featureBlurbs: [], + changedFileCount: 3, + issuesUrl: "https://acme.sentry.io/issues/", + agentInstallCommand: "npx @sentry/ai install", + }; + + test("is a compact confirmation — no changed-files or feature dump", () => { + const output = stripAnsi( + formatSuccessExitLine({ + fields: [{ label: "Platform", value: "next.js" }], + changedFiles: [{ action: "create", path: "instrument.ts" }], + featureBlurbs: [{ label: "Errors", blurb: "Captures exceptions." }], + completion: { ...baseCompletion, verification: { received: false } }, + }) + ); + expect(output).toContain("Sentry is watching my-app"); + expect(output).toContain("See your first error"); + expect(output).toContain("https://acme.sentry.io/issues/"); + // The full summary stays on the interactive screen, not the exit echo. + expect(output).not.toContain("Here's what we set up"); + expect(output).not.toContain("Changed files"); + expect(output).not.toContain("instrument.ts"); + // No feedback prompt chained onto the success exit. + expect(output).not.toContain("sentry cli feedback"); + }); + + test("celebrates and deep-links the event when verified", () => { + const output = stripAnsi( + formatSuccessExitLine({ + fields: [], + completion: { + ...baseCompletion, + verification: { + received: true, + eventUrl: "https://acme.sentry.io/issues/?query=event.id:abc", + }, + }, + }) + ); + expect(output).toContain("View your first event"); + expect(output).toContain("event.id:abc"); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-shortcuts.test.ts b/packages/cli/test/lib/init/ui/ink-shortcuts.test.ts new file mode 100644 index 000000000..a342de921 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-shortcuts.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { arrangeShortcutHints } from "../../../../src/lib/init/ui/ink-shortcuts.js"; + +describe("arrangeShortcutHints", () => { + test("orders by priority while preserving same-priority order", () => { + expect( + arrangeShortcutHints([ + { key: "enter", action: "confirm", priority: 40 }, + { key: "\u2190\u2192", action: "switch tab", priority: 10 }, + { key: "esc", action: "cancel", priority: 40 }, + ]) + ).toEqual([ + { key: "\u2190\u2192", action: "switch tab", priority: 10 }, + { key: "enter", action: "confirm", priority: 40 }, + { key: "esc", action: "cancel", priority: 40 }, + ]); + }); + + test("drops duplicate key/action pairs", () => { + expect( + arrangeShortcutHints([ + { key: "s", action: "toggle status", priority: 20 }, + { key: "s", action: "toggle status", priority: 20 }, + { key: "s", action: "save", priority: 30 }, + ]) + ).toEqual([ + { key: "s", action: "toggle status", priority: 20 }, + { key: "s", action: "save", priority: 30 }, + ]); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-ui-teardown.test.ts b/packages/cli/test/lib/init/ui/ink-ui-teardown.test.ts new file mode 100644 index 000000000..94df2e436 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-ui-teardown.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +// Stub the post-exit installer spawn so teardown never launches a real process. +// `runInheritedCommand` only listens for "close"/"error", so a fake child that +// resolves "close" on the next microtask is enough to let disposal complete. +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(() => { + const child = { + on(event: string, cb: () => void) { + if (event === "close") { + queueMicrotask(cb); + } + return child; + }, + }; + return child as unknown as ReturnType; + }), + }; +}); + +const { InkUI } = await import("../../../../src/lib/init/ui/ink-ui.js"); +const { WizardStore } = await import( + "../../../../src/lib/init/ui/wizard-store.js" +); + +function createUi(): { + ui: InstanceType; + store: InstanceType; +} { + const store = new WizardStore(); + const instance = { + clear: vi.fn(), + rerender: vi.fn(), + unmount: vi.fn(), + waitUntilExit: vi.fn().mockResolvedValue(undefined), + }; + return { ui: new InkUI(instance, store, null), store }; +} + +function captureStdout(): string[] { + const writes: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }); + return writes; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("InkUI teardown — installer handoff", () => { + test("clears the screen and homes the cursor when a post-exit action is queued", async () => { + const { ui, store } = createUi(); + // The completion screen queues this when the user opts into the plugin. + store.queuePostExitAction("npx @sentry/ai install"); + const writes = captureStdout(); + + await ui[Symbol.asyncDispose](); + + // Exiting the alt screen returns the cursor to where `sentry init` was + // invoked (usually low on the screen). Clearing + homing after the exit + // makes the installer's full-screen UI start at the top instead of + // mid-screen with a blank gap above it. + expect(writes.join("")).toContain("\x1b[?1049l\x1b[2J\x1b[H"); + }); + + test("only restores the alt screen on a normal exit (no post-exit action)", async () => { + const { ui } = createUi(); + const writes = captureStdout(); + + await ui[Symbol.asyncDispose](); + + const joined = writes.join(""); + expect(joined).toContain("\x1b[?1049l"); + // No clear/home — the compact exit summary flows into the restored + // scrollback at the invocation point, as before. + expect(joined).not.toContain("\x1b[?1049l\x1b[2J\x1b[H"); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-ui-telemetry.test.ts b/packages/cli/test/lib/init/ui/ink-ui-telemetry.test.ts new file mode 100644 index 000000000..d90f2d10d --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-ui-telemetry.test.ts @@ -0,0 +1,349 @@ +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { InkUI } from "../../../../src/lib/init/ui/ink-ui.js"; +import { + CANCELLED, + type Cancelled, +} from "../../../../src/lib/init/ui/types.js"; +import { WizardStore } from "../../../../src/lib/init/ui/wizard-store.js"; +import { createWizardPromptTelemetry } from "../../../../src/lib/telemetry.js"; + +function createUi(options: { initialWelcome?: boolean } = {}): { + ui: InkUI; + store: WizardStore; +} { + const store = new WizardStore(); + const instance = { + clear: vi.fn(), + rerender: vi.fn(), + unmount: vi.fn(), + waitUntilExit: vi.fn().mockResolvedValue(undefined), + }; + if (!options.initialWelcome) { + return { ui: new InkUI(instance, store, null), store }; + } + + let resolvePromise!: (value: "continue" | Cancelled) => void; + const initialWelcome: { + promise: Promise<"continue" | Cancelled>; + tracedPromise?: Promise<"continue" | Cancelled>; + resolve(value: "continue" | Cancelled): void; + settled: boolean; + } = { + promise: new Promise<"continue" | Cancelled>((resolve) => { + resolvePromise = resolve; + }), + resolve(value: "continue" | Cancelled) { + if (initialWelcome.settled) { + return; + } + initialWelcome.settled = true; + resolvePromise(value); + }, + settled: false, + }; + store.setPrompt({ + kind: "welcome", + options: { + title: "Welcome", + body: ["Configure Sentry"], + punchline: "Continue?", + }, + resolve(value) { + store.setPrompt(null); + initialWelcome.resolve(value === null ? CANCELLED : value); + }, + }); + const promptTelemetry = createWizardPromptTelemetry(); + return { + ui: new InkUI(instance, store, null, { + initialWelcome, + telemetry: promptTelemetry, + }), + store, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("InkUI prompt telemetry", () => { + test("replaces sequential prompts without an empty frame", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const promptKinds: Array = []; + const unsubscribe = store.subscribe(() => { + promptKinds.push(store.getSnapshot().prompt?.kind ?? null); + }); + + const resultPromise = (async () => { + await ui.multiselect({ + message: "Choose features", + options: [{ label: "Tracing", value: "performanceMonitoring" }], + }); + return ui.select({ + message: "Review your Sentry setup", + options: [ + { label: "Continue", value: "continue" }, + { label: "Back", value: "back" }, + ], + }); + })(); + + const featurePrompt = store.getSnapshot().prompt; + expect(featurePrompt?.kind).toBe("multiselect"); + if (featurePrompt?.kind !== "multiselect") { + throw new Error("Expected a multiselect prompt"); + } + featurePrompt.resolve(["performanceMonitoring"]); + + await vi.waitFor(() => { + expect(store.getSnapshot().prompt?.kind).toBe("select"); + }); + expect(promptKinds).toEqual(["multiselect", "select"]); + + const reviewPrompt = store.getSnapshot().prompt; + if (reviewPrompt?.kind !== "select") { + throw new Error("Expected a select prompt"); + } + reviewPrompt.resolve("continue"); + await expect(resultPromise).resolves.toBe("continue"); + await new Promise((resolve) => setImmediate(resolve)); + expect(promptKinds).toEqual(["multiselect", "select", null]); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + + test("returns from review to feature selection without an empty frame", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const promptKinds: Array = []; + const unsubscribe = store.subscribe(() => { + promptKinds.push(store.getSnapshot().prompt?.kind ?? null); + }); + + const resultPromise = (async () => { + await ui.select({ + message: "Review your Sentry setup", + options: [ + { label: "Continue", value: "continue" }, + { label: "Back", value: "back" }, + ], + }); + return ui.multiselect({ + message: "Choose features", + options: [{ label: "Tracing", value: "performanceMonitoring" }], + }); + })(); + + const reviewPrompt = store.getSnapshot().prompt; + expect(reviewPrompt?.kind).toBe("select"); + if (reviewPrompt?.kind !== "select") { + throw new Error("Expected a select prompt"); + } + reviewPrompt.resolve("back"); + + await vi.waitFor(() => { + expect(store.getSnapshot().prompt?.kind).toBe("multiselect"); + }); + expect(promptKinds).toEqual(["select", "multiselect"]); + + const featurePrompt = store.getSnapshot().prompt; + if (featurePrompt?.kind !== "multiselect") { + throw new Error("Expected a multiselect prompt"); + } + featurePrompt.resolve(["performanceMonitoring"]); + await expect(resultPromise).resolves.toEqual(["performanceMonitoring"]); + await new Promise((resolve) => setImmediate(resolve)); + expect(promptKinds).toEqual(["select", "multiselect", null]); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + + test("replaces the completed review atomically with planning progress", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const states: Array<{ + prompt: string | null; + spinnerActive: boolean; + spinnerMessage: string; + }> = []; + const unsubscribe = store.subscribe(() => { + const snapshot = store.getSnapshot(); + states.push({ + prompt: snapshot.prompt?.kind ?? null, + spinnerActive: snapshot.spinner.active, + spinnerMessage: snapshot.spinner.message, + }); + }); + + const resultPromise = (async () => { + const result = await ui.select({ + message: "Review your Sentry setup", + options: [ + { label: "Continue", value: "continue" }, + { label: "Back", value: "back" }, + ], + }); + ui.spinner().start("Planning code changes..."); + return result; + })(); + + const reviewPrompt = store.getSnapshot().prompt; + if (reviewPrompt?.kind !== "select") { + throw new Error("Expected a select prompt"); + } + reviewPrompt.resolve("continue"); + + await vi.waitFor(() => { + expect(store.getSnapshot().spinner.active).toBe(true); + }); + await expect(resultPromise).resolves.toBe("continue"); + expect(states).not.toContainEqual( + expect.objectContaining({ prompt: null, spinnerActive: false }) + ); + expect(states).not.toContainEqual( + expect.objectContaining({ prompt: "select", spinnerActive: true }) + ); + expect(store.getSnapshot()).toMatchObject({ + prompt: null, + spinner: { + active: true, + message: "Planning code changes...", + }, + }); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + + test.each([ + "select", + "multiselect", + ] as const)("clears a cancelled %s prompt after returning the cancellation", async (kind) => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const promptKinds: Array = []; + const unsubscribe = store.subscribe(() => { + promptKinds.push(store.getSnapshot().prompt?.kind ?? null); + }); + + const resultPromise = + kind === "select" + ? ui.select({ + message: "Review your Sentry setup", + options: [{ label: "Continue", value: "continue" }], + }) + : ui.multiselect({ + message: "Choose features", + options: [{ label: "Tracing", value: "performanceMonitoring" }], + }); + const prompt = store.getSnapshot().prompt; + expect(prompt?.kind).toBe(kind); + if (prompt?.kind !== "select" && prompt?.kind !== "multiselect") { + throw new Error(`Expected a ${kind} prompt`); + } + + prompt.resolve(null); + await expect(resultPromise).resolves.toBe(CANCELLED); + await new Promise((resolve) => setImmediate(resolve)); + expect(store.getSnapshot().prompt).toBeNull(); + expect(promptKinds).toEqual([kind, null]); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + + test("attributes workflow prompts to the active step", async () => { + const metricSpy = vi.spyOn(Sentry.metrics, "distribution"); + const startSpanSpy = vi.spyOn(Sentry, "startSpan"); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + + ui.setStep("select-features", "in_progress"); + const resultPromise = ui.multiselect({ + message: "Choose features", + options: [{ label: "Tracing", value: "performanceMonitoring" }], + }); + const prompt = store.getSnapshot().prompt; + expect(prompt?.kind).toBe("multiselect"); + if (prompt?.kind !== "multiselect") { + throw new Error("Expected a multiselect prompt"); + } + prompt.resolve(["performanceMonitoring"]); + + await expect(resultPromise).resolves.toEqual(["performanceMonitoring"]); + expect(metricSpy).toHaveBeenCalledWith( + "wizard.user_wait_ms", + expect.any(Number), + { + attributes: { + prompt_kind: "multiselect", + workflow_step: "select-features", + }, + } + ); + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: "wizard.prompt.multiselect", + op: "ui.prompt", + onlyIfParent: true, + attributes: { + "wizard.prompt.kind": "multiselect", + "wizard.prompt.phase": "workflow", + "wizard.step.id": "select-features", + }, + }, + expect.any(Function) + ); + + await ui[Symbol.asyncDispose](); + }); + + test("records the welcome prompt as preflight rather than a workflow step", async () => { + const metricSpy = vi.spyOn(Sentry.metrics, "distribution"); + const startSpanSpy = vi.spyOn(Sentry, "startSpan"); + let now = 100; + vi.spyOn(globalThis.performance, "now").mockImplementation(() => now); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi({ initialWelcome: true }); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const prompt = store.getSnapshot().prompt; + expect(prompt?.kind).toBe("welcome"); + if (prompt?.kind !== "welcome") { + throw new Error("Expected a welcome prompt"); + } + now = 150; + prompt.resolve("continue"); + const resultPromise = ui.welcome({ + title: "Welcome", + body: ["Configure Sentry"], + punchline: "Continue?", + }); + + await expect(resultPromise).resolves.toBe("continue"); + expect(metricSpy).toHaveBeenCalledWith("wizard.user_wait_ms", 50, { + attributes: { prompt_kind: "welcome" }, + }); + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: "wizard.prompt.welcome", + op: "ui.prompt", + onlyIfParent: true, + attributes: { + "wizard.prompt.kind": "welcome", + "wizard.prompt.phase": "preflight", + }, + }, + expect.any(Function) + ); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + + await ui[Symbol.asyncDispose](); + }); +}); diff --git a/packages/cli/test/lib/init/ui/logging-ui.test.ts b/packages/cli/test/lib/init/ui/logging-ui.test.ts new file mode 100644 index 000000000..ad854d537 --- /dev/null +++ b/packages/cli/test/lib/init/ui/logging-ui.test.ts @@ -0,0 +1,333 @@ +/** + * Tests for LoggingUI — verifies non-interactive output is emitted to the + * appropriate stream (stdout vs stderr), spinners are line-stable, and + * prompt methods throw `LoggingUIPromptError`. + * + * Output is captured via injected `Writable` streams so we don't write to + * the real terminal during tests. + */ + +import { Writable } from "node:stream"; +import { describe, expect, test } from "vitest"; +import { stripAnsi } from "../../../../src/lib/formatters/plain-detect.js"; +import { + LoggingUI, + LoggingUIPromptError, +} from "../../../../src/lib/init/ui/logging-ui.js"; + +/** + * Test helper: constructs a LoggingUI with two in-memory sinks and + * exposes them as ANSI-stripped string snapshots. + * + * Stripping ANSI keeps assertions terminal-agnostic — `LoggingUI` runs + * markdown through `renderInlineMarkdown`, which can emit color codes + * depending on the parent process's TTY/`FORCE_COLOR` state. + */ +function createUI(): { + ui: LoggingUI; + stdout: () => string; + stderr: () => string; +} { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const stdout = new Writable({ + write(chunk, _encoding, callback): void { + stdoutChunks.push(Buffer.from(chunk)); + callback(); + }, + }); + const stderr = new Writable({ + write(chunk, _encoding, callback): void { + stderrChunks.push(Buffer.from(chunk)); + callback(); + }, + }); + const ui = new LoggingUI({ stdout, stderr }); + return { + ui, + stdout: () => stripAnsi(Buffer.concat(stdoutChunks).toString("utf-8")), + stderr: () => stripAnsi(Buffer.concat(stderrChunks).toString("utf-8")), + }; +} + +describe("LoggingUI lifecycle messages", () => { + test("intro writes to stdout", () => { + const { ui, stdout, stderr } = createUI(); + ui.intro("Starting wizard"); + expect(stdout()).toBe("Starting wizard\n"); + expect(stderr()).toBe(""); + }); + + test("outro writes to stdout", () => { + const { ui, stdout, stderr } = createUI(); + ui.outro("All done"); + expect(stdout()).toBe("All done\n"); + expect(stderr()).toBe(""); + }); + + test("cancel writes to stderr", () => { + const { ui, stdout, stderr } = createUI(); + ui.cancel("Aborted by user"); + expect(stdout()).toBe(""); + expect(stderr()).toBe("Aborted by user\n"); + }); + + test("feedback writes the copy-paste command to stdout", () => { + const { ui, stdout, stderr } = createUI(); + ui.feedback("cancelled"); + expect(stdout()).toBe( + [ + "Sad to see setup stop. Was something going sideways?", + "Tell us so we can fix it:", + '$ sentry cli feedback "sentry init was cancelled"', + "", + "", + ].join("\n") + ); + expect(stderr()).toBe(""); + }); +}); + +describe("LoggingUI log API", () => { + test("info writes to stdout with prefix", () => { + const { ui, stdout, stderr } = createUI(); + ui.log.info("hello"); + expect(stdout()).toBe("info: hello\n"); + expect(stderr()).toBe(""); + }); + + test("success writes to stdout with prefix", () => { + const { ui, stdout } = createUI(); + ui.log.success("done"); + expect(stdout()).toBe("ok: done\n"); + }); + + test("warn writes to stderr with prefix", () => { + const { ui, stdout, stderr } = createUI(); + ui.log.warn("careful"); + expect(stdout()).toBe(""); + expect(stderr()).toBe("warn: careful\n"); + }); + + test("error writes to stderr with prefix", () => { + const { ui, stdout, stderr } = createUI(); + ui.log.error("nope"); + expect(stdout()).toBe(""); + expect(stderr()).toBe("error: nope\n"); + }); + + test("message renders markdown to stdout", () => { + const { ui, stdout } = createUI(); + ui.log.message("# Heading\n\nbody"); + const out = stdout(); + // We don't assert exact ANSI output — just confirm content survived. + expect(out).toContain("Heading"); + expect(out).toContain("body"); + expect(out.endsWith("\n")).toBe(true); + }); +}); + +describe("LoggingUI spinner", () => { + test("emits a single line per lifecycle event", () => { + const { ui, stdout } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.message("Still working"); + spinner.stop("Done", 0); + const lines = stdout().split("\n").filter(Boolean); + expect(lines).toEqual(["Working", "Still working", "ok: Done"]); + }); + + test("error stop routes to stderr with error prefix", () => { + const { ui, stdout, stderr } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.stop("Boom", 1); + expect(stdout()).toBe("Working\n"); + expect(stderr()).toBe("error: Boom\n"); + }); + + test("warn stop uses warn prefix", () => { + const { ui, stdout } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.stop("Heads up", 2); + const lines = stdout().split("\n").filter(Boolean); + expect(lines.at(-1)).toBe("warn: Heads up"); + }); + + test("stop without start is a no-op", () => { + const { ui, stdout, stderr } = createUI(); + ui.spinner().stop("nothing", 0); + expect(stdout()).toBe(""); + expect(stderr()).toBe(""); + }); + + test("message after stop does not emit", () => { + const { ui, stdout } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.stop("Done"); + spinner.message("ignored"); + const lines = stdout().split("\n").filter(Boolean); + expect(lines).toEqual(["Working", "ok: Done"]); + }); +}); + +describe("LoggingUI prompts throw", () => { + test("select rejects with LoggingUIPromptError", async () => { + const { ui } = createUI(); + expect( + ui.select({ + message: "Pick one", + options: [{ value: "a", label: "A" }], + }) + ).rejects.toBeInstanceOf(LoggingUIPromptError); + }); + + test("multiselect rejects with LoggingUIPromptError", async () => { + const { ui } = createUI(); + expect( + ui.multiselect({ + message: "Pick many", + options: [{ value: "a", label: "A" }], + }) + ).rejects.toBeInstanceOf(LoggingUIPromptError); + }); + + test("confirm rejects with LoggingUIPromptError", async () => { + const { ui } = createUI(); + expect(ui.confirm({ message: "Sure?" })).rejects.toBeInstanceOf( + LoggingUIPromptError + ); + }); + + test("error message identifies the prompt kind and message", async () => { + const { ui } = createUI(); + let caught: unknown; + try { + await ui.select({ + message: "Pick org", + options: [{ value: "a", label: "A" }], + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(LoggingUIPromptError); + const message = (caught as Error).message; + expect(message).toContain("select"); + expect(message).toContain("Pick org"); + expect(message).toContain("--yes"); + }); +}); + +describe("LoggingUI disposal", () => { + test("[Symbol.asyncDispose] resolves without writing", async () => { + const { ui, stdout, stderr } = createUI(); + await ui[Symbol.asyncDispose](); + expect(stdout()).toBe(""); + expect(stderr()).toBe(""); + }); + + test("works with await using", async () => { + const stdoutChunks: Buffer[] = []; + const stdout = new Writable({ + write(chunk, _encoding, callback): void { + stdoutChunks.push(Buffer.from(chunk)); + callback(); + }, + }); + { + await using ui = new LoggingUI({ stdout, stderr: stdout }); + ui.intro("hi"); + } + expect(stripAnsi(Buffer.concat(stdoutChunks).toString("utf-8"))).toBe( + "hi\n" + ); + }); +}); + +describe("LoggingUI summary", () => { + test("empty summary (no fields, no changedFiles) is a no-op", () => { + const { ui, stdout, stderr } = createUI(); + ui.summary({ fields: [] }); + expect(stdout()).toBe(""); + expect(stderr()).toBe(""); + }); + + test("fields are written as aligned key/value pairs to stdout", () => { + const { ui, stdout } = createUI(); + ui.summary({ + fields: [ + { label: "Project", value: "my-app" }, + { label: "Org", value: "acme" }, + { label: "LongerLabel", value: "val" }, + ], + }); + const out = stdout(); + expect(out).toContain("Project"); + expect(out).toContain("my-app"); + expect(out).toContain("LongerLabel"); + expect(out).toContain("val"); + // Labels padded to longest: "Project my-app" + const lines = out.split("\n").filter((l) => l.trim().length > 0); + const projectLine = lines.find((l) => l.includes("my-app")) ?? ""; + const longerLine = lines.find((l) => l.includes("val")) ?? ""; + expect(projectLine.indexOf("my-app")).toBe(longerLine.indexOf("val")); + }); + + test("changedFiles section shows heading and tree", () => { + const { ui, stdout } = createUI(); + ui.summary({ + fields: [], + changedFiles: [ + { path: "src/index.ts", action: "modify" }, + { path: "sentry.config.ts", action: "create" }, + ], + }); + const out = stdout(); + expect(out).toContain("Changed files:"); + expect(out).toContain("index.ts"); + expect(out).toContain("sentry.config.ts"); + }); + + test("create action uses + glyph, delete uses −, modify/other uses ~", () => { + const { ui, stdout } = createUI(); + ui.summary({ + fields: [], + changedFiles: [ + { path: "a.ts", action: "create" }, + { path: "b.ts", action: "delete" }, + { path: "c.ts", action: "modify" }, + ], + }); + const out = stdout(); + const lines = out.split("\n").filter(Boolean); + expect(lines.some((l) => l.includes("+") && l.includes("a.ts"))).toBe(true); + expect(lines.some((l) => l.includes("−") && l.includes("b.ts"))).toBe(true); + expect(lines.some((l) => l.includes("~") && l.includes("c.ts"))).toBe(true); + }); + + test("featureBlurbs renders Here's what we set up table with label and blurb", () => { + const { ui, stdout } = createUI(); + ui.summary({ + fields: [], + featureBlurbs: [ + { label: "Error Monitoring", blurb: "Captures exceptions." }, + { label: "Tracing", blurb: "Traces requests." }, + ], + }); + const out = stdout(); + expect(out).toContain("Here's what we set up"); + expect(out).toContain("Error Monitoring"); + expect(out).toContain("Captures exceptions."); + expect(out).toContain("Tracing"); + expect(out).toContain("Traces requests."); + }); + + test("no featureBlurbs section when featureBlurbs is absent", () => { + const { ui, stdout } = createUI(); + ui.summary({ fields: [{ label: "Platform", value: "Next.js" }] }); + expect(stdout()).not.toContain("Here's what we set up"); + }); +}); diff --git a/packages/cli/test/lib/init/ui/mock-ui.ts b/packages/cli/test/lib/init/ui/mock-ui.ts new file mode 100644 index 000000000..457f387f5 --- /dev/null +++ b/packages/cli/test/lib/init/ui/mock-ui.ts @@ -0,0 +1,213 @@ +/** + * MockUI — test double for the `WizardUI` interface. + * + * Records every method call as a JSON-serialisable trace so tests can + * make assertions about ordering, arguments, and call counts. Prompt + * methods are programmable: tests push fake responses onto a queue and + * `MockUI` returns them in order. Empty queue → returns `CANCELLED` so + * cancellation paths are easy to exercise. + * + * Lives in `test/lib/init/ui/` rather than `src/` because it's a + * test-only helper — it should not be bundled into the CLI. + */ + +import type { InitFeedbackOutcome } from "../../../../src/lib/init/feedback.js"; +import { + CANCELLED, + type Cancelled, + type ConfirmOptions, + type MultiSelectOptions, + type SelectOptions, + type SpinnerExitCode, + type SpinnerHandle, + type WelcomeOptions, + type WizardLog, + type WizardSummary, + type WizardUI, +} from "../../../../src/lib/init/ui/types.js"; + +export type MockCall = + | { kind: "banner"; art: string } + | { kind: "intro"; title: string } + | { kind: "summary"; summary: WizardSummary } + | { kind: "outro"; message: string } + | { kind: "cancel"; message: string } + | { kind: "feedback"; outcome: InitFeedbackOutcome } + | { kind: "log.info"; message: string } + | { kind: "log.warn"; message: string } + | { kind: "log.error"; message: string } + | { kind: "log.success"; message: string } + | { kind: "log.message"; message: string } + | { kind: "spinner.start"; message?: string } + | { kind: "spinner.message"; message?: string } + | { kind: "spinner.stop"; message?: string; code?: SpinnerExitCode } + | { + kind: "select"; + message: string; + details?: Array<{ text: string; tone?: "muted" | "success" }>; + footer?: { text: string; tone?: "muted" | "success" }; + options: string[]; + } + | { kind: "welcome"; options: WelcomeOptions } + | { + kind: "multiselect"; + message: string; + details?: Array<{ text: string; tone?: "muted" | "success" }>; + options: string[]; + optionDetails: Array<{ + value: string; + label: string; + description?: string; + locked?: boolean; + }>; + initialValues?: string[]; + } + | { kind: "confirm"; message: string; initialValue?: boolean } + | { kind: "setIntroMode"; enabled: boolean } + | { kind: "recordFilesReading"; paths: string[] } + | { kind: "markFilesAnalyzed"; paths: string[] } + | { + kind: "setStep"; + stepId: string; + status: "in_progress" | "completed" | "failed" | "skipped"; + }; + +/** + * Programmable prompt response. `value` is what the impl returns when + * the matching prompt method is invoked (or `CANCELLED` to simulate a + * user abort). + */ +export type MockResponse = + | { kind: "welcome"; value: "continue" | Cancelled } + | { kind: "select"; value: string | Cancelled } + | { kind: "multiselect"; value: string[] | Cancelled } + | { kind: "confirm"; value: boolean | Cancelled }; + +type MockUIOptions = { + welcome?: boolean; +}; + +/** + * Build a mock `WizardUI` plus its observable state. + * + * Returns the impl, the call trace, and a `respond()` helper for + * pushing canned responses onto the prompt queue. + */ +export function createMockUI(options: MockUIOptions = {}): { + ui: WizardUI; + calls: MockCall[]; + respond: { + welcome(value: "continue" | Cancelled): void; + select(value: string | Cancelled): void; + multiselect(value: string[] | Cancelled): void; + confirm(value: boolean | Cancelled): void; + }; +} { + const calls: MockCall[] = []; + const responses: MockResponse[] = []; + + const log: WizardLog = { + info: (message) => calls.push({ kind: "log.info", message }), + warn: (message) => calls.push({ kind: "log.warn", message }), + error: (message) => calls.push({ kind: "log.error", message }), + success: (message) => calls.push({ kind: "log.success", message }), + message: (message) => calls.push({ kind: "log.message", message }), + }; + + const spinner = (): SpinnerHandle => ({ + start: (message) => calls.push({ kind: "spinner.start", message }), + message: (message) => calls.push({ kind: "spinner.message", message }), + stop: (message, code) => + calls.push({ kind: "spinner.stop", message, code }), + }); + + function takeResponse( + kind: K + ): Extract["value"] | Cancelled { + const next = responses.shift(); + if (!next) { + // Tests that don't push a response get a clean cancel — easier to + // detect mistakes than silent default values. + return CANCELLED; + } + if (next.kind !== kind) { + throw new Error( + `MockUI: expected next response of kind "${kind}" but found "${next.kind}"` + ); + } + return next.value as Extract["value"]; + } + + const ui: WizardUI = { + banner: (art) => calls.push({ kind: "banner", art }), + intro: (title) => calls.push({ kind: "intro", title }), + summary: (summary) => calls.push({ kind: "summary", summary }), + outro: (message) => calls.push({ kind: "outro", message }), + cancel: (message) => calls.push({ kind: "cancel", message }), + feedback: (outcome) => calls.push({ kind: "feedback", outcome }), + recordFilesReading: (paths) => + calls.push({ kind: "recordFilesReading", paths }), + markFilesAnalyzed: (paths) => + calls.push({ kind: "markFilesAnalyzed", paths }), + setStep: (stepId, status) => + calls.push({ kind: "setStep", stepId, status }), + setIntroMode: (enabled) => calls.push({ kind: "setIntroMode", enabled }), + log, + spinner, + select: (opts: SelectOptions) => { + calls.push({ + kind: "select", + message: opts.message, + ...(opts.details ? { details: opts.details } : {}), + ...(opts.footer ? { footer: opts.footer } : {}), + options: opts.options.map((option) => option.value), + }); + return Promise.resolve(takeResponse("select")); + }, + multiselect: (opts: MultiSelectOptions) => { + calls.push({ + kind: "multiselect", + message: opts.message, + ...(opts.details ? { details: opts.details } : {}), + options: opts.options.map((option) => option.value), + optionDetails: opts.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.locked ? { locked: true } : {}), + })), + ...(opts.initialValues ? { initialValues: opts.initialValues } : {}), + }); + return Promise.resolve(takeResponse("multiselect")); + }, + confirm: (opts: ConfirmOptions) => { + calls.push({ + kind: "confirm", + message: opts.message, + ...(opts.initialValue !== undefined + ? { initialValue: opts.initialValue } + : {}), + }); + return Promise.resolve(takeResponse("confirm")); + }, + [Symbol.asyncDispose]: () => Promise.resolve(), + }; + + if (options.welcome) { + ui.welcome = (opts: WelcomeOptions) => { + calls.push({ kind: "welcome", options: opts }); + return Promise.resolve(takeResponse("welcome")); + }; + } + + return { + ui, + calls, + respond: { + welcome: (value) => responses.push({ kind: "welcome", value }), + select: (value) => responses.push({ kind: "select", value }), + multiselect: (value) => responses.push({ kind: "multiselect", value }), + confirm: (value) => responses.push({ kind: "confirm", value }), + }, + }; +} diff --git a/packages/cli/test/lib/init/ui/types.test.ts b/packages/cli/test/lib/init/ui/types.test.ts new file mode 100644 index 000000000..077d0ffc1 --- /dev/null +++ b/packages/cli/test/lib/init/ui/types.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the WizardUI shared cancellation sentinel and type guard. + * + * The interface itself has no runtime surface — these tests cover only + * the helpers in `types.ts` that ship with it. + */ + +import { describe, expect, test } from "vitest"; +import { CANCELLED, isCancelled } from "../../../../src/lib/init/ui/types.js"; + +describe("CANCELLED sentinel", () => { + test("is a symbol", () => { + expect(typeof CANCELLED).toBe("symbol"); + }); + + test("is registered globally so cross-bundle equality holds", () => { + // Symbol.for ensures any caller that imports `CANCELLED` from this + // module path gets the exact same symbol — important when the wizard + // straddles bundled and source contexts (compiled binary vs tests). + expect(CANCELLED).toBe(Symbol.for("sentry-cli:wizard-ui:cancelled")); + }); +}); + +describe("isCancelled", () => { + test("returns true for the sentinel", () => { + expect(isCancelled(CANCELLED)).toBe(true); + }); + + test("returns false for arbitrary values", () => { + expect(isCancelled(undefined)).toBe(false); + expect(isCancelled(null)).toBe(false); + expect(isCancelled(false)).toBe(false); + expect(isCancelled(0)).toBe(false); + expect(isCancelled("")).toBe(false); + expect(isCancelled("CANCELLED")).toBe(false); + expect(isCancelled({})).toBe(false); + }); + + test("returns false for unrelated symbols", () => { + expect(isCancelled(Symbol("cancelled"))).toBe(false); + expect(isCancelled(Symbol.for("other"))).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/init/ui/wizard-store.test.ts b/packages/cli/test/lib/init/ui/wizard-store.test.ts new file mode 100644 index 000000000..fb48c419e --- /dev/null +++ b/packages/cli/test/lib/init/ui/wizard-store.test.ts @@ -0,0 +1,540 @@ +/** + * Tests for the wizard store's step-progress state. + * + * Covers: + * - canonical pre-population from CHECKLIST_VISIBLE_STEPS + * - in_progress / completed transitions + * - implicit skip back-fill when a later step starts + * - idempotent re-entry (a step suspending multiple times) + * - protection against `skipped` clobbering completed entries + * + * The Ink app itself is not tested here — see the React tree + * verification via direct `createInkUI()` invocation in + * dev/binary builds. This test file focuses on the pure data layer. + */ + +import { describe, expect, test } from "vitest"; +import { + CANONICAL_STEP_ORDER, + CHECKLIST_VISIBLE_STEPS, +} from "../../../../src/lib/init/clack-utils.js"; +import { WizardStore } from "../../../../src/lib/init/ui/wizard-store.js"; + +describe("WizardStore step progress", () => { + test("pre-populates the checklist from CHECKLIST_VISIBLE_STEPS", () => { + const store = new WizardStore(); + const snapshot = store.getSnapshot(); + expect(snapshot.steps.map((entry) => entry.id)).toEqual( + CHECKLIST_VISIBLE_STEPS.slice() + ); + expect(snapshot.steps.every((entry) => entry.status === "pending")).toBe( + true + ); + expect(snapshot.steps.some((entry) => entry.id === "install-deps")).toBe( + false + ); + expect( + snapshot.steps.find((entry) => entry.id === "apply-codemods")?.label + ).toBe("Applying changes + deps"); + }); + + test("flips a step to in_progress on first call", () => { + const store = new WizardStore(); + store.setStepStatus("ensure-sentry-project", "in_progress"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "ensure-sentry-project"); + expect(entry?.status).toBe("in_progress"); + }); + + test("re-entering an in_progress step is idempotent (no flicker)", () => { + const store = new WizardStore(); + store.setStepStatus("apply-codemods", "in_progress"); + const before = store.getSnapshot().steps; + store.setStepStatus("apply-codemods", "in_progress"); + const after = store.getSnapshot().steps; + // Reference equality: no update emitted, so the array is the + // same instance. This is what the store guarantees for no-op + // mutations and what `useSyncExternalStore` relies on. + expect(after).toBe(before); + }); + + test("back-fills earlier pending steps as skipped when a later step starts", () => { + const store = new WizardStore(); + // Jump straight to a later step — simulates the workflow + // taking an `if`-branch that bypassed the earlier ones. + store.setStepStatus("verify-changes", "in_progress"); + const steps = store.getSnapshot().steps; + const verifyIndex = CANONICAL_STEP_ORDER.indexOf("verify-changes"); + for (const entry of steps) { + const idx = CANONICAL_STEP_ORDER.indexOf(entry.id); + if (idx >= 0 && idx < verifyIndex) { + expect(entry.status).toBe("skipped"); + } + } + expect(steps.find((entry) => entry.id === "verify-changes")?.status).toBe( + "in_progress" + ); + }); + + test("does not back-fill steps that have already completed", () => { + const store = new WizardStore(); + store.setStepStatus("discover-context", "in_progress"); + store.setStepStatus("discover-context", "completed"); + store.setStepStatus("verify-changes", "in_progress"); + const discover = store + .getSnapshot() + .steps.find((row) => row.id === "discover-context"); + expect(discover?.status).toBe("completed"); + }); + + test("ignores stepIds outside the visible allowlist", () => { + const store = new WizardStore(); + // `select-target-app` is in CANONICAL_STEP_ORDER but not in + // CHECKLIST_VISIBLE_STEPS — the call should still drive the + // back-fill on visible earlier rows but not add a new row. + const initialLength = store.getSnapshot().steps.length; + store.setStepStatus("select-target-app", "in_progress"); + // Note: variable is deliberately not named `after` because + // Biome's `noDoneCallback` rule pattern-matches Mocha hooks + // (`after`, `before`, …) by identifier and would flag the + // arrow-function callback inside `.find()` below. + const updated = store.getSnapshot().steps; + expect(updated.length).toBe(initialLength); + // Visible rows earlier than `select-target-app` (i.e. + // `discover-context`) should be back-filled to skipped. + const discover = updated.find((entry) => entry.id === "discover-context"); + expect(discover?.status).toBe("skipped"); + }); + + test("completed transition wins over the existing status", () => { + const store = new WizardStore(); + store.setStepStatus("apply-codemods", "in_progress"); + store.setStepStatus("apply-codemods", "completed"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "apply-codemods"); + expect(entry?.status).toBe("completed"); + }); + + test("failed transition wins over the existing status", () => { + const store = new WizardStore(); + store.setStepStatus("apply-codemods", "in_progress"); + store.setStepStatus("apply-codemods", "failed"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "apply-codemods"); + expect(entry?.status).toBe("failed"); + }); + + test("explicit skipped does not overwrite a completed entry", () => { + const store = new WizardStore(); + store.setStepStatus("discover-context", "in_progress"); + store.setStepStatus("discover-context", "completed"); + // A bogus (and impossible) explicit skip call should be a no-op. + store.setStepStatus("discover-context", "skipped"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "discover-context"); + expect(entry?.status).toBe("completed"); + }); + + test("notifies subscribers on step transitions", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.setStepStatus("apply-codemods", "in_progress"); + store.setStepStatus("apply-codemods", "in_progress"); // idempotent + store.setStepStatus("apply-codemods", "completed"); + unsubscribe(); + // Two real transitions = two notifications. The middle no-op + // does not fire a listener — saves a render in React. + expect(notifications).toBe(2); + }); +}); + +/** + * Cancellation callback contract. + * + * The Ink App reads `snapshot.requestCancel` from inside its + * top-level `useInput` handler when no prompt is mounted (spinner + * window). The bridge (`InkUI`) registers the callback at + * construction and clears it on teardown so a stale Ink dispatch + * after unmount can't re-enter cancellation. + */ +describe("WizardStore status messages", () => { + test("starts with empty status messages", () => { + const store = new WizardStore(); + expect(store.getSnapshot().statusMessages).toEqual([]); + expect(store.getSnapshot().statusExpanded).toBe(false); + }); + + test("appendStatus adds messages", () => { + const store = new WizardStore(); + store.appendStatus("Analyzing project..."); + store.appendStatus("Reading files..."); + expect(store.getSnapshot().statusMessages).toEqual([ + "Analyzing project...", + "Reading files...", + ]); + }); + + test("appendStatus ignores empty strings", () => { + const store = new WizardStore(); + store.appendStatus(""); + expect(store.getSnapshot().statusMessages).toEqual([]); + }); + + test("toggleStatusExpanded flips the flag", () => { + const store = new WizardStore(); + expect(store.getSnapshot().statusExpanded).toBe(false); + store.toggleStatusExpanded(); + expect(store.getSnapshot().statusExpanded).toBe(true); + store.toggleStatusExpanded(); + expect(store.getSnapshot().statusExpanded).toBe(false); + }); +}); + +describe("WizardStore log mutations", () => { + test("appendLog assigns monotonically-increasing ids", () => { + const store = new WizardStore(); + const a = store.appendLog("info", "first"); + const b = store.appendLog("warn", "second"); + const c = store.appendLog("error", "third"); + expect(b.id).toBeGreaterThan(a.id); + expect(c.id).toBeGreaterThan(b.id); + }); + + test("appendLog stores severity and text on the entry", () => { + const store = new WizardStore(); + const entry = store.appendLog("success", "all good"); + expect(entry.severity).toBe("success"); + expect(entry.text).toBe("all good"); + }); + + test("appendLog appends to the snapshot logs array", () => { + const store = new WizardStore(); + store.appendLog("info", "one"); + store.appendLog("error", "two"); + const { logs } = store.getSnapshot(); + expect(logs.length).toBe(2); + expect(logs[0]?.text).toBe("one"); + expect(logs[1]?.text).toBe("two"); + }); + + test("appendLog notifies subscribers", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.appendLog("info", "msg"); + unsubscribe(); + expect(notifications).toBe(1); + }); +}); + +describe("WizardStore spinner lifecycle", () => { + test("startSpinner activates the spinner, resets frame, stores message", () => { + const store = new WizardStore(); + store.startSpinner("Loading…"); + const { spinner } = store.getSnapshot(); + expect(spinner.active).toBe(true); + expect(spinner.frame).toBe(0); + expect(spinner.message).toBe("Loading…"); + }); + + test("tickSpinner increments frame when active", () => { + const store = new WizardStore(); + store.startSpinner("Working"); + store.tickSpinner(); + store.tickSpinner(); + expect(store.getSnapshot().spinner.frame).toBe(2); + }); + + test("tickSpinner is a no-op when spinner is inactive", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.tickSpinner(); + unsubscribe(); + expect(notifications).toBe(0); + expect(store.getSnapshot().spinner.frame).toBe(0); + }); + + test("setSpinnerMessage updates message when active", () => { + const store = new WizardStore(); + store.startSpinner("first"); + store.setSpinnerMessage("second"); + expect(store.getSnapshot().spinner.message).toBe("second"); + }); + + test("setSpinnerMessage is a no-op when spinner is inactive", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.setSpinnerMessage("ignored"); + unsubscribe(); + expect(notifications).toBe(0); + }); + + test("stopSpinner deactivates and clears message and frame", () => { + const store = new WizardStore(); + store.startSpinner("busy"); + store.tickSpinner(); + store.stopSpinner(); + const { spinner } = store.getSnapshot(); + expect(spinner.active).toBe(false); + expect(spinner.message).toBe(""); + expect(spinner.frame).toBe(0); + }); +}); + +describe("WizardStore file read state machine", () => { + test("new paths land as reading", () => { + const store = new WizardStore(); + store.recordFilesReading(["src/index.ts", "package.json"]); + const { filesRead } = store.getSnapshot(); + expect(filesRead.every((e) => e.status === "reading")).toBe(true); + expect(filesRead.map((e) => e.path)).toEqual([ + "src/index.ts", + "package.json", + ]); + }); + + test("recordFilesReading does not downgrade an already-analyzed entry", () => { + const store = new WizardStore(); + store.recordFilesReading(["src/index.ts"]); + store.markFilesAnalyzed(["src/index.ts"]); + store.recordFilesReading(["src/index.ts"]); + const entry = store + .getSnapshot() + .filesRead.find((e) => e.path === "src/index.ts"); + expect(entry?.status).toBe("analyzed"); + }); + + test("markFilesAnalyzed flips reading entries to analyzed", () => { + const store = new WizardStore(); + store.recordFilesReading(["a.ts", "b.ts"]); + store.markFilesAnalyzed(["a.ts"]); + const snap = store.getSnapshot(); + expect(snap.filesRead.find((e) => e.path === "a.ts")?.status).toBe( + "analyzed" + ); + expect(snap.filesRead.find((e) => e.path === "b.ts")?.status).toBe( + "reading" + ); + }); + + test("markFilesAnalyzed adds unknown paths as pre-analyzed", () => { + const store = new WizardStore(); + store.markFilesAnalyzed(["never-recorded.ts"]); + const entry = store + .getSnapshot() + .filesRead.find((e) => e.path === "never-recorded.ts"); + expect(entry?.status).toBe("analyzed"); + }); + + test("empty arrays are no-ops and do not notify", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.recordFilesReading([]); + store.markFilesAnalyzed([]); + unsubscribe(); + expect(notifications).toBe(0); + }); +}); + +describe("WizardStore idempotent guards", () => { + test("setLayout does not notify when the layout is unchanged", () => { + const store = new WizardStore({ layout: "intro" }); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.setLayout("intro"); + unsubscribe(); + expect(notifications).toBe(0); + }); + + test("setTipIndex does not notify when the index is unchanged", () => { + const store = new WizardStore({ tipIndex: 3 }); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.setTipIndex(3); + unsubscribe(); + expect(notifications).toBe(0); + }); + + test("clearOverlay does not notify when overlay is already null", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.clearOverlay(); + unsubscribe(); + expect(notifications).toBe(0); + }); + + test("setLearnComplete does not notify when already complete", () => { + const store = new WizardStore({ + learnState: { blockIndex: 6, lineIndex: 7, complete: true }, + }); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.setLearnComplete(); + unsubscribe(); + expect(notifications).toBe(0); + }); +}); + +describe("WizardStore overlay lifecycle", () => { + test("setOverlay stores the overlay and notifies", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.setOverlay({ kind: "health", message: "Retrying…", retryCount: 1 }); + unsubscribe(); + expect(notifications).toBe(1); + expect(store.getSnapshot().overlay).toEqual({ + kind: "health", + message: "Retrying…", + retryCount: 1, + }); + }); + + test("clearOverlay nulls the overlay and notifies", () => { + const store = new WizardStore(); + store.setOverlay({ kind: "health", message: "x", retryCount: 0 }); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.clearOverlay(); + unsubscribe(); + expect(notifications).toBe(1); + expect(store.getSnapshot().overlay).toBeNull(); + }); +}); + +describe("WizardStore learn state progression", () => { + test("advanceLearnLine increments lineIndex, leaves blockIndex unchanged", () => { + const store = new WizardStore(); + store.advanceLearnLine(); + store.advanceLearnLine(); + const { learnState } = store.getSnapshot(); + expect(learnState.lineIndex).toBe(2); + expect(learnState.blockIndex).toBe(0); + expect(learnState.complete).toBe(false); + }); + + test("advanceLearnBlock increments blockIndex and resets lineIndex to 0", () => { + const store = new WizardStore({ + learnState: { blockIndex: 1, lineIndex: 5, complete: false }, + }); + store.advanceLearnBlock(); + const { learnState } = store.getSnapshot(); + expect(learnState.blockIndex).toBe(2); + expect(learnState.lineIndex).toBe(0); + }); + + test("advance methods are no-ops once complete", () => { + const store = new WizardStore({ + learnState: { blockIndex: 6, lineIndex: 7, complete: true }, + }); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.advanceLearnLine(); + store.advanceLearnBlock(); + store.setLearnComplete(); + unsubscribe(); + expect(notifications).toBe(0); + expect(store.getSnapshot().learnState).toEqual({ + blockIndex: 6, + lineIndex: 7, + complete: true, + }); + }); +}); + +describe("WizardStore.prefixFor", () => { + test("maps each LogSeverity to the correct glyph", () => { + expect(WizardStore.prefixFor("info")).toBe("●"); + expect(WizardStore.prefixFor("warn")).toBe("▲"); + expect(WizardStore.prefixFor("error")).toBe("✖"); + expect(WizardStore.prefixFor("success")).toBe("✔"); + expect(WizardStore.prefixFor("message")).toBe(" "); + }); +}); + +describe("WizardStore.setRequestCancel", () => { + test("starts undefined so an early Ctrl+C is a no-op", () => { + const store = new WizardStore(); + expect(store.getSnapshot().requestCancel).toBeUndefined(); + }); + + test("registers a callback and exposes it on the snapshot", () => { + const store = new WizardStore(); + const cancel = () => { + /* no-op */ + }; + store.setRequestCancel(cancel); + expect(store.getSnapshot().requestCancel).toBe(cancel); + }); + + test("clears the callback on teardown by passing undefined", () => { + const store = new WizardStore(); + store.setRequestCancel(() => { + /* no-op */ + }); + store.setRequestCancel(undefined); + expect(store.getSnapshot().requestCancel).toBeUndefined(); + }); + + test("setting the same callback reference twice is a no-op", () => { + // Avoid React re-render churn when the bridge re-registers the + // same callback (idempotency for cheap callers). + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + const cancel = () => { + /* no-op */ + }; + store.setRequestCancel(cancel); + store.setRequestCancel(cancel); + unsubscribe(); + expect(notifications).toBe(1); + }); + + test("invocation runs the registered callback", () => { + // The store doesn't invoke the callback itself — the App does + // — but verify the wiring lets callers reach the function. + const store = new WizardStore(); + let invoked = 0; + store.setRequestCancel(() => { + invoked += 1; + }); + store.getSnapshot().requestCancel?.(); + expect(invoked).toBe(1); + }); +}); diff --git a/packages/cli/test/lib/init/verify-setup-windows.mocked.test.ts b/packages/cli/test/lib/init/verify-setup-windows.mocked.test.ts new file mode 100644 index 000000000..0ed40fb5d --- /dev/null +++ b/packages/cli/test/lib/init/verify-setup-windows.mocked.test.ts @@ -0,0 +1,174 @@ +/** Windows process-tree cleanup tests with child_process mocked before import. */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "../../../src/lib/logger.js"; +import { TEST_TMP_DIR } from "../../constants.js"; +import { createMockUI } from "./ui/mock-ui.js"; + +const mocks = vi.hoisted(() => ({ + childKillCalls: [] as NodeJS.Signals[], + spawnCalls: [] as Array<{ options: { detached?: boolean } }>, + taskkillCalls: [] as Array<{ + command: string; + args: string[]; + options: { timeout?: number }; + }>, + taskkillErrors: [] as Array, + taskkillStatuses: [] as number[], +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + const { EventEmitter } = await import("node:events"); + const { PassThrough } = await import("node:stream"); + + return { + ...actual, + spawn: vi.fn( + (_command: string, _args: string[], options: { detached?: boolean }) => { + mocks.spawnCalls.push({ options }); + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null; + kill: (signal: NodeJS.Signals) => boolean; + pid: number; + stderr: PassThrough; + stdout: PassThrough; + }; + child.pid = 4321; + child.exitCode = null; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = (signal) => { + mocks.childKillCalls.push(signal); + return true; + }; + queueMicrotask(() => { + child.stderr.write("SyntaxError: Windows verification fixture\n"); + }); + return child; + } + ), + spawnSync: vi.fn( + (command: string, args: string[], options: { timeout?: number }) => { + mocks.taskkillCalls.push({ command, args, options }); + return { + error: mocks.taskkillErrors.shift(), + pid: 0, + output: [], + signal: null, + status: mocks.taskkillStatuses.shift() ?? 0, + stderr: null, + stdout: null, + }; + } + ), + }; +}); + +vi.mock("@sentry/node-core/light", () => ({ + addBreadcrumb: vi.fn(), + captureException: vi.fn(), + setTag: vi.fn(), +})); + +import { verifySetup } from "../../../src/lib/init/verify-setup.js"; + +const originalPlatform = process.platform; +let tmpDir: string; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +beforeEach(async () => { + setPlatform("win32"); + mocks.childKillCalls.length = 0; + mocks.spawnCalls.length = 0; + mocks.taskkillCalls.length = 0; + mocks.taskkillErrors.length = 0; + mocks.taskkillStatuses.length = 0; + tmpDir = await mkdtemp(join(TEST_TMP_DIR, "verify-setup-windows-test-")); + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "node server.js" } }) + ); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + setPlatform(originalPlatform); + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe("verifySetup Windows cleanup", () => { + test("force-terminates the process tree with bounded taskkill", async () => { + const { ui } = createMockUI(); + + await verifySetup( + { status: "success", result: { platform: "javascript-nextjs" } }, + ui, + tmpDir + ); + + expect(mocks.spawnCalls[0]?.options.detached).toBe(false); + expect(mocks.taskkillCalls).toEqual([ + { + command: "taskkill", + args: ["/PID", "4321", "/T", "/F"], + options: expect.objectContaining({ timeout: 1000 }), + }, + ]); + expect(mocks.childKillCalls).toEqual([]); + }); + + test("logs a non-zero status and falls back to the direct child", async () => { + mocks.taskkillStatuses.push(1); + const debugSpy = vi.spyOn(logger, "debug"); + const { ui } = createMockUI(); + + await verifySetup( + { status: "success", result: { platform: "javascript-nextjs" } }, + ui, + tmpDir + ); + + expect(mocks.taskkillCalls.map(({ args }) => args)).toEqual([ + ["/PID", "4321", "/T", "/F"], + ]); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("taskkill exited with status 1") + ); + expect(mocks.childKillCalls).toEqual(["SIGKILL"]); + }); + + test("logs a taskkill timeout and falls back to the direct child", async () => { + const timeoutError = Object.assign( + new Error("spawnSync taskkill ETIMEDOUT"), + { + code: "ETIMEDOUT", + } + ); + mocks.taskkillErrors.push(timeoutError); + const debugSpy = vi.spyOn(logger, "debug"); + const { ui } = createMockUI(); + + await verifySetup( + { status: "success", result: { platform: "javascript-nextjs" } }, + ui, + tmpDir + ); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Failed to terminate Windows verification process tree" + ), + timeoutError + ); + expect(mocks.childKillCalls).toEqual(["SIGKILL"]); + }); +}); diff --git a/packages/cli/test/lib/init/verify-setup.test.ts b/packages/cli/test/lib/init/verify-setup.test.ts new file mode 100644 index 000000000..fb9d29bcf --- /dev/null +++ b/packages/cli/test/lib/init/verify-setup.test.ts @@ -0,0 +1,242 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { verifySetup } from "../../../src/lib/init/verify-setup.js"; +import { TEST_TMP_DIR } from "../../constants.js"; +import { createMockUI } from "./ui/mock-ui.js"; + +const sentryMocks = vi.hoisted(() => ({ + addBreadcrumb: vi.fn(), + captureException: vi.fn(), + setTag: vi.fn(), +})); + +vi.mock("@sentry/node-core/light", () => sentryMocks); + +type FixtureProcesses = { + shellPid: number; + parentPid: number; + childPid: number; +}; + +let tmpDir: string; +let fixtureProcesses: FixtureProcesses | undefined; + +beforeEach(async () => { + sentryMocks.addBreadcrumb.mockClear(); + sentryMocks.captureException.mockClear(); + sentryMocks.setTag.mockClear(); + tmpDir = await mkdtemp(join(TEST_TMP_DIR, "verify-setup-test-")); +}); + +afterEach(async () => { + if (fixtureProcesses) { + for (const pid of [ + fixtureProcesses.shellPid, + fixtureProcesses.parentPid, + fixtureProcesses.childPid, + ]) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // The verification cleanup should already have terminated the fixture. + } + } + } + await rm(tmpDir, { recursive: true, force: true }); + fixtureProcesses = undefined; +}); + +/** Return whether a process currently exists. */ +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +/** Wait for a short, bounded interval. */ +async function wait(ms: number): Promise { + await new Promise((resolveDelay) => { + setTimeout(resolveDelay, ms); + }); +} + +/** Wait briefly for a killed process to be reaped by the operating system. */ +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + 2000; + while (processExists(pid)) { + if (Date.now() >= deadline) { + return false; + } + await wait(25); + } + return true; +} + +/** Write a shell-based dev script that leaves a process tree holding stdio. */ +async function writeProcessTreeFixture(ignoreSigterm = false): Promise { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ + scripts: { + // `&&` deliberately makes detectDevCommand wrap this in `sh -c`, + // matching scripts that use concurrently or chained commands. + dev: 'node verify-parent.mjs && node -e "process.exit(0)"', + }, + }) + ); + const sigtermHandler = ignoreSigterm + ? 'process.on("SIGTERM", () => {});' + : ""; + await writeFile( + join(tmpDir, "verify-parent.mjs"), + ` + import { spawn } from "node:child_process"; + import { writeFileSync } from "node:fs"; + + ${sigtermHandler} + const child = spawn( + process.execPath, + [ + "-e", + ${JSON.stringify(`${sigtermHandler} setInterval(() => {}, 1000);`)}, + ], + { stdio: "inherit" } + ); + writeFileSync( + "processes.json", + JSON.stringify({ + shellPid: process.ppid, + parentPid: process.pid, + childPid: child.pid, + }) + ); + process.stderr.write("SyntaxError: verification fixture\\n"); + setInterval(() => {}, 1000); + ` + ); +} + +/** Poll for the fixture's PID file while verification is still running. */ +async function readFixtureProcesses(): Promise { + const path = join(tmpDir, "processes.json"); + const deadline = Date.now() + 2000; + while (true) { + try { + return JSON.parse(await readFile(path, "utf8")) as FixtureProcesses; + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== "ENOENT" || + Date.now() >= deadline + ) { + throw error; + } + await wait(25); + } + } +} + +describe("verifySetup", () => { + test("records a skipped verify as a scope tag, not an error, when there is no dev command", async () => { + const { ui, calls } = createMockUI(); + + const result = await verifySetup( + { status: "success", result: { platform: "cocoa" } }, + ui, + tmpDir + ); + + expect(result).toEqual({ verified: false, kind: "skipped" }); + expect(calls).toContainEqual({ + kind: "log.info", + message: "Skipping verification — could not detect a dev command", + }); + expect(sentryMocks.captureException).not.toHaveBeenCalled(); + expect(sentryMocks.setTag).toHaveBeenCalledWith("wizard.verify", "skipped"); + expect(sentryMocks.addBreadcrumb).toHaveBeenCalledWith({ + category: "wizard.verify", + level: "info", + message: "skipped:no_dev_command", + }); + }); + + test("does not fail init when the detected command cannot be spawned", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "missing-sentry-test-command" } }) + ); + const { ui, calls } = createMockUI(); + + const result = await verifySetup( + { status: "success", result: { platform: "javascript-nextjs" } }, + ui, + tmpDir + ); + // The SDK never phoned home, so nothing to verify or deep-link. + expect(result.verified).toBe(false); + expect(result.eventId).toBeUndefined(); + + expect(calls).toContainEqual({ + kind: "log.warn", + message: "Skipping verification — could not start the dev command.", + }); + expect(sentryMocks.captureException).not.toHaveBeenCalled(); + expect(sentryMocks.setTag).toHaveBeenCalledWith("wizard.verify", "skipped"); + expect(sentryMocks.addBreadcrumb).toHaveBeenCalledWith({ + category: "wizard.verify", + level: "info", + message: "skipped:spawn_failed", + }); + }); + + test.skipIf(process.platform === "win32")( + "terminates shell descendants that inherit the verification pipes", + async () => { + await writeProcessTreeFixture(); + + const { ui, calls } = createMockUI(); + const verification = verifySetup( + { status: "success", result: { platform: "javascript-nextjs" } }, + ui, + tmpDir + ); + fixtureProcesses = await readFixtureProcesses(); + await verification; + + expect(await waitForProcessExit(fixtureProcesses.shellPid)).toBe(true); + expect(await waitForProcessExit(fixtureProcesses.parentPid)).toBe(true); + expect(await waitForProcessExit(fixtureProcesses.childPid)).toBe(true); + fixtureProcesses = undefined; + expect(calls).toContainEqual({ + kind: "log.warn", + message: + "Could not verify — startup error: SyntaxError: verification fixture", + }); + } + ); + + test.skipIf(process.platform === "win32")( + "force-kills descendants that ignore SIGTERM", + async () => { + await writeProcessTreeFixture(true); + + const { ui } = createMockUI(); + const verification = verifySetup( + { status: "success", result: { platform: "javascript-nextjs" } }, + ui, + tmpDir + ); + fixtureProcesses = await readFixtureProcesses(); + await verification; + + expect(await waitForProcessExit(fixtureProcesses.shellPid)).toBe(true); + expect(await waitForProcessExit(fixtureProcesses.parentPid)).toBe(true); + expect(await waitForProcessExit(fixtureProcesses.childPid)).toBe(true); + fixtureProcesses = undefined; + }, + 10_000 + ); +}); diff --git a/packages/cli/test/lib/init/wizard-runner.test.ts b/packages/cli/test/lib/init/wizard-runner.test.ts new file mode 100644 index 000000000..5e2c150a2 --- /dev/null +++ b/packages/cli/test/lib/init/wizard-runner.test.ts @@ -0,0 +1,2374 @@ +import { MastraClient } from "@mastra/client-js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as Sentry from "@sentry/node-core/light"; +import { + afterEach, + beforeEach, + describe, + expect, + type mock, + test, + vi, +} from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as banner from "../../../src/lib/banner.js"; +import { clearAuth, setAuthToken } from "../../../src/lib/db/auth.js"; +import { ENV_VAR_AGENTS } from "../../../src/lib/detect-agent.js"; +import { setEnv } from "../../../src/lib/env.js"; +import { classifySilenced } from "../../../src/lib/error-reporting.js"; +import { + ApiError, + EXIT, + HostScopeError, + WizardError, +} from "../../../src/lib/errors.js"; +import { + CHECKLIST_VISIBLE_STEPS, + WizardCancelledError, +} from "../../../src/lib/init/clack-utils.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as fmt from "../../../src/lib/init/formatters.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as git from "../../../src/lib/init/git.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as inter from "../../../src/lib/init/interactive.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as preflight from "../../../src/lib/init/preflight.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as readiness from "../../../src/lib/init/readiness.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as registry from "../../../src/lib/init/tools/registry.js"; +import type { + ResolvedInitContext, + SuspendPayload, + ToolPayload, + WizardOptions, + WorkflowRunResult, +} from "../../../src/lib/init/types.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as uiFactory from "../../../src/lib/init/ui/factory.js"; +import { + CANCELLED, + type SpinnerHandle, + type WizardUI, +} from "../../../src/lib/init/ui/types.js"; +import { runWizard } from "../../../src/lib/init/wizard-runner.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as workflowInputs from "../../../src/lib/init/workflow-inputs.js"; +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; + +const noop = () => { + /* suppress output */ +}; + +/** + * Per-test reference to the spinner mock. The wizard-runner calls + * `ui.spinner()` exactly once and reuses the handle for the entire run, + * so we expose a singleton with mock fns the test cases can assert on. + */ +const spinnerMock: SpinnerHandle & { + start: ReturnType; + stop: ReturnType; + message: ReturnType; +} = { + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), +}; + +let mockUICalls: MockCall[]; + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + yes: true, + dryRun: false, + ...overrides, + }; +} + +function makeContext( + overrides?: Partial +): ResolvedInitContext { + return { + directory: "/tmp/test", + yes: true, + dryRun: false, + org: "acme", + team: "platform", + authToken: "test-token", + ...overrides, + }; +} + +let mockStartResult: WorkflowRunResult; +let mockResumeResults: WorkflowRunResult[]; +let resumeCallCount = 0; +let sharedResumeAsyncMock: ReturnType; +let startAsyncMock: ReturnType; +let mockRunByIdResult: WorkflowRunResult | Error; +let runByIdMock: ReturnType; + +let getUISpy: ReturnType; +let formatBannerSpy: ReturnType; +let formatResultSpy: ReturnType; +let formatErrorSpy: ReturnType; +let checkGitStatusSpy: ReturnType; +let handleInteractiveSpy: ReturnType; +let resolveInitContextSpy: ReturnType; +let describeToolSpy: ReturnType; +let executeToolSpy: ReturnType; +let precomputeDirListingSpy: ReturnType; +let preReadCommonFilesSpy: ReturnType; +let precomputeSentryDetectionSpy: ReturnType; +let getWorkflowSpy: ReturnType; +let stderrSpy: ReturnType; +/** + * ClientOptions captured from each MastraClient instance constructed by + * runWizard. Used by the MastraClient lifecycle suite to assert that the + * `abortSignal` passed at construction time is aborted on teardown. + */ +let capturedClientOptions: { abortSignal?: AbortSignal; retries?: number }[] = + []; + +let savedPlainOutput: string | undefined; +let testRequestSequence = 0; +let testEnvelopeByPayload = new WeakMap< + object, + { protocolVersion: 1; requestId: string } +>(); + +/** Model the current server: every valid suspend payload carries a v1 ID. */ +function withV1SuspendEnvelope(raw: unknown): unknown { + if ( + typeof raw !== "object" || + raw === null || + !("type" in raw) || + !["tool", "interactive"].includes(String(raw.type)) || + "protocolVersion" in raw || + "requestId" in raw + ) { + return raw; + } + + let envelope = testEnvelopeByPayload.get(raw); + if (!envelope) { + testRequestSequence += 1; + envelope = { + protocolVersion: 1, + requestId: `00000000-0000-4000-8000-${String(testRequestSequence).padStart(12, "0")}`, + }; + testEnvelopeByPayload.set(raw, envelope); + } + return { ...raw, ...envelope }; +} + +function withV1SuspendEnvelopes(result: WorkflowRunResult): WorkflowRunResult { + const steps = result.steps + ? Object.fromEntries( + Object.entries(result.steps).map(([stepId, step]) => [ + stepId, + step.suspendPayload === undefined + ? step + : { + ...step, + suspendPayload: withV1SuspendEnvelope(step.suspendPayload), + }, + ]) + ) + : undefined; + return { + ...result, + ...(steps ? { steps } : {}), + ...(result.suspendPayload === undefined + ? {} + : { suspendPayload: withV1SuspendEnvelope(result.suspendPayload) }), + }; +} + +function forceStdinTty(action: () => Promise): Promise { + const originalDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY" + ); + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + writable: true, + }); + return action().finally(() => { + if (originalDescriptor) { + Object.defineProperty(process.stdin, "isTTY", originalDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + }); +} + +function useMockUI(ui: WizardUI, calls: MockCall[]): void { + mockUICalls = calls; + getUISpy.mockResolvedValue({ + ...ui, + spinner: () => spinnerMock, + }); +} + +beforeEach(() => { + // Force rich output so clack-plain.ts delegates to real clack (spied below) + savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + + mockStartResult = { + status: "success", + result: { exitCode: 0, platform: "React" }, + }; + mockResumeResults = []; + resumeCallCount = 0; + mockRunByIdResult = new Error("runById not configured"); + testRequestSequence = 0; + testEnvelopeByPayload = new WeakMap(); + process.exitCode = 0; + + spinnerMock.start.mockClear(); + spinnerMock.stop.mockClear(); + spinnerMock.message.mockClear(); + + // The wizard runner constructs a UI via `getUI()`. Replace it with a + // MockUI whose spinner() returns the shared `spinnerMock` so tests can + // assert on lifecycle calls. + const { ui, calls, respond } = createMockUI(); + mockUICalls = calls; + // Pre-load a confirm response so the experimental confirm prompt + // resolves to "true" by default — the legacy default before MockUI. + // Tests that exercise `--yes` skip this prompt entirely; the response + // sits unused on the queue and is harmless. + respond.confirm(true); + const wrapped: WizardUI = { + ...ui, + spinner: () => spinnerMock, + }; + getUISpy = vi.spyOn(uiFactory, "getUIAsync").mockResolvedValue(wrapped); + + vi.spyOn(readiness, "checkReadiness").mockResolvedValue(undefined); + formatBannerSpy = vi.spyOn(banner, "formatBanner").mockReturnValue("BANNER"); + formatResultSpy = vi.spyOn(fmt, "formatResult").mockImplementation(noop); + formatErrorSpy = vi.spyOn(fmt, "formatError").mockImplementation(noop); + checkGitStatusSpy = vi.spyOn(git, "checkGitStatus").mockResolvedValue(true); + handleInteractiveSpy = vi + .spyOn(inter, "handleInteractive") + .mockResolvedValue({ + action: "continue", + }); + resolveInitContextSpy = vi + .spyOn(preflight, "resolveInitContext") + .mockResolvedValue(makeContext()); + describeToolSpy = vi + .spyOn(registry, "describeTool") + .mockReturnValue("Running tool..."); + executeToolSpy = vi.spyOn(registry, "executeTool").mockResolvedValue({ + ok: true, + data: { results: [] }, + }); + precomputeDirListingSpy = vi + .spyOn(workflowInputs, "precomputeDirListing") + .mockResolvedValue([]); + preReadCommonFilesSpy = vi + .spyOn(workflowInputs, "preReadCommonFiles") + .mockResolvedValue({}); + precomputeSentryDetectionSpy = vi + .spyOn(workflowInputs, "precomputeSentryDetection") + .mockResolvedValue({ + ok: true, + data: { status: "none", signals: [] }, + }); + stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true as any); + + startAsyncMock = vi.fn(() => + Promise.resolve(withV1SuspendEnvelopes(mockStartResult)) + ); + runByIdMock = vi.fn(() => + mockRunByIdResult instanceof Error + ? Promise.reject(mockRunByIdResult) + : Promise.resolve(withV1SuspendEnvelopes(mockRunByIdResult)) + ); + sharedResumeAsyncMock = vi.fn(() => { + const result = mockResumeResults[resumeCallCount] ?? { + status: "success", + result: { exitCode: 0 }, + }; + resumeCallCount += 1; + return Promise.resolve(withV1SuspendEnvelopes(result)); + }); + const run = { + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: sharedResumeAsyncMock, + }; + const workflow = { + createRun: vi.fn(() => Promise.resolve(run)), + runById: runByIdMock, + }; + capturedClientOptions = []; + getWorkflowSpy = vi + .spyOn(MastraClient.prototype, "getWorkflow") + .mockImplementation(function (this: MastraClient) { + // `this` is the MastraClient instance. `BaseResource.options` holds the + // full ClientOptions passed to the constructor — including abortSignal. + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return workflow as any; + }); +}); + +afterEach(() => { + vi.useRealTimers(); + + getUISpy.mockRestore(); + formatBannerSpy.mockRestore(); + formatResultSpy.mockRestore(); + formatErrorSpy.mockRestore(); + checkGitStatusSpy.mockRestore(); + handleInteractiveSpy.mockRestore(); + resolveInitContextSpy.mockRestore(); + describeToolSpy.mockRestore(); + executeToolSpy.mockRestore(); + precomputeDirListingSpy.mockRestore(); + preReadCommonFilesSpy.mockRestore(); + precomputeSentryDetectionSpy.mockRestore(); + getWorkflowSpy.mockRestore(); + stderrSpy.mockRestore(); + + process.exitCode = 0; + + if (savedPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; + } + + // Restore the env sandbox in case any test used setEnv without try/finally. + setEnv(process.env); +}); + +function lastCancelMessage(): string | undefined { + for (let i = mockUICalls.length - 1; i >= 0; i--) { + const call = mockUICalls[i]; + if (call?.kind === "cancel") { + return call.message; + } + } + return; +} + +function lastFeedbackOutcome(): string | undefined { + for (let i = mockUICalls.length - 1; i >= 0; i--) { + const call = mockUICalls[i]; + if (call?.kind === "feedback") { + return call.outcome; + } + } + return; +} + +function lastWarn(): string | undefined { + for (let i = mockUICalls.length - 1; i >= 0; i--) { + const call = mockUICalls[i]; + if (call?.kind === "log.warn") { + return call.message; + } + } + return; +} + +function lastError(): string | undefined { + for (let i = mockUICalls.length - 1; i >= 0; i--) { + const call = mockUICalls[i]; + if (call?.kind === "log.error") { + return call.message; + } + } + return; +} + +function initService401Error(): Error { + return new Error( + 'HTTP error! status: 401 - {"error":"Unauthorized: invalid token"}' + ); +} + +async function useSaaSOAuthAuth(authToken = "oauth-token"): Promise { + await clearAuth(); + const env = { ...process.env }; + delete env.SENTRY_AUTH_TOKEN; + delete env.SENTRY_TOKEN; + delete env.SENTRY_FORCE_ENV_TOKEN; + setEnv(env); + setAuthToken(authToken, 3600, "refresh-token", { + host: "https://sentry.io", + }); + resolveInitContextSpy.mockResolvedValue(makeContext({ authToken })); +} + +function initServiceApiErrorShape(endpoint: string) { + return { + name: "ApiError", + status: 401, + endpoint, + }; +} + +function hasExpectedInitServiceAuthPolicy(err: unknown): boolean { + return ( + err instanceof ApiError && + err.status === 401 && + classifySilenced(err) === "api_user_error" + ); +} + +describe("runWizard", () => { + test("rejects a malformed bearer before constructing the service client", async () => { + resolveInitContextSpy.mockResolvedValue( + makeContext({ authToken: "synthetic-prefix\nsynthetic-secret-tail" }) + ); + + const error = await runWizard(makeOptions()).catch( + (caught: unknown) => caught + ); + expect(error).toMatchObject({ + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(String(error)).not.toContain("synthetic-secret-tail"); + expect(capturedClientOptions).toEqual([]); + expect(getWorkflowSpy).not.toHaveBeenCalled(); + }); + + test("formats successful results", async () => { + await runWizard(makeOptions()); + + expect(formatResultSpy).toHaveBeenCalled(); + expect(formatErrorSpy).not.toHaveBeenCalled(); + expect(spinnerMock.stop).toHaveBeenCalledWith("Done"); + }); + + test("throws when stdin is not a TTY without --yes", async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY" + ); + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + writable: true, + }); + + try { + await expect(runWizard(makeOptions({ yes: false }))).rejects.toThrow( + WizardError + ); + } finally { + if (originalDescriptor) { + Object.defineProperty(process.stdin, "isTTY", originalDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + } + }); + + test("passes dry-run as non-interactive into preflight", async () => { + await runWizard(makeOptions({ dryRun: true, yes: false })); + + expect(resolveInitContextSpy).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true, yes: true }), + expect.anything() + ); + expect(lastWarn()).toContain("Dry-run"); + }); + + test("uses rich welcome screen when available", async () => { + const { ui, calls, respond } = createMockUI({ welcome: true }); + respond.welcome("continue"); + useMockUI(ui, calls); + + await forceStdinTty(() => + runWizard( + makeOptions({ + yes: false, + features: ["errorMonitoring", "performanceMonitoring"], + org: "bete-dev", + project: "nextjs", + }) + ) + ); + + const welcome = calls.find((call) => call.kind === "welcome"); + expect(welcome).toBeDefined(); + if (welcome?.kind !== "welcome") { + throw new Error("expected welcome call"); + } + expect(welcome.options.title).toBe("Sentry Init"); + expect(welcome.options.body).toContain( + "We'll use AI to inspect this project and configure Sentry." + ); + expect(welcome.options.punchline).toContain("use AI for setup"); + expect(getUISpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + initialWelcome: expect.objectContaining({ + title: "Sentry Init", + }), + }) + ); + expect( + calls.some((call) => call.kind === "select" || call.kind === "confirm") + ).toBe(false); + const introOn = calls.findIndex( + (call) => call.kind === "setIntroMode" && call.enabled + ); + const introOff = calls.findIndex( + (call) => call.kind === "setIntroMode" && !call.enabled + ); + expect(introOn).toBeGreaterThanOrEqual(0); + expect(introOff).toBeGreaterThanOrEqual(0); + expect(spinnerMock.message.mock.calls).toContainEqual([ + "Connecting to wizard...", + ]); + expect(formatResultSpy).toHaveBeenCalled(); + }); + + test("does not log a second AI disclaimer after welcome", async () => { + const { ui, calls, respond } = createMockUI({ welcome: true }); + respond.welcome("continue"); + useMockUI(ui, calls); + + await forceStdinTty(() => + runWizard( + makeOptions({ + yes: false, + features: ["errorMonitoring"], + org: "bete-dev", + project: "nextjs", + }) + ) + ); + + const infoMessages = calls + .filter((call) => call.kind === "log.info") + .map((call) => call.message); + expect( + infoMessages.some((message) => message.includes("This wizard uses AI")) + ).toBe(false); + expect( + infoMessages.some((message) => message.includes("For manual setup")) + ).toBe(false); + }); + + test("cancels cleanly from rich welcome screen", async () => { + const { ui, calls, respond } = createMockUI({ welcome: true }); + respond.welcome(CANCELLED); + useMockUI(ui, calls); + const captureSpy = vi.spyOn(Sentry, "captureException"); + + try { + await forceStdinTty(() => runWizard(makeOptions({ yes: false }))); + + expect(process.exitCode).toBe(0); + expect(lastCancelMessage()).toBe("Setup cancelled."); + expect(lastFeedbackOutcome()).toBe("cancelled"); + expect(getWorkflowSpy).not.toHaveBeenCalled(); + expect(captureSpy).not.toHaveBeenCalled(); + } finally { + captureSpy.mockRestore(); + } + }); + + test("falls back to generic continue prompt without rich welcome", async () => { + const { ui, calls, respond } = createMockUI(); + respond.select("continue"); + useMockUI(ui, calls); + + await forceStdinTty(() => runWizard(makeOptions({ yes: false }))); + + const select = calls.find((call) => call.kind === "select"); + expect(select).toBeDefined(); + if (select?.kind !== "select") { + throw new Error("expected select call"); + } + expect(select.message).toContain("experimental"); + expect(formatResultSpy).toHaveBeenCalled(); + }); + + test("aborts cleanly when user declines the experimental prompt", async () => { + const { ui, calls, respond } = createMockUI(); + respond.select("exit"); + useMockUI(ui, calls); + + await forceStdinTty(() => runWizard(makeOptions({ yes: false }))); + + expect(process.exitCode).toBe(0); + expect(lastCancelMessage()).toBe("Setup cancelled."); + expect(lastFeedbackOutcome()).toBe("cancelled"); + expect(getWorkflowSpy).not.toHaveBeenCalled(); + }); + + test("stops before workflow creation when preflight returns null", async () => { + resolveInitContextSpy.mockResolvedValue(null); + + await runWizard(makeOptions()); + + expect(getWorkflowSpy).not.toHaveBeenCalled(); + expect(formatResultSpy).not.toHaveBeenCalled(); + }); + + test("aborts cleanly when git safety check fails", async () => { + checkGitStatusSpy.mockResolvedValue(false); + + await runWizard(makeOptions()); + + expect(lastCancelMessage()).toBe("Setup cancelled."); + expect(lastFeedbackOutcome()).toBe("cancelled"); + expect(getWorkflowSpy).not.toHaveBeenCalled(); + }); + + test("suppresses the ASCII art banner when an agent is detected", async () => { + setEnv({ ...process.env, CLAUDE_CODE: "1" } as NodeJS.ProcessEnv); + try { + await runWizard(makeOptions()); + } finally { + setEnv(process.env); + } + + expect(formatBannerSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining("BANNER") + ); + }); + + test("prints the ASCII art banner when no agent is detected", async () => { + // Strip all agent-detection env vars so detectAgent() returns undefined + // even when running inside an agent environment (e.g. OpenCode in CI). + const agentKeys = new Set([ + "AI_AGENT", + "AGENT", + "CLAUDECODE", + "CLAUDE_CODE", + "CURSOR_EXTENSION_HOST_ROLE", + ...ENV_VAR_AGENTS.keys(), + ]); + const cleanEnv = Object.fromEntries( + Object.entries(process.env).filter(([k]) => !agentKeys.has(k)) + ); + setEnv(cleanEnv as NodeJS.ProcessEnv); + try { + await runWizard(makeOptions()); + } finally { + setEnv(process.env); + } + + expect(formatBannerSpy).toHaveBeenCalled(); + expect(mockUICalls).toContainEqual({ kind: "banner", art: "BANNER" }); + }); + + test("dispatches tool payloads through the registry", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { + "apply-codemods": { suspendPayload: payload }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + expect(describeToolSpy).toHaveBeenCalledWith(payload); + expect(executeToolSpy).toHaveBeenCalledWith(payload, makeContext()); + expect(spinnerMock.message).toHaveBeenCalledWith("Running tool..."); + }); + + test("keeps v1 transport metadata out of local tool payloads", async () => { + const requestId = "8c7ee6b9-e955-4514-9164-f01844584a28"; + const toolPayload: ToolPayload = { + type: "tool", + operation: "apply-patchset", + cwd: "/tmp/test", + params: { patches: [] }, + }; + const protocolPayload: SuspendPayload = { + ...toolPayload, + protocolVersion: 1, + requestId, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { + "apply-codemods": { suspendPayload: protocolPayload }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + expect(describeToolSpy).toHaveBeenCalledWith(toolPayload); + expect(executeToolSpy).toHaveBeenCalledWith(toolPayload, makeContext()); + expect(sharedResumeAsyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + resumeData: expect.objectContaining({ + protocolVersion: 1, + requestId, + }), + }) + ); + }); + + test("keeps v1 transport metadata out of interactive payloads", async () => { + const requestId = "8c7ee6b9-e955-4514-9164-f01844584a28"; + mockStartResult = { + status: "suspended", + suspended: [["pick-feature"]], + steps: { + "pick-feature": { + suspendPayload: { + type: "interactive", + kind: "confirm", + prompt: "Continue?", + protocolVersion: 1, + requestId, + }, + }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + expect(handleInteractiveSpy).toHaveBeenCalledWith( + { + type: "interactive", + kind: "confirm", + prompt: "Continue?", + }, + makeContext(), + expect.anything() + ); + expect(sharedResumeAsyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + resumeData: expect.objectContaining({ + protocolVersion: 1, + requestId, + }), + }) + ); + }); + + test("moves feature review directly into code-planning progress", async () => { + mockStartResult = { + status: "suspended", + suspended: [["select-features"]], + steps: { + "select-features": { + suspendPayload: { + type: "interactive", + kind: "multi-select", + prompt: "Select features to enable", + availableFeatures: ["errorMonitoring", "performanceMonitoring"], + }, + }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + expect(spinnerMock.start).toHaveBeenCalledWith("Planning code changes..."); + expect(spinnerMock.start).not.toHaveBeenCalledWith("Processing..."); + }); + + test("skips verify-changes interactive prompts during dry-run", async () => { + const requestId = "3bc6b6f4-e2f5-40e4-9ac5-bbd69bf7af70"; + resolveInitContextSpy.mockResolvedValue(makeContext({ dryRun: true })); + mockStartResult = { + status: "suspended", + suspended: [["verify-changes"]], + steps: { + "verify-changes": { + suspendPayload: { + type: "interactive", + kind: "confirm", + prompt: "Verify changes?", + protocolVersion: 1, + requestId, + }, + }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions({ dryRun: true })); + + expect(handleInteractiveSpy).not.toHaveBeenCalled(); + expect(sharedResumeAsyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + resumeData: expect.objectContaining({ + protocolVersion: 1, + requestId, + }), + }) + ); + }); + + test("surfaces malformed suspend payload types", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "unknown", + operation: "list-dir", + cwd: "/tmp/test", + params: { path: "." }, + }, + }, + }, + }; + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + }); + + test.each([ + ["a missing envelope", {}], + ["an unsupported version", { protocolVersion: 2, requestId: "request" }], + ["a missing request ID", { protocolVersion: 1 }], + ["an empty request ID", { protocolVersion: 1, requestId: "" }], + ])("rejects suspend payloads with %s", async (_label, envelope) => { + startAsyncMock.mockResolvedValueOnce({ + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "tool", + operation: "list-dir", + cwd: "/tmp/test", + params: { path: "." }, + ...envelope, + }, + }, + }, + }); + + await expect(runWizard(makeOptions())).rejects.toThrow( + "Invalid init protocol envelope" + ); + expect(executeToolSpy).not.toHaveBeenCalled(); + }); + + test("fails when a suspended step has no payload", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": {}, + }, + }; + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + }); + + test("tears down forwarding and stops the spinner on tool errors", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { + "apply-codemods": { suspendPayload: payload }, + }, + }; + executeToolSpy.mockRejectedValue(new Error("boom")); + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + + expect(spinnerMock.stop).toHaveBeenCalledWith("Error", 1); + expect(lastCancelMessage()).toBe("Setup failed"); + expect(lastFeedbackOutcome()).toBe("failed"); + }); + + test("preserves 403 errors thrown for command-level scope inspection", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { name: "my-app", platform: "javascript-react" }, + }; + mockStartResult = { + status: "suspended", + suspended: [["ensure-sentry-project"]], + steps: { + "ensure-sentry-project": { suspendPayload: payload }, + }, + }; + const scopeError = new ApiError("Forbidden", 403); + executeToolSpy.mockRejectedValue(scopeError); + + await expect(runWizard(makeOptions())).rejects.toBe(scopeError); + + expect(spinnerMock.stop).toHaveBeenCalledWith( + "Sentry API request denied", + 1 + ); + expect(lastCancelMessage()).toBe("Sentry API request denied"); + }); + + test("tears down forwarding and stops the spinner on cancellation", async () => { + const captureSpy = vi.spyOn(Sentry, "captureException"); + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { + "apply-codemods": { suspendPayload: payload }, + }, + }; + executeToolSpy.mockRejectedValue(new WizardCancelledError()); + + try { + await runWizard(makeOptions()); + + expect(process.exitCode).toBe(0); + expect(spinnerMock.stop).toHaveBeenCalledWith("Cancelled", 0); + expect(lastCancelMessage()).toBe("Setup cancelled."); + expect(lastFeedbackOutcome()).toBe("cancelled"); + expect(captureSpy).not.toHaveBeenCalled(); + } finally { + captureSpy.mockRestore(); + } + }); + + test("tears down forwarding when a WizardError is rethrown from a tool", async () => { + // The reordered catch block stops the spinner BEFORE the WizardError + // rethrow branch, so any WizardError thrown from handleSuspendedStep + // (e.g. tool handlers, malformed payloads) must still release the TTY + // handle via `using` teardown. + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { + "apply-codemods": { suspendPayload: payload }, + }, + }; + executeToolSpy.mockRejectedValue( + new WizardError("tool rejected by server") + ); + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + + expect(spinnerMock.stop).toHaveBeenCalledWith("Error", 1); + expect(lastCancelMessage()).toBe("Setup failed"); + expect(lastFeedbackOutcome()).toBe("failed"); + }); + + test("shows count-based messages while reading and analyzing files", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "tool", + operation: "read-files", + cwd: "/tmp/test", + params: { + paths: ["src/settings.py", "src/urls.py"], + resultVersion: 2, + }, + }, + }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + const messages = spinnerMock.message.mock.calls.map( + (call: string[]) => call[0] + ); + expect(messages).toContain("Reading 2 files..."); + expect(messages).toContain("Analyzing 2 files..."); + }); + + test("passes precomputed dirListing/fileCache/existingSentry via initialState, not inputData", async () => { + const dirListing = [ + { name: "package.json", path: "package.json", type: "file" as const }, + ]; + const fileCache = { "package.json": '{"name":"app"}' }; + const detectedSentry = { status: "none" as const, signals: [] }; + + precomputeDirListingSpy.mockResolvedValue(dirListing); + preReadCommonFilesSpy.mockResolvedValue(fileCache); + precomputeSentryDetectionSpy.mockResolvedValue({ + ok: true, + data: detectedSentry, + }); + + await runWizard(makeOptions()); + + expect(startAsyncMock).toHaveBeenCalledTimes(1); + const call = startAsyncMock.mock.calls[0] as + | [ + { + inputData?: Record; + initialState?: Record; + }, + ] + | undefined; + expect(call).toBeDefined(); + const args = call?.[0] ?? {}; + + // Large shared context lives on state, not on inputData. + expect(args.inputData).not.toHaveProperty("dirListing"); + expect(args.inputData).not.toHaveProperty("fileCache"); + expect(args.inputData).not.toHaveProperty("existingSentry"); + expect(args.inputData?.client).toEqual({ + cliVersion: expect.any(String), + protocolVersion: 1, + }); + expect(args.initialState?.dirListing).toEqual(dirListing); + expect(args.initialState?.fileCache).toEqual(fileCache); + expect(args.initialState?.existingSentry).toEqual(detectedSentry); + }); + + test("renders tool result messages as a transient spinner message, not a persisted line", async () => { + mockStartResult = { + status: "suspended", + suspended: [["ensure-sentry-project"]], + steps: { + "ensure-sentry-project": { + suspendPayload: { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { name: "my-app", platform: "javascript-react" }, + }, + }, + }, + }; + executeToolSpy.mockResolvedValue({ + ok: true, + message: "Using existing project", + data: {}, + }); + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + // Tool messages update the live spinner instead of persisting a ✔ line — + // the sidebar Tasks checklist already tracks step completion, so a + // duplicate line in the activity log is just noise. + expect(spinnerMock.message).toHaveBeenCalledWith("Using existing project"); + expect(spinnerMock.stop).not.toHaveBeenCalledWith("Using existing project"); + }); + + test("captures the created Sentry project identity and forwards it to formatResult", async () => { + const identity = { + orgSlug: "acme", + projectSlug: "my-app", + projectId: "4507", + dsn: "https://k@o0.ingest.sentry.io/4507", + url: "https://acme.sentry.io/settings/projects/my-app/", + }; + mockStartResult = { + status: "suspended", + suspended: [["ensure-sentry-project"]], + steps: { + "ensure-sentry-project": { + suspendPayload: { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { name: "my-app", platform: "javascript-react" }, + }, + }, + }, + }; + executeToolSpy.mockResolvedValue({ ok: true, data: identity }); + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + // The identity comes from the local tool result, not the server output — + // the CLI creates the project itself, so it passes what it already knows + // as formatResult's 4th arg to build the Issues link without a round-trip. + expect(formatResultSpy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + identity + ); + }); + + test("shows --yes hint when LoggingUI prompt fails", async () => { + const { LoggingUIPromptError } = await import( + "../../../src/lib/init/ui/logging-ui.js" + ); + const { ui } = createMockUI(); + const failingUI: WizardUI = { + ...ui, + spinner: () => spinnerMock, + select: () => + Promise.reject( + new LoggingUIPromptError( + "select", + "This is experimental and will modify files" + ) + ), + }; + getUISpy.mockResolvedValue(failingUI); + + await expect( + forceStdinTty(() => runWizard(makeOptions({ yes: false }))) + ).rejects.toThrow("Run with --yes for non-interactive mode."); + }); +}); + +describe("runWizard — MastraClient lifecycle", () => { + test("aborts the MastraClient signal after a successful run", async () => { + await runWizard(makeOptions()); + + expect(capturedClientOptions).toHaveLength(1); + expect(capturedClientOptions[0]?.retries).toBe(0); + const signal = capturedClientOptions[0]?.abortSignal; + expect(signal).toBeInstanceOf(AbortSignal); + // Using the non-null assertion safely — we asserted toBeInstanceOf above. + expect((signal as AbortSignal).aborted).toBe(true); + }); + + test("aborts the MastraClient signal when a tool throws", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { + "apply-codemods": { suspendPayload: payload }, + }, + }; + executeToolSpy.mockRejectedValue(new Error("tool blew up")); + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + + expect(capturedClientOptions).toHaveLength(1); + const signal = capturedClientOptions[0]?.abortSignal; + expect(signal).toBeInstanceOf(AbortSignal); + expect((signal as AbortSignal).aborted).toBe(true); + }); + + test("aborts the MastraClient signal on cancellation", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { + "apply-codemods": { suspendPayload: payload }, + }, + }; + executeToolSpy.mockRejectedValue(new WizardCancelledError()); + + await runWizard(makeOptions()); + + expect(capturedClientOptions).toHaveLength(1); + const signal = capturedClientOptions[0]?.abortSignal; + expect(signal).toBeInstanceOf(AbortSignal); + expect((signal as AbortSignal).aborted).toBe(true); + }); + + test("signal is live (not pre-aborted) while the wizard is running", async () => { + // `getWorkflow` runs BEFORE `startAsync` (client.getWorkflow is called + // synchronously right after `new MastraClient(...)`), so the signal + // observed at that time is the same instance that in-flight fetches + // would see during the wizard. If the signal were somehow pre-aborted + // at construction, it would be aborted here too. This proves the + // `using _mastraCleanup` disposable does NOT fire until teardown. + let abortedAtConstruction: boolean | undefined; + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + const opts = ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options; + capturedClientOptions.push(opts); + abortedAtConstruction = opts.abortSignal?.aborted; + return { + createRun: vi.fn(() => + Promise.resolve({ + startAsync: startAsyncMock, + resumeAsync: vi.fn(() => + Promise.resolve({ status: "success", result: { exitCode: 0 } }) + ), + }) + ), + } as any; + }); + + await runWizard(makeOptions()); + + expect(abortedAtConstruction).toBe(false); + // And teardown aborted it by the time the wizard returned. + expect(capturedClientOptions[0]?.abortSignal?.aborted).toBe(true); + }); +}); + +// ─── Additional coverage tests ─────────────────────────────────────────────── + +describe("runWizard — workflow exit codes", () => { + test("rejects workflow success without an explicit exit code", async () => { + mockStartResult = { status: "success", result: { platform: "React" } }; + + const error = await runWizard(makeOptions()).catch((caught) => caught); + + expect(error).toBeInstanceOf(WizardError); + expect((error as WizardError).exitCode).not.toBe(0); + }); + + // handleFinalResult calls mapWorkflowExitCode when the workflow result + // carries a non-zero exitCode. Each case maps a server-internal code to + // the CLI's semantic EXIT constant. + test.each([ + [20, EXIT.CONFIG], + [30, EXIT.WIZARD_DEPS], + [40, EXIT.WIZARD_CODEMOD], + [41, EXIT.WIZARD_CODEMOD], + [50, EXIT.WIZARD_VERIFY], + // 999 is an unknown code; also exercises the default branch of mapWorkflowExitCode + [999, EXIT.WIZARD], + ])("maps workflow exit code %s to the expected EXIT constant", async (workflowCode, expectedExitCode) => { + mockStartResult = { + status: "success", + result: { exitCode: workflowCode }, + }; + + const err = await runWizard(makeOptions()).catch((e) => e); + + expect(err).toBeInstanceOf(WizardError); + expect((err as WizardError).exitCode).toBe(expectedExitCode); + }); +}); + +describe("runWizard — resumeWithRetry stale-step recovery", () => { + const toolPayload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install"] }, + }; + + function makeStaleStepRun( + resumeAsyncImpl: ( + args: Record + ) => Promise + ) { + let runByIdRef: ReturnType; + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + runByIdRef = runByIdMock; + return { + createRun: vi.fn(() => + Promise.resolve({ + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: vi.fn(async (args) => + withV1SuspendEnvelopes(await resumeAsyncImpl(args)) + ), + }) + ), + runById: runByIdRef, + } as any; + }); + } + + function httpError( + status: number, + body: unknown + ): Error & { body: unknown; status: number } { + return Object.assign( + new Error(`HTTP error! status: ${status} - ${JSON.stringify(body)}`), + { body, status } + ); + } + + function staleStepError(status = 500): Error & { status: number } { + return httpError(status, { + error: + "This workflow step 'tool-step' was not suspended. Available suspended steps: [next-step]", + }); + } + + function staleRunError(status = 500): Error & { status: number } { + return httpError(status, { + error: "This workflow run was not suspended", + }); + } + + function protocolConflictError(): Error & { + body: unknown; + status: number; + } { + return httpError(409, { + code: "init_request_conflict", + error: "The init tool result does not match the active suspended request", + }); + } + + function selectWorkflowFields( + result: WorkflowRunResult, + fields: string[] | undefined + ): Record { + const source = result as unknown as Record; + const selected: Record = {}; + for (const field of fields ?? Object.keys(source)) { + if (field in source) { + selected[field] = source[field]; + } + } + return selected; + } + + test("echoes the v1 request identity with the local tool result", async () => { + const protocolPayload: SuspendPayload = { + ...toolPayload, + protocolVersion: 1, + requestId: "8c7ee6b9-e955-4514-9164-f01844584a28", + }; + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: protocolPayload } }, + }; + let capturedResume: Record | undefined; + makeStaleStepRun((args) => { + capturedResume = args.resumeData as Record; + return Promise.resolve({ status: "success", result: { exitCode: 0 } }); + }); + + await runWizard(makeOptions()); + + expect(capturedResume).toMatchObject({ + protocolVersion: 1, + requestId: protocolPayload.requestId, + }); + }); + + test("sends the explicit agent-checkpoint acknowledgement", async () => { + const checkpointPayload: SuspendPayload = { + cwd: "/tmp/test", + detail: "Checking Sentry support for the detected project", + operation: "agent-checkpoint", + params: {}, + protocolVersion: 1, + requestId: "5f61cbd5-1051-4b52-928f-06eb78ba40ee", + type: "tool", + }; + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { suspendPayload: checkpointPayload }, + }, + }; + executeToolSpy.mockResolvedValue({ + data: { acknowledged: true }, + ok: true, + }); + let capturedResume: Record | undefined; + makeStaleStepRun((args) => { + capturedResume = args.resumeData as Record; + return Promise.resolve({ status: "success", result: { exitCode: 0 } }); + }); + + await runWizard(makeOptions()); + + expect(executeToolSpy).toHaveBeenCalledWith( + expect.objectContaining({ operation: "agent-checkpoint" }), + expect.anything() + ); + expect(capturedResume).toMatchObject({ + data: { acknowledged: true }, + ok: true, + protocolVersion: 1, + requestId: checkpointPayload.requestId, + }); + }); + + test("uses request identity instead of mutable payload details during recovery", async () => { + vi.useFakeTimers(); + const requestId = "8c7ee6b9-e955-4514-9164-f01844584a28"; + const protocolPayload: SuspendPayload = { + ...toolPayload, + protocolVersion: 1, + requestId, + }; + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: protocolPayload } }, + }; + runByIdMock + .mockResolvedValueOnce({ + status: "suspended", + suspendPayload: { ...protocolPayload, detail: "new display text" }, + }) + .mockResolvedValueOnce({ + status: "success", + result: { exitCode: 0 }, + }); + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + return Promise.reject(staleRunError(409)); + }); + + const run = runWizard(makeOptions()); + await vi.advanceTimersByTimeAsync(250); + await run; + + expect(runByIdMock).toHaveBeenCalledTimes(2); + expect(resumeCount).toBe(1); + }); + + test("recovers from a sparse 409 stale-resume conflict", async () => { + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: toolPayload } }, + }; + const currentRunState: WorkflowRunResult = { + status: "success", + result: { exitCode: 0 }, + suspended: [], + }; + runByIdMock.mockImplementation( + (_runId: string, opts?: { fields?: string[] }) => + Promise.resolve(selectWorkflowFields(currentRunState, opts?.fields)) + ); + + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + if (resumeCount === 1) { + return Promise.reject(staleStepError(409)); + } + return Promise.resolve({ status: "success", result: { exitCode: 0 } }); + }); + + await runWizard(makeOptions()); + + expect(formatResultSpy).toHaveBeenCalled(); + expect(runByIdMock).toHaveBeenCalledWith( + "test-run-id", + expect.objectContaining({ + fields: expect.arrayContaining([ + "status", + "suspended", + "activeStepsPath", + ]), + }) + ); + // Recovery succeeded on the first attempt — resumeAsync was not called again. + expect(resumeCount).toBe(1); + }); + + test("recovers from a v1 request-correlation conflict", async () => { + const protocolPayload: SuspendPayload = { + ...toolPayload, + protocolVersion: 1, + requestId: "8c7ee6b9-e955-4514-9164-f01844584a28", + }; + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: protocolPayload } }, + }; + runByIdMock.mockResolvedValue({ + status: "success", + result: { exitCode: 0 }, + suspended: [], + }); + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + return Promise.reject(protocolConflictError()); + }); + + await runWizard(makeOptions()); + + expect(runByIdMock).toHaveBeenCalledTimes(1); + expect(resumeCount).toBe(1); + }); + + test("keeps polling when runById returns the same suspended payload snapshot", async () => { + vi.useFakeTimers(); + const protocolPayload: SuspendPayload = { + ...toolPayload, + protocolVersion: 1, + requestId: "8c7ee6b9-e955-4514-9164-f01844584a28", + }; + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: protocolPayload } }, + }; + runByIdMock + .mockResolvedValueOnce({ + status: "suspended", + suspendPayload: protocolPayload, + }) + .mockResolvedValueOnce({ + status: "success", + result: { exitCode: 0 }, + }); + + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + return Promise.reject(staleRunError(409)); + }); + + const run = runWizard(makeOptions()); + await vi.advanceTimersByTimeAsync(250); + await run; + + expect(formatResultSpy).toHaveBeenCalled(); + expect(runByIdMock).toHaveBeenCalledTimes(2); + expect(resumeCount).toBe(1); + }); + + test("uses active recovered payload instead of stale historical step payloads", async () => { + const stalePayload: ToolPayload = { + type: "tool", + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["old-package.json"], resultVersion: 2 }, + }; + const activePayload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["echo apply"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: toolPayload } }, + }; + mockRunByIdResult = { + status: "suspended", + suspended: [["discover-context"]], + activeStepsPath: { "apply-codemods": [] }, + steps: { + "discover-context": { suspendPayload: stalePayload }, + "apply-codemods": { suspendPayload: activePayload }, + }, + }; + + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + if (resumeCount === 1) { + return Promise.reject(staleStepError()); + } + return Promise.resolve({ status: "success", result: { exitCode: 0 } }); + }); + + await runWizard(makeOptions()); + + expect(executeToolSpy).toHaveBeenCalledWith(toolPayload, makeContext()); + expect(executeToolSpy).toHaveBeenCalledWith(activePayload, makeContext()); + expect(executeToolSpy).not.toHaveBeenCalledWith( + stalePayload, + makeContext() + ); + expect(resumeCount).toBe(2); + }); + + test("does not replay non-stale HTTP 500 resume responses", async () => { + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: toolPayload } }, + }; + + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + return Promise.reject(httpError(500, { error: "Error calling handler" })); + }); + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + + expect(resumeCount).toBe(1); + expect(runByIdMock).not.toHaveBeenCalled(); + }); + + test("classifies resumeAsync 401 instead of recovering as a transport failure", async () => { + await useSaaSOAuthAuth(); + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: toolPayload } }, + }; + + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + return Promise.reject(initService401Error()); + }); + + try { + const err = await runWizard(makeOptions()).catch((error) => error); + + expect(err).toMatchObject( + initServiceApiErrorShape("/api/workflows/sentry-wizard/resume-async") + ); + expect(hasExpectedInitServiceAuthPolicy(err)).toBe(true); + expect(err).not.toBeInstanceOf(WizardError); + expect(resumeCount).toBe(1); + expect(runByIdMock).not.toHaveBeenCalled(); + expect(spinnerMock.stop).toHaveBeenCalledWith("Authentication failed", 1); + expect(err.format()).toContain("sentry auth login"); + expect(lastError()).toBeUndefined(); + expect(lastCancelMessage()).toBe("Authentication failed"); + } finally { + await clearAuth(); + } + }); + + test("observes run state after a resume timeout without replaying resumeAsync", async () => { + vi.useFakeTimers(); + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: toolPayload } }, + }; + mockRunByIdResult = { status: "success", result: { exitCode: 0 } }; + + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + return new Promise(() => { + /* Simulate a response that never arrives. */ + }); + }); + + const run = runWizard(makeOptions()); + await vi.advanceTimersByTimeAsync(209_999); + expect(runByIdMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await run; + + expect(formatResultSpy).toHaveBeenCalled(); + expect(resumeCount).toBe(1); + expect(runByIdMock).toHaveBeenCalledTimes(1); + }); + + test("throws when stale-step error occurs and runById keeps failing", async () => { + vi.useFakeTimers(); + mockStartResult = { + status: "suspended", + suspended: [["tool-step"]], + steps: { "tool-step": { suspendPayload: toolPayload } }, + }; + // runById is unreachable — recovery fails, wizard throws without retrying + // the stale resume request. + mockRunByIdResult = new Error("runById network error"); + + let resumeCount = 0; + makeStaleStepRun(() => { + resumeCount += 1; + return Promise.reject(staleStepError()); + }); + + const run = runWizard(makeOptions()); + const rejection = expect(run).rejects.toThrow(WizardError); + await vi.runAllTimersAsync(); + await rejection; + + // Threw after recovery polling failed — no futile retries of the stale step. + expect(resumeCount).toBe(1); + expect(runByIdMock).toHaveBeenCalled(); + }); + + test("sends compact _prevPhases only for apply-codemods", async () => { + const largeContent = "x".repeat(10_000); + const readPayload: ToolPayload = { + type: "tool", + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["package.json"], resultVersion: 2 }, + }; + const applyPayload: ToolPayload = { + type: "tool", + operation: "apply-patchset", + cwd: "/tmp/test", + params: { patches: [] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { "apply-codemods": { suspendPayload: readPayload } }, + }; + executeToolSpy + .mockResolvedValueOnce({ + ok: true, + data: { + files: { + "package.json": { + content: largeContent, + status: "ok", + truncated: false, + }, + }, + version: 2, + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: "patch conflict", + }); + + const resumeArgs: Record[] = []; + makeStaleStepRun((args) => { + resumeArgs.push(args); + if (resumeArgs.length === 1) { + return Promise.resolve({ + status: "suspended", + suspended: [["apply-codemods"]], + steps: { "apply-codemods": { suspendPayload: applyPayload } }, + }); + } + return Promise.resolve({ status: "success", result: { exitCode: 0 } }); + }); + + await runWizard(makeOptions()); + + const secondResumeData = resumeArgs[1]?.resumeData as + | Record + | undefined; + expect(secondResumeData?._prevPhases).toEqual([ + { + ok: true, + operation: "read-files", + _phase: "read-files", + data: { files: { "package.json": null } }, + }, + ]); + expect(JSON.stringify(secondResumeData?._prevPhases)).not.toContain( + largeContent + ); + }); +}); + +describe("runWizard — additional coverage", () => { + test("throws WizardError and stops spinner when workflow start fails", async () => { + startAsyncMock.mockRejectedValue(new Error("connection refused")); + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + + expect(spinnerMock.stop).toHaveBeenCalledWith("Connection failed", 1); + expect(lastCancelMessage()).toBe("Setup failed"); + }); + + test("classifies init service 401 as expected OAuth auth state", async () => { + await useSaaSOAuthAuth(); + startAsyncMock.mockRejectedValue(initService401Error()); + const captureSpy = vi.spyOn(Sentry, "captureException"); + + try { + const err = await runWizard(makeOptions()).catch((error) => error); + + expect(err).toMatchObject( + initServiceApiErrorShape("/api/workflows/sentry-wizard/start-async") + ); + expect(hasExpectedInitServiceAuthPolicy(err)).toBe(true); + expect(err.format()).toContain("sentry auth login"); + expect(spinnerMock.stop).toHaveBeenCalledWith("Authentication failed", 1); + expect(lastError()).toBeUndefined(); + expect(lastCancelMessage()).toBe("Authentication failed"); + expect(captureSpy).not.toHaveBeenCalled(); + } finally { + captureSpy.mockRestore(); + await clearAuth(); + } + }); + + test("classifies createRun 401 before starting the workflow", async () => { + await useSaaSOAuthAuth(); + const createRunMock = vi.fn(() => Promise.reject(initService401Error())); + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return { + createRun: createRunMock, + runById: runByIdMock, + } as any; + }); + + try { + const err = await runWizard(makeOptions()).catch((error) => error); + + expect(err).toMatchObject( + initServiceApiErrorShape("/api/workflows/sentry-wizard/create-run") + ); + expect(hasExpectedInitServiceAuthPolicy(err)).toBe(true); + expect(createRunMock).toHaveBeenCalledTimes(1); + expect(startAsyncMock).not.toHaveBeenCalled(); + expect(err.format()).toContain("sentry auth login"); + expect(spinnerMock.stop).toHaveBeenCalledWith("Authentication failed", 1); + expect(lastError()).toBeUndefined(); + expect(lastCancelMessage()).toBe("Authentication failed"); + } finally { + await clearAuth(); + } + }); + + test("classifies init service 401 with env-token guidance", async () => { + await clearAuth(); + const env = { + ...process.env, + SENTRY_AUTH_TOKEN: "env-token", + SENTRY_FORCE_ENV_TOKEN: "1", + }; + delete env.SENTRY_TOKEN; + setEnv(env); + resolveInitContextSpy.mockResolvedValue( + makeContext({ authToken: "env-token" }) + ); + startAsyncMock.mockRejectedValue(initService401Error()); + + try { + const err = await runWizard(makeOptions()).catch((error) => error); + + expect(err).toMatchObject( + initServiceApiErrorShape("/api/workflows/sentry-wizard/start-async") + ); + expect(hasExpectedInitServiceAuthPolicy(err)).toBe(true); + expect(err.format()).toContain("SENTRY_AUTH_TOKEN"); + expect(err.format()).toContain("not recognized or has been revoked"); + expect(err.format()).toContain("auth-tokens"); + expect(lastError()).toBeUndefined(); + } finally { + setEnv(process.env); + await clearAuth(); + } + }); + + test("blocks self-hosted tokens before constructing hosted init workflow", async () => { + await clearAuth(); + setAuthToken("self-hosted-token", 3600, "refresh-token", { + host: "https://sentry.internal.example.com", + }); + resolveInitContextSpy.mockResolvedValue( + makeContext({ authToken: "self-hosted-token" }) + ); + + try { + const err = await runWizard(makeOptions()).catch((error) => error); + + expect(err).toBeInstanceOf(HostScopeError); + expect(getWorkflowSpy).not.toHaveBeenCalled(); + expect(startAsyncMock).not.toHaveBeenCalled(); + expect(capturedClientOptions).toHaveLength(0); + expect(err.format()).toContain("sentry.internal.example.com"); + expect(err.format()).toContain( + "sentry auth login --url https://sentry.io" + ); + } finally { + await clearAuth(); + } + }); + + test("throws when the workflow response has an unrecognised status", async () => { + startAsyncMock.mockResolvedValue({ status: "bailed" }); + + await expect(runWizard(makeOptions())).rejects.toThrow( + /Unexpected workflow status/ + ); + }); + + test("throws when a suspend payload is a non-object truthy value", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + // 42 is truthy, so extractSuspendPayload passes it to + // assertSuspendPayload, which rejects non-objects. + suspendPayload: 42, + }, + }, + }; + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + }); + + test("does not use inactive step payloads when active step info exists", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["echo hi"] }, + }; + // `suspended` points to "step-a", so the stale payload on "step-b" + // must not be used. + mockStartResult = { + status: "suspended", + suspended: [["step-a"]], + steps: { + "step-a": {}, + "step-b": { suspendPayload: payload }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + + expect(executeToolSpy).not.toHaveBeenCalledWith(payload, makeContext()); + }); + + test("uses the sole payload fallback when no active step info exists", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["echo hi"] }, + }; + mockStartResult = { + status: "suspended", + steps: { + "step-b": { suspendPayload: payload }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + expect(executeToolSpy).toHaveBeenCalledWith(payload, makeContext()); + }); + + test("marks the previous step completed when the workflow advances", async () => { + const payloadA: ToolPayload = { + type: "tool", + operation: "list-dir", + cwd: "/tmp/test", + params: { path: "." }, + }; + const payloadB: ToolPayload = { + type: "tool", + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["package.json"], resultVersion: 2 }, + }; + + mockStartResult = { + status: "suspended", + suspended: [["discover-context"]], + steps: { "discover-context": { suspendPayload: payloadA } }, + }; + mockResumeResults = [ + { + status: "suspended", + suspended: [["detect-platform"]], + steps: { "detect-platform": { suspendPayload: payloadB } }, + }, + { status: "success", result: { exitCode: 0 } }, + ]; + + await runWizard(makeOptions()); + + const stepCalls = mockUICalls.filter((c) => c.kind === "setStep"); + expect(stepCalls).toContainEqual({ + kind: "setStep", + stepId: "discover-context", + status: "in_progress", + }); + expect(stepCalls).toContainEqual({ + kind: "setStep", + stepId: "discover-context", + status: "completed", + }); + expect(stepCalls).toContainEqual({ + kind: "setStep", + stepId: "detect-platform", + status: "in_progress", + }); + const inProgressIdx = stepCalls.findIndex( + (c) => + c.kind === "setStep" && + c.stepId === "discover-context" && + c.status === "in_progress" + ); + const completedIdx = stepCalls.findIndex( + (c) => + c.kind === "setStep" && + c.stepId === "discover-context" && + c.status === "completed" + ); + expect(inProgressIdx).toBeLessThan(completedIdx); + }); + + test("uses server step results for tasks that finish without suspending", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["package.json"], resultVersion: 2 }, + }; + + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "discover-context": { status: "success" }, + "detect-platform": { status: "suspended", suspendPayload: payload }, + }, + }; + mockResumeResults = [ + { + status: "success", + result: { exitCode: 0 }, + steps: { + "discover-context": { status: "success" }, + "detect-platform": { status: "success" }, + "ensure-sentry-project": { status: "success" }, + "select-features": { status: "success" }, + "plan-codemods": { status: "success" }, + "apply-codemods": { status: "success" }, + "verify-changes": { status: "success" }, + "open-sentry-ui": { status: "success" }, + }, + }, + ]; + + await runWizard(makeOptions()); + + const stepCalls = mockUICalls.filter((call) => call.kind === "setStep"); + for (const stepId of CHECKLIST_VISIBLE_STEPS) { + expect(stepCalls).toContainEqual({ + kind: "setStep", + stepId, + status: "completed", + }); + } + expect(stepCalls).not.toContainEqual( + expect.objectContaining({ stepId: "install-deps" }) + ); + + const discoverCompletedIndex = stepCalls.findIndex( + (call) => + call.kind === "setStep" && + call.stepId === "discover-context" && + call.status === "completed" + ); + const detectStartedIndex = stepCalls.findIndex( + (call) => + call.kind === "setStep" && + call.stepId === "detect-platform" && + call.status === "in_progress" + ); + expect(discoverCompletedIndex).toBeLessThan(detectStartedIndex); + }); + + test("marks a server-reported failure before any step suspends", async () => { + mockStartResult = { + status: "failed", + error: "Framework detection failed", + steps: { + "discover-context": { status: "success" }, + "detect-platform": { status: "failed" }, + }, + }; + + await expect(runWizard(makeOptions())).rejects.toThrow( + "Framework detection failed" + ); + + expect(mockUICalls).toContainEqual({ + kind: "setStep", + stepId: "discover-context", + status: "completed", + }); + expect(mockUICalls).toContainEqual({ + kind: "setStep", + stepId: "detect-platform", + status: "failed", + }); + }); + + test("uses existing platform name in detect-platform spinner label", async () => { + resolveInitContextSpy.mockResolvedValue( + makeContext({ existingProject: { platform: "javascript-nextjs" } }) + ); + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "tool", + operation: "list-dir", + cwd: "/tmp/test", + params: { path: "." }, + }, + }, + }, + }; + mockResumeResults = [{ status: "success", result: { exitCode: 0 } }]; + + await runWizard(makeOptions()); + + const messages = spinnerMock.message.mock.calls.map( + (c: unknown[]) => c[0] as string + ); + expect(messages.some((m) => m.includes("javascript-nextjs"))).toBe(true); + }); +}); + +describe("runWizard — progress rotation for long-running steps", () => { + test("rotates spinner messages during plan-codemods resume", async () => { + vi.useFakeTimers(); + const toolPayload = { + type: "tool" as const, + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["src/app.tsx"], resultVersion: 2 }, + }; + mockStartResult = { + status: "suspended", + suspended: [["plan-codemods"]], + steps: { "plan-codemods": { suspendPayload: toolPayload } }, + }; + + // resumeAsync will block until we advance timers, then resolve + let resolveResume!: (value: unknown) => void; + const resumePromise = new Promise((resolve) => { + resolveResume = resolve; + }); + const resumeAsyncMock = vi.fn(() => resumePromise); + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return { + createRun: vi.fn(() => + Promise.resolve({ + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: resumeAsyncMock, + }) + ), + runById: runByIdMock, + } as any; + }); + + const runPromise = runWizard(makeOptions()); + + // Let the wizard start and reach the resume call + await vi.advanceTimersByTimeAsync(100); + + // Advance past one rotation interval + await vi.advanceTimersByTimeAsync(12_000); + + // Check that the spinner received a rotating message + const messagesAfterFirstRotation = spinnerMock.message.mock.calls.map( + (c: unknown[]) => c[0] as string + ); + expect( + messagesAfterFirstRotation.some((m) => + m.includes("Fetching SDK documentation") + ) + ).toBe(true); + + // Advance past another rotation interval + await vi.advanceTimersByTimeAsync(12_000); + + const messagesAfterSecondRotation = spinnerMock.message.mock.calls.map( + (c: unknown[]) => c[0] as string + ); + expect( + messagesAfterSecondRotation.some((m) => + m.includes("Analyzing integration requirements") + ) + ).toBe(true); + + // Resolve the resume and let the wizard finish + resolveResume({ status: "success", result: { exitCode: 0 } }); + await vi.advanceTimersByTimeAsync(100); + await runPromise; + }); + + test("appends elapsed time after exhausting all progress messages", async () => { + vi.useFakeTimers(); + const toolPayload = { + type: "tool" as const, + operation: "read-files", + cwd: "/tmp/test", + params: { paths: ["src/app.tsx"], resultVersion: 2 }, + }; + mockStartResult = { + status: "suspended", + suspended: [["plan-codemods"]], + steps: { "plan-codemods": { suspendPayload: toolPayload } }, + }; + + let resolveResume!: (value: unknown) => void; + const resumePromise = new Promise((resolve) => { + resolveResume = resolve; + }); + const resumeAsyncMock = vi.fn(() => resumePromise); + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return { + createRun: vi.fn(() => + Promise.resolve({ + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: resumeAsyncMock, + }) + ), + runById: runByIdMock, + } as any; + }); + + const runPromise = runWizard(makeOptions()); + await vi.advanceTimersByTimeAsync(100); + + // Advance past all 5 plan-codemods messages (5 * 12s = 60s) + // plus one more interval to trigger the elapsed time display + await vi.advanceTimersByTimeAsync(72_000); + + const messages = spinnerMock.message.mock.calls.map( + (c: unknown[]) => c[0] as string + ); + // After exhausting messages, should show elapsed time + expect(messages.some((m) => /\(\d+s\)/.test(m))).toBe(true); + + resolveResume({ status: "success", result: { exitCode: 0 } }); + await vi.advanceTimersByTimeAsync(100); + await runPromise; + }); + + test("does not rotate messages for steps without progress messages", async () => { + vi.useFakeTimers(); + const toolPayload = { + type: "tool" as const, + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["apply-codemods"]], + steps: { "apply-codemods": { suspendPayload: toolPayload } }, + }; + + let resolveResume!: (value: unknown) => void; + const resumePromise = new Promise((resolve) => { + resolveResume = resolve; + }); + const resumeAsyncMock = vi.fn(() => resumePromise); + getWorkflowSpy.mockImplementation(function (this: MastraClient) { + capturedClientOptions.push( + ( + this as unknown as { + options: { abortSignal?: AbortSignal; retries?: number }; + } + ).options + ); + return { + createRun: vi.fn(() => + Promise.resolve({ + runId: "test-run-id", + startAsync: startAsyncMock, + resumeAsync: resumeAsyncMock, + }) + ), + runById: runByIdMock, + } as any; + }); + + const runPromise = runWizard(makeOptions()); + await vi.advanceTimersByTimeAsync(100); + + const messagesBefore = spinnerMock.message.mock.calls.length; + + // Advance past a rotation interval + await vi.advanceTimersByTimeAsync(12_000); + + const messagesAfter = spinnerMock.message.mock.calls.length; + // No new messages should have been added by the rotation timer + expect(messagesAfter).toBe(messagesBefore); + + resolveResume({ status: "success", result: { exitCode: 0 } }); + await vi.advanceTimersByTimeAsync(100); + await runPromise; + }); +}); diff --git a/packages/cli/test/lib/input-validation.property.test.ts b/packages/cli/test/lib/input-validation.property.test.ts new file mode 100644 index 000000000..cea0a6dde --- /dev/null +++ b/packages/cli/test/lib/input-validation.property.test.ts @@ -0,0 +1,303 @@ +/** + * Property-Based Tests for Input Validation + * + * Uses fast-check to verify that input validation correctly rejects + * dangerous inputs while allowing all valid ones. These tests defend + * against agent hallucinations: query injection, path traversal, + * double-encoding, and control character injection. + * + * @see https://github.com/getsentry/cli/issues/350 + */ + +import { + constantFrom, + assert as fcAssert, + oneof, + property, + stringMatching, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + rejectControlChars, + rejectPreEncoded, + validateEndpoint, + validateResourceId, +} from "../../src/lib/input-validation.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Valid Sentry slug characters: lowercase alphanumeric + hyphens */ +const validSlugArb = stringMatching(/^[a-z][a-z0-9-]{0,30}[a-z0-9]$/); + +/** Valid issue suffixes: alphanumeric base-36 strings */ +const validSuffixArb = stringMatching(/^[A-Z0-9]{1,10}$/); + +/** Valid API endpoint paths: segments of alphanum + hyphens separated by slashes */ +const validEndpointArb = stringMatching( + /^\/?(api\/0\/)?[a-z][a-z0-9-]{0,30}(\/[a-z][a-z0-9-]{0,30}){0,5}\/?$/ +); + +/** Characters that should be rejected in resource IDs */ +const injectionCharArb = constantFrom("?", "#", "%20", " ", "\t", "\n"); + +/** Pre-encoded sequences that should be rejected */ +const preEncodedArb = constantFrom( + "%2F", + "%20", + "%3A", + "%3F", + "%23", + "%00", + "%0A", + "%7E", + "%41" +); + +/** Control characters (ASCII 0x00-0x1F) as strings */ +const controlCharArb = constantFrom( + "\x00", + "\x01", + "\x02", + "\x07", + "\x08", + "\x0b", + "\x0c", + "\x0d", + "\x0e", + "\x0f", + "\x1b", + "\x1f" +); + +describe("rejectControlChars properties", () => { + test("valid slugs never contain control characters and always pass", async () => { + await fcAssert( + property(validSlugArb, (input) => { + // Should not throw + rejectControlChars(input, "test"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("any string with an embedded control character always throws", async () => { + await fcAssert( + property( + tuple(validSlugArb, controlCharArb, validSlugArb), + ([prefix, ctrl, suffix]) => { + const input = `${prefix}${ctrl}${suffix}`; + expect(() => rejectControlChars(input, "test")).toThrow(/Invalid/); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("error message includes the label", async () => { + await fcAssert( + property(controlCharArb, (ctrl) => { + try { + rejectControlChars(`abc${ctrl}def`, "organization slug"); + // Should not reach here + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toContain("organization slug"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("carriage return produces specific error description", () => { + expect(() => rejectControlChars("abc\rdef", "test")).toThrow( + /carriage return/ + ); + }); + + test("printable ASCII strings always pass", async () => { + // Generate strings of printable ASCII (0x20-0x7E) + const printableArb = stringMatching(/^[\x20-\x7e]{1,50}$/); + await fcAssert( + property(printableArb, (input) => { + // Should not throw — all printable ASCII is valid + rejectControlChars(input, "test"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("rejectPreEncoded properties", () => { + test("valid slugs without percent signs always pass", async () => { + await fcAssert( + property(validSlugArb, (input) => { + rejectPreEncoded(input, "test"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("any string with %XX hex pattern always throws", async () => { + await fcAssert( + property(tuple(validSlugArb, preEncodedArb), ([prefix, encoded]) => { + const input = `${prefix}${encoded}`; + expect(() => rejectPreEncoded(input, "test")).toThrow( + /URL-encoded sequence/ + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("percent sign followed by non-hex does not throw", () => { + // "%ZZ" is not a valid encoding — we only reject real %XX patterns + rejectPreEncoded("my-org%ZZstuff", "test"); + rejectPreEncoded("my-org%Gxstuff", "test"); + }); +}); + +describe("validateResourceId properties", () => { + test("valid slugs always pass", async () => { + await fcAssert( + property(validSlugArb, (input) => { + validateResourceId(input, "test"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("valid issue suffixes always pass", async () => { + await fcAssert( + property(validSuffixArb, (input) => { + validateResourceId(input, "test"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("slug with injection character always throws", async () => { + await fcAssert( + property(tuple(validSlugArb, injectionCharArb), ([slug, injection]) => { + const input = `${slug}${injection}`; + expect(() => validateResourceId(input, "test")).toThrow(/Invalid/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("slug with control character always throws", async () => { + await fcAssert( + property(tuple(validSlugArb, controlCharArb), ([slug, ctrl]) => { + const input = `${slug}${ctrl}`; + expect(() => validateResourceId(input, "test")).toThrow(/Invalid/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("specific hallucination patterns are caught", () => { + // Query injection + expect(() => + validateResourceId("my-org?query=foo", "organization slug") + ).toThrow(/\?/); + + // Fragment injection + expect(() => + validateResourceId("my-project#anchor", "project slug") + ).toThrow(/#/); + + // Pre-encoded space + expect(() => + validateResourceId("CLI-G%20extra", "issue identifier") + ).toThrow(/%/); + + // Tab injection + expect(() => + validateResourceId("my-org\tother", "organization slug") + ).toThrow(/tab/); + + // Non-breaking space (U+00A0) — exotic whitespace matched by \s + expect(() => + validateResourceId("my-org\u00a0other", "organization slug") + ).toThrow(/whitespace/); + }); +}); + +describe("validateEndpoint properties", () => { + test("valid endpoints always pass", async () => { + await fcAssert( + property(validEndpointArb, (input) => { + validateEndpoint(input); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("endpoints with .. path traversal always throw", async () => { + // Generate valid path segments and inject ".." in various positions + const traversalArb = oneof( + validSlugArb.map((s) => `../${s}/`), + validSlugArb.map((s) => `${s}/../admin/`), + validSlugArb.map((s) => `${s}/../../admin/`), + constantFrom("..", "../admin", "../../admin/settings/") + ); + + await fcAssert( + property(traversalArb, (input) => { + expect(() => validateEndpoint(input)).toThrow(/path traversal/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("endpoints with control characters always throw", async () => { + await fcAssert( + property(tuple(validEndpointArb, controlCharArb), ([endpoint, ctrl]) => { + const input = `${endpoint}${ctrl}`; + expect(() => validateEndpoint(input)).toThrow(/Invalid/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("single dots in paths are allowed (not traversal)", () => { + // Single dots are valid path segments + validateEndpoint("organizations/my-org/.well-known/"); + validateEndpoint("api/0/organizations/my-org/"); + }); + + test("double dots inside a segment name are allowed", () => { + // ".." is only traversal when it's a complete segment + validateEndpoint("organizations/my..org/issues/"); + }); +}); + +describe("cross-validator consistency", () => { + test("validateResourceId catches everything rejectControlChars catches", async () => { + await fcAssert( + property(tuple(validSlugArb, controlCharArb), ([slug, ctrl]) => { + const input = `${slug}${ctrl}`; + // Both should throw + expect(() => rejectControlChars(input, "test")).toThrow(); + expect(() => validateResourceId(input, "test")).toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("validateResourceId is stricter than rejectControlChars", () => { + // These pass rejectControlChars but fail validateResourceId + const inputsPassingControlButFailingResource = [ + "my-org?query=foo", + "my-project#anchor", + "my org", + ]; + + for (const input of inputsPassingControlButFailingResource) { + rejectControlChars(input, "test"); // Should not throw + expect(() => validateResourceId(input, "test")).toThrow(); // Should throw + } + }); +}); diff --git a/packages/cli/test/lib/install-script.test.ts b/packages/cli/test/lib/install-script.test.ts new file mode 100644 index 000000000..0e61c5929 --- /dev/null +++ b/packages/cli/test/lib/install-script.test.ts @@ -0,0 +1,750 @@ +/** + * Exercises the shell installer with a fake downloaded executable. Real PTYs + * verify terminal reconnection for setup and the existing init handoff. + */ + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +const installScript = join(import.meta.dirname, "..", "..", "install"); +const downloadedExecutable = `#!/usr/bin/env bash +set -euo pipefail +record_tty() { + for fd in 0 1 2; do + if [[ -t "$fd" ]]; then + printf '%s:true\\n' "$fd" >&3 + else + printf '%s:false\\n' "$fd" >&3 + fi + done + if { : /dev/null; then + printf 'controlling:true\\n' >&3 + else + printf 'controlling:false\\n' >&3 + fi +} +if [[ "$1" == "cli" && "$2" == "setup" ]]; then + printf '%s\\n' "$@" > "$SENTRY_TEST_DIR/setup-args" + printf '%s\\n' "$0" > "$SENTRY_TEST_DIR/setup-binary" + record_tty 3> "$SENTRY_TEST_DIR/setup-tty" + setup_dir="\${SENTRY_TEST_SETUP_INSTALL_DIR:-$SENTRY_INSTALL_DIR}" + mkdir -p "$setup_dir" + cp "$0" "$setup_dir/sentry" + if [[ "\${SENTRY_TEST_KEEP_TEMP_BINARY:-}" != "1" ]]; then + rm "$0" + fi + exit "\${SENTRY_TEST_SETUP_EXIT:-0}" +fi +printf '%s\\n' "$@" >> "$SENTRY_TEST_DIR/post-args" +printf '%s\\n' "$0" >> "$SENTRY_TEST_DIR/post-binary" +record_tty 3> "$SENTRY_TEST_DIR/post-tty" +exit "\${SENTRY_TEST_POST_EXIT:-0}" +`; + +describe("install script", () => { + let testDir: string; + let binDir: string; + let installDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "sentry-install-test-")); + binDir = join(testDir, "bin"); + installDir = join(testDir, "installed bin"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(join(testDir, "home")); + env = { + PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + HOME: join(testDir, "home"), + SENTRY_INSTALL_DIR: installDir, + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_TEST_DIR: testDir, + SENTRY_TEST_INSTALL_SCRIPT: installScript, + SENTRY_TEST_GITHUB_FAIL: "22", + SENTRY_TEST_GITHUB_RESPONSE: '{"tag_name":"0.42.2"}', + SENTRY_TEST_TOOLKIT_STATUS: "404", + TMPDIR: testDir, + }; + + writeFileSync( + join(binDir, "curl"), + `#!/usr/bin/env bash +set -euo pipefail +url="\${!#}" +printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/curl-urls" +case "$url" in + https://release-registry.services.sentry.io/apps/sentry/latest) + printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls" + if [[ "\${SENTRY_TEST_REGISTRY_FAIL:-0}" != "0" ]]; then + exit "$SENTRY_TEST_REGISTRY_FAIL" + fi + printf '%s\\n' "\${SENTRY_TEST_REGISTRY_RESPONSE:-}" + ;; + *"/repos/getsentry/sentry-mcp/releases/tags/"*) + printf '%s' "\${SENTRY_TEST_TOOLKIT_STATUS:-200}" + ;; + *"/repos/getsentry/cli/releases/tags/"*) + printf '%s' "\${SENTRY_TEST_LEGACY_STATUS:-200}" + ;; + *"/repos/getsentry/sentry-mcp/releases?per_page=100&page="*) + page="\${url##*=}" + case "$page" in + 1) if [[ -n "\${SENTRY_TEST_RELEASES_PAGE_1:-}" ]]; then cat "$SENTRY_TEST_RELEASES_PAGE_1"; else printf '[]'; fi ;; + 2) if [[ -n "\${SENTRY_TEST_RELEASES_PAGE_2:-}" ]]; then cat "$SENTRY_TEST_RELEASES_PAGE_2"; else printf '[]'; fi ;; + *) printf '[]' ;; + esac + ;; + *"/repos/getsentry/cli/releases/latest") + printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls" + if [[ "\${SENTRY_TEST_GITHUB_FAIL:-0}" != "0" ]]; then + exit "$SENTRY_TEST_GITHUB_FAIL" + fi + printf '%s\\n' "$SENTRY_TEST_GITHUB_RESPONSE" + ;; + *) + printf '%s\\n' "$url" >> "$SENTRY_TEST_DIR/download-urls" + cat <<'SCRIPT' +${downloadedExecutable}SCRIPT + ;; +esac +` + ); + chmodSync(join(binDir, "curl"), 0o755); + writeFileSync(join(binDir, "gunzip"), "#!/usr/bin/env bash\ncat\n"); + chmodSync(join(binDir, "gunzip"), 0o755); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test.each([ + '{"canonical":"app:sentry","version":"0.45.0"}', + '{\n "canonical": "app:sentry",\n "version": "0.45.0"\n}', + JSON.stringify( + { version: "0.45.0", description: "x".repeat(96 * 1024) }, + null, + 2 + ), + ])("installs the latest stable release when GitHub API access is blocked", (metadata) => { + env.SENTRY_TEST_REGISTRY_RESPONSE = metadata; + const result = spawnSync("bash", [installScript], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("download-urls")).toEqual([ + "https://release-registry.services.sentry.io/apps/sentry/latest", + expect.stringMatching( + /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/ + ), + ]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + }); + + test.each([ + { name: "HTTP error", metadata: "", failure: "22" }, + { name: "timeout", metadata: "", failure: "28" }, + { name: "missing version", metadata: "{}" }, + { name: "non-JSON response", metadata: "Unavailable" }, + { name: "nonstable version", metadata: '{"version":"nightly"}' }, + ])("falls back to GitHub after a registry $name", ({ metadata, failure }) => { + env.SENTRY_TEST_REGISTRY_RESPONSE = metadata; + env.SENTRY_TEST_REGISTRY_FAIL = failure; + env.SENTRY_TEST_GITHUB_FAIL = "0"; + env.SENTRY_TEST_GITHUB_RESPONSE = '{"tag_name":"0.45.0"}'; + const result = spawnSync("bash", [installScript], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stderr).toContain("Trying GitHub"); + expect(recorded("download-urls")).toEqual([ + "https://release-registry.services.sentry.io/apps/sentry/latest", + "https://api.github.com/repos/getsentry/cli/releases/latest", + expect.stringMatching( + /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/ + ), + ]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + }); + + test.each([ + { name: "a leading v", metadata: '{\n "tag_name": "v0.45.0"\n}' }, + { + name: "large release metadata", + metadata: JSON.stringify( + { tag_name: "0.45.0", body: "x".repeat(96 * 1024) }, + null, + 2 + ), + }, + ])("accepts a GitHub release with $name", ({ metadata }) => { + env.SENTRY_TEST_REGISTRY_FAIL = "22"; + env.SENTRY_TEST_GITHUB_FAIL = "0"; + env.SENTRY_TEST_GITHUB_RESPONSE = metadata; + const result = spawnSync("bash", [installScript], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("download-urls").at(-1)).toMatch( + /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.45\.0\/sentry-.+\.gz$/ + ); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + }); + + test.each([ + { name: "HTTP error", metadata: "", failure: "22" }, + { name: "timeout", metadata: "", failure: "28" }, + { name: "missing tag", metadata: "{}", failure: "0" }, + { + name: "non-JSON response", + metadata: "Unavailable", + failure: "0", + }, + { name: "nonstable tag", metadata: '{"tag_name":"nightly"}', failure: "0" }, + ])("stops when the registry and GitHub fail: $name", ({ + metadata, + failure, + }) => { + env.SENTRY_TEST_REGISTRY_FAIL = "22"; + env.SENTRY_TEST_GITHUB_RESPONSE = metadata; + env.SENTRY_TEST_GITHUB_FAIL = failure; + const result = spawnSync("bash", [installScript], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Failed to fetch latest stable version from Sentry's release registry and GitHub" + ); + expect(result.stderr).toContain("--version "); + expect(result.stderr).not.toContain("Unexpected failure at line"); + expect(recorded("download-urls")).toEqual([ + "https://release-registry.services.sentry.io/apps/sentry/latest", + "https://api.github.com/repos/getsentry/cli/releases/latest", + ]); + expect(recorded("setup-args")).toEqual([]); + expect(existsSync(join(installDir, "sentry"))).toBe(false); + }); + + function recorded(name: string): string[] { + const path = join(testDir, name); + return existsSync(path) + ? readFileSync(path, "utf8").trim().split("\n") + : []; + } + + function installerTempFiles(): string[] { + return readdirSync(testDir).filter((name) => + name.startsWith("sentry-install-") + ); + } + + function configureNightlyDownload(redirect: boolean): void { + const platform = process.platform === "darwin" ? "darwin" : "linux"; + const architecture = process.arch === "arm64" ? "arm64" : "x64"; + const assetName = `sentry-${platform}-${architecture}.gz`; + const gzipPath = join(testDir, assetName); + const manifest = JSON.stringify({ + version: "nightly-test", + layers: [ + { + mediaType: "application/vnd.oci.image.layer.v1.tar", + digest: "sha256:test", + annotations: { "org.opencontainers.image.title": assetName }, + }, + ], + }); + + writeFileSync(gzipPath, gzipSync(downloadedExecutable)); + env.SENTRY_TEST_GZIP = gzipPath; + env.SENTRY_TEST_NIGHTLY_REDIRECT = redirect ? "1" : "0"; + writeFileSync( + join(binDir, "curl"), + `#!/usr/bin/env bash +set -euo pipefail +url="\${!#}" +case "$url" in + *"/token?"*) + printf '{"token":"test-token"}' + ;; + *"/manifests/nightly") + cat <<'JSON' +${manifest} +JSON + ;; + "https://objects.example/nightly.gz") + for arg in "$@"; do + if [[ "$arg" == Authorization:* ]]; then + exit 90 + fi + done + cat "$SENTRY_TEST_GZIP" + ;; + *"/blobs/"*) + headers="" + output="" + while [[ $# -gt 0 ]]; do + case "$1" in + -D) headers="$2"; shift 2 ;; + -o) output="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ "$SENTRY_TEST_NIGHTLY_REDIRECT" == "1" ]]; then + printf 'HTTP/1.1 307 Temporary Redirect\\r\\nLocation: https://objects.example/nightly.gz\\r\\n\\r\\n' > "$headers" + : > "$output" + printf '307' + else + printf 'HTTP/1.1 200 OK\\r\\n\\r\\n' > "$headers" + cp "$SENTRY_TEST_GZIP" "$output" + printf '200' + fi + ;; + *) + exit 91 + ;; +esac +` + ); + chmodSync(join(binDir, "curl"), 0o755); + writeFileSync( + join(binDir, "gunzip"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ $# -gt 0 && "$1" != "-c" ]]; then + exit 64 +fi +exec /usr/bin/gunzip "$@" +` + ); + chmodSync(join(binDir, "gunzip"), 0o755); + } + + /** Run curl-style piped installation in a real controlling terminal. */ + function runInTerminal( + options: { redirect?: string; detached?: boolean } = {} + ) { + const launcher = join(testDir, "piped-install.cjs"); + const command = + 'cat "$SENTRY_TEST_INSTALL_SCRIPT" | bash -s -- --version 0.31.0' + + (options.redirect ?? ""); + writeFileSync( + launcher, + `const { spawnSync } = require("node:child_process"); +const result = spawnSync("bash", ["-c", ${JSON.stringify(command)}], { + stdio: "inherit", detached: ${options.detached ?? false} +}); +process.exitCode = result.status ?? 1; +` + ); + // script(1) has different argument syntax on BSD and util-linux. A + // detached shell retains its PTY output but cannot open /dev/tty. + const args = + process.platform === "darwin" + ? ["-q", "/dev/null", process.execPath, launcher] + : [ + "-q", + "-e", + "-c", + '"$SENTRY_TEST_NODE" "$SENTRY_TEST_LAUNCHER"', + "/dev/null", + ]; + return spawnSync("script", args, { + env: { + ...env, + SENTRY_TEST_NODE: process.execPath, + SENTRY_TEST_LAUNCHER: launcher, + }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + timeout: 10_000, + }); + } + + test("passes setup flags through and skips login without a terminal", () => { + const result = spawnSync( + "bash", + [ + installScript, + "--version", + "0.31.0", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + { env, encoding: "utf8", timeout: 10_000 } + ); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("setup-args")).toEqual([ + "cli", + "setup", + "--install", + "--method", + "curl", + "--channel", + "stable", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ]); + expect(recorded("setup-tty").slice(0, 3)).toEqual([ + "0:false", + "1:false", + "2:false", + ]); + expect(recorded("post-args")).toEqual([]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + expect(installerTempFiles()).toEqual([]); + expect(recorded("download-urls")).toEqual([ + expect.stringMatching( + /^https:\/\/github\.com\/getsentry\/cli\/releases\/download\/0\.31\.0\/sentry-.+\.gz$/ + ), + ]); + }); + + test.each([ + { response: "direct HTTP 200 response", redirect: false }, + { response: "HTTP redirect", redirect: true }, + ])("installs nightly from a $response", ({ redirect }) => { + configureNightlyDownload(redirect); + const result = spawnSync( + "bash", + [installScript, "--version", "nightly", "--no-modify-path"], + { env, encoding: "utf8", timeout: 10_000 } + ); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("setup-args")).toEqual([ + "cli", + "setup", + "--install", + "--method", + "curl", + "--channel", + "nightly", + "--no-modify-path", + ]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + expect(installerTempFiles()).toEqual([]); + }); + + test("uses the legacy release only after a Toolkit tag returns HTTP 404", () => { + env.SENTRY_TEST_TOOLKIT_STATUS = "404"; + const result = spawnSync( + "bash", + [installScript, "--version", "0.42.2", "--no-modify-path"], + { env, encoding: "utf8", timeout: 10_000 } + ); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("curl-urls")).toEqual([ + "https://api.github.com/repos/getsentry/sentry-mcp/releases/tags/cli%400.42.2", + "https://api.github.com/repos/getsentry/cli/releases/tags/0.42.2", + `https://github.com/getsentry/cli/releases/download/0.42.2/sentry-${process.platform === "darwin" ? "darwin" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}.gz`, + ]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + }); + + test("never falls back to legacy for a Toolkit server failure", () => { + env.SENTRY_TEST_TOOLKIT_STATUS = "500"; + const result = spawnSync("bash", [installScript, "--version", "0.42.2"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Toolkit release check failed (HTTP 500)"); + expect(recorded("curl-urls")).toEqual([ + "https://api.github.com/repos/getsentry/sentry-mcp/releases/tags/cli%400.42.2", + ]); + }); + + test("checks further release pages before selecting a stable Toolkit CLI", () => { + const firstPage = join(testDir, "releases-1.json"); + const secondPage = join(testDir, "releases-2.json"); + writeFileSync( + firstPage, + '[\n {\n "tag_name": "mcp@1.0.0",\n "prerelease": false\n }\n]\n' + ); + writeFileSync( + secondPage, + '[\n {\n "tag_name": "cli@0.46.0",\n "prerelease": false\n }\n]\n' + ); + env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; + env.SENTRY_TEST_RELEASES_PAGE_2 = secondPage; + env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + const result = spawnSync("bash", [installScript, "--no-modify-path"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("curl-urls").slice(0, 3)).toEqual([ + "https://release-registry.services.sentry.io/apps/sentry/latest", + "https://api.github.com/repos/getsentry/sentry-mcp/releases?per_page=100&page=1", + "https://api.github.com/repos/getsentry/sentry-mcp/releases?per_page=100&page=2", + ]); + expect(recorded("curl-urls")[3]).toBe( + "https://api.github.com/repos/getsentry/sentry-mcp/releases/tags/cli%400.46.0" + ); + expect(recorded("curl-urls")[4]).toContain( + "/getsentry/sentry-mcp/releases/download/cli@0.46.0/" + ); + }); + + test("skips SemVer prereleases even when GitHub marks them stable", () => { + const firstPage = join(testDir, "releases-1.json"); + writeFileSync( + firstPage, + '[\n {\n "tag_name": "cli@0.47.0-dev.1",\n "prerelease": false\n },\n {\n "tag_name": "cli@0.46.0",\n "prerelease": false\n }\n]\n' + ); + env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; + env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + const result = spawnSync("bash", [installScript, "--no-modify-path"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("curl-urls")[2]).toBe( + "https://api.github.com/repos/getsentry/sentry-mcp/releases/tags/cli%400.46.0" + ); + expect(recorded("curl-urls")[3]).toContain( + "/getsentry/sentry-mcp/releases/download/cli@0.46.0/" + ); + expect(recorded("setup-args")).toContain("stable"); + }); + + test("selects the first stable CLI release from compact GitHub JSON", () => { + const firstPage = join(testDir, "releases-1.json"); + writeFileSync( + firstPage, + JSON.stringify([ + { + tag_name: "mcp@0.42.0", + prerelease: false, + body: 'A quoted "tag_name" in the release notes', + }, + { + prerelease: false, + tag_name: "cli@0.47.0-dev.1", + }, + { + prerelease: false, + assets: [{ tag_name: "cli@99.0.0", prerelease: false }], + tag_name: "cli@0.46.0", + }, + ]) + ); + env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; + env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + const result = spawnSync("bash", [installScript, "--no-modify-path"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("curl-urls")[2]).toBe( + "https://api.github.com/repos/getsentry/sentry-mcp/releases/tags/cli%400.46.0" + ); + expect(recorded("curl-urls")[3]).toContain( + "/getsentry/sentry-mcp/releases/download/cli@0.46.0/" + ); + }); + + test("reads a large releases page without SIGPIPE and selects its first stable CLI", () => { + const firstPage = join(testDir, "releases-large.json"); + writeFileSync( + firstPage, + `[ + { + "tag_name": "cli@0.46.0", + "prerelease": false + }, + { + "tag_name": "cli@0.45.0", + "prerelease": false, + "body": "${"x".repeat(256 * 1024)}" + } +]` + ); + env.SENTRY_TEST_RELEASES_PAGE_1 = firstPage; + env.SENTRY_TEST_TOOLKIT_STATUS = "200"; + const result = spawnSync("bash", [installScript, "--no-modify-path"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("curl-urls")).toContain( + "https://api.github.com/repos/getsentry/sentry-mcp/releases/tags/cli%400.46.0" + ); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + }); + + test("directs musl users to the npm package before downloading a glibc binary", () => { + writeFileSync( + join(binDir, "ldd"), + "#!/bin/sh\nprintf 'musl libc (x86_64)\\n'\n", + { + mode: 0o700, + } + ); + const result = spawnSync("bash", [installScript, "--no-modify-path"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("npm install -g sentry"); + expect(recorded("curl-urls")).toEqual([]); + }); + + test("resolves the latest legacy version until Toolkit has a CLI release", () => { + env.SENTRY_TEST_TOOLKIT_STATUS = "404"; + env.SENTRY_TEST_GITHUB_FAIL = "0"; + const result = spawnSync("bash", [installScript, "--no-modify-path"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("curl-urls").slice(0, 4)).toEqual([ + "https://release-registry.services.sentry.io/apps/sentry/latest", + "https://api.github.com/repos/getsentry/sentry-mcp/releases?per_page=100&page=1", + "https://api.github.com/repos/getsentry/cli/releases/latest", + "https://api.github.com/repos/getsentry/sentry-mcp/releases/tags/cli%400.42.2", + ]); + expect(recorded("curl-urls")[4]).toBe( + "https://api.github.com/repos/getsentry/cli/releases/tags/0.42.2" + ); + }); + + test("connects setup to the controlling terminal and cleans up after setup", () => { + env.SENTRY_TEST_KEEP_TEMP_BINARY = "1"; + const result = runInTerminal(); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("setup-tty")).toEqual([ + "0:true", + "1:true", + "2:true", + "controlling:true", + ]); + expect(recorded("post-args")).toEqual([]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + expect(installerTempFiles()).toEqual([]); + }); + + test.each([ + { + name: "stdout is redirected", + redirect: " >/dev/null", + expected: ["0:false", "1:false", "2:true", "controlling:true"], + }, + { + name: "stderr is redirected", + redirect: " 2>/dev/null", + expected: ["0:false", "1:true", "2:false", "controlling:true"], + }, + { + name: "terminal output has no controlling terminal", + detached: true, + expected: ["0:false", "1:true", "2:true", "controlling:false"], + }, + ])("skips login when $name", ({ redirect, detached, expected }) => { + const result = runInTerminal({ redirect, detached }); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("setup-tty")).toEqual(expected); + expect(recorded("post-args")).toEqual([]); + }); + + test("hands off to init instead of login when SENTRY_INIT is set", () => { + env.SENTRY_INIT = "1"; + env.SENTRY_TEST_KEEP_TEMP_BINARY = "1"; + env.SENTRY_TEST_POST_EXIT = "7"; + const result = runInTerminal(); + expect(result.status, result.stdout + result.stderr).toBe(7); + expect(recorded("setup-tty")[0]).toBe("0:false"); + expect(recorded("post-args")).toEqual(["init"]); + expect(recorded("post-binary")).toEqual([join(installDir, "sentry")]); + expect(recorded("post-tty")).toEqual([ + "0:true", + "1:true", + "2:true", + "controlling:true", + ]); + expect(installerTempFiles()).toEqual([]); + }); + + test("initializes the new binary instead of a stale install outside PATH", () => { + const home = env.HOME!; + const staleDir = join(home, ".local", "bin"); + const currentDir = join(home, "bin"); + mkdirSync(staleDir, { recursive: true }); + mkdirSync(currentDir); + writeFileSync(join(staleDir, "sentry"), downloadedExecutable); + chmodSync(join(staleDir, "sentry"), 0o755); + env.SENTRY_INSTALL_DIR = ""; + env.SENTRY_TEST_SETUP_INSTALL_DIR = currentDir; + env.PATH = `${currentDir}:${env.PATH}`; + env.SENTRY_INIT = "1"; + + const result = runInTerminal(); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(recorded("post-args")).toEqual(["init"]); + expect(recorded("post-binary")).toEqual([join(currentDir, "sentry")]); + }); + + test.each([ + 1, 130, + ])("preserves setup failure or interruption exit %i", (exitCode) => { + env.SENTRY_TEST_SETUP_EXIT = String(exitCode); + const result = runInTerminal(); + expect(result.status, result.stdout + result.stderr).toBe(exitCode); + expect(recorded("post-args")).toEqual([]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + }); + + test.each([1, 130])("preserves non-interactive setup exit %i", (exitCode) => { + env.SENTRY_TEST_SETUP_EXIT = String(exitCode); + const result = spawnSync("bash", [installScript, "--version", "0.31.0"], { + env, + encoding: "utf8", + timeout: 10_000, + }); + expect(result.status, result.stdout + result.stderr).toBe(exitCode); + expect(recorded("post-args")).toEqual([]); + expect(existsSync(join(installDir, "sentry"))).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/interactive-login.test.ts b/packages/cli/test/lib/interactive-login.test.ts new file mode 100644 index 000000000..f8eabf644 --- /dev/null +++ b/packages/cli/test/lib/interactive-login.test.ts @@ -0,0 +1,290 @@ +/** + * Tests for interactive login flow. + * + * - buildDeviceFlowDisplay: extracted display logic (pure function) + * - runInteractiveLogin: full OAuth device flow with mocked dependencies + * + * Uses SENTRY_PLAIN_OUTPUT=1 to get predictable raw markdown output + * (no ANSI codes) for string assertions. + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../src/lib/browser.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as clipboard from "../../src/lib/clipboard.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbInstance from "../../src/lib/db/index.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbUser from "../../src/lib/db/user.js"; +import { + buildDeviceFlowDisplay, + runInteractiveLogin, +} from "../../src/lib/interactive-login.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as oauth from "../../src/lib/oauth.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as qrcode from "../../src/lib/qrcode.js"; +import type { TokenResponse } from "../../src/types/index.js"; + +// Force plain output for predictable string matching +let origPlain: string | undefined; +beforeAll(() => { + origPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "1"; +}); +afterAll(() => { + if (origPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = origPlain; + } +}); + +describe("buildDeviceFlowDisplay", () => { + const CODE = "ABCD-EFGH"; + const URL = "https://sentry.io/auth/device/?user_code=ABCD-EFGH"; + + test("includes complete URL as plain text for copy-paste", () => { + const lines = buildDeviceFlowDisplay(CODE, URL, true, false); + const joined = lines.join("\n"); + // URL must be literal (no markdown escaping) so it's copyable + expect(joined).toContain(URL); + }); + + test("preserves underscores in URLs (no markdown escaping)", () => { + const urlWithUnderscores = + "https://self_hosted.example.com/auth/device/?user_code=AB_CD"; + const lines = buildDeviceFlowDisplay( + "AB_CD", + urlWithUnderscores, + true, + false + ); + const joined = lines.join("\n"); + // URL must not be escaped — underscores stay as-is for copy-paste + expect(joined).toContain(urlWithUnderscores); + expect(joined).not.toContain("\\_"); + }); + + test("includes user code in output", () => { + const lines = buildDeviceFlowDisplay(CODE, URL, true, false); + const joined = lines.join("\n"); + // Plain mode strips backtick code spans — check for bare code + expect(joined).toContain(CODE); + }); + + test("omits copy hint when browser opened", () => { + const lines = buildDeviceFlowDisplay(CODE, URL, true, true); + const joined = lines.join("\n"); + expect(joined).not.toContain("Copy the URL above"); + expect(joined).not.toContain("to copy URL"); + }); + + test("shows copy hint when browser did not open (TTY)", () => { + const lines = buildDeviceFlowDisplay(CODE, URL, false, true); + const joined = lines.join("\n"); + expect(joined).toContain("Copy the URL above to sign in."); + expect(joined).toContain("to copy URL"); + }); + + test("shows copy hint without keyboard shortcut in non-TTY", () => { + const lines = buildDeviceFlowDisplay(CODE, URL, false, false); + const joined = lines.join("\n"); + expect(joined).toContain("Copy the URL above to sign in."); + expect(joined).not.toContain("to copy URL"); + }); + + test("returns more lines when browser did not open", () => { + const withBrowser = buildDeviceFlowDisplay(CODE, URL, true, false); + const withoutBrowser = buildDeviceFlowDisplay(CODE, URL, false, false); + // Without browser: extra copy-hint line + blank line + expect(withoutBrowser.length).toBeGreaterThan(withBrowser.length); + }); +}); + +describe("runInteractiveLogin", () => { + let performDeviceFlowSpy: ReturnType; + let completeOAuthFlowSpy: ReturnType; + let openBrowserSpy: ReturnType; + let generateQRCodeSpy: ReturnType; + let setupCopyKeyListenerSpy: ReturnType; + let setUserInfoSpy: ReturnType; + let getDbPathSpy: ReturnType; + + /** Helper to build a mock TokenResponse with a user whose fields may be null. */ + function makeTokenResponse(user?: { + id: string; + name: string | null; + email: string | null; + }): TokenResponse { + return { + access_token: "sntrys_test_token", + token_type: "Bearer", + expires_in: 3600, + ...(user ? { user } : {}), + }; + } + + beforeEach(() => { + completeOAuthFlowSpy = vi + .spyOn(oauth, "completeOAuthFlow") + .mockResolvedValue(undefined); + openBrowserSpy = vi.spyOn(browser, "openBrowser").mockResolvedValue(false); + generateQRCodeSpy = vi + .spyOn(qrcode, "generateQRCode") + .mockResolvedValue("[QR]"); + setupCopyKeyListenerSpy = vi + .spyOn(clipboard, "setupCopyKeyListener") + .mockReturnValue(() => { + // no-op cleanup + }); + setUserInfoSpy = vi.spyOn(dbUser, "setUserInfo").mockReturnValue(undefined); + getDbPathSpy = vi.spyOn(dbInstance, "getDbPath").mockReturnValue("/tmp/db"); + }); + + afterEach(() => { + performDeviceFlowSpy.mockRestore(); + completeOAuthFlowSpy.mockRestore(); + openBrowserSpy.mockRestore(); + generateQRCodeSpy.mockRestore(); + setupCopyKeyListenerSpy.mockRestore(); + setUserInfoSpy.mockRestore(); + getDbPathSpy.mockRestore(); + }); + + test("null user.name is omitted from result and stored as undefined in setUserInfo", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockImplementation(async (callbacks) => { + await callbacks.onUserCode( + "ABCD", + "https://sentry.io/auth/device/", + "https://sentry.io/auth/device/?user_code=ABCD" + ); + return makeTokenResponse({ + id: "48168", + name: null, + email: "user@example.com", + }); + }); + + const result = await runInteractiveLogin({ timeout: 1000 }); + + expect(result).not.toBeNull(); + expect(result!.user).toBeDefined(); + // name must be absent from the result object (not present as undefined) + expect("name" in result!.user!).toBe(false); + expect(result!.user!.email).toBe("user@example.com"); + expect(result!.user!.id).toBe("48168"); + + expect(setUserInfoSpy).toHaveBeenCalledWith({ + userId: "48168", + email: "user@example.com", + name: undefined, + }); + }); + + test("null user.email is omitted from result", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockImplementation(async (callbacks) => { + await callbacks.onUserCode( + "EFGH", + "https://sentry.io/auth/device/", + "https://sentry.io/auth/device/?user_code=EFGH" + ); + return makeTokenResponse({ + id: "123", + name: "Jane Doe", + email: null, + }); + }); + + const result = await runInteractiveLogin({ timeout: 1000 }); + + expect(result).not.toBeNull(); + expect(result!.user!.name).toBe("Jane Doe"); + // email must be absent from the result object + expect("email" in result!.user!).toBe(false); + + expect(setUserInfoSpy).toHaveBeenCalledWith({ + userId: "123", + email: undefined, + name: "Jane Doe", + }); + }); + + test("no user in token response: result.user is undefined, setUserInfo not called", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockImplementation(async (callbacks) => { + await callbacks.onUserCode( + "WXYZ", + "https://sentry.io/auth/device/", + "https://sentry.io/auth/device/?user_code=WXYZ" + ); + return makeTokenResponse(); // no user + }); + + const result = await runInteractiveLogin({ timeout: 1000 }); + + expect(result).not.toBeNull(); + expect(result!.user).toBeUndefined(); + expect(setUserInfoSpy).not.toHaveBeenCalled(); + }); + + test("forwards an explicit scope string to performDeviceFlow", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockImplementation(async (callbacks) => { + await callbacks.onUserCode( + "SCOP", + "https://sentry.io/auth/device/", + "https://sentry.io/auth/device/?user_code=SCOP" + ); + return makeTokenResponse(); + }); + + await runInteractiveLogin({ + timeout: 1000, + scope: "project:read org:read", + }); + + expect(performDeviceFlowSpy).toHaveBeenCalledWith( + expect.any(Object), + 1000, + "project:read org:read" + ); + }); + + test("forwards undefined scope when none is provided", async () => { + performDeviceFlowSpy = vi + .spyOn(oauth, "performDeviceFlow") + .mockImplementation(async (callbacks) => { + await callbacks.onUserCode( + "NONE", + "https://sentry.io/auth/device/", + "https://sentry.io/auth/device/?user_code=NONE" + ); + return makeTokenResponse(); + }); + + await runInteractiveLogin({ timeout: 1000 }); + + expect(performDeviceFlowSpy).toHaveBeenCalledWith( + expect.any(Object), + 1000, + undefined + ); + }); +}); diff --git a/packages/cli/test/lib/interactive-prompts.test.ts b/packages/cli/test/lib/interactive-prompts.test.ts new file mode 100644 index 000000000..cae56abf6 --- /dev/null +++ b/packages/cli/test/lib/interactive-prompts.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { + interactivePromptsAllowed, + setInteractivePromptsAllowed, +} from "../../src/lib/interactive-prompts.js"; + +describe("interactive-prompts gate", () => { + afterEach(() => { + // Restore the default so other tests/files aren't affected. + setInteractivePromptsAllowed(true); + }); + + test("defaults to allowed so non-wrapper callers fall back to their own checks", () => { + expect(interactivePromptsAllowed()).toBe(true); + }); + + test("reflects the value set by the command wrapper (JSON mode disables it)", () => { + setInteractivePromptsAllowed(false); + expect(interactivePromptsAllowed()).toBe(false); + setInteractivePromptsAllowed(true); + expect(interactivePromptsAllowed()).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/introspect.property.test.ts b/packages/cli/test/lib/introspect.property.test.ts new file mode 100644 index 000000000..9c46a8bb2 --- /dev/null +++ b/packages/cli/test/lib/introspect.property.test.ts @@ -0,0 +1,233 @@ +/** + * Property-Based Tests for Route Tree Introspection + * + * Uses fast-check to verify invariants that should hold for any valid + * route tree structure. + */ + +import { + array, + boolean, + constantFrom, + assert as fcAssert, + oneof, + property, + record, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import type { + Command, + FlagDef, + RouteMap, + RouteMapEntry, +} from "../../src/lib/introspect.js"; +import { + extractAllRoutes, + extractFlags, + getPositionalString, + resolveCommandPath, +} from "../../src/lib/introspect.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Valid flag kinds */ +const flagKindArb = constantFrom(...(["boolean", "parsed", "enum"] as const)); + +/** Generate a FlagDef */ +const flagDefArb = record({ + kind: flagKindArb, + brief: string(), + hidden: boolean(), + optional: boolean(), + variadic: boolean(), +}) as import("fast-check").Arbitrary; + +/** Generate a simple name (letters + hyphens, no leading/trailing hyphen) */ +const nameArb = array(constantFrom(..."abcdefghijklmnopqrstuvwxyz".split("")), { + minLength: 1, + maxLength: 10, +}).map((chars) => chars.join("")); + +/** Generate flag definitions map */ +const flagsMapArb = array(tuple(nameArb, flagDefArb), { + minLength: 0, + maxLength: 5, +}).map((pairs) => Object.fromEntries(pairs)); + +/** Generate a Command */ +const commandArb = record({ + brief: string(), + flags: flagsMapArb, +}).map( + ({ brief, flags }): Command => ({ + brief, + parameters: { flags, aliases: {} }, + }) +); + +/** Generate a RouteMapEntry with a command */ +const commandEntryArb = tuple(nameArb, commandArb, boolean()).map( + ([name, cmd, hidden]): RouteMapEntry => ({ + name: { original: name }, + target: cmd, + hidden, + }) +); + +/** Generate a simple RouteMap (one level) */ +const routeMapArb = tuple( + string(), + array(commandEntryArb, { minLength: 0, maxLength: 5 }) +).map( + ([brief, entries]): RouteMap => ({ + brief, + getAllEntries: () => entries, + }) +); + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +describe("property: extractFlags", () => { + test("always returns one FlagInfo per input entry", () => { + fcAssert( + property(flagsMapArb, (flags) => { + const result = extractFlags(flags); + expect(result).toHaveLength(Object.keys(flags).length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("preserves flag names", () => { + fcAssert( + property(flagsMapArb, (flags) => { + const result = extractFlags(flags); + const resultNames = new Set(result.map((f) => f.name)); + for (const name of Object.keys(flags)) { + expect(resultNames.has(name)).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("boolean flags default to optional=true", () => { + fcAssert( + property(flagsMapArb, (flags) => { + const result = extractFlags(flags); + for (const flag of result) { + if ( + flag.kind === "boolean" && + flags[flag.name].optional === undefined + ) { + expect(flag.optional).toBe(true); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: getPositionalString", () => { + test("tuple always produces angle-bracket placeholders", () => { + fcAssert( + property( + array( + record({ placeholder: oneof(string(), constantFrom(undefined)) }) + ), + (params) => { + const result = getPositionalString({ + kind: "tuple", + parameters: params, + }); + if (params.length > 0) { + expect(result).toContain("<"); + expect(result).toContain(">"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("array always includes ellipsis", () => { + fcAssert( + property(string(), (placeholder) => { + const result = getPositionalString({ + kind: "array", + parameter: { placeholder: placeholder || undefined }, + }); + expect(result).toContain("..."); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: extractAllRoutes", () => { + test("never includes hidden entries", () => { + fcAssert( + property(routeMapArb, (routeMap) => { + const routes = extractAllRoutes(routeMap); + const allEntries = routeMap.getAllEntries(); + + // Build a set of names that are ONLY hidden (no visible entry with same name) + const visibleNames = new Set( + allEntries.filter((e) => !e.hidden).map((e) => e.name.original) + ); + const onlyHiddenNames = allEntries + .filter((e) => e.hidden && !visibleNames.has(e.name.original)) + .map((e) => e.name.original); + + for (const route of routes) { + expect(onlyHiddenNames).not.toContain(route.name); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("route count <= total visible entries", () => { + fcAssert( + property(routeMapArb, (routeMap) => { + const routes = extractAllRoutes(routeMap); + const visibleCount = routeMap + .getAllEntries() + .filter((e) => !e.hidden).length; + expect(routes.length).toBeLessThanOrEqual(visibleCount); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: resolveCommandPath", () => { + test("empty path always returns null", () => { + fcAssert( + property(routeMapArb, (routeMap) => { + expect(resolveCommandPath(routeMap, [])).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("resolved command path starts with 'sentry'", () => { + fcAssert( + property(routeMapArb, nameArb, (routeMap, name) => { + const result = resolveCommandPath(routeMap, [name]); + if (result && result.kind === "command") { + expect(result.info.path.startsWith("sentry ")).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts new file mode 100644 index 000000000..5636f0e85 --- /dev/null +++ b/packages/cli/test/lib/introspect.test.ts @@ -0,0 +1,501 @@ +/** + * Unit Tests for Route Tree Introspection + * + * Tests the shared introspection module used by `sentry help --json` + * and `script/generate-skill.ts`. + */ + +import { describe, expect, test } from "vitest"; +import type { + Command, + FlagDef, + RouteMap, + RouteMapEntry, +} from "../../src/lib/introspect.js"; +import { + buildCommandInfo, + extractAllRoutes, + extractFlags, + extractRouteGroupCommands, + getPositionalString, + isCommand, + isRouteMap, + resolveCommandPath, +} from "../../src/lib/introspect.js"; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +function makeCommand(overrides: Partial = {}): Command { + return { + brief: "Test command", + parameters: { + flags: {}, + aliases: {}, + }, + ...overrides, + }; +} + +function makeRouteMap( + entries: RouteMapEntry[], + overrides: Partial = {} +): RouteMap { + return { + brief: "Test route", + getAllEntries: () => entries, + ...overrides, + }; +} + +function makeEntry( + name: string, + target: RouteMap | Command, + hidden = false +): RouteMapEntry { + return { + name: { original: name }, + target, + hidden, + }; +} + +// --------------------------------------------------------------------------- +// Type Guards +// --------------------------------------------------------------------------- + +describe("isRouteMap", () => { + test("returns true for route maps", () => { + const routeMap = makeRouteMap([]); + expect(isRouteMap(routeMap)).toBe(true); + }); + + test("returns false for commands", () => { + const cmd = makeCommand(); + expect(isRouteMap(cmd)).toBe(false); + }); +}); + +describe("isCommand", () => { + test("returns true for commands", () => { + const cmd = makeCommand(); + expect(isCommand(cmd)).toBe(true); + }); + + test("returns false for route maps", () => { + const routeMap = makeRouteMap([]); + expect(isCommand(routeMap)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getPositionalString +// --------------------------------------------------------------------------- + +describe("getPositionalString", () => { + test("returns empty string for undefined", () => { + expect(getPositionalString(undefined)).toBe(""); + }); + + test("returns tuple placeholders", () => { + const result = getPositionalString({ + kind: "tuple", + parameters: [{ placeholder: "org" }, { placeholder: "project" }], + }); + expect(result).toBe(" "); + }); + + test("uses default arg names for tuple without placeholders", () => { + const result = getPositionalString({ + kind: "tuple", + parameters: [{}, {}], + }); + expect(result).toBe(" "); + }); + + test("returns array placeholder with ellipsis", () => { + const result = getPositionalString({ + kind: "array", + parameter: { placeholder: "command" }, + }); + expect(result).toBe(""); + }); + + test("uses default for array without placeholder", () => { + const result = getPositionalString({ + kind: "array", + parameter: {}, + }); + expect(result).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// extractFlags +// --------------------------------------------------------------------------- + +describe("extractFlags", () => { + test("returns empty array for undefined", () => { + expect(extractFlags(undefined)).toEqual([]); + }); + + test("extracts boolean flag with defaults", () => { + const flags: Record = { + json: { kind: "boolean", brief: "Output JSON", default: false }, + }; + const result = extractFlags(flags); + expect(result).toEqual([ + { + name: "json", + brief: "Output JSON", + kind: "boolean", + default: false, + optional: true, + variadic: false, + hidden: false, + }, + ]); + }); + + test("extracts parsed flag", () => { + const flags: Record = { + limit: { + kind: "parsed", + brief: "Max items", + default: 25, + optional: false, + }, + }; + const result = extractFlags(flags); + expect(result).toEqual([ + { + name: "limit", + brief: "Max items", + kind: "parsed", + default: 25, + optional: false, + variadic: false, + hidden: false, + }, + ]); + }); + + test("extracts hidden flag", () => { + const flags: Record = { + verbose: { kind: "boolean", hidden: true }, + }; + const result = extractFlags(flags); + expect(result[0].hidden).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// buildCommandInfo +// --------------------------------------------------------------------------- + +describe("buildCommandInfo", () => { + test("builds info with all fields", () => { + const cmd = makeCommand({ + brief: "List things", + fullDescription: "List all the things.", + parameters: { + positional: { + kind: "tuple", + parameters: [{ placeholder: "target" }], + }, + flags: { + limit: { + kind: "parsed", + brief: "Max items", + default: 10, + }, + }, + aliases: { l: "limit" }, + }, + }); + + const info = buildCommandInfo(cmd, "sentry thing list", [ + "sentry thing list myorg", + ]); + expect(info.path).toBe("sentry thing list"); + expect(info.brief).toBe("List things"); + expect(info.fullDescription).toBe("List all the things."); + expect(info.positional).toBe(""); + expect(info.flags).toHaveLength(1); + expect(info.flags[0].name).toBe("limit"); + expect(info.aliases).toEqual({ l: "limit" }); + expect(info.examples).toEqual(["sentry thing list myorg"]); + }); + + test("handles command with no positional args", () => { + const cmd = makeCommand({ brief: "Do something" }); + const info = buildCommandInfo(cmd, "sentry do"); + expect(info.positional).toBe(""); + }); + + test("prefers a public positional syntax override", () => { + const cmd = makeCommand({ + __primaryUsage: ":...", + parameters: { + positional: { + kind: "array", + parameter: { placeholder: "name:kind" }, + }, + }, + }); + + const info = buildCommandInfo(cmd, "sentry project create"); + expect(info.positional).toBe(":..."); + }); +}); + +// --------------------------------------------------------------------------- +// extractRouteGroupCommands +// --------------------------------------------------------------------------- + +describe("extractRouteGroupCommands", () => { + test("extracts visible subcommands", () => { + const listCmd = makeCommand({ brief: "List items" }); + const viewCmd = makeCommand({ brief: "View item" }); + const hiddenCmd = makeCommand({ brief: "Hidden" }); + + const routeMap = makeRouteMap([ + makeEntry("list", listCmd), + makeEntry("view", viewCmd), + makeEntry("secret", hiddenCmd, true), + ]); + + const commands = extractRouteGroupCommands(routeMap, "thing"); + expect(commands).toHaveLength(2); + expect(commands[0].path).toBe("sentry thing list"); + expect(commands[1].path).toBe("sentry thing view"); + }); + + test("skips route map entries (only extracts commands)", () => { + const nestedMap = makeRouteMap([]); + const cmd = makeCommand({ brief: "A command" }); + + const routeMap = makeRouteMap([ + makeEntry("nested", nestedMap), + makeEntry("cmd", cmd), + ]); + + const commands = extractRouteGroupCommands(routeMap, "parent"); + expect(commands).toHaveLength(1); + expect(commands[0].path).toBe("sentry parent cmd"); + }); +}); + +// --------------------------------------------------------------------------- +// extractAllRoutes +// --------------------------------------------------------------------------- + +describe("extractAllRoutes", () => { + test("extracts route groups and standalone commands", () => { + const listCmd = makeCommand({ brief: "List items" }); + const viewCmd = makeCommand({ brief: "View item" }); + const apiCmd = makeCommand({ brief: "Make API calls" }); + + const issueRoute = makeRouteMap( + [makeEntry("list", listCmd), makeEntry("view", viewCmd)], + { brief: "Manage issues" } + ); + + const topLevel = makeRouteMap([ + makeEntry("issue", issueRoute), + makeEntry("api", apiCmd), + ]); + + const routes = extractAllRoutes(topLevel); + expect(routes).toHaveLength(2); + + // Route group + expect(routes[0].name).toBe("issue"); + expect(routes[0].brief).toBe("Manage issues"); + expect(routes[0].commands).toHaveLength(2); + + // Standalone command + expect(routes[1].name).toBe("api"); + expect(routes[1].commands).toHaveLength(1); + expect(routes[1].commands[0].path).toBe("sentry api"); + }); + + test("skips hidden entries", () => { + const cmd = makeCommand({ brief: "Visible" }); + const hidden = makeCommand({ brief: "Hidden" }); + + const topLevel = makeRouteMap([ + makeEntry("visible", cmd), + makeEntry("hidden", hidden, true), + ]); + + const routes = extractAllRoutes(topLevel); + expect(routes).toHaveLength(1); + expect(routes[0].name).toBe("visible"); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCommandPath +// --------------------------------------------------------------------------- + +describe("resolveCommandPath", () => { + const listCmd = makeCommand({ brief: "List items" }); + const viewCmd = makeCommand({ brief: "View item" }); + const apiCmd = makeCommand({ brief: "API calls" }); + + const issueRoute = makeRouteMap( + [makeEntry("list", listCmd), makeEntry("view", viewCmd)], + { brief: "Manage issues" } + ); + + const topLevel = makeRouteMap([ + makeEntry("issue", issueRoute), + makeEntry("api", apiCmd), + ]); + + test("returns null for empty path", () => { + expect(resolveCommandPath(topLevel, [])).toBeNull(); + }); + + test("returns unresolved for unknown top-level entry", () => { + const result = resolveCommandPath(topLevel, ["nonexistent"]); + expect(result).not.toBeNull(); + expect(result?.kind).toBe("unresolved"); + if (result?.kind === "unresolved") { + expect(result.input).toBe("nonexistent"); + } + }); + + test("resolves route group", () => { + const result = resolveCommandPath(topLevel, ["issue"]); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("group"); + if (result!.kind === "group") { + expect(result!.info.name).toBe("issue"); + expect(result!.info.commands).toHaveLength(2); + } + }); + + test("resolves standalone command", () => { + const result = resolveCommandPath(topLevel, ["api"]); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("command"); + if (result!.kind === "command") { + expect(result!.info.path).toBe("sentry api"); + } + }); + + test("resolves subcommand in group", () => { + const result = resolveCommandPath(topLevel, ["issue", "list"]); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("command"); + if (result!.kind === "command") { + expect(result!.info.path).toBe("sentry issue list"); + expect(result!.info.brief).toBe("List items"); + } + }); + + test("returns unresolved for unknown subcommand", () => { + const result = resolveCommandPath(topLevel, ["issue", "unknown"]); + expect(result).not.toBeNull(); + expect(result?.kind).toBe("unresolved"); + if (result?.kind === "unresolved") { + expect(result.input).toBe("unknown"); + } + }); + + test("returns null when navigating deeper into standalone command", () => { + expect(resolveCommandPath(topLevel, ["api", "something"])).toBeNull(); + }); + + test("returns null for extra path segments beyond 2 levels", () => { + expect(resolveCommandPath(topLevel, ["issue", "list", "extra"])).toBeNull(); + expect( + resolveCommandPath(topLevel, ["issue", "list", "extra", "more"]) + ).toBeNull(); + }); + + // ------------------------------------------------------------------------- + // Fuzzy suggestions + // ------------------------------------------------------------------------- + + test("suggests close top-level match for typo", () => { + const result = resolveCommandPath(topLevel, ["issu"]); + expect(result?.kind).toBe("unresolved"); + if (result?.kind === "unresolved") { + expect(result.suggestions).toContain("issue"); + } + }); + + test("suggests close subcommand match for typo", () => { + const result = resolveCommandPath(topLevel, ["issue", "lis"]); + expect(result?.kind).toBe("unresolved"); + if (result?.kind === "unresolved") { + expect(result.suggestions).toContain("list"); + } + }); + + test("returns empty suggestions for completely unrelated input", () => { + const result = resolveCommandPath(topLevel, ["xyzfoo123"]); + expect(result?.kind).toBe("unresolved"); + if (result?.kind === "unresolved") { + expect(result.suggestions).toEqual([]); + } + }); +}); + +// --------------------------------------------------------------------------- +// Integration: real route tree +// --------------------------------------------------------------------------- + +describe("integration: real CLI route tree", () => { + test("extractAllRoutes returns entries for the actual CLI", async () => { + // Dynamic import to avoid circular deps in test setup + const { routes } = await import("../../src/app.js"); + type IntrospectRouteMap = import("../../src/lib/introspect.js").RouteMap; + const routeMap = routes as unknown as IntrospectRouteMap; + + const allRoutes = extractAllRoutes(routeMap); + + // Should have multiple route entries + expect(allRoutes.length).toBeGreaterThan(5); + + // Known routes should exist + const routeNames = allRoutes.map((r) => r.name); + expect(routeNames).toContain("help"); + expect(routeNames).toContain("auth"); + expect(routeNames).toContain("issue"); + expect(routeNames).toContain("api"); + }); + + test("resolveCommandPath finds 'issue list' in the actual CLI", async () => { + const { routes } = await import("../../src/app.js"); + type IntrospectRouteMap = import("../../src/lib/introspect.js").RouteMap; + const routeMap = routes as unknown as IntrospectRouteMap; + + const result = resolveCommandPath(routeMap, ["issue", "list"]); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("command"); + if (result!.kind === "command") { + expect(result!.info.path).toBe("sentry issue list"); + expect(result!.info.flags.length).toBeGreaterThan(0); + } + }); + + test("resolveCommandPath finds 'issue' group in the actual CLI", async () => { + const { routes } = await import("../../src/app.js"); + type IntrospectRouteMap = import("../../src/lib/introspect.js").RouteMap; + const routeMap = routes as unknown as IntrospectRouteMap; + + const result = resolveCommandPath(routeMap, ["issue"]); + expect(result).not.toBeNull(); + expect(result!.kind).toBe("group"); + if (result!.kind === "group") { + expect(result!.info.commands.length).toBeGreaterThan(0); + const cmdNames = result!.info.commands.map((c) => c.path); + expect(cmdNames).toContain("sentry issue list"); + } + }); +}); diff --git a/packages/cli/test/lib/issue-collapse.property.test.ts b/packages/cli/test/lib/issue-collapse.property.test.ts new file mode 100644 index 000000000..d0842f5d0 --- /dev/null +++ b/packages/cli/test/lib/issue-collapse.property.test.ts @@ -0,0 +1,157 @@ +/** + * Property-based tests for buildIssueListCollapse and ISSUE_DETAIL_COLLAPSE. + * + * Verifies invariants that must hold for any configuration of the collapse + * parameter: always-collapsed fields, stats/lifetime control, and safety constraints. + */ + +import { boolean, assert as fcAssert, property, tuple } from "fast-check"; +import { describe, expect, test } from "vitest"; + +import { + buildIssueListCollapse, + ISSUE_DETAIL_COLLAPSE, +} from "../../src/lib/api/issues.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("property: buildIssueListCollapse", () => { + test("always collapses filtered and unhandled regardless of optional flags", () => { + fcAssert( + property( + tuple(boolean(), boolean()), + ([collapseStats, collapseLifetime]) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + shouldCollapseLifetime: collapseLifetime, + }); + expect(result).toContain("filtered"); + expect(result).toContain("unhandled"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("stats presence is exactly controlled by shouldCollapseStats", () => { + fcAssert( + property( + tuple(boolean(), boolean()), + ([collapseStats, collapseLifetime]) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + shouldCollapseLifetime: collapseLifetime, + }); + expect(result.includes("stats")).toBe(collapseStats); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("lifetime presence is exactly controlled by shouldCollapseLifetime", () => { + fcAssert( + property( + tuple(boolean(), boolean()), + ([collapseStats, collapseLifetime]) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + shouldCollapseLifetime: collapseLifetime, + }); + expect(result.includes("lifetime")).toBe(collapseLifetime); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("defaults to not collapsing lifetime when option omitted", () => { + fcAssert( + property(boolean(), (collapseStats) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + }); + expect(result).not.toContain("lifetime"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("never collapses base (would break all rendering)", () => { + fcAssert( + property( + tuple(boolean(), boolean()), + ([collapseStats, collapseLifetime]) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + shouldCollapseLifetime: collapseLifetime, + }); + expect(result).not.toContain("base"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns no duplicates", () => { + fcAssert( + property( + tuple(boolean(), boolean()), + ([collapseStats, collapseLifetime]) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + shouldCollapseLifetime: collapseLifetime, + }); + expect(new Set(result).size).toBe(result.length); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("length equals 2 + number of optional flags enabled", () => { + fcAssert( + property( + tuple(boolean(), boolean()), + ([collapseStats, collapseLifetime]) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + shouldCollapseLifetime: collapseLifetime, + }); + const expected = + 2 + (collapseStats ? 1 : 0) + (collapseLifetime ? 1 : 0); + expect(result.length).toBe(expected); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("ISSUE_DETAIL_COLLAPSE", () => { + test("contains exactly stats, lifetime, filtered, unhandled", () => { + expect(ISSUE_DETAIL_COLLAPSE).toEqual([ + "stats", + "lifetime", + "filtered", + "unhandled", + ]); + }); + + test("never contains base (would break issue rendering)", () => { + expect(ISSUE_DETAIL_COLLAPSE).not.toContain("base"); + }); + + test("always includes stats (detail views never show sparklines)", () => { + expect(ISSUE_DETAIL_COLLAPSE).toContain("stats"); + }); + + test("is a superset of buildIssueListCollapse with all options enabled", () => { + const listCollapse = buildIssueListCollapse({ + shouldCollapseStats: true, + shouldCollapseLifetime: true, + }); + for (const field of listCollapse) { + expect(ISSUE_DETAIL_COLLAPSE).toContain(field); + } + }); +}); diff --git a/packages/cli/test/lib/issue-id.property.test.ts b/packages/cli/test/lib/issue-id.property.test.ts new file mode 100644 index 000000000..8200e7cd3 --- /dev/null +++ b/packages/cli/test/lib/issue-id.property.test.ts @@ -0,0 +1,420 @@ +/** + * Property-Based Tests for Issue ID Parsing + * + * Uses fast-check to verify properties that should always hold true + * for the issue ID parsing functions, regardless of input. + */ + +import { + array, + constantFrom, + assert as fcAssert, + nat, + property, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + expandToFullShortId, + isShortId, + isShortSuffix, + parseAliasSuffix, +} from "../../src/lib/issue-id.js"; +import { isAllDigits } from "../../src/lib/utils.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Generate pure digit strings */ +const numericArb = array(constantFrom(..."0123456789".split("")), { + minLength: 1, + maxLength: 20, +}).map((chars) => chars.join("")); + +/** Generate alphanumeric strings with at least one letter */ +const alphanumericWithLetterArb = tuple( + array( + constantFrom( + ..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") + ), + { + minLength: 1, + maxLength: 10, + } + ), + array(constantFrom(..."0123456789".split("")), { minLength: 0, maxLength: 5 }) +).map(([letters, digits]) => [...letters, ...digits].join("")); + +/** Generate valid project slugs (lowercase alphanumeric with hyphens) */ +const projectSlugArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789-".split("")), + { + minLength: 1, + maxLength: 30, + } +) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-")) && s.length > 0); + +/** Generate valid short suffixes (alphanumeric, no hyphens) */ +const shortSuffixArb = array( + constantFrom( + ..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split( + "" + ) + ), + { minLength: 1, maxLength: 10 } +).map((chars) => chars.join("")); + +/** Generate alias-suffix format strings */ +const aliasSuffixFormatArb = tuple( + array(constantFrom(..."abcdefghijklmnopqrstuvwxyz".split("")), { + minLength: 1, + maxLength: 10, + }), + shortSuffixArb +).map(([aliasChars, suffix]) => `${aliasChars.join("")}-${suffix}`); + +// Properties for isAllDigits + +describe("property: isAllDigits", () => { + test("returns true for all pure digit strings", () => { + fcAssert( + property(numericArb, (digits) => { + expect(isAllDigits(digits)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns false for strings containing letters", () => { + fcAssert( + property(alphanumericWithLetterArb, (str) => { + expect(isAllDigits(str)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns false for strings containing hyphens", () => { + fcAssert( + property(tuple(numericArb, numericArb), ([a, b]) => { + const withHyphen = `${a}-${b}`; + expect(isAllDigits(withHyphen)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty string returns false", () => { + expect(isAllDigits("")).toBe(false); + }); + + test("nat() values converted to string are always numeric", () => { + fcAssert( + property(nat(999_999_999), (n) => { + expect(isAllDigits(String(n))).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for isShortSuffix + +describe("property: isShortSuffix", () => { + test("returns true for alphanumeric strings without hyphens", () => { + fcAssert( + property(shortSuffixArb, (suffix) => { + expect(isShortSuffix(suffix)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns false for any string containing hyphens", () => { + fcAssert( + property(tuple(shortSuffixArb, shortSuffixArb), ([a, b]) => { + const withHyphen = `${a}-${b}`; + expect(isShortSuffix(withHyphen)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("pure numeric strings are valid short suffixes", () => { + fcAssert( + property(numericArb, (digits) => { + expect(isShortSuffix(digits)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns false for strings with special characters", () => { + const specialChars = "!@#$%^&*()_+=[]{}|;':\",./<>?`~ "; + fcAssert( + property( + tuple(shortSuffixArb, constantFrom(...specialChars.split(""))), + ([suffix, special]) => { + expect(isShortSuffix(suffix + special)).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for isShortId + +describe("property: isShortId", () => { + test("returns true for any string containing at least one letter", () => { + fcAssert( + property(alphanumericWithLetterArb, (str) => { + expect(isShortId(str)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns false for pure numeric strings", () => { + fcAssert( + property(numericArb, (digits) => { + expect(isShortId(digits)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("isShortId and isAllDigits are mutually exclusive for non-empty alphanumeric strings", () => { + fcAssert( + property( + tuple(shortSuffixArb, constantFrom(true, false)), + ([base, addLetter]) => { + // Create either pure numeric or alphanumeric with letter + const str = addLetter ? base : base.replace(/[a-zA-Z]/g, "0"); + if (str.length === 0) return; // Skip empty + + // At most one can be true + const isShort = isShortId(str); + const isNumeric = isAllDigits(str); + expect(isShort && isNumeric).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for parseAliasSuffix + +describe("property: parseAliasSuffix", () => { + test("successfully parses valid alias-suffix format", () => { + fcAssert( + property(aliasSuffixFormatArb, (input) => { + const result = parseAliasSuffix(input); + expect(result).not.toBeNull(); + expect(result?.alias).toBeDefined(); + expect(result?.suffix).toBeDefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("alias is always lowercase", () => { + fcAssert( + property(aliasSuffixFormatArb, (input) => { + const result = parseAliasSuffix(input); + if (result) { + expect(result.alias).toBe(result.alias.toLowerCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("suffix is always uppercase", () => { + fcAssert( + property(aliasSuffixFormatArb, (input) => { + const result = parseAliasSuffix(input); + if (result) { + expect(result.suffix).toBe(result.suffix.toUpperCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns null for strings without hyphens", () => { + fcAssert( + property(shortSuffixArb, (input) => { + // shortSuffixArb has no hyphens + const result = parseAliasSuffix(input); + expect(result).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("parsed alias + suffix reconstruct to original input (case-insensitive)", () => { + fcAssert( + property(aliasSuffixFormatArb, (input) => { + const result = parseAliasSuffix(input); + if (result) { + // Verify round-trip: reconstructed form matches original input + const reconstructed = `${result.alias}-${result.suffix}`; + expect(reconstructed.toLowerCase()).toBe(input.toLowerCase()); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Properties for expandToFullShortId + +describe("property: expandToFullShortId", () => { + test("result is always uppercase", () => { + fcAssert( + property( + tuple(shortSuffixArb, projectSlugArb), + ([suffix, projectSlug]) => { + const result = expandToFullShortId(suffix, projectSlug); + expect(result).toBe(result.toUpperCase()); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result contains hyphen separator", () => { + fcAssert( + property( + tuple(shortSuffixArb, projectSlugArb), + ([suffix, projectSlug]) => { + const result = expandToFullShortId(suffix, projectSlug); + expect(result).toContain("-"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result ends with uppercase suffix", () => { + fcAssert( + property( + tuple(shortSuffixArb, projectSlugArb), + ([suffix, projectSlug]) => { + const result = expandToFullShortId(suffix, projectSlug); + expect(result.endsWith(suffix.toUpperCase())).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result starts with uppercase project slug", () => { + fcAssert( + property( + tuple(shortSuffixArb, projectSlugArb), + ([suffix, projectSlug]) => { + const result = expandToFullShortId(suffix, projectSlug); + expect(result.startsWith(projectSlug.toUpperCase())).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result format is PROJECT-SUFFIX", () => { + fcAssert( + property( + tuple(shortSuffixArb, projectSlugArb), + ([suffix, projectSlug]) => { + const result = expandToFullShortId(suffix, projectSlug); + const expected = `${projectSlug.toUpperCase()}-${suffix.toUpperCase()}`; + expect(result).toBe(expected); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is idempotent when inputs are already uppercase", () => { + fcAssert( + property( + tuple(shortSuffixArb, projectSlugArb), + ([suffix, projectSlug]) => { + const result1 = expandToFullShortId(suffix, projectSlug); + const result2 = expandToFullShortId( + suffix.toUpperCase(), + projectSlug.toUpperCase() + ); + expect(result1).toBe(result2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Cross-function Properties + +describe("property: cross-function invariants", () => { + test("expanded short IDs are always detected as short IDs", () => { + fcAssert( + property( + tuple(shortSuffixArb, projectSlugArb), + ([suffix, projectSlug]) => { + // Skip if suffix is pure numeric (would make the whole thing numeric) + if (isAllDigits(suffix)) return; + + const expanded = expandToFullShortId(suffix, projectSlug); + expect(isShortId(expanded)).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("parseAliasSuffix result can be expanded back", () => { + fcAssert( + property(aliasSuffixFormatArb, (input) => { + const parsed = parseAliasSuffix(input); + if (parsed) { + // Use alias as project slug for expansion + const expanded = expandToFullShortId(parsed.suffix, parsed.alias); + expect(expanded).toBe( + `${parsed.alias.toUpperCase()}-${parsed.suffix}` + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("random strings are categorized consistently", () => { + fcAssert( + property(string({ minLength: 1, maxLength: 50 }), (input) => { + // Every non-empty string should be categorized into at most one type: + // - isAllDigits (pure digits) + // - isShortId (contains letters) + // - neither (empty, which we filter out) + + const numeric = isAllDigits(input); + const short = isShortId(input); + + // Can't be both + expect(numeric && short).toBe(false); + + // If it's alphanumeric, should be one or the other + const isAlphanumeric = /^[a-zA-Z0-9]+$/.test(input); + if (isAlphanumeric && input.length > 0) { + expect(numeric || short).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/issue-id.test.ts b/packages/cli/test/lib/issue-id.test.ts new file mode 100644 index 000000000..4576c6153 --- /dev/null +++ b/packages/cli/test/lib/issue-id.test.ts @@ -0,0 +1,154 @@ +/** + * Tests for issue ID parsing utilities + * + * Note: Core invariants (isAllDigits, isShortSuffix, isShortId, expandToFullShortId) + * are tested via property-based tests in issue-id.property.test.ts. These tests + * focus on parseAliasSuffix edge cases and integration flow documentation. + */ + +import { describe, expect, test } from "vitest"; +import { + expandToFullShortId, + isShortId, + isShortSuffix, + parseAliasSuffix, +} from "../../src/lib/issue-id.js"; + +describe("parseAliasSuffix", () => { + // These tests document specific parsing behaviors and edge cases + + test("parses simple alias-suffix format", () => { + const result = parseAliasSuffix("e-4y"); + expect(result).toEqual({ alias: "e", suffix: "4Y" }); + }); + + test("parses multi-character alias", () => { + const result = parseAliasSuffix("fr-a3"); + expect(result).toEqual({ alias: "fr", suffix: "A3" }); + }); + + test("parses alias with hyphens", () => { + const result = parseAliasSuffix("spotlight-e-4y"); + expect(result).toEqual({ alias: "spotlight-e", suffix: "4Y" }); + }); + + test("handles case-insensitive input", () => { + const result = parseAliasSuffix("E-4Y"); + expect(result).toEqual({ alias: "e", suffix: "4Y" }); + }); + + test("returns null for plain suffix without alias", () => { + const result = parseAliasSuffix("4y"); + expect(result).toBeNull(); + }); + + test("returns null for full short ID (looks like alias-suffix but is PROJECT-XXX)", () => { + // This is actually a valid alias-suffix parse, but the caller should verify + // if the alias exists in the cache + const result = parseAliasSuffix("CRAFT-G"); + expect(result).toEqual({ alias: "craft", suffix: "G" }); + }); + + test("returns null for empty string", () => { + const result = parseAliasSuffix(""); + expect(result).toBeNull(); + }); +}); + +describe("expandToFullShortId representative examples", () => { + // Representative examples for documentation (invariants covered by property tests) + + test("expands suffix with project slug", () => { + expect(expandToFullShortId("G", "craft")).toBe("CRAFT-G"); + expect(expandToFullShortId("4y", "spotlight-electron")).toBe( + "SPOTLIGHT-ELECTRON-4Y" + ); + }); + + test("handles mixed case input", () => { + expect(expandToFullShortId("aB", "Project")).toBe("PROJECT-AB"); + }); +}); + +describe("short ID resolution flow", () => { + // Integration test simulating the resolution logic from issue view command. + // This documents the expected behavior when combining multiple functions. + + const mockAliasCache: Record< + string, + { orgSlug: string; projectSlug: string } + > = { + e: { orgSlug: "sentry", projectSlug: "spotlight-electron" }, + w: { orgSlug: "sentry", projectSlug: "spotlight-website" }, + s: { orgSlug: "sentry", projectSlug: "spotlight" }, + }; + + function getProjectByAlias(alias: string) { + return mockAliasCache[alias.toLowerCase()]; + } + + function resolveIssueId( + input: string, + defaultProject?: string + ): string | null { + // Check alias-suffix pattern first + const aliasSuffix = parseAliasSuffix(input); + const projectEntry = aliasSuffix + ? getProjectByAlias(aliasSuffix.alias) + : null; + + if (aliasSuffix && projectEntry) { + return expandToFullShortId(aliasSuffix.suffix, projectEntry.projectSlug); + } + + if (isShortSuffix(input) && defaultProject) { + return expandToFullShortId(input, defaultProject); + } + + if (isShortId(input)) { + return input.toUpperCase(); + } + + return null; // Numeric ID or unresolvable + } + + test("resolves alias-suffix to full short ID", () => { + expect(resolveIssueId("e-4y")).toBe("SPOTLIGHT-ELECTRON-4Y"); + expect(resolveIssueId("w-2c")).toBe("SPOTLIGHT-WEBSITE-2C"); + expect(resolveIssueId("s-73")).toBe("SPOTLIGHT-73"); + }); + + test("resolves short suffix with default project", () => { + expect(resolveIssueId("4y", "spotlight-electron")).toBe( + "SPOTLIGHT-ELECTRON-4Y" + ); + expect(resolveIssueId("G", "craft")).toBe("CRAFT-G"); + // Pure numeric suffix works when project context is available + expect(resolveIssueId("12", "craft")).toBe("CRAFT-12"); + }); + + test("passes through full short ID", () => { + expect(resolveIssueId("SPOTLIGHT-ELECTRON-4Y")).toBe( + "SPOTLIGHT-ELECTRON-4Y" + ); + expect(resolveIssueId("craft-g")).toBe("CRAFT-G"); + }); + + test("returns null for unknown alias", () => { + // "x" is not in our mock cache, so x-4y won't resolve via alias + // It will try isShortSuffix (false, has hyphen), then isShortId (true) + // So it gets treated as a regular short ID + expect(resolveIssueId("x-4y")).toBe("X-4Y"); + }); + + test("treats plain suffix as short ID when no default project", () => { + // "4y" contains letters so isShortId returns true, treating it as a short ID + // The actual API call would fail, but resolution succeeds + expect(resolveIssueId("4y")).toBe("4Y"); + }); + + test("returns null for pure numeric ID", () => { + // Pure numbers are not short IDs, return null to indicate numeric ID + expect(resolveIssueId("12345")).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/jvm-bundle.test.ts b/packages/cli/test/lib/jvm-bundle.test.ts new file mode 100644 index 000000000..b7924ac3d --- /dev/null +++ b/packages/cli/test/lib/jvm-bundle.test.ts @@ -0,0 +1,333 @@ +/** + * Tests for JVM source bundle builder core logic. + * + * Tests path filtering (ambiguous build dirs, safe excludes), + * source-set prefix stripping, and URL construction. + */ + +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + _buildSourceUrl, + _isInAmbiguousBuildDir, + _stripSourceSetPrefix, + buildJvmBundle, +} from "../../src/lib/jvm-bundle.js"; + +describe("isInAmbiguousBuildDir", () => { + test("excludes build/ at root", () => { + expect(_isInAmbiguousBuildDir("build/generated/Foo.java")).toBe(true); + }); + + test("excludes target/ at root", () => { + expect(_isInAmbiguousBuildDir("target/classes/Foo.java")).toBe(true); + }); + + test("keeps build/ under src/", () => { + expect( + _isInAmbiguousBuildDir("src/main/java/com/example/build/Builder.java") + ).toBe(false); + }); + + test("excludes build/ with src/ inside it", () => { + expect(_isInAmbiguousBuildDir("build/src/main/java/Foo.java")).toBe(true); + }); + + test("keeps non-ambiguous directories", () => { + expect(_isInAmbiguousBuildDir("src/main/java/Foo.java")).toBe(false); + }); + + test("excludes out/ at root", () => { + expect(_isInAmbiguousBuildDir("out/production/Foo.java")).toBe(true); + }); + + test("excludes bin/ at root", () => { + expect(_isInAmbiguousBuildDir("bin/Foo.java")).toBe(true); + }); + + test("keeps deeply nested build/ under src/", () => { + expect( + _isInAmbiguousBuildDir( + "module/src/main/java/com/build/target/out/Foo.java" + ) + ).toBe(false); + }); +}); + +describe("stripSourceSetPrefix", () => { + test("strips src/main/java/", () => { + expect(_stripSourceSetPrefix("src/main/java/io/sentry/core/Foo.java")).toBe( + "io/sentry/core/Foo.java" + ); + }); + + test("strips module/src/main/kotlin/", () => { + expect( + _stripSourceSetPrefix("sentry-core/src/main/kotlin/io/sentry/Foo.kt") + ).toBe("io/sentry/Foo.kt"); + }); + + test("strips src/test/java/", () => { + expect( + _stripSourceSetPrefix("src/test/java/com/example/FooTest.java") + ).toBe("com/example/FooTest.java"); + }); + + test("strips src/main/scala/", () => { + expect(_stripSourceSetPrefix("src/main/scala/com/example/App.scala")).toBe( + "com/example/App.scala" + ); + }); + + test("strips src/main/groovy/", () => { + expect( + _stripSourceSetPrefix("src/main/groovy/com/example/Script.groovy") + ).toBe("com/example/Script.groovy"); + }); + + test("strips src/main/clojure/", () => { + expect(_stripSourceSetPrefix("src/main/clojure/com/example/core.clj")).toBe( + "com/example/core.clj" + ); + }); + + test("keeps path without source-set prefix", () => { + expect(_stripSourceSetPrefix("just/a/File.java")).toBe("just/a/File.java"); + }); + + test("normalizes backslashes", () => { + expect( + _stripSourceSetPrefix("src\\main\\java\\com\\example\\Foo.java") + ).toBe("com/example/Foo.java"); + }); +}); + +describe("buildSourceUrl", () => { + test("adds ~/ prefix and .jvm extension", () => { + expect(_buildSourceUrl("io/sentry/core/Foo.java")).toBe( + "~/io/sentry/core/Foo.jvm" + ); + }); + + test("replaces .kt extension", () => { + expect(_buildSourceUrl("com/example/Bar.kt")).toBe("~/com/example/Bar.jvm"); + }); + + test("replaces .scala extension", () => { + expect(_buildSourceUrl("com/example/App.scala")).toBe( + "~/com/example/App.jvm" + ); + }); +}); + +describe("buildJvmBundle", () => { + let tempDir: string; + let outputDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "jvm-bundle-test-")); + outputDir = join(tempDir, "output"); + await mkdir(outputDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("bundles Java files", async () => { + await mkdir(join(tempDir, "src", "main", "java", "com", "example"), { + recursive: true, + }); + await writeFile( + join(tempDir, "src", "main", "java", "com", "example", "Main.java"), + "public class Main {}" + ); + + const result = await buildJvmBundle({ + sourcePath: tempDir, + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + }); + + expect(result.fileCount).toBe(1); + expect(result.collisionCount).toBe(0); + expect(existsSync(result.outputPath)).toBe(true); + }); + + test("bundles Kotlin files", async () => { + await mkdir(join(tempDir, "src", "main", "kotlin", "com", "example"), { + recursive: true, + }); + await writeFile( + join(tempDir, "src", "main", "kotlin", "com", "example", "App.kt"), + "fun main() {}" + ); + + const result = await buildJvmBundle({ + sourcePath: tempDir, + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + }); + + expect(result.fileCount).toBe(1); + }); + + test("excludes build output directories", async () => { + // File in build/ should be excluded + await mkdir(join(tempDir, "build", "generated"), { recursive: true }); + await writeFile( + join(tempDir, "build", "generated", "R.java"), + "// generated" + ); + + // File in src/ should be included + await mkdir(join(tempDir, "src", "main", "java"), { recursive: true }); + await writeFile( + join(tempDir, "src", "main", "java", "App.java"), + "class App {}" + ); + + const result = await buildJvmBundle({ + sourcePath: tempDir, + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + }); + + expect(result.fileCount).toBe(1); + }); + + test("respects excludePatterns", async () => { + await mkdir(join(tempDir, "generated"), { recursive: true }); + await writeFile(join(tempDir, "generated", "Auto.java"), "class Auto {}"); + await mkdir(join(tempDir, "src", "main", "java"), { recursive: true }); + await writeFile( + join(tempDir, "src", "main", "java", "App.java"), + "class App {}" + ); + + const result = await buildJvmBundle({ + sourcePath: tempDir, + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + excludePatterns: ["generated"], + }); + + expect(result.fileCount).toBe(1); + }); + + test("returns zero files for empty directory", async () => { + const result = await buildJvmBundle({ + sourcePath: tempDir, + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + }); + + expect(result.fileCount).toBe(0); + }); + + test("handles sourcePath ending in src/", async () => { + const srcDir = join(tempDir, "src"); + await mkdir(join(srcDir, "main", "java", "com", "example"), { + recursive: true, + }); + await writeFile( + join(srcDir, "main", "java", "com", "example", "App.java"), + "class App {}" + ); + + const result = await buildJvmBundle({ + sourcePath: srcDir, + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + }); + + expect(result.fileCount).toBe(1); + // URL should be package-relative, not main/java/... + const urls = [...result.files.keys()]; + expect(urls[0]).toBe("~/com/example/App.jvm"); + }); + + test("includes debug ID in manifest", async () => { + await mkdir(join(tempDir, "src", "main", "java"), { recursive: true }); + await writeFile( + join(tempDir, "src", "main", "java", "App.java"), + "class App {}" + ); + + const debugId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const result = await buildJvmBundle({ + sourcePath: tempDir, + outputPath: join(outputDir, "test.zip"), + debugId, + }); + + expect(result.fileCount).toBe(1); + expect(existsSync(result.outputPath)).toBe(true); + }); + + test("follows symlinked source files", async () => { + // Real file lives outside the scanned source tree. + const externalDir = join(tempDir, "external"); + await mkdir(externalDir, { recursive: true }); + const realFile = join(externalDir, "Linked.java"); + await writeFile(realFile, "class Linked {}"); + + // A symlink to it sits inside the source tree. + const pkgDir = join(tempDir, "src", "main", "java", "com", "example"); + await mkdir(pkgDir, { recursive: true }); + await symlink(realFile, join(pkgDir, "Linked.java")); + + const result = await buildJvmBundle({ + sourcePath: join(tempDir, "src"), + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + excludePatterns: ["external"], + }); + + expect(result.fileCount).toBe(1); + expect([...result.files.keys()][0]).toBe("~/com/example/Linked.jvm"); + }); + + test("follows symlinked directories", async () => { + // Real package directory outside the scanned source tree. + const externalPkg = join(tempDir, "shared", "com", "example"); + await mkdir(externalPkg, { recursive: true }); + await writeFile(join(externalPkg, "Shared.java"), "class Shared {}"); + + // Source tree contains a symlink to the external "com" package dir. + const srcJava = join(tempDir, "src", "main", "java"); + await mkdir(srcJava, { recursive: true }); + await symlink(join(tempDir, "shared", "com"), join(srcJava, "com")); + + const result = await buildJvmBundle({ + sourcePath: join(tempDir, "src"), + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + }); + + expect(result.fileCount).toBe(1); + expect([...result.files.keys()][0]).toBe("~/com/example/Shared.jvm"); + }); + + test("does not loop on symlink cycles", async () => { + const pkgDir = join(tempDir, "src", "main", "java", "com", "example"); + await mkdir(pkgDir, { recursive: true }); + await writeFile(join(pkgDir, "App.java"), "class App {}"); + + // Create a self-referential symlink cycle: example/loop -> src + await symlink(join(tempDir, "src"), join(pkgDir, "loop")); + + const result = await buildJvmBundle({ + sourcePath: join(tempDir, "src"), + outputPath: join(outputDir, "test.zip"), + debugId: "12345678-1234-1234-1234-123456789abc", + }); + + // The single real file is collected exactly once despite the cycle. + expect(result.fileCount).toBe(1); + expect([...result.files.keys()][0]).toBe("~/com/example/App.jvm"); + }); +}); diff --git a/packages/cli/test/lib/kitty-image.test.ts b/packages/cli/test/lib/kitty-image.test.ts new file mode 100644 index 000000000..76888126b --- /dev/null +++ b/packages/cli/test/lib/kitty-image.test.ts @@ -0,0 +1,114 @@ +/** + * Runtime image → kitty graphics encoder tests. + * + * Exercises the shape of the emitted kitty escape sequence (APC introducer, + * RGBA format keys, base64 payload, chunking) and the decode-then-encode + * convenience wrapper. Real terminal I/O is not involved — everything here is + * pure and deterministic. + */ + +import { PNG } from "pngjs"; +import { describe, expect, test } from "vitest"; +import { + encodeImageToKitty, + imageBytesToKitty, +} from "../../src/lib/kitty-image.js"; +import type { DecodedImage } from "../../src/lib/sixel-image.js"; + +const ESC = "\x1b"; + +/** Build a solid-color RGBA image of the given size. */ +function solidImage( + width: number, + height: number, + rgba: [number, number, number, number] +): DecodedImage { + const data = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + data[i * 4] = rgba[0]; + data[i * 4 + 1] = rgba[1]; + data[i * 4 + 2] = rgba[2]; + data[i * 4 + 3] = rgba[3]; + } + return { width, height, data }; +} + +/** Encode an RGBA DecodedImage as PNG bytes. */ +function toPngBytes(img: DecodedImage): Uint8Array { + const png = new PNG({ width: img.width, height: img.height }); + png.data = Buffer.from(img.data); + return new Uint8Array(PNG.sync.write(png)); +} + +describe("encodeImageToKitty", () => { + test("emits an APC graphics sequence with RGBA format and dimensions", () => { + const out = encodeImageToKitty(solidImage(2, 2, [255, 0, 0, 255])); + expect(out).toBeDefined(); + const s = out as string; + // APC introducer ... String Terminator. + expect(s.startsWith(`${ESC}_G`)).toBe(true); + expect(s.endsWith(`${ESC}\\`)).toBe(true); + // Transmit+display, 32-bit RGBA, and the pixel dimensions of the buffer. + expect(s).toContain("a=T"); + expect(s).toContain("f=32"); + expect(s).toContain("s=2"); + expect(s).toContain("v=2"); + }); + + test("encodes the pixel buffer as base64", () => { + const out = encodeImageToKitty(solidImage(1, 1, [1, 2, 3, 4])) as string; + const payload = out.slice(out.indexOf(";") + 1, out.indexOf(`${ESC}\\`)); + expect(Buffer.from(payload, "base64")).toEqual(Buffer.from([1, 2, 3, 4])); + }); + + test("chunks large payloads with m=1 on all but the last chunk", () => { + // 64×64 RGBA ≈ 16 KiB → ~21 KiB base64, well over the 4096-byte chunk cap. + const out = encodeImageToKitty( + solidImage(64, 64, [10, 20, 30, 255]) + ) as string; + // Each chunk is an APC sequence terminated by ESC \; split on the introducer. + const chunks = out + .split(`${ESC}_G`) + .filter((c) => c.length > 0) + .map((c) => `${ESC}_G${c}`); + expect(chunks.length).toBeGreaterThan(1); + // Every chunk but the last declares more data follows. + for (const chunk of chunks.slice(0, -1)) { + expect(chunk).toContain("m=1"); + } + expect(chunks.at(-1)).toContain("m=0"); + // Metadata keys ride only the first chunk. + expect(chunks[0]).toContain("f=32"); + expect(chunks[1]).not.toContain("f=32"); + }); + + test("downscales wide images to the max width", () => { + const out = encodeImageToKitty( + solidImage(2000, 10, [0, 0, 0, 255]) + ) as string; + expect(out).toContain("s=800"); + }); +}); + +describe("imageBytesToKitty", () => { + test("decodes PNG bytes and encodes them to kitty graphics", () => { + const png = toPngBytes(solidImage(3, 3, [0, 128, 255, 255])); + const out = imageBytesToKitty(png, "image/png"); + expect(out).toBeDefined(); + expect((out as string).startsWith(`${ESC}_G`)).toBe(true); + }); + + test("returns undefined for an unsupported format", () => { + expect( + imageBytesToKitty(new Uint8Array([1, 2, 3]), "text/plain") + ).toBeUndefined(); + }); + + test("returns undefined for bytes that fail to decode", () => { + // PNG magic bytes but a truncated/garbage body. + const bogus = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + expect(imageBytesToKitty(bogus, "image/png")).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/list-command.test.ts b/packages/cli/test/lib/list-command.test.ts new file mode 100644 index 000000000..0d1f780d4 --- /dev/null +++ b/packages/cli/test/lib/list-command.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for the shared list-command building blocks. + * + * Tests `parseCursorFlag` validation and `buildOrgListCommand` delegation + * to `dispatchOrgScopedList`. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + buildOrgListCommand, + type OrgListCommandDocs, + parseCursorFlag, +} from "../../src/lib/list-command.js"; +import type { OrgListConfig } from "../../src/lib/org-list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as orgListModule from "../../src/lib/org-list.js"; + +describe("parseCursorFlag", () => { + test("passes through 'last' keyword", () => { + expect(parseCursorFlag("last")).toBe("last"); + }); + + test("passes through valid cursor strings", () => { + expect(parseCursorFlag("1735689600:0:0")).toBe("1735689600:0:0"); + expect(parseCursorFlag("abc:def:0")).toBe("abc:def:0"); + }); + + test("rejects bare integer strings", () => { + expect(() => parseCursorFlag("12345")).toThrow("not a valid cursor"); + expect(() => parseCursorFlag("0")).toThrow("not a valid cursor"); + expect(() => parseCursorFlag("999999999")).toThrow("not a valid cursor"); + }); + + test("accepts strings with digits and other characters", () => { + expect(parseCursorFlag("123abc")).toBe("123abc"); + expect(parseCursorFlag("abc123")).toBe("abc123"); + }); +}); + +// --------------------------------------------------------------------------- +// buildOrgListCommand: integration with dispatchOrgScopedList +// --------------------------------------------------------------------------- + +type FakeEntity = { id: string; name: string }; +type FakeWithOrg = FakeEntity & { orgSlug: string }; + +function makeFakeConfig( + overrides?: Partial> +): OrgListConfig { + return { + paginationKey: "fake-list", + entityPlural: "widgets", + commandPrefix: "sentry widget list", + listForOrg: vi.fn(() => Promise.resolve([])), + listPaginated: vi.fn(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ), + withOrg: (entity, orgSlug) => ({ ...entity, orgSlug }), + displayTable: vi.fn(() => ""), + ...overrides, + }; +} + +function createContext() { + const write = vi.fn((_chunk: string) => true); + return { + context: { + stdout: { write }, + stderr: { write: vi.fn((_chunk: string) => true) }, + cwd: "/tmp", + }, + write, + }; +} + +describe("buildOrgListCommand", () => { + let dispatchSpy: ReturnType; + + afterEach(() => { + dispatchSpy?.mockRestore(); + }); + + test("returns a command object with a loader", () => { + const config = makeFakeConfig(); + const docs: OrgListCommandDocs = { brief: "List widgets" }; + const cmd = buildOrgListCommand(config, docs, "widget"); + expect(typeof cmd.loader).toBe("function"); + }); + + test("calls dispatchOrgScopedList with correct config and flags", async () => { + dispatchSpy = vi + .spyOn(orgListModule, "dispatchOrgScopedList") + .mockResolvedValue({ items: [] }); + + const config = makeFakeConfig(); + const docs: OrgListCommandDocs = { brief: "List widgets" }; + const cmd = buildOrgListCommand(config, docs, "widget"); + const func = await cmd.loader(); + const { context } = createContext(); + + // Stricli loader returns CommandModule | CommandFunction union; + // .call() exists on the function variant used at runtime. + await (func as any).call(context, { + limit: 5, + json: true, + cursor: undefined, + }); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const callArgs = dispatchSpy.mock.calls[0]?.[0]; + expect(callArgs?.config).toBe(config); + expect(callArgs?.flags).toEqual({ + limit: 5, + json: true, + cursor: undefined, + }); + }); + + test("passes parsed target to dispatchOrgScopedList", async () => { + dispatchSpy = vi + .spyOn(orgListModule, "dispatchOrgScopedList") + .mockResolvedValue({ items: [] }); + + const config = makeFakeConfig(); + const cmd = buildOrgListCommand( + config, + { brief: "List widgets" }, + "widget" + ); + const func = await cmd.loader(); + const { context } = createContext(); + + await (func as any).call(context, { limit: 30, json: false }, "my-org/"); + + const callArgs = dispatchSpy.mock.calls[0]?.[0]; + expect(callArgs?.parsed).toMatchObject({ type: "org-all", org: "my-org" }); + }); + + test("passes undefined parsed target when no positional arg given", async () => { + dispatchSpy = vi + .spyOn(orgListModule, "dispatchOrgScopedList") + .mockResolvedValue({ items: [] }); + + const config = makeFakeConfig(); + const cmd = buildOrgListCommand( + config, + { brief: "List widgets" }, + "widget" + ); + const func = await cmd.loader(); + const { context } = createContext(); + + await (func as any).call(context, { limit: 30, json: false }); + + const callArgs = dispatchSpy.mock.calls[0]?.[0]; + expect(callArgs?.parsed).toMatchObject({ type: "auto-detect" }); + }); +}); diff --git a/packages/cli/test/lib/logger.test.ts b/packages/cli/test/lib/logger.test.ts new file mode 100644 index 000000000..03ce6b6aa --- /dev/null +++ b/packages/cli/test/lib/logger.test.ts @@ -0,0 +1,284 @@ +/** + * Unit tests for the logger module. + * + * Tests parseLogLevel, getEnvLogLevel, setLogLevel (with withTag propagation), + * attachSentryReporter, and the logger instance configuration. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + attachSentryReporter, + getEnvLogLevel, + LOG_LEVEL_ENV_VAR, + LOG_LEVEL_NAMES, + logger, + parseLogLevel, + printLine, + setLogLevel, +} from "../../src/lib/logger.js"; + +describe("parseLogLevel", () => { + test("maps known level names to correct numeric values", () => { + expect(parseLogLevel("error")).toBe(0); + expect(parseLogLevel("warn")).toBe(1); + expect(parseLogLevel("log")).toBe(2); + expect(parseLogLevel("info")).toBe(3); + expect(parseLogLevel("debug")).toBe(4); + expect(parseLogLevel("trace")).toBe(5); + }); + + test("is case-insensitive", () => { + expect(parseLogLevel("ERROR")).toBe(0); + expect(parseLogLevel("Debug")).toBe(4); + expect(parseLogLevel("TRACE")).toBe(5); + expect(parseLogLevel("Warn")).toBe(1); + }); + + test("trims whitespace", () => { + expect(parseLogLevel(" debug ")).toBe(4); + expect(parseLogLevel("\ttrace\n")).toBe(5); + }); + + test("returns default (info=3) for unrecognized values", () => { + expect(parseLogLevel("verbose")).toBe(3); + expect(parseLogLevel("")).toBe(3); + expect(parseLogLevel("unknown")).toBe(3); + expect(parseLogLevel("42")).toBe(3); + }); +}); + +describe("LOG_LEVEL_NAMES", () => { + test("contains all expected level names with index = consola numeric level", () => { + expect(LOG_LEVEL_NAMES).toEqual([ + "error", // 0 + "warn", // 1 + "log", // 2 + "info", // 3 + "debug", // 4 + "trace", // 5 + ]); + }); + + test("all names are valid for parseLogLevel", () => { + for (const name of LOG_LEVEL_NAMES) { + const level = parseLogLevel(name); + expect(level).toBeGreaterThanOrEqual(0); + expect(level).toBeLessThanOrEqual(5); + } + }); +}); + +describe("LOG_LEVEL_ENV_VAR", () => { + test("is SENTRY_LOG_LEVEL", () => { + expect(LOG_LEVEL_ENV_VAR).toBe("SENTRY_LOG_LEVEL"); + }); +}); + +describe("getEnvLogLevel", () => { + let savedEnv: string | undefined; + + beforeEach(() => { + savedEnv = process.env[LOG_LEVEL_ENV_VAR]; + }); + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env[LOG_LEVEL_ENV_VAR]; + } else { + process.env[LOG_LEVEL_ENV_VAR] = savedEnv; + } + }); + + test("returns null when env var is not set", () => { + delete process.env[LOG_LEVEL_ENV_VAR]; + expect(getEnvLogLevel()).toBeNull(); + }); + + test("returns parsed level when env var is set", () => { + process.env[LOG_LEVEL_ENV_VAR] = "debug"; + expect(getEnvLogLevel()).toBe(4); + }); + + test("returns parsed level for each valid name", () => { + for (const [idx, name] of LOG_LEVEL_NAMES.entries()) { + process.env[LOG_LEVEL_ENV_VAR] = name; + expect(getEnvLogLevel()).toBe(idx); + } + }); + + test("returns null for empty string", () => { + process.env[LOG_LEVEL_ENV_VAR] = ""; + expect(getEnvLogLevel()).toBeNull(); + }); +}); + +describe("setLogLevel", () => { + let originalLevel: number; + + beforeEach(() => { + originalLevel = logger.level; + }); + + afterEach(() => { + logger.level = originalLevel; + }); + + test("changes the logger level", () => { + setLogLevel(4); + expect(logger.level).toBe(4); + }); + + test("can set to error level", () => { + setLogLevel(0); + expect(logger.level).toBe(0); + }); + + test("can set to trace level", () => { + setLogLevel(5); + expect(logger.level).toBe(5); + }); + + test("propagates level to withTag children", () => { + const child1 = logger.withTag("test-child-1"); + const child2 = logger.withTag("test-child-2"); + + // Children start at the logger's current level + expect(child1.level).toBe(logger.level); + expect(child2.level).toBe(logger.level); + + // Change the level + setLogLevel(5); + expect(child1.level).toBe(5); + expect(child2.level).toBe(5); + + // Change again + setLogLevel(0); + expect(child1.level).toBe(0); + expect(child2.level).toBe(0); + }); + + test("propagates to children created before level change", () => { + // Simulate the real-world scenario: module-level child created at default level, + // then setLogLevel called later by cli.ts + const moduleChild = logger.withTag("upgrade"); + expect(moduleChild.level).toBe(originalLevel); + + setLogLevel(4); // debug + expect(moduleChild.level).toBe(4); + }); + + test("propagates to grandchildren (nested withTag)", () => { + const child = logger.withTag("parent"); + const grandchild = child.withTag("sub-scope"); + + expect(grandchild.level).toBe(logger.level); + + setLogLevel(5); // trace + expect(child.level).toBe(5); + expect(grandchild.level).toBe(5); + + setLogLevel(0); // error + expect(child.level).toBe(0); + expect(grandchild.level).toBe(0); + }); +}); + +describe("logger instance", () => { + test("is a consola instance with expected methods", () => { + expect(typeof logger.info).toBe("function"); + expect(typeof logger.debug).toBe("function"); + expect(typeof logger.warn).toBe("function"); + expect(typeof logger.error).toBe("function"); + expect(typeof logger.trace).toBe("function"); + expect(typeof logger.success).toBe("function"); + expect(typeof logger.withTag).toBe("function"); + }); + + test("withTag returns a scoped logger", () => { + const scoped = logger.withTag("test-scope"); + expect(typeof scoped.info).toBe("function"); + expect(typeof scoped.debug).toBe("function"); + }); + + test("level can be set to info (3) via setLogLevel", () => { + const before = logger.level; + try { + setLogLevel(3); + expect(logger.level).toBe(3); + } finally { + setLogLevel(before); + } + }); +}); + +/** Matches an `HH:MM:SS` clock time anywhere in a rendered log line. */ +const TIME_RE = /\d{2}:\d{2}:\d{2}/g; + +describe("printLine", () => { + test("writes the line verbatim to stderr with a trailing newline", () => { + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + printLine("12:34:56 [INFO] [SERVER] Hello"); + expect(spy).toHaveBeenCalledWith("12:34:56 [INFO] [SERVER] Hello\n"); + } finally { + spy.mockRestore(); + } + }); + + test("does not add a second timestamp the way logger.log does", () => { + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + printLine("12:34:56 [INFO] [SERVER] Hello"); + const written = spy.mock.calls.map((call) => String(call[0])).join(""); + expect(written.match(TIME_RE)).toHaveLength(1); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("printJsonLine", () => { + test("writes an NDJSON record only to stdout", async () => { + const loggerModule = (await import( + "../../src/lib/logger.js" + )) as typeof import("../../src/lib/logger.js") & { + printJsonLine?: (line: string) => void; + }; + const stdout = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + expect(loggerModule.printJsonLine).toBeTypeOf("function"); + loggerModule.printJsonLine?.('{"schema_version":1,"type":"error"}'); + expect(stdout).toHaveBeenCalledWith( + '{"schema_version":1,"type":"error"}\n' + ); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); +}); + +describe("attachSentryReporter", () => { + test("can be called without error", () => { + // attachSentryReporter is idempotent and safe to call even when + // Sentry is not initialized (it catches errors internally) + expect(() => attachSentryReporter()).not.toThrow(); + }); + + test("is idempotent (second call is a no-op)", () => { + // First call may or may not succeed depending on Sentry state + attachSentryReporter(); + // Second call should be a no-op due to internal guard + expect(() => attachSentryReporter()).not.toThrow(); + }); +}); diff --git a/packages/cli/test/lib/metrics-transform.test.ts b/packages/cli/test/lib/metrics-transform.test.ts new file mode 100644 index 000000000..2380c645e --- /dev/null +++ b/packages/cli/test/lib/metrics-transform.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vitest"; +import type { MetricMeta } from "../../src/lib/api/explore.js"; +import { ResolutionError } from "../../src/lib/errors.js"; +import { + makeTracemetricsAggregate, + resolveMetricField, +} from "../../src/lib/metrics-transform.js"; + +const SAMPLE_METRICS: MetricMeta[] = [ + { name: "llm.token_usage", type: "distribution", unit: "none" }, + { name: "cache.hit_rate", type: "distribution", unit: "none" }, + { name: "http.response_time", type: "distribution", unit: "millisecond" }, + { name: "request.count", type: "counter", unit: "none" }, +]; + +describe("makeTracemetricsAggregate", () => { + test("builds standard format", () => { + expect( + makeTracemetricsAggregate( + "sum", + "llm.token_usage", + "distribution", + "none" + ) + ).toBe("sum(value,llm.token_usage,distribution,none)"); + }); + + test("preserves unit", () => { + expect( + makeTracemetricsAggregate( + "avg", + "http.response_time", + "distribution", + "millisecond" + ) + ).toBe("avg(value,http.response_time,distribution,millisecond)"); + }); + + test("works with p50 aggregation", () => { + expect( + makeTracemetricsAggregate("p50", "cache.hit_rate", "distribution", "none") + ).toBe("p50(value,cache.hit_rate,distribution,none)"); + }); +}); + +describe("resolveMetricField", () => { + test("resolves known metric with default agg", () => { + expect(resolveMetricField("llm.token_usage", "sum", SAMPLE_METRICS)).toBe( + "sum(value,llm.token_usage,distribution,none)" + ); + }); + + test("resolves with custom agg", () => { + expect(resolveMetricField("cache.hit_rate", "avg", SAMPLE_METRICS)).toBe( + "avg(value,cache.hit_rate,distribution,none)" + ); + }); + + test("preserves metric unit from metadata", () => { + expect( + resolveMetricField("http.response_time", "p95", SAMPLE_METRICS) + ).toBe("p95(value,http.response_time,distribution,millisecond)"); + }); + + test("throws ResolutionError for unknown metric", () => { + expect(() => + resolveMetricField("nonexistent.metric", "sum", SAMPLE_METRICS) + ).toThrow(ResolutionError); + }); + + test("suggests similar metrics when not found", () => { + try { + resolveMetricField("llm.token", "sum", SAMPLE_METRICS); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ResolutionError); + expect((err as ResolutionError).message).toContain("llm.token_usage"); + } + }); + + test("deduplicates suggestions when metrics list has duplicate names", () => { + // The API can return the same metric name multiple times (e.g. one entry + // per tag combination). Suggestions must not repeat the same name. + const metricsWithDuplicates: MetricMeta[] = [ + { name: "llm.token_usage", type: "distribution", unit: "none" }, + { name: "llm.token_usage", type: "distribution", unit: "none" }, + { name: "llm.token_usage", type: "distribution", unit: "none" }, + ]; + try { + resolveMetricField("llm.token", "sum", metricsWithDuplicates); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ResolutionError); + const suggestionLine = (err as ResolutionError).message; + const occurrences = suggestionLine.split("llm.token_usage").length - 1; + expect(occurrences).toBe(1); + } + }); + + test("throws ResolutionError for invalid aggregation", () => { + expect(() => + resolveMetricField("llm.token_usage", "invalid_agg", SAMPLE_METRICS) + ).toThrow(ResolutionError); + }); + + test("resolves counter-type metric", () => { + expect(resolveMetricField("request.count", "sum", SAMPLE_METRICS)).toBe( + "sum(value,request.count,counter,none)" + ); + }); +}); diff --git a/packages/cli/test/lib/mutate-command.test.ts b/packages/cli/test/lib/mutate-command.test.ts new file mode 100644 index 000000000..a92d3dbd5 --- /dev/null +++ b/packages/cli/test/lib/mutate-command.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for the shared mutate-command building blocks. + * + * Tests Level A flag constants, Level B utilities (isConfirmationBypassed, + * guardNonInteractive, requireExplicitTarget), and Level C buildDeleteCommand + * flag/alias injection. + */ + +import { describe, expect, test } from "vitest"; +import { + DESTRUCTIVE_ALIASES, + DESTRUCTIVE_FLAGS, + DRY_RUN_ALIASES, + DRY_RUN_FLAG, + FORCE_FLAG, + guardNonInteractive, + isConfirmationBypassed, + requireExplicitTarget, + YES_FLAG, +} from "../../src/lib/mutate-command.js"; + +// --------------------------------------------------------------------------- +// Level A: flag constant shapes +// --------------------------------------------------------------------------- + +describe("flag constants", () => { + test("DRY_RUN_FLAG has correct shape", () => { + expect(DRY_RUN_FLAG.kind).toBe("boolean"); + expect(DRY_RUN_FLAG.default).toBe(false); + expect(DRY_RUN_FLAG.brief).toBeString(); + }); + + test("YES_FLAG has correct shape", () => { + expect(YES_FLAG.kind).toBe("boolean"); + expect(YES_FLAG.default).toBe(false); + expect(YES_FLAG.brief).toBeString(); + }); + + test("FORCE_FLAG has correct shape", () => { + expect(FORCE_FLAG.kind).toBe("boolean"); + expect(FORCE_FLAG.default).toBe(false); + expect(FORCE_FLAG.brief).toBeString(); + }); + + test("DESTRUCTIVE_FLAGS bundles all three flags", () => { + expect(DESTRUCTIVE_FLAGS.yes).toBe(YES_FLAG); + expect(DESTRUCTIVE_FLAGS.force).toBe(FORCE_FLAG); + expect(DESTRUCTIVE_FLAGS["dry-run"]).toBe(DRY_RUN_FLAG); + }); + + test("DESTRUCTIVE_ALIASES maps correct shorthand keys", () => { + expect(DESTRUCTIVE_ALIASES.y).toBe("yes"); + expect(DESTRUCTIVE_ALIASES.f).toBe("force"); + expect(DESTRUCTIVE_ALIASES.n).toBe("dry-run"); + }); + + test("DRY_RUN_ALIASES maps -n to dry-run", () => { + expect(DRY_RUN_ALIASES.n).toBe("dry-run"); + }); +}); + +// --------------------------------------------------------------------------- +// Level B: isConfirmationBypassed +// --------------------------------------------------------------------------- + +describe("isConfirmationBypassed", () => { + test("returns false when both yes and force are false", () => { + expect(isConfirmationBypassed({ yes: false, force: false })).toBe(false); + }); + + test("returns false when both are undefined", () => { + expect(isConfirmationBypassed({})).toBe(false); + }); + + test("returns true when yes is true", () => { + expect(isConfirmationBypassed({ yes: true })).toBe(true); + }); + + test("returns true when force is true", () => { + expect(isConfirmationBypassed({ force: true })).toBe(true); + }); + + test("returns true when both are true", () => { + expect(isConfirmationBypassed({ yes: true, force: true })).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Level B: guardNonInteractive +// --------------------------------------------------------------------------- + +describe("guardNonInteractive", () => { + // Note: These tests validate the logic paths. The isatty(0) check can't + // be easily tested without mocking node:tty, but the bypass paths can. + + test("does not throw when dry-run is set", () => { + // Even if we're not in a TTY, dry-run should pass + expect(() => + guardNonInteractive({ + yes: false, + force: false, + "dry-run": true, + }) + ).not.toThrow(); + }); + + test("does not throw when yes is set", () => { + expect(() => + guardNonInteractive({ + yes: true, + force: false, + "dry-run": false, + }) + ).not.toThrow(); + }); + + test("does not throw when force is set", () => { + expect(() => + guardNonInteractive({ + yes: false, + force: true, + "dry-run": false, + }) + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Level B: requireExplicitTarget +// --------------------------------------------------------------------------- + +describe("requireExplicitTarget", () => { + test("throws ContextError for auto-detect type", () => { + expect(() => + requireExplicitTarget( + { type: "auto-detect" }, + "Project target", + "sentry project delete /" + ) + ).toThrow("Project target"); + }); + + test("does not throw for explicit type", () => { + expect(() => + requireExplicitTarget( + { type: "explicit", org: "acme", project: "my-app" }, + "Project target", + "sentry project delete /" + ) + ).not.toThrow(); + }); + + test("does not throw for project-search type", () => { + expect(() => + requireExplicitTarget( + { type: "project-search", projectSlug: "my-app" }, + "Project target", + "sentry project delete /" + ) + ).not.toThrow(); + }); + + test("does not throw for org-all type", () => { + expect(() => + requireExplicitTarget( + { type: "org-all", org: "acme" }, + "Project target", + "sentry project delete /" + ) + ).not.toThrow(); + }); + + test("error message includes the auto-detection note", () => { + try { + requireExplicitTarget( + { type: "auto-detect" }, + "Project target", + "sentry project delete /" + ); + expect.unreachable("should have thrown"); + } catch (error) { + expect((error as Error).message).toContain( + "Auto-detection is disabled for destructive operations" + ); + } + }); +}); diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts new file mode 100644 index 000000000..4d32db281 --- /dev/null +++ b/packages/cli/test/lib/oauth.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for `resolveOAuthScopeString` — the helper that maps `auth login` + * scope-selection flags (--read-only / --scope) to the space-joined scope + * string sent in the OAuth device-code request. + * + * Core invariants (default = full set, read-only subset, explicit-scope + * round-trips) are covered with property-based tests; the remaining cases + * document specific error behavior and edge cases. + */ + +import { + constantFrom, + assert as fcAssert, + property, + uniqueArray, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { SENTRY_SCOPES } from "../../src/lib/api-scope.js"; +import { ValidationError } from "../../src/lib/errors.js"; +import { OAUTH_SCOPES, resolveOAuthScopeString } from "../../src/lib/oauth.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const knownScopeArb = constantFrom(...SENTRY_SCOPES); + +describe("resolveOAuthScopeString", () => { + test("default scopes include Team Admin for project creation", () => { + expect(OAUTH_SCOPES).toContain("team:admin"); + }); + + test("default (no selection) returns the full OAUTH_SCOPES set", () => { + expect(resolveOAuthScopeString()).toBe(OAUTH_SCOPES.join(" ")); + expect(resolveOAuthScopeString({})).toBe(OAUTH_SCOPES.join(" ")); + }); + + test("readOnly returns only :read scopes from OAUTH_SCOPES", () => { + const result = resolveOAuthScopeString({ readOnly: true }).split(" "); + expect(result.length).toBeGreaterThan(0); + for (const scope of result) { + expect(scope.endsWith(":read")).toBe(true); + } + // Every read scope the CLI requests is present. + const expected = OAUTH_SCOPES.filter((s) => s.endsWith(":read")); + expect(result).toEqual(expected); + }); + + test("readOnly never includes write or admin scopes", () => { + const result = resolveOAuthScopeString({ readOnly: true }); + expect(result).not.toContain(":write"); + expect(result).not.toContain(":admin"); + }); + + test("readOnly is ignored when explicit scopes are provided", () => { + // `scopes` takes precedence over `readOnly`. + expect( + resolveOAuthScopeString({ readOnly: true, scopes: ["project:write"] }) + ).toBe("project:write"); + }); + + test("explicit scopes preserve first-seen order and lowercase", () => { + expect( + resolveOAuthScopeString({ scopes: ["ORG:READ", "project:read"] }) + ).toBe("org:read project:read"); + }); + + test("explicit scopes are de-duplicated", () => { + expect( + resolveOAuthScopeString({ + scopes: ["org:read", "project:read", "org:read"], + }) + ).toBe("org:read project:read"); + }); + + test("blank entries are skipped", () => { + expect(resolveOAuthScopeString({ scopes: [" ", "org:read", ""] })).toBe( + "org:read" + ); + }); + + test("throws ValidationError with field 'scope' for unknown scope", () => { + try { + resolveOAuthScopeString({ scopes: ["not:a:scope"] }); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).field).toBe("scope"); + expect((error as ValidationError).message).toContain("not:a:scope"); + } + }); + + test("throws ValidationError when scopes resolve to empty", () => { + expect(() => resolveOAuthScopeString({ scopes: [] })).toThrow( + ValidationError + ); + expect(() => resolveOAuthScopeString({ scopes: ["", " "] })).toThrow( + ValidationError + ); + }); +}); + +describe("property: resolveOAuthScopeString", () => { + test("any subset of SENTRY_SCOPES round-trips to its space-joined form", () => { + fcAssert( + property(uniqueArray(knownScopeArb, { minLength: 1 }), (scopes) => { + const result = resolveOAuthScopeString({ scopes }); + // Order preserved, lowercase, space-joined. + expect(result).toBe(scopes.map((s) => s.toLowerCase()).join(" ")); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("any single known scope is accepted", () => { + fcAssert( + property(knownScopeArb, (scope) => { + expect(resolveOAuthScopeString({ scopes: [scope] })).toBe(scope); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/objectstore.test.ts b/packages/cli/test/lib/objectstore.test.ts new file mode 100644 index 000000000..b401eb783 --- /dev/null +++ b/packages/cli/test/lib/objectstore.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the minimal Objectstore HTTP client. + * + * `customFetch` is mocked so URL construction, the `x-os-auth` header, HEAD + * existence semantics, and PUT request shape can be verified without a network. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ApiError } from "../../src/lib/errors.js"; + +const { customFetchMock } = vi.hoisted(() => ({ customFetchMock: vi.fn() })); +vi.mock("../../src/lib/custom-ca.js", () => ({ customFetch: customFetchMock })); + +import { + buildObjectUrl, + type ObjectstoreConfig, + objectExists, + putObject, +} from "../../src/lib/objectstore.js"; + +const config: ObjectstoreConfig = { + url: "https://objectstore.example.com/", + usecase: "preprod_snapshots", + scopes: [ + ["org", "123"], + ["project", "456"], + ], + authToken: "jwt-token", + expirationPolicy: "ttl:30d", +}; + +beforeEach(() => { + customFetchMock.mockReset(); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildObjectUrl", () => { + test("joins usecase, scope, and key (stripping a trailing slash)", () => { + expect( + buildObjectUrl({ ...config, usecase: "preprod" }, "123/456/abc") + ).toBe( + "https://objectstore.example.com/v1/objects/preprod/org=123;project=456/123/456/abc" + ); + }); +}); + +describe("objectExists", () => { + test("returns true on a 2xx HEAD and sends the auth header", async () => { + customFetchMock.mockResolvedValue({ ok: true, status: 200 }); + expect(await objectExists(config, "123/456/abc")).toBe(true); + const [url, init] = customFetchMock.mock.calls[0] ?? []; + expect(url).toContain( + "/v1/objects/preprod_snapshots/org=123;project=456/123/456/abc" + ); + expect(init.method).toBe("HEAD"); + expect(init.headers["x-os-auth"]).toBe("Bearer jwt-token"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + test("returns false on a 404", async () => { + customFetchMock.mockResolvedValue({ ok: false, status: 404 }); + expect(await objectExists(config, "123/456/abc")).toBe(false); + }); + + test("throws on other non-2xx responses", async () => { + customFetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: "err", + }); + await expect(objectExists(config, "123/456/abc")).rejects.toThrow(ApiError); + }); + + test("omits the auth header when no token is configured", async () => { + customFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await objectExists({ ...config, authToken: null }, "k"); + const [, init] = customFetchMock.mock.calls[0] ?? []; + expect(init.headers["x-os-auth"]).toBeUndefined(); + }); +}); + +describe("putObject", () => { + test("PUTs the body with auth + expiration headers", async () => { + customFetchMock.mockResolvedValue({ ok: true, status: 200 }); + const body = new Uint8Array([1, 2, 3]); + await putObject(config, "123/456/abc", body); + + const [url, init] = customFetchMock.mock.calls[0] ?? []; + expect(url).toBe( + "https://objectstore.example.com/v1/objects/preprod_snapshots/org=123;project=456/123/456/abc" + ); + expect(init.method).toBe("PUT"); + expect(init.headers["x-os-auth"]).toBe("Bearer jwt-token"); + expect(init.headers["x-sn-expiration"]).toBe("ttl:30d"); + expect(init.body).toBe(body); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + test("throws on a non-2xx response", async () => { + customFetchMock.mockResolvedValue({ + ok: false, + status: 413, + statusText: "too large", + }); + await expect(putObject(config, "k", new Uint8Array([0]))).rejects.toThrow( + ApiError + ); + }); +}); diff --git a/packages/cli/test/lib/org-list.test.ts b/packages/cli/test/lib/org-list.test.ts new file mode 100644 index 000000000..da36c408b --- /dev/null +++ b/packages/cli/test/lib/org-list.test.ts @@ -0,0 +1,1225 @@ +/** + * Tests for the shared org-scoped list infrastructure. + * + * Covers: fetchOrgSafe, fetchAllOrgs, handleOrgAll, handleAutoDetect, + * handleExplicitOrg, handleExplicitProject, handleProjectSearch, + * dispatchOrgScopedList (with and without overrides, metadata-only config). + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../src/lib/api-client.js"; + +vi.mock("../../src/lib/db/defaults.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as defaults from "../../src/lib/db/defaults.js"; + +vi.mock("../../src/lib/db/pagination.js"); + +// biome-ignore lint/performance/noNamespaceImport: needed for vi.mocked access +import * as paginationDb from "../../src/lib/db/pagination.js"; + +vi.mock("../../src/lib/db/regions.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +import { + AuthError, + ResolutionError, + ValidationError, +} from "../../src/lib/errors.js"; +import { + dispatchOrgScopedList, + fetchAllOrgs, + fetchOrgSafe, + handleExplicitOrg, + handleExplicitProject, + handleOrgAll, + handleProjectSearch, + isOrgListConfig, + type ListCommandMeta, + type ListResult, + type OrgListConfig, +} from "../../src/lib/org-list.js"; + +vi.mock("../../src/lib/polling.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../src/lib/polling.js"; + +vi.mock("../../src/lib/region.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as region from "../../src/lib/region.js"; + +vi.mock("../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../src/lib/resolve-target.js"; + +/** + * Bypass the withProgress spinner in all tests — prevents real stderr + * timers from piling up during full-suite runs and causing 5s timeouts. + */ +let withProgressSpy: ReturnType; +beforeEach(() => { + withProgressSpy = vi + .spyOn(polling, "withProgress") + .mockImplementation((_opts, fn) => + fn(() => { + /* no-op setMessage */ + }) + ); +}); +afterEach(() => { + withProgressSpy.mockRestore(); +}); + +type FakeEntity = { id: string; name: string }; +type FakeWithOrg = FakeEntity & { orgSlug: string }; + +function makeConfig( + overrides?: Partial> +): OrgListConfig { + return { + paginationKey: "test-list", + entityPlural: "widgets", + commandPrefix: "sentry widget list", + listForOrg: vi.fn(() => Promise.resolve([])), + listPaginated: vi.fn(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ), + withOrg: (entity, orgSlug) => ({ ...entity, orgSlug }), + displayTable: vi.fn(() => ""), + ...overrides, + }; +} + +const META_ONLY: ListCommandMeta = { + paginationKey: "meta-list", + entityPlural: "things", + commandPrefix: "sentry thing list", +}; + +function createStdout() { + const write = vi.fn((_chunk: string) => true); + return { writer: { write }, write }; +} + +// --------------------------------------------------------------------------- +// isOrgListConfig +// --------------------------------------------------------------------------- + +describe("isOrgListConfig", () => { + test("returns true for full OrgListConfig", () => { + expect(isOrgListConfig(makeConfig())).toBe(true); + }); + + test("returns false for ListCommandMeta only", () => { + expect(isOrgListConfig(META_ONLY)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// fetchOrgSafe +// --------------------------------------------------------------------------- + +describe("fetchOrgSafe", () => { + test("returns entities with org context on success", async () => { + const items: FakeEntity[] = [ + { id: "1", name: "A" }, + { id: "2", name: "B" }, + ]; + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.resolve(items)), + }); + const result = await fetchOrgSafe(config, "my-org"); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ id: "1", name: "A", orgSlug: "my-org" }); + expect(result[1]).toEqual({ id: "2", name: "B", orgSlug: "my-org" }); + }); + + test("returns empty array on non-auth error", async () => { + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.reject(new Error("network"))), + }); + const result = await fetchOrgSafe(config, "my-org"); + expect(result).toEqual([]); + }); + + test("rethrows AuthError", async () => { + const config = makeConfig({ + listForOrg: vi.fn(() => + Promise.reject(new AuthError("not_authenticated")) + ), + }); + await expect(fetchOrgSafe(config, "my-org")).rejects.toThrow(AuthError); + }); +}); + +// --------------------------------------------------------------------------- +// fetchAllOrgs +// --------------------------------------------------------------------------- + +describe("fetchAllOrgs", () => { + let listOrganizationsSpy: ReturnType; + + beforeEach(() => { + listOrganizationsSpy = vi.spyOn(apiClient, "listOrganizations"); + }); + + afterEach(() => { + listOrganizationsSpy.mockRestore(); + }); + + test("fetches entities from all accessible orgs", async () => { + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + + const items: FakeEntity[] = [{ id: "1", name: "Widget" }]; + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.resolve(items)), + }); + + const result = await fetchAllOrgs(config); + expect(result).toHaveLength(2); + expect(result[0]!.orgSlug).toBe("org-a"); + expect(result[1]!.orgSlug).toBe("org-b"); + }); + + test("skips orgs with non-auth errors", async () => { + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + + let callCount = 0; + const config = makeConfig({ + listForOrg: vi.fn(() => { + callCount += 1; + if (callCount === 1) return Promise.reject(new Error("forbidden")); + return Promise.resolve([{ id: "1", name: "Widget" }]); + }), + }); + + const result = await fetchAllOrgs(config); + expect(result).toHaveLength(1); + expect(result[0]!.orgSlug).toBe("org-b"); + }); + + test("rethrows AuthError from any org", async () => { + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + ]); + + const config = makeConfig({ + listForOrg: vi.fn(() => + Promise.reject(new AuthError("not_authenticated")) + ), + }); + + await expect(fetchAllOrgs(config)).rejects.toThrow(AuthError); + }); +}); + +// --------------------------------------------------------------------------- +// handleOrgAll +// --------------------------------------------------------------------------- + +describe("handleOrgAll", () => { + const advancePaginationStateSpy = vi.mocked( + paginationDb.advancePaginationState + ); + const hasPreviousPageSpy = vi.mocked(paginationDb.hasPreviousPage); + + beforeEach(() => { + advancePaginationStateSpy.mockReturnValue(undefined); + hasPreviousPageSpy.mockReturnValue(false); + }); + + afterEach(() => { + advancePaginationStateSpy.mockReset(); + hasPreviousPageSpy.mockReset(); + }); + + test("returns ListResult with hasMore=true and nextCursor", async () => { + const items: FakeEntity[] = [{ id: "1", name: "A" }]; + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ data: items, nextCursor: "next:123" }) + ), + }); + + const result = await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 10, json: true }, + contextKey: "key", + cursor: undefined, + direction: "next", + }); + + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe("next:123"); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.orgSlug).toBe("my-org"); + }); + + test("caps perPage at API_MAX_PER_PAGE when limit exceeds it", async () => { + const listPaginated = vi.fn(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ); + const config = makeConfig({ listPaginated }); + + await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 200, json: true }, + contextKey: "key", + cursor: undefined, + direction: "next", + }); + + expect(listPaginated).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ perPage: 100 }) + ); + }); + + test("passes limit through as perPage when below API_MAX_PER_PAGE", async () => { + const listPaginated = vi.fn(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ); + const config = makeConfig({ listPaginated }); + + await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 25, json: true }, + contextKey: "key", + cursor: undefined, + direction: "next", + }); + + expect(listPaginated).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ perPage: 25 }) + ); + }); + + test("returns ListResult with hasMore=false when no nextCursor", async () => { + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ + data: [{ id: "1", name: "A" }], + nextCursor: undefined, + }) + ), + }); + + const result = await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 10, json: true }, + contextKey: "key", + cursor: undefined, + direction: "next", + }); + + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + expect(result.items).toHaveLength(1); + }); + + test("returns hint with 'no entities found' when empty", async () => { + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ), + }); + + const result = await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "key", + cursor: undefined, + direction: "next", + }); + + expect(result.items).toHaveLength(0); + expect(result.hint).toContain("No widgets found in organization 'my-org'."); + }); + + test("returns hint with next page info when more available", async () => { + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ data: [{ id: "1", name: "A" }], nextCursor: "x" }) + ), + }); + + const result = await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "key", + cursor: undefined, + direction: "next", + }); + + expect(result.header).toContain("more available"); + expect(result.header).toContain("sentry widget list my-org/ -c next"); + }); + + test("calls advancePaginationState when nextCursor present", async () => { + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ + data: [{ id: "1", name: "A" }], + nextCursor: "cursor:abc", + }) + ), + }); + + await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "ctx", + cursor: undefined, + direction: "next", + }); + + expect(advancePaginationStateSpy).toHaveBeenCalledWith( + "test-list", + "ctx", + "next", + "cursor:abc" + ); + }); + + test("calls advancePaginationState with undefined when no nextCursor", async () => { + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ + data: [{ id: "1", name: "A" }], + nextCursor: undefined, + }) + ), + }); + + await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "ctx", + cursor: undefined, + direction: "next", + }); + + expect(advancePaginationStateSpy).toHaveBeenCalledWith( + "test-list", + "ctx", + "next", + undefined + ); + }); + + test("auto-paginates when limit exceeds API_MAX_PER_PAGE and never requests a larger page", async () => { + const listPaginated = vi.fn( + (_org: string, opts: { cursor?: string; perPage: number }) => { + expect(opts.perPage).toBeLessThanOrEqual(100); + const offset = opts.cursor ? Number(opts.cursor) : 0; + const data = Array.from({ length: opts.perPage }, (_, i) => ({ + id: String(offset + i), + name: `W${offset + i}`, + })); + return Promise.resolve({ + data, + nextCursor: String(offset + opts.perPage), + }); + } + ); + const config = makeConfig({ listPaginated }); + + const result = await handleOrgAll({ + config, + org: "my-org", + flags: { limit: 250, json: true }, + contextKey: "key", + cursor: undefined, + direction: "next", + }); + + expect(result.items).toHaveLength(250); + expect(listPaginated).toHaveBeenCalledTimes(3); + // Each request uses only the remaining item budget, capped at API_MAX_PER_PAGE, + // so the final page requests only the remaining 50 items (250 - 100 - 100). + expect(listPaginated.mock.calls.map((call) => call[1].perPage)).toEqual([ + 100, 100, 50, + ]); + // The mock always returns a nextCursor, so hasMore is true and + // the cursor points to the first item beyond the fetched 250. + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe("250"); + }); +}); + +// --------------------------------------------------------------------------- +// handleExplicitOrg +// --------------------------------------------------------------------------- + +describe("handleExplicitOrg", () => { + test("returns items with org context", async () => { + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + + const result = await handleExplicitOrg({ + config, + org: "my-org", + flags: { limit: 10, json: true }, + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]!.orgSlug).toBe("my-org"); + }); + + test("includes org-scoped note in header when noteOrgScoped=true", async () => { + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + + const result = await handleExplicitOrg({ + config, + org: "my-org", + flags: { limit: 10, json: false }, + noteOrgScoped: true, + }); + + expect(result.header).toContain("widgets are org-scoped"); + expect(result.header).toContain("my-org"); + }); + + test("does not include org-scoped note when noteOrgScoped=false (default)", async () => { + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + + const result = await handleExplicitOrg({ + config, + org: "my-org", + flags: { limit: 10, json: false }, + }); + + expect(result.header ?? "").not.toContain("org-scoped"); + }); + + test("header includes org-scoped note even in JSON mode (rendering decision is caller's)", async () => { + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + + const result = await handleExplicitOrg({ + config, + org: "my-org", + flags: { limit: 10, json: true }, + noteOrgScoped: true, + }); + + // Header is always populated; caller suppresses it in JSON mode + expect(result.items).toHaveLength(1); + expect(result.header).toContain("org-scoped"); + }); +}); + +// --------------------------------------------------------------------------- +// handleExplicitProject +// --------------------------------------------------------------------------- + +describe("handleExplicitProject", () => { + test("fetches and returns project-scoped entities", async () => { + const listForProject = vi.fn(() => + Promise.resolve([{ id: "1", name: "Team A" }]) + ); + const config = makeConfig({ listForProject }); + + const result = await handleExplicitProject({ + config, + org: "my-org", + project: "my-proj", + flags: { limit: 10, json: true }, + }); + + expect(listForProject).toHaveBeenCalledWith("my-org", "my-proj"); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.orgSlug).toBe("my-org"); + }); + + test("throws when listForProject is not defined on config", async () => { + const config = makeConfig(); // no listForProject + + await expect( + handleExplicitProject({ + config, + org: "my-org", + project: "my-proj", + flags: { limit: 10, json: false }, + }) + ).rejects.toThrow("listForProject is not defined"); + }); + + test("returns hint with 'no entities found' when project has none", async () => { + const config = makeConfig({ + listForProject: vi.fn(() => Promise.resolve([])), + }); + + const result = await handleExplicitProject({ + config, + org: "my-org", + project: "my-proj", + flags: { limit: 10, json: false }, + }); + + expect(result.items).toHaveLength(0); + expect(result.hint).toContain("No widgets found"); + expect(result.hint).toContain("my-org/my-proj"); + }); +}); + +// --------------------------------------------------------------------------- +// handleProjectSearch +// --------------------------------------------------------------------------- + +describe("handleProjectSearch", () => { + let findProjectsBySlugSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + }); + + test("throws ResolutionError when no project found", async () => { + findProjectsBySlugSpy.mockResolvedValue({ projects: [], orgs: [] }); + const config = makeConfig(); + + await expect( + handleProjectSearch(config, "no-such-project", { + flags: { limit: 10, json: false }, + }) + ).rejects.toThrow(ResolutionError); + }); + + test("returns empty items when no project found with --json", async () => { + findProjectsBySlugSpy.mockResolvedValue({ projects: [], orgs: [] }); + const config = makeConfig(); + + const result = await handleProjectSearch(config, "no-such-project", { + flags: { limit: 10, json: true }, + }); + + expect(result.items).toEqual([]); + }); + + test("with listForProject: fetches project-scoped entities", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + ], + orgs: [], + }); + const listForProject = vi.fn(() => + Promise.resolve([{ id: "1", name: "Team A" }]) + ); + const config = makeConfig({ listForProject }); + + const result = await handleProjectSearch(config, "my-proj", { + flags: { limit: 10, json: true }, + }); + + expect(listForProject).toHaveBeenCalledWith("org-a", "my-proj"); + expect(result.items[0]!.orgSlug).toBe("org-a"); + }); + + test("reuses fuzzy project data without a second slug search", async () => { + const listForProject = vi.fn(() => Promise.resolve([])); + const config = makeConfig({ listForProject }); + + const result = await handleProjectSearch(config, "app-front", { + flags: { limit: 10, json: false }, + projectSearchResolution: { + kind: "fuzzy-project", + org: "org-a", + project: "app-frontend", + projectData: { + id: "1", + slug: "app-frontend", + name: "App Frontend", + orgSlug: "org-a", + }, + displaySlug: "app-front", + scopedOrg: undefined, + }, + }); + + expect(findProjectsBySlugSpy).not.toHaveBeenCalled(); + expect(listForProject).toHaveBeenCalledWith("org-a", "app-frontend"); + expect(result.hint).toContain("app-frontend"); + }); + + test("without listForProject: fetches from parent org (entity is org-scoped)", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + ], + orgs: [], + }); + const listForOrg = vi.fn(() => + Promise.resolve([{ id: "1", name: "Repo A" }]) + ); + const config = makeConfig({ listForOrg }); + + const result = await handleProjectSearch(config, "my-proj", { + flags: { limit: 10, json: true }, + }); + + expect(listForOrg).toHaveBeenCalledWith("org-a"); + expect(result.items[0]!.orgSlug).toBe("org-a"); + }); + + test("deduplicates orgs when multiple projects share one org", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-a", slug: "proj-1", id: "1", name: "Proj 1" }, + { orgSlug: "org-a", slug: "proj-2", id: "2", name: "Proj 2" }, + ], + orgs: [], + }); + const listForOrg = vi.fn(() => + Promise.resolve([{ id: "1", name: "Repo A" }]) + ); + const config = makeConfig({ listForOrg }); + + await handleProjectSearch(config, "proj", { + flags: { limit: 10, json: true }, + }); + + // org-a should only be fetched once + expect(listForOrg).toHaveBeenCalledTimes(1); + }); + + test("calls orgAllFallback when slug matches an org and fallback provided", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme-corp", name: "Acme Corp" }], + }); + const config = makeConfig(); + const fallback = vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ); + + await handleProjectSearch(config, "acme-corp", { + flags: { limit: 10, json: false }, + orgAllFallback: fallback, + }); + + expect(fallback).toHaveBeenCalledWith("acme-corp"); + }); + + test("throws ResolutionError when slug matches an org but no fallback", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme-corp", name: "Acme Corp" }], + }); + const config = makeConfig(); + + await expect( + handleProjectSearch(config, "acme-corp", { + flags: { limit: 10, json: false }, + }) + ).rejects.toThrow(ResolutionError); + }); + + test("includes multi-org note in hint when project found in multiple orgs", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + { orgSlug: "org-b", slug: "my-proj", id: "2", name: "My Project" }, + ], + orgs: [], + }); + const config = makeConfig({ + listForOrg: vi.fn(() => Promise.resolve([{ id: "1", name: "Widget" }])), + }); + + const result = await handleProjectSearch(config, "my-proj", { + flags: { limit: 10, json: false }, + }); + + expect(result.hint).toContain("2 organizations"); + }); + + test("scopes matches to the explicit org, ignoring a same-slug project in another org", async () => { + // findProjectsBySlug fans out across orgs and finds the slug in BOTH + // org-a (the requested scope) and org-b. With an explicit org the result + // must only fetch from org-a — never leak org-b. + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-b", slug: "my-proj", id: "2", name: "My Project" }, + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + ], + orgs: [ + { slug: "org-a", name: "Org A" }, + { slug: "org-b", name: "Org B" }, + ], + }); + const listForOrg = vi.fn(() => + Promise.resolve([{ id: "1", name: "Widget" }]) + ); + const config = makeConfig({ listForOrg }); + + const result = await handleProjectSearch(config, "my-proj", { + flags: { limit: 10, json: false }, + org: "org-a", + }); + + expect(listForOrg).toHaveBeenCalledTimes(1); + expect(listForOrg).toHaveBeenCalledWith("org-a"); + expect(result.items.every((i) => i.orgSlug === "org-a")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// dispatchOrgScopedList — cursor validation and handler map pattern +// --------------------------------------------------------------------------- + +describe("dispatchOrgScopedList", () => { + let getDefaultOrganizationSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + let advancePaginationStateSpy: ReturnType; + let hasPreviousPageSpy: ReturnType; + let resolveCursorSpy: ReturnType; + let resolveEffectiveOrgSpy: ReturnType; + + beforeEach(() => { + getDefaultOrganizationSpy = vi.spyOn(defaults, "getDefaultOrganization"); + resolveAllTargetsSpy = vi.spyOn(resolveTarget, "resolveAllTargets"); + advancePaginationStateSpy = vi + .spyOn(paginationDb, "advancePaginationState") + .mockReturnValue(undefined); + hasPreviousPageSpy = vi + .spyOn(paginationDb, "hasPreviousPage") + .mockReturnValue(false); + resolveCursorSpy = vi.spyOn(paginationDb, "resolveCursor").mockReturnValue({ + cursor: undefined, + direction: "next" as const, + }); + // Prevent resolveEffectiveOrg from making real HTTP calls during + // full-suite runs where earlier tests may leave auth state behind. + resolveEffectiveOrgSpy = vi + .spyOn(region, "resolveEffectiveOrg") + .mockImplementation((org: string) => Promise.resolve(org)); + + getDefaultOrganizationSpy.mockReturnValue(null); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + }); + + afterEach(() => { + getDefaultOrganizationSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + advancePaginationStateSpy.mockRestore(); + hasPreviousPageSpy.mockRestore(); + resolveCursorSpy.mockRestore(); + resolveEffectiveOrgSpy.mockRestore(); + }); + + test("throws ValidationError when --cursor used outside org-all mode", async () => { + const config = makeConfig(); + const { writer } = createStdout(); + + await expect( + dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false, cursor: "some-cursor" }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, + }) + ).rejects.toThrow(ValidationError); + }); + + test("error message includes entity plural name", async () => { + const config = makeConfig(); + const { writer } = createStdout(); + + try { + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false, cursor: "x" }, + parsed: { type: "auto-detect" }, + }); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + expect((e as ValidationError).message).toContain("/"); + } + }); + + test("delegates to handleOrgAll for org-all mode and returns ListResult", async () => { + const items: FakeEntity[] = [{ id: "1", name: "A" }]; + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ data: items, nextCursor: undefined }) + ), + }); + const { writer } = createStdout(); + + const result = await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "org-all", org: "my-org" }, + }); + + expect(result.hasMore).toBe(false); + expect(result.items).toHaveLength(1); + }); + + test("explicit mode uses listForProject when available", async () => { + const listForProject = vi.fn(() => + Promise.resolve([{ id: "1", name: "T" }]) + ); + const config = makeConfig({ listForProject }); + const { writer } = createStdout(); + + const result = await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, + }); + + expect(listForProject).toHaveBeenCalledWith("my-org", "my-proj"); + expect(result.items).toHaveLength(1); + expect(result.items[0].orgSlug).toBe("my-org"); + }); + + test("explicit mode falls back to org-scoped with note when no listForProject", async () => { + const listForOrg = vi.fn(() => Promise.resolve([{ id: "1", name: "R" }])); + const config = makeConfig({ listForOrg }); // no listForProject + const { writer } = createStdout(); + + const result = await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, + }); + + expect(listForOrg).toHaveBeenCalledWith("my-org"); + expect(result.header).toContain("org-scoped"); + }); + + test("override replaces default handler for that mode", async () => { + const config = makeConfig(); + const { writer } = createStdout(); + const overrideCalled = vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ); + + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "auto-detect" }, + overrides: { + "auto-detect": overrideCalled, + }, + }); + + expect(overrideCalled).toHaveBeenCalledTimes(1); + }); + + test("override does not affect other modes", async () => { + const items: FakeEntity[] = [{ id: "1", name: "A" }]; + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ data: items, nextCursor: undefined }) + ), + }); + const { writer } = createStdout(); + const autoDetectOverride = vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ); + + const result = await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "org-all", org: "my-org" }, + overrides: { + "auto-detect": autoDetectOverride, // overrides auto-detect, not org-all + }, + }); + + // org-all default handler ran, not the auto-detect override + expect(autoDetectOverride).not.toHaveBeenCalled(); + expect(result.hasMore).toBe(false); + }); + + test("metadata-only config with full overrides dispatches correctly", async () => { + const { writer } = createStdout(); + const handler = vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ); + + await dispatchOrgScopedList({ + config: META_ONLY, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, + overrides: { + "auto-detect": handler, + explicit: handler, + "project-search": handler, + "org-all": handler, + }, + }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + test("metadata-only config without override for invoked mode throws", async () => { + const { writer } = createStdout(); + + await expect( + dispatchOrgScopedList({ + config: META_ONLY, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "auto-detect" }, + overrides: { + // missing auto-detect override — should throw + explicit: vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ), + }, + }) + ).rejects.toThrow("No handler for 'auto-detect' mode"); + }); + + // ------------------------------------------------------------------------- + // Project-search pre-check + // ------------------------------------------------------------------------- + + describe("project-search pre-check", () => { + const findProjectsBySlugMock = vi.mocked(apiClient.findProjectsBySlug); + + beforeEach(() => { + findProjectsBySlugMock.mockReset(); + }); + + afterEach(() => { + findProjectsBySlugMock.mockReset(); + }); + + test("redirects to org-all when no project matches an organization slug", async () => { + findProjectsBySlugMock.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme-corp", id: "1", name: "Acme Corp" }], + }); + + const items: FakeEntity[] = [{ id: "1", name: "Widget A" }]; + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.resolve({ data: items, nextCursor: undefined }) + ), + }); + + const result = await dispatchOrgScopedList({ + config, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "project-search", projectSlug: "acme-corp" }, + }); + + expect(config.listPaginated).toHaveBeenCalled(); + expect(result.items).toHaveLength(1); + expect(result.items[0].orgSlug).toBe("acme-corp"); + expect(findProjectsBySlugMock).toHaveBeenCalledTimes(1); + }); + + test("keeps the project when an organization has the same slug", async () => { + findProjectsBySlugMock.mockResolvedValue({ + projects: [ + { + id: "9", + slug: "acme-corp", + name: "Acme Project", + orgSlug: "other-org", + }, + ], + orgs: [ + { slug: "acme-corp", id: "1", name: "Acme Corp" }, + { slug: "other-org", id: "2", name: "Other Org" }, + ], + }); + + const projectHandler = vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ); + const orgHandler = vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ); + + await dispatchOrgScopedList({ + config: META_ONLY, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "project-search", projectSlug: "acme-corp" }, + overrides: { + "auto-detect": projectHandler, + explicit: projectHandler, + "project-search": projectHandler, + "org-all": orgHandler, + }, + }); + + expect(projectHandler).toHaveBeenCalledTimes(1); + expect(orgHandler).not.toHaveBeenCalled(); + expect(findProjectsBySlugMock).toHaveBeenCalledTimes(1); + expect(projectHandler).toHaveBeenCalledWith( + expect.objectContaining({ + projectSearchResolution: expect.objectContaining({ + kind: "projects", + projects: [ + expect.objectContaining({ + slug: "acme-corp", + orgSlug: "other-org", + }), + ], + }), + }) + ); + }); + + test("rejects an invalid cursor before fuzzy project fan-out", async () => { + findProjectsBySlugMock.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme", id: "1", name: "Acme" }], + }); + const listProjectsMock = vi.mocked(apiClient.listProjects); + listProjectsMock.mockClear(); + + await expect( + dispatchOrgScopedList({ + config: makeConfig(), + cwd: "/tmp", + flags: { limit: 10, json: false, cursor: "next" }, + parsed: { type: "project-search", projectSlug: "missing" }, + }) + ).rejects.toThrow(ValidationError); + + expect(findProjectsBySlugMock).toHaveBeenCalledTimes(1); + expect(listProjectsMock).not.toHaveBeenCalled(); + }); + + test("falls through when the slug matches neither a project nor an organization", async () => { + findProjectsBySlugMock.mockResolvedValue({ + projects: [], + orgs: [{ slug: "other-org", id: "2", name: "Other Org" }], + }); + + const handler = vi.fn(() => + Promise.resolve({ items: [] } as ListResult) + ); + + await dispatchOrgScopedList({ + config: META_ONLY, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "project-search", projectSlug: "acme-corp" }, + overrides: { + "auto-detect": handler, + explicit: handler, + "project-search": handler, + "org-all": handler, + }, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0]?.[0].parsed.type).toBe("project-search"); + }); + }); +}); diff --git a/packages/cli/test/lib/parse-bool.property.test.ts b/packages/cli/test/lib/parse-bool.property.test.ts new file mode 100644 index 000000000..515fcce42 --- /dev/null +++ b/packages/cli/test/lib/parse-bool.property.test.ts @@ -0,0 +1,150 @@ +/** + * Property-based tests for parseBoolValue. + * + * Verifies that the boolean parser correctly handles all recognized + * true/false values, arbitrary casing, whitespace padding, and + * returns null for unrecognized input. + */ + +import { + constantFrom, + assert as fcAssert, + property, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { parseBoolValue } from "../../src/lib/parse-bool.js"; + +const DEFAULT_NUM_RUNS = 50; + +/** Known true string values (canonical lowercase) */ +const TRUTHY_STRINGS = ["true", "t", "y", "yes", "on", "1"] as const; + +/** Known false string values (canonical lowercase) */ +const FALSY_STRINGS = ["false", "f", "n", "no", "off", "0"] as const; + +/** Arbitrary that generates a known truthy value */ +const truthyArb = constantFrom(...TRUTHY_STRINGS); + +/** Arbitrary that generates a known falsy value */ +const falsyArb = constantFrom(...FALSY_STRINGS); + +/** + * Arbitrary that randomizes case of each character in a string. + * E.g., "yes" → "yEs", "YES", "Yes", etc. + */ +function randomCase(input: string): string { + return input + .split("") + .map((c) => (Math.random() > 0.5 ? c.toUpperCase() : c.toLowerCase())) + .join(""); +} + +/** Arbitrary for whitespace padding (spaces and tabs only) */ +const whitespaceArb = constantFrom("", " ", " ", "\t", " \t "); + +describe("property: parseBoolValue", () => { + test("all known truthy values → true", () => { + fcAssert( + property(truthyArb, (input) => { + expect(parseBoolValue(input)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("all known falsy values → false", () => { + fcAssert( + property(falsyArb, (input) => { + expect(parseBoolValue(input)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("truthy values are case-insensitive", () => { + fcAssert( + property(truthyArb, (input) => { + const cased = randomCase(input); + expect(parseBoolValue(cased)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("falsy values are case-insensitive", () => { + fcAssert( + property(falsyArb, (input) => { + const cased = randomCase(input); + expect(parseBoolValue(cased)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("truthy values with whitespace padding → true", () => { + fcAssert( + property( + tuple(whitespaceArb, truthyArb, whitespaceArb), + ([pre, val, post]) => { + expect(parseBoolValue(`${pre}${val}${post}`)).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("falsy values with whitespace padding → false", () => { + fcAssert( + property( + tuple(whitespaceArb, falsyArb, whitespaceArb), + ([pre, val, post]) => { + expect(parseBoolValue(`${pre}${val}${post}`)).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("unrecognized values → null", () => { + fcAssert( + property(string(), (input) => { + const result = parseBoolValue(input); + // If the result is not null, the input must be a recognized value + if (result !== null) { + const normalized = input.toLowerCase().trim(); + const recognized = [ + "true", + "t", + "y", + "yes", + "on", + "1", + "false", + "f", + "n", + "no", + "off", + "0", + ]; + expect(recognized).toContain(normalized); + } + }), + { numRuns: DEFAULT_NUM_RUNS * 2 } + ); + }); + + test("never throws", () => { + fcAssert( + property(string(), (input) => { + // Should never throw, always returns boolean | null + const result = parseBoolValue(input); + expect(result === true || result === false || result === null).toBe( + true + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/patch-cache.test.ts b/packages/cli/test/lib/patch-cache.test.ts new file mode 100644 index 000000000..65ee055af --- /dev/null +++ b/packages/cli/test/lib/patch-cache.test.ts @@ -0,0 +1,620 @@ +/** + * Unit Tests for Patch Cache Module + * + * Tests the file-based cache for delta upgrade patches, including + * save, load, stitching from multiple runs, and cleanup. + */ + +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + type ChainMeta, + chainFileName, + cleanupPatchCache, + clearPatchCache, + loadCachedChain, + patchFileName, + savePatchesToCache, +} from "../../src/lib/patch-cache.js"; +import { useTestConfigDir } from "../helpers.js"; + +// All tests need an isolated config dir to store patch cache files +const getConfigDir = useTestConfigDir("patch-cache-test-"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Get the patch-cache subdirectory path */ +function getCacheDir(): string { + return join(getConfigDir(), "patch-cache"); +} + +/** Build a simple patch chain for testing */ +function makeChain( + patchDataList: Uint8Array[], + expectedSha256: string +): { + patches: { data: Uint8Array; size: number }[]; + expectedSha256: string; +} { + return { + patches: patchDataList.map((data) => ({ data, size: data.byteLength })), + expectedSha256, + }; +} + +/** Build version steps for a sequential chain */ +function makeSteps( + versions: string[] +): { fromVersion: string; toVersion: string }[] { + const steps: { fromVersion: string; toVersion: string }[] = []; + for (let i = 0; i < versions.length - 1; i++) { + const from = versions[i]; + const to = versions[i + 1]; + if (from && to) { + steps.push({ fromVersion: from, toVersion: to }); + } + } + return steps; +} + +// =========================================================================== +// patchFileName & chainFileName +// =========================================================================== + +describe("patchFileName", () => { + test("produces expected format for simple versions", () => { + expect(patchFileName("0.13.0", "0.14.0")).toBe("0.13.0-0.14.0.patch"); + }); + + test("sanitizes special characters in version strings", () => { + expect(patchFileName("0.14.0-dev.100", "0.14.0-dev.101")).toBe( + "0.14.0-dev.100-0.14.0-dev.101.patch" + ); + }); + + test("replaces non-safe characters with underscore", () => { + expect(patchFileName("1.0.0+build", "1.0.1+build")).toBe( + "1.0.0_build-1.0.1_build.patch" + ); + }); +}); + +describe("chainFileName", () => { + test("produces expected format", () => { + expect(chainFileName("0.13.0", "0.14.0")).toBe("chain-0.13.0-0.14.0.json"); + }); + + test("handles nightly versions", () => { + expect(chainFileName("0.14.0-dev.100", "0.14.0-dev.101")).toBe( + "chain-0.14.0-dev.100-0.14.0-dev.101.json" + ); + }); +}); + +// =========================================================================== +// savePatchesToCache +// =========================================================================== + +describe("savePatchesToCache", () => { + test("creates cache directory and saves patch + metadata files", async () => { + const patchData = new Uint8Array([1, 2, 3, 4, 5]); + const chain = makeChain([patchData], "abc123"); + const steps = [{ fromVersion: "0.13.0", toVersion: "0.14.0" }]; + + await savePatchesToCache(chain, steps); + + const cacheDir = getCacheDir(); + expect(existsSync(cacheDir)).toBe(true); + + // Verify patch file + const patchFilePath = join(cacheDir, patchFileName("0.13.0", "0.14.0")); + expect( + await access(patchFilePath).then( + () => true, + () => false + ) + ).toBe(true); + expect(new Uint8Array(await readFile(patchFilePath))).toEqual(patchData); + + // Verify chain metadata + const metaFilePath = join(cacheDir, chainFileName("0.13.0", "0.14.0")); + expect( + await access(metaFilePath).then( + () => true, + () => false + ) + ).toBe(true); + const meta = JSON.parse(await readFile(metaFilePath, "utf-8")) as ChainMeta; + expect(meta.fromVersion).toBe("0.13.0"); + expect(meta.toVersion).toBe("0.14.0"); + expect(meta.expectedSha256).toBe("abc123"); + expect(meta.patches).toHaveLength(1); + expect(meta.cachedAt).toBeGreaterThan(0); + }); + + test("saves multi-step chain with correct metadata", async () => { + const patches = [ + new Uint8Array([1, 2]), + new Uint8Array([3, 4]), + new Uint8Array([5, 6]), + ]; + const chain = makeChain(patches, "target-hash"); + const steps = makeSteps(["0.12.0", "0.13.0", "0.14.0", "0.15.0"]); + + await savePatchesToCache(chain, steps); + + const cacheDir = getCacheDir(); + // All 3 patch files + expect( + await access(join(cacheDir, patchFileName("0.12.0", "0.13.0"))).then( + () => true, + () => false + ) + ).toBe(true); + expect( + await access(join(cacheDir, patchFileName("0.13.0", "0.14.0"))).then( + () => true, + () => false + ) + ).toBe(true); + expect( + await access(join(cacheDir, patchFileName("0.14.0", "0.15.0"))).then( + () => true, + () => false + ) + ).toBe(true); + + // Chain metadata spans 0.12.0 → 0.15.0 + const metaFilePath2 = join(cacheDir, chainFileName("0.12.0", "0.15.0")); + const meta = JSON.parse( + await readFile(metaFilePath2, "utf-8") + ) as ChainMeta; + expect(meta.patches).toHaveLength(3); + expect(meta.expectedSha256).toBe("target-hash"); + }); + + test("handles empty steps gracefully", async () => { + const chain = makeChain([], "nope"); + await savePatchesToCache(chain, []); + // Should not create cache dir or fail + }); +}); + +// =========================================================================== +// loadCachedChain +// =========================================================================== + +describe("loadCachedChain", () => { + test("loads a single-hop cached chain", async () => { + const patchData = new Uint8Array([10, 20, 30]); + const chain = makeChain([patchData], "sha-single"); + const steps = [{ fromVersion: "0.13.0", toVersion: "0.14.0" }]; + + await savePatchesToCache(chain, steps); + + const result = await loadCachedChain("0.13.0", "0.14.0"); + expect(result).not.toBeNull(); + expect(result?.patches).toHaveLength(1); + expect(result?.patches[0]?.data).toEqual(patchData); + expect(result?.totalSize).toBe(3); + expect(result?.expectedSha256).toBe("sha-single"); + }); + + test("loads a multi-hop cached chain", async () => { + const patchA = new Uint8Array([1, 2]); + const patchB = new Uint8Array([3, 4, 5]); + const chain = makeChain([patchA, patchB], "sha-multi"); + const steps = makeSteps(["0.12.0", "0.13.0", "0.14.0"]); + + await savePatchesToCache(chain, steps); + + const result = await loadCachedChain("0.12.0", "0.14.0"); + expect(result).not.toBeNull(); + expect(result?.patches).toHaveLength(2); + expect(result?.patches[0]?.data).toEqual(patchA); + expect(result?.patches[1]?.data).toEqual(patchB); + expect(result?.totalSize).toBe(5); + expect(result?.expectedSha256).toBe("sha-multi"); + }); + + test("stitches patches from multiple version check runs", async () => { + // First run: caches 0.12→0.13 + const patchA = new Uint8Array([10]); + await savePatchesToCache(makeChain([patchA], "sha-a"), [ + { fromVersion: "0.12.0", toVersion: "0.13.0" }, + ]); + + // Second run: caches 0.13→0.14 with updated target hash + const patchB = new Uint8Array([20]); + await savePatchesToCache(makeChain([patchB], "sha-final"), [ + { fromVersion: "0.13.0", toVersion: "0.14.0" }, + ]); + + // Load full chain 0.12→0.14 — stitches both patches + const result = await loadCachedChain("0.12.0", "0.14.0"); + expect(result).not.toBeNull(); + expect(result?.patches).toHaveLength(2); + expect(result?.patches[0]?.data).toEqual(patchA); + expect(result?.patches[1]?.data).toEqual(patchB); + expect(result?.expectedSha256).toBe("sha-final"); + }); + + test("returns null when cache directory does not exist", async () => { + const result = await loadCachedChain("0.13.0", "0.14.0"); + expect(result).toBeNull(); + }); + + test("returns null when chain metadata is missing", async () => { + // Create cache dir with just a patch file (no metadata) + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + await writeFile( + join(cacheDir, patchFileName("0.13.0", "0.14.0")), + new Uint8Array([1, 2, 3]) + ); + + const result = await loadCachedChain("0.13.0", "0.14.0"); + expect(result).toBeNull(); + }); + + test("returns null when patch file is missing", async () => { + // Create metadata without the actual patch file + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + const meta: ChainMeta = { + fromVersion: "0.13.0", + toVersion: "0.14.0", + expectedSha256: "abc", + cachedAt: Date.now(), + patches: [{ fromVersion: "0.13.0", toVersion: "0.14.0", size: 100 }], + }; + await writeFile( + join(cacheDir, chainFileName("0.13.0", "0.14.0")), + JSON.stringify(meta) + ); + + const result = await loadCachedChain("0.13.0", "0.14.0"); + expect(result).toBeNull(); + }); + + test("returns null when intermediate step is missing", async () => { + // Cache 0.12→0.13 and 0.14→0.15 but not 0.13→0.14 + const patchA = new Uint8Array([10]); + await savePatchesToCache(makeChain([patchA], "sha-a"), [ + { fromVersion: "0.12.0", toVersion: "0.13.0" }, + ]); + const patchB = new Uint8Array([20]); + await savePatchesToCache(makeChain([patchB], "sha-b"), [ + { fromVersion: "0.14.0", toVersion: "0.15.0" }, + ]); + + const result = await loadCachedChain("0.12.0", "0.15.0"); + expect(result).toBeNull(); + }); + + test("returns null when expectedSha256 is missing from metadata", async () => { + // Create metadata with empty expectedSha256 + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + const meta: ChainMeta = { + fromVersion: "0.13.0", + toVersion: "0.14.0", + expectedSha256: "", + cachedAt: Date.now(), + patches: [{ fromVersion: "0.13.0", toVersion: "0.14.0", size: 3 }], + }; + await writeFile( + join(cacheDir, chainFileName("0.13.0", "0.14.0")), + JSON.stringify(meta) + ); + await writeFile( + join(cacheDir, patchFileName("0.13.0", "0.14.0")), + new Uint8Array([1, 2, 3]) + ); + + const result = await loadCachedChain("0.13.0", "0.14.0"); + expect(result).toBeNull(); + }); + + test("handles corrupt metadata JSON gracefully", async () => { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync( + join(cacheDir, "chain-0.13.0-0.14.0.json"), + "not valid json!!!" + ); + + const result = await loadCachedChain("0.13.0", "0.14.0"); + expect(result).toBeNull(); + }); + + test("handles nightly version strings", async () => { + const patchData = new Uint8Array([42]); + const chain = makeChain([patchData], "nightly-sha"); + const steps = [ + { fromVersion: "0.14.0-dev.100", toVersion: "0.14.0-dev.101" }, + ]; + + await savePatchesToCache(chain, steps); + + const result = await loadCachedChain("0.14.0-dev.100", "0.14.0-dev.101"); + expect(result).not.toBeNull(); + expect(result?.patches[0]?.data).toEqual(patchData); + expect(result?.expectedSha256).toBe("nightly-sha"); + }); +}); + +// =========================================================================== +// cleanupPatchCache +// =========================================================================== + +describe("cleanupPatchCache", () => { + test("does nothing when cache directory does not exist", async () => { + // Should not throw + await cleanupPatchCache(); + }); + + test("removes entries older than 7 days", async () => { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + + // Create an old chain entry (8 days ago) + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + const meta: ChainMeta = { + fromVersion: "0.13.0", + toVersion: "0.14.0", + expectedSha256: "old-sha", + cachedAt: eightDaysAgo, + patches: [{ fromVersion: "0.13.0", toVersion: "0.14.0", size: 10 }], + }; + await writeFile( + join(cacheDir, chainFileName("0.13.0", "0.14.0")), + JSON.stringify(meta) + ); + await writeFile( + join(cacheDir, patchFileName("0.13.0", "0.14.0")), + new Uint8Array([1, 2, 3]) + ); + + await cleanupPatchCache(); + + // Both metadata and patch file should be removed + expect(existsSync(join(cacheDir, chainFileName("0.13.0", "0.14.0")))).toBe( + false + ); + expect(existsSync(join(cacheDir, patchFileName("0.13.0", "0.14.0")))).toBe( + false + ); + }); + + test("preserves entries less than 7 days old", async () => { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + + // Create a fresh chain entry (1 hour ago) + const oneHourAgo = Date.now() - 60 * 60 * 1000; + const meta: ChainMeta = { + fromVersion: "0.13.0", + toVersion: "0.14.0", + expectedSha256: "fresh-sha", + cachedAt: oneHourAgo, + patches: [{ fromVersion: "0.13.0", toVersion: "0.14.0", size: 10 }], + }; + await writeFile( + join(cacheDir, chainFileName("0.13.0", "0.14.0")), + JSON.stringify(meta) + ); + await writeFile( + join(cacheDir, patchFileName("0.13.0", "0.14.0")), + new Uint8Array([1, 2, 3]) + ); + + await cleanupPatchCache(); + + // Both files should still exist + expect(existsSync(join(cacheDir, chainFileName("0.13.0", "0.14.0")))).toBe( + true + ); + expect(existsSync(join(cacheDir, patchFileName("0.13.0", "0.14.0")))).toBe( + true + ); + }); + + test("removes corrupt metadata files", async () => { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + const corruptPath = join(cacheDir, "chain-corrupt-0.14.0.json"); + writeFileSync(corruptPath, "not json"); + + await cleanupPatchCache(); + + expect(existsSync(corruptPath)).toBe(false); + }); + + test("handles mixed old and fresh entries", async () => { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + + // Old entry + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + const oldMeta: ChainMeta = { + fromVersion: "0.12.0", + toVersion: "0.13.0", + expectedSha256: "old", + cachedAt: eightDaysAgo, + patches: [{ fromVersion: "0.12.0", toVersion: "0.13.0", size: 10 }], + }; + await writeFile( + join(cacheDir, chainFileName("0.12.0", "0.13.0")), + JSON.stringify(oldMeta) + ); + await writeFile( + join(cacheDir, patchFileName("0.12.0", "0.13.0")), + new Uint8Array([1]) + ); + + // Fresh entry + const freshMeta: ChainMeta = { + fromVersion: "0.13.0", + toVersion: "0.14.0", + expectedSha256: "fresh", + cachedAt: Date.now(), + patches: [{ fromVersion: "0.13.0", toVersion: "0.14.0", size: 10 }], + }; + await writeFile( + join(cacheDir, chainFileName("0.13.0", "0.14.0")), + JSON.stringify(freshMeta) + ); + await writeFile( + join(cacheDir, patchFileName("0.13.0", "0.14.0")), + new Uint8Array([2]) + ); + + await cleanupPatchCache(); + + // Old entry removed + expect(existsSync(join(cacheDir, chainFileName("0.12.0", "0.13.0")))).toBe( + false + ); + expect(existsSync(join(cacheDir, patchFileName("0.12.0", "0.13.0")))).toBe( + false + ); + + // Fresh entry preserved + expect(existsSync(join(cacheDir, chainFileName("0.13.0", "0.14.0")))).toBe( + true + ); + expect(existsSync(join(cacheDir, patchFileName("0.13.0", "0.14.0")))).toBe( + true + ); + }); + + test("preserves shared patch files referenced by live chains", async () => { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + const oneHourAgo = Date.now() - 60 * 60 * 1000; + + // Old chain: A→B (expired) + const oldMeta: ChainMeta = { + fromVersion: "0.12.0", + toVersion: "0.13.0", + expectedSha256: "old-sha", + cachedAt: eightDaysAgo, + patches: [{ fromVersion: "0.12.0", toVersion: "0.13.0", size: 10 }], + }; + await writeFile( + join(cacheDir, chainFileName("0.12.0", "0.13.0")), + JSON.stringify(oldMeta) + ); + await writeFile( + join(cacheDir, patchFileName("0.12.0", "0.13.0")), + new Uint8Array([1]) + ); + + // Fresh chain: A→B→C (alive) — shares the A→B patch file + const freshMeta: ChainMeta = { + fromVersion: "0.12.0", + toVersion: "0.14.0", + expectedSha256: "fresh-sha", + cachedAt: oneHourAgo, + patches: [ + { fromVersion: "0.12.0", toVersion: "0.13.0", size: 10 }, + { fromVersion: "0.13.0", toVersion: "0.14.0", size: 10 }, + ], + }; + await writeFile( + join(cacheDir, chainFileName("0.12.0", "0.14.0")), + JSON.stringify(freshMeta) + ); + await writeFile( + join(cacheDir, patchFileName("0.13.0", "0.14.0")), + new Uint8Array([2]) + ); + + await cleanupPatchCache(); + + // Old chain metadata removed + expect(existsSync(join(cacheDir, chainFileName("0.12.0", "0.13.0")))).toBe( + false + ); + + // Shared A→B patch file preserved (still used by fresh chain) + expect(existsSync(join(cacheDir, patchFileName("0.12.0", "0.13.0")))).toBe( + true + ); + + // Fresh chain fully intact + expect(existsSync(join(cacheDir, chainFileName("0.12.0", "0.14.0")))).toBe( + true + ); + expect(existsSync(join(cacheDir, patchFileName("0.13.0", "0.14.0")))).toBe( + true + ); + }); +}); + +// --------------------------------------------------------------------------- +// clearPatchCache — wipe all cached patches after successful upgrade +// --------------------------------------------------------------------------- + +describe("clearPatchCache", () => { + test("removes all patch and chain files", async () => { + const cacheDir = getCacheDir(); + + // Populate cache with a multi-step chain + await savePatchesToCache( + { + patches: [ + { data: new Uint8Array([1, 2, 3]), size: 3 }, + { data: new Uint8Array([4, 5, 6]), size: 3 }, + ], + expectedSha256: "abc123", + }, + [ + { fromVersion: "0.10.0", toVersion: "0.11.0" }, + { fromVersion: "0.11.0", toVersion: "0.12.0" }, + ] + ); + + // Verify files exist + expect(existsSync(join(cacheDir, patchFileName("0.10.0", "0.11.0")))).toBe( + true + ); + expect(existsSync(join(cacheDir, patchFileName("0.11.0", "0.12.0")))).toBe( + true + ); + expect(existsSync(join(cacheDir, chainFileName("0.10.0", "0.12.0")))).toBe( + true + ); + + await clearPatchCache(); + + // All files gone + expect(existsSync(join(cacheDir, patchFileName("0.10.0", "0.11.0")))).toBe( + false + ); + expect(existsSync(join(cacheDir, patchFileName("0.11.0", "0.12.0")))).toBe( + false + ); + expect(existsSync(join(cacheDir, chainFileName("0.10.0", "0.12.0")))).toBe( + false + ); + }); + + test("is a no-op when cache directory does not exist", async () => { + // Don't create anything — cache dir doesn't exist + await clearPatchCache(); // Should not throw + }); + + test("is a no-op when cache directory is empty", async () => { + // Create the cache dir but no files + mkdirSync(getCacheDir(), { recursive: true }); + await clearPatchCache(); // Should not throw + }); +}); diff --git a/packages/cli/test/lib/platforms.test.ts b/packages/cli/test/lib/platforms.test.ts new file mode 100644 index 000000000..5f7355afe --- /dev/null +++ b/packages/cli/test/lib/platforms.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; +import { + COMMON_PLATFORMS, + isValidPlatform, + suggestPlatform, + VALID_PLATFORM_SET, +} from "../../src/lib/platforms.js"; + +describe("isValidPlatform", () => { + test("returns true for known platforms", () => { + expect(isValidPlatform("node")).toBe(true); + expect(isValidPlatform("node-hono")).toBe(true); + expect(isValidPlatform("javascript-nextjs")).toBe(true); + expect(isValidPlatform("other")).toBe(true); + expect(isValidPlatform("python-django")).toBe(true); + }); + + test("returns false for invalid platforms", () => { + expect(isValidPlatform("javascript-node")).toBe(false); + expect(isValidPlatform("foo")).toBe(false); + expect(isValidPlatform("sentry.javascript.node")).toBe(false); + expect(isValidPlatform("")).toBe(false); + }); +}); + +describe("suggestPlatform", () => { + test("suggests suffix match: nextjs → javascript-nextjs", () => { + const results = suggestPlatform("nextjs"); + expect(results).toContain("javascript-nextjs"); + }); + + test("suggests suffix match: hono → node-hono", () => { + const results = suggestPlatform("hono"); + expect(results).toContain("node-hono"); + }); + + test("suggests prefix swap: javascript-node → node", () => { + const results = suggestPlatform("javascript-node"); + expect(results).toContain("node"); + }); + + test("suggests prefix swap: javascript-hono → node-hono", () => { + const results = suggestPlatform("javascript-hono"); + expect(results).toContain("node-hono"); + }); + + test("suggests fuzzy match for typo: noude → node family", () => { + const results = suggestPlatform("noude"); + expect(results).toContain("node"); + expect(results.length).toBeGreaterThan(1); + expect(results.some((r) => r.startsWith("node-"))).toBe(true); + }); + + test("suggests fuzzy match for typo: pythn → python family", () => { + const results = suggestPlatform("pythn"); + expect(results).toContain("python"); + expect(results.length).toBeGreaterThan(1); + expect(results.some((r) => r.startsWith("python-"))).toBe(true); + }); + + test("suggests fuzzy match for extra char: node-expresss → node-express", () => { + const results = suggestPlatform("node-expresss"); + expect(results).toContain("node-express"); + }); + + test("suggests fuzzy match for compound typo: javascript-reeact → javascript-react", () => { + const results = suggestPlatform("javascript-reeact"); + expect(results).toContain("javascript-react"); + }); + + test("suggests middle-component match: javascript-cloudflare → node-cloudflare-* (CLI-WD)", () => { + const results = suggestPlatform("javascript-cloudflare"); + expect(results).toContain("node-cloudflare-pages"); + expect(results).toContain("node-cloudflare-workers"); + }); + + test("returns empty array for garbage input", () => { + expect(suggestPlatform("xyzgarbage")).toEqual([]); + }); + + test("returns at most 15 suggestions", () => { + const results = suggestPlatform("javascript"); + expect(results.length).toBeLessThanOrEqual(15); + }); +}); + +describe("COMMON_PLATFORMS is a subset of VALID_PLATFORMS", () => { + test("every common platform is valid", () => { + for (const p of COMMON_PLATFORMS) { + expect(VALID_PLATFORM_SET.has(p)).toBe(true); + } + }); +}); diff --git a/packages/cli/test/lib/polling.property.test.ts b/packages/cli/test/lib/polling.property.test.ts new file mode 100644 index 000000000..6b033f45a --- /dev/null +++ b/packages/cli/test/lib/polling.property.test.ts @@ -0,0 +1,258 @@ +/** + * Property-Based Tests for Polling Utility + * + * Uses fast-check to verify invariants of the poll() function + * that are difficult to exhaustively test with example-based tests. + */ + +import { + array, + asyncProperty, + assert as fcAssert, + integer, + nat, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { TimeoutError } from "../../src/lib/errors.js"; +import { poll } from "../../src/lib/polling.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("poll properties", () => { + test("returns immediately when shouldStop is true on first fetch", async () => { + await fcAssert( + asyncProperty(nat(100), async (stateValue) => { + let fetchCount = 0; + + const result = await poll({ + fetchState: async () => { + fetchCount += 1; + return { value: stateValue }; + }, + shouldStop: () => true, // Always stop + getProgressMessage: () => "Testing...", + json: true, // Suppress output for cleaner tests + pollIntervalMs: 10, + timeoutMs: 1000, + }); + + expect(result.value).toBe(stateValue); + expect(fetchCount).toBe(1); // Only called once + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns state that satisfies shouldStop predicate", async () => { + await fcAssert( + asyncProperty( + integer({ min: 1, max: 5 }), // stopAfter: 1-5 fetches + nat(100), // stateValue + async (stopAfter, stateValue) => { + let fetchCount = 0; + + const result = await poll({ + fetchState: async () => { + fetchCount += 1; + return { value: stateValue, count: fetchCount }; + }, + shouldStop: (state) => state.count >= stopAfter, + getProgressMessage: () => "Testing...", + json: true, + pollIntervalMs: 5, // Fast polling for tests + timeoutMs: 5000, + }); + + // Result should satisfy the stop condition + expect(result.count).toBeGreaterThanOrEqual(stopAfter); + expect(result.value).toBe(stateValue); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("throws TimeoutError when shouldStop never returns true", async () => { + await fcAssert( + asyncProperty(nat(50), async (stateValue) => { + const timeoutMs = 50; // Very short timeout for testing + const customMessage = `Custom timeout: ${stateValue}`; + + try { + await poll({ + fetchState: async () => ({ value: stateValue }), + shouldStop: () => false, // Never stop + getProgressMessage: () => "Testing...", + json: true, + pollIntervalMs: 10, + timeoutMs, + timeoutMessage: customMessage, + }); + // Should not reach here + expect.unreachable("Expected TimeoutError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(TimeoutError); + expect((error as TimeoutError).message).toBe(customMessage); + } + }), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } // Fewer runs since timeout tests are slow + ); + }); + + test("TimeoutError includes hint when provided", async () => { + const customHint = "Try running the command again."; + + try { + await poll({ + fetchState: async () => ({ value: 1 }), + shouldStop: () => false, + getProgressMessage: () => "Testing...", + json: true, + pollIntervalMs: 10, + timeoutMs: 50, + timeoutHint: customHint, + }); + expect.unreachable("Expected TimeoutError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(TimeoutError); + const te = error as TimeoutError; + expect(te.hint).toBe(customHint); + expect(te.format()).toContain(customHint); + } + }); + + test("fetchState call count is bounded by timeout/interval", async () => { + await fcAssert( + asyncProperty( + integer({ min: 10, max: 50 }), // pollIntervalMs + integer({ min: 50, max: 200 }), // timeoutMs + async (pollIntervalMs, timeoutMs) => { + let fetchCount = 0; + + // Ensure timeout > interval + const actualTimeout = Math.max(timeoutMs, pollIntervalMs * 2); + + try { + await poll({ + fetchState: async () => { + fetchCount += 1; + return { count: fetchCount }; + }, + shouldStop: () => false, // Never stop + getProgressMessage: () => "Testing...", + json: true, + pollIntervalMs, + timeoutMs: actualTimeout, + }); + } catch { + // Expected timeout + } + + // Fetch count should be bounded by timeout/interval + some tolerance + const maxExpectedCalls = + Math.ceil(actualTimeout / pollIntervalMs) + 2; + expect(fetchCount).toBeLessThanOrEqual(maxExpectedCalls); + expect(fetchCount).toBeGreaterThanOrEqual(1); // At least one call + } + ), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } + ); + }); + + test("null fetchState results are skipped until valid state", async () => { + await fcAssert( + asyncProperty( + integer({ min: 1, max: 5 }), // nullCount: number of nulls before valid state + nat(100), // stateValue + async (nullCount, stateValue) => { + let fetchCount = 0; + + const result = await poll({ + fetchState: async () => { + fetchCount += 1; + // Return null for first N calls, then valid state + if (fetchCount <= nullCount) { + return null; + } + return { value: stateValue }; + }, + shouldStop: () => true, + getProgressMessage: () => "Testing...", + json: true, + pollIntervalMs: 5, + timeoutMs: 5000, + }); + + expect(result.value).toBe(stateValue); + expect(fetchCount).toBe(nullCount + 1); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("progress message is updated from state", async () => { + await fcAssert( + asyncProperty( + array(nat(100), { minLength: 1, maxLength: 5 }), + async (stateValues) => { + let fetchIndex = 0; + const messages: string[] = []; + + await poll({ + fetchState: async () => { + const value = stateValues[fetchIndex]; + fetchIndex = Math.min(fetchIndex + 1, stateValues.length - 1); + return { value }; + }, + shouldStop: (state) => + state.value === stateValues.at(-1) && + fetchIndex >= stateValues.length - 1, + getProgressMessage: (state) => { + const msg = `State: ${state.value}`; + messages.push(msg); + return msg; + }, + json: true, // Suppress animation + pollIntervalMs: 5, + timeoutMs: 5000, + }); + + // Messages should have been generated for each non-null state + expect(messages.length).toBeGreaterThanOrEqual(1); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("poll edge cases", () => { + test("handles immediate timeout (timeoutMs = 0)", async () => { + // With 0 timeout, should throw TimeoutError immediately or after first fetch + await expect( + poll({ + fetchState: async () => ({ value: 1 }), + shouldStop: () => false, + getProgressMessage: () => "Testing...", + json: true, + pollIntervalMs: 10, + timeoutMs: 0, + }) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + test("handles fetchState throwing errors", async () => { + await expect( + poll({ + fetchState: async () => { + throw new Error("Fetch failed"); + }, + shouldStop: () => true, + getProgressMessage: () => "Testing...", + json: true, + pollIntervalMs: 10, + timeoutMs: 1000, + }) + ).rejects.toThrow("Fetch failed"); + }); +}); diff --git a/packages/cli/test/lib/polling.test.ts b/packages/cli/test/lib/polling.test.ts new file mode 100644 index 000000000..a66afe2a5 --- /dev/null +++ b/packages/cli/test/lib/polling.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { withProgress } from "../../src/lib/polling.js"; + +const originalIsTTY = process.stdout.isTTY; + +function createDeferred() { + let resolve: (value: T) => void; + let reject: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, reject: reject!, resolve: resolve! }; +} + +function enableInteractiveOutput() { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + vi.stubEnv("SENTRY_PLAIN_OUTPUT", "0"); + return vi.spyOn(process.stdout, "write").mockReturnValue(true); +} + +afterEach(() => { + vi.clearAllTimers(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalIsTTY, + }); + vi.restoreAllMocks(); +}); + +describe("withProgress", () => { + test("rotates progress messages until an interactive operation resolves", async () => { + vi.useFakeTimers(); + const stdoutWrite = enableInteractiveOutput(); + const deferred = createDeferred(); + const operation = withProgress( + { + message: "Searching Sentry docs…", + rotatingMessages: ["Finding the relevant bits…"], + rotationIntervalMs: 4000, + }, + async () => deferred.promise + ); + + await vi.advanceTimersByTimeAsync(4000); + + expect(stdoutWrite.mock.calls.flat().join("")).toContain( + "Finding the relevant bits…" + ); + + deferred.resolve("done"); + await expect(operation).resolves.toBe("done"); + + const writesAfterCompletion = stdoutWrite.mock.calls.length; + await vi.advanceTimersByTimeAsync(8000); + expect(stdoutWrite).toHaveBeenCalledTimes(writesAfterCompletion); + }); + + test("stops rotating progress messages when an interactive operation rejects", async () => { + vi.useFakeTimers(); + const stdoutWrite = enableInteractiveOutput(); + const deferred = createDeferred(); + const operation = withProgress( + { + message: "Searching Sentry docs…", + rotatingMessages: ["It’s getting there…"], + rotationIntervalMs: 4000, + }, + async () => deferred.promise + ); + + await vi.advanceTimersByTimeAsync(4000); + expect(stdoutWrite.mock.calls.flat().join("")).toContain( + "It’s getting there…" + ); + + deferred.reject(new Error("docs unavailable")); + await expect(operation).rejects.toThrow("docs unavailable"); + + const writesAfterRejection = stdoutWrite.mock.calls.length; + await vi.advanceTimersByTimeAsync(8000); + expect(stdoutWrite).toHaveBeenCalledTimes(writesAfterRejection); + }); + + test("keeps JSON output free of progress messages", async () => { + const stdoutWrite = enableInteractiveOutput(); + + await expect( + withProgress( + { + json: true, + message: "Searching Sentry docs…", + rotatingMessages: ["It’s getting there…"], + rotationIntervalMs: 4000, + }, + async () => "done" + ) + ).resolves.toBe("done"); + + expect(stdoutWrite).not.toHaveBeenCalled(); + }); + + test("keeps plain output free of progress messages", async () => { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + vi.stubEnv("SENTRY_PLAIN_OUTPUT", "1"); + const stdoutWrite = vi.spyOn(process.stdout, "write").mockReturnValue(true); + + await expect( + withProgress( + { + message: "Searching Sentry docs…", + rotatingMessages: ["It’s getting there…"], + rotationIntervalMs: 4000, + }, + async () => "done" + ) + ).resolves.toBe("done"); + + expect(stdoutWrite).not.toHaveBeenCalled(); + }); + + test("keeps forced-rich piped output free of interactive-only progress", async () => { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); + vi.stubEnv("SENTRY_PLAIN_OUTPUT", "0"); + const stdoutWrite = vi.spyOn(process.stdout, "write").mockReturnValue(true); + + await expect( + withProgress( + { + interactiveOnly: true, + message: "Searching Sentry docs…", + rotatingMessages: ["It’s getting there…"], + rotationIntervalMs: 4000, + }, + async () => "done" + ) + ).resolves.toBe("done"); + + expect(stdoutWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/progress.test.ts b/packages/cli/test/lib/progress.test.ts new file mode 100644 index 000000000..7bb935e0d --- /dev/null +++ b/packages/cli/test/lib/progress.test.ts @@ -0,0 +1,229 @@ +/** + * Unit tests for the byte-driven upgrade progress reporter. + * + * The reporter is cosmetic-only and must never abort the operation it + * decorates. It feeds a `setMessage` callback (the surrounding withProgress + * spinner) rather than drawing its own bar, so there's no second in-place + * redraw competing with the spinner. + * + * Coverage: determinate vs indeterminate formatting, byte accumulation, + * full-bar clamping, update throttling, a final un-throttled done() emit, the + * no-op path when no setMessage is provided, the never-throws contract, and + * the `format: "pct"` mode that suppresses the inflated byte counter for + * multi-hop patch chains. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test, vi } from "vitest"; + +import { makeByteProgress } from "../../src/lib/progress.js"; + +describe("makeByteProgress", () => { + test("formats a determinate bar with size and percent", () => { + const msgs: string[] = []; + let now = 0; + const p = makeByteProgress( + "Applying 3 patch(es)", + 1000, + (m) => msgs.push(m), + { + nowMs: () => now, + } + ); + p.onProgress(500); // first call: now=0, lastEmit=0 → throttled (0-0 < 100) + now = 200; + p.onProgress(500); // now past throttle → emits at 1000/1000 + const last = msgs.at(-1) ?? ""; + expect(last).toContain("Applying 3 patch(es)"); + expect(last).toContain("1000 B / 1000 B"); + expect(last).toContain("100%"); + expect(last).toContain("█".repeat(16)); + }); + + test("formats an indeterminate byte counter when total is null", () => { + const msgs: string[] = []; + let now = 0; + const p = makeByteProgress("Downloading", null, (m) => msgs.push(m), { + nowMs: () => now, + }); + now = 200; + p.onProgress(2048); + const last = msgs.at(-1) ?? ""; + expect(last).toContain("Downloading"); + expect(last).toContain("2.0 KB"); + expect(last).not.toContain("░"); // no bar in indeterminate mode + }); + + test("accumulates bytes across calls and clamps the bar at full", () => { + const msgs: string[] = []; + let now = 0; + const p = makeByteProgress("Applying", 100, (m) => msgs.push(m), { + nowMs: () => now, + }); + p.onProgress(50); + now = 500; + p.onProgress(9000); // way over total → clamp to 100% + const last = msgs.at(-1) ?? ""; + expect(last).toContain("100%"); + expect(last).toContain("█".repeat(16)); + expect(last).not.toContain("░"); + }); + + test("throttles updates so a fast byte stream doesn't spam the spinner", () => { + const set = vi.fn(); + const now = 1000; + const p = makeByteProgress("Applying", 1000, set, { nowMs: () => now }); + // Many calls within the same throttle window → at most one emit. + for (let i = 0; i < 50; i += 1) { + p.onProgress(10); + } + expect(set.mock.calls.length).toBeLessThanOrEqual(1); + }); + + test("done() emits a final, un-throttled message reflecting the total", () => { + const set = vi.fn(); + const now = 0; + const p = makeByteProgress("Applying", 100, set, { nowMs: () => now }); + p.onProgress(100); // throttled (now-0 < 100), no emit yet + set.mockClear(); + p.done(); // must emit regardless of throttle + expect(set).toHaveBeenCalledTimes(1); + expect(set.mock.calls[0]?.[0]).toContain("100%"); + }); + + test("is a no-op when no setMessage is provided (JSON/non-TTY)", () => { + // Nothing to assert beyond: it must not throw and must still track bytes + // so a later done() with a setMessage-less reporter is harmless. + const p = makeByteProgress("Applying", 100, undefined); + expect(() => { + p.onProgress(50); + p.done(); + }).not.toThrow(); + }); + + test("never throws even if setMessage throws", () => { + const p = makeByteProgress( + "Applying", + 100, + () => { + throw new Error("boom"); + }, + { nowMs: () => 1000 } + ); + expect(() => { + p.onProgress(100); + p.done(); + }).not.toThrow(); + }); + + test("renders percentage only when format='pct' (apply bar suppresses GB scare)", () => { + // Multi-hop chains sum newSize across hops, so event.total can far + // exceed the final binary size (e.g. 930 MB for a 3-hop 310 MB chain). + // The apply bar should show only percentage in that case so users + // don't see "applied 1.5 GB / 3.1 GB" for what ends up being a + // 310 MB install. + const msgs: string[] = []; + let now = 0; + const p = makeByteProgress( + "Applying 3 patch(es)", + 930 * 1024 * 1024, + (m) => msgs.push(m), + { format: "pct", nowMs: () => now } + ); + now = 200; + p.onProgress(310 * 1024 * 1024); // 33% + now = 400; + p.onProgress(310 * 1024 * 1024); // 66% + now = 600; + p.onProgress(310 * 1024 * 1024); // 100% + p.done(); + + const last = msgs.at(-1) ?? ""; + expect(last).toContain("Applying 3 patch(es)"); + expect(last).toMatch(/\[█+░*\] 100%/); + // No byte count in pct mode + expect(last).not.toMatch(/\d+\s*(B|KB|MB|GB|TB)/); + expect(last).not.toContain("/"); + }); + + test("format='pct' is ignored for indeterminate (null total) byte counters", () => { + // Indeterminate bars have no meaningful percentage — they always show + // the live byte total regardless of the format option. + const msgs: string[] = []; + let now = 0; + const p = makeByteProgress("Downloading", null, (m) => msgs.push(m), { + format: "pct", + nowMs: () => now, + }); + now = 200; + p.onProgress(2048); + const last = msgs.at(-1) ?? ""; + expect(last).toContain("Downloading"); + expect(last).toContain("2.0 KB"); + expect(last).not.toContain("%"); + }); + + test("apply bar call site passes 'pct' format (regression: multi-hop GB scare)", () => { + // Regression: delta-upgrade.ts apply bar previously called + // makeByteProgress(label, total, setMessage) without passing format, so + // the bar fell back to bytes mode and the "pct only" UX never applied. + // Reads the source to assert the apply-phase call site actually wires + // "pct", so a future regression to bytes mode is caught even if the + // helper itself still defaults to "bytes". + const src = readFileSync( + join(__dirname, "../../src/lib/delta-upgrade.ts"), + "utf8" + ); + // Find the makeByteProgress call inside the apply bar branch by scanning + // for balanced parentheses, so nested calls like + // makeByteProgress("label", computeTotal()) still match. Regex-based + // extraction breaks on nested parens, which a real refactor could + // easily introduce. + const matches = extractCalls(src, "makeByteProgress"); + const applyCall = matches.find((m) => m.includes("isApply")); + expect(applyCall).toBeDefined(); + expect(applyCall).toMatch(/["']pct["']/); + }); +}); + +/** + * Walk a source string and return every `name(`...`)` call as a string slice. + * Uses a balanced-parentheses scan rather than a regex so nested calls like + * `makeByteProgress("label", computeTotal())` still extract cleanly. + */ +function extractCalls(source: string, name: string): string[] { + const out: string[] = []; + let i = 0; + while (i < source.length) { + const found = source.indexOf(name, i); + if (found === -1) { + break; + } + const open = source.indexOf("(", found); + if (open === -1) { + break; + } + let depth = 1; + let j = open + 1; + while (j < source.length && depth > 0) { + const ch = source[j]; + if (ch === "(") { + depth += 1; + } else if (ch === ")") { + depth -= 1; + } + if (depth > 0) { + j += 1; + } + } + if (depth === 0) { + out.push(source.slice(found, j + 1)); + i = j + 1; + } else { + break; // unbalanced, stop scanning + } + } + return out; +} diff --git a/packages/cli/test/lib/proguard.property.test.ts b/packages/cli/test/lib/proguard.property.test.ts new file mode 100644 index 000000000..82adcdae9 --- /dev/null +++ b/packages/cli/test/lib/proguard.property.test.ts @@ -0,0 +1,83 @@ +/** + * Property-based + golden tests for ProGuard mapping UUID computation. + * + * The golden vectors are taken directly from the legacy `sentry-cli` + * integration test fixtures (`tests/integration/_cases/proguard/`) and verify + * byte-for-byte parity with the `rust-proguard` crate's `uuid()`. + */ + +import { assert as fcAssert, property, uint8Array } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + computeProguardUuid, + PROGUARD_NAMESPACE, +} from "../../src/lib/proguard.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** Lowercase hyphenated UUID shape (8-4-4-4-12 hex). */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe("proguard: computeProguardUuid", () => { + test("derived namespace matches legacy sentry-cli", () => { + // uuidv5(NAMESPACE_DNS, "guardsquare.com") + expect(PROGUARD_NAMESPACE).toBe("4f44f30f-24be-53d0-bab6-f47c7120ad6c"); + }); + + test("golden: 'void\\n' fixture", () => { + // tests/integration/_fixtures/proguard.txt (5 bytes) + const uuid = computeProguardUuid(Buffer.from("void\n", "utf-8")); + expect(uuid).toBe("5db7294d-87fc-5726-a5c0-4a90679657a5"); + }); + + test("golden: sample mapping.txt fixture", () => { + // tests/integration/_fixtures/proguard/upload/mapping.txt (155 bytes) + const mapping = + "HelloWorld -> HelloWorld:\n" + + '# {"fileName":"HelloWorld.java","id":"sourceFile"}\n' + + " 1:1:void () -> \n" + + " 3:4:void main(java.lang.String[]) -> main\n"; + const uuid = computeProguardUuid(Buffer.from(mapping, "utf-8")); + expect(uuid).toBe("c038584d-c366-570c-ad1e-034fa0d194d7"); + }); + + test("always returns a well-formed lowercase UUID with version 5", () => { + fcAssert( + property(uint8Array(), (bytes) => { + const uuid = computeProguardUuid(Buffer.from(bytes)); + expect(uuid).toMatch(UUID_RE); + // Version nibble (first char of 3rd group) is always 5. + expect(uuid[14]).toBe("5"); + // Variant nibble (first char of 4th group) is RFC 4122 (8/9/a/b). + expect(["8", "9", "a", "b"]).toContain(uuid[19]); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic: same bytes yield the same UUID", () => { + fcAssert( + property(uint8Array(), (bytes) => { + const a = computeProguardUuid(Buffer.from(bytes)); + const b = computeProguardUuid(Buffer.from(bytes)); + expect(a).toBe(b); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("differs when content differs", () => { + fcAssert( + property(uint8Array({ minLength: 1 }), (bytes) => { + const original = Buffer.from(bytes); + const mutated = Buffer.from(bytes); + // Flip the first byte to guarantee different content. + mutated[0] = (mutated[0]! + 1) % 256; + expect(computeProguardUuid(original)).not.toBe( + computeProguardUuid(mutated) + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/promises.property.test.ts b/packages/cli/test/lib/promises.property.test.ts new file mode 100644 index 000000000..1c8eff8ca --- /dev/null +++ b/packages/cli/test/lib/promises.property.test.ts @@ -0,0 +1,158 @@ +/** + * Property-Based Tests for Promise Utilities + * + * Uses fast-check to verify invariants of anyTrue() that are difficult + * to exhaustively test with example-based tests. + */ + +import { setTimeout as sleep } from "node:timers/promises"; +import { + array, + asyncProperty, + boolean, + assert as fcAssert, + integer, + nat, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { anyTrue } from "../../src/lib/promises.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("anyTrue properties", () => { + test("returns true if and only if at least one predicate returns true", async () => { + await fcAssert( + asyncProperty( + array(boolean(), { minLength: 0, maxLength: 20 }), + async (results) => { + const items = results.map((_, i) => i); + const expectedResult = results.includes(true); + + const actualResult = await anyTrue(items, async (i) => results[i]); + + expect(actualResult).toBe(expectedResult); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty array always returns false regardless of predicate", async () => { + await fcAssert( + asyncProperty(boolean(), async (predicateResult) => { + const result = await anyTrue([], async () => predicateResult); + expect(result).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("single-element array result equals predicate result", async () => { + await fcAssert( + asyncProperty(boolean(), async (predicateResult) => { + const result = await anyTrue([1], async () => predicateResult); + expect(result).toBe(predicateResult); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("errors in predicates are treated as false", async () => { + await fcAssert( + asyncProperty( + integer({ min: 0, max: 10 }), // errorIndex + integer({ min: 0, max: 10 }), // trueIndex + integer({ min: 3, max: 15 }), // arrayLength + async (errorIndex, trueIndex, arrayLength) => { + const items = Array.from({ length: arrayLength }, (_, i) => i); + + const result = await anyTrue(items, async (i) => { + if (i === errorIndex % arrayLength) { + throw new Error("Test error"); + } + return i === trueIndex % arrayLength; + }); + + // Result should be true if trueIndex exists and isn't the error index + const expectedTrue = + trueIndex % arrayLength !== errorIndex % arrayLength; + expect(result).toBe(expectedTrue); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("all predicates throwing returns false", async () => { + await fcAssert( + asyncProperty(integer({ min: 1, max: 10 }), async (arrayLength) => { + const items = Array.from({ length: arrayLength }, (_, i) => i); + + const result = await anyTrue(items, async () => { + throw new Error("Test error"); + }); + + expect(result).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result does not depend on predicate completion order", async () => { + await fcAssert( + asyncProperty( + array(nat(50), { minLength: 2, maxLength: 10 }), // delays + integer({ min: 0, max: 9 }), // trueIndex + async (delays, trueIndex) => { + const items = delays.map((_, i) => i); + const actualTrueIndex = trueIndex % items.length; + + // Run with different delays + const result = await anyTrue(items, async (i) => { + await sleep(delays[i] ?? 0); + return i === actualTrueIndex; + }); + + expect(result).toBe(true); + } + ), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } // Fewer runs since we use delays + ); + }); + + test("all false predicates return false regardless of timing", async () => { + await fcAssert( + asyncProperty( + array(nat(20), { minLength: 1, maxLength: 5 }), // delays + async (delays) => { + const items = delays.map((_, i) => i); + + const result = await anyTrue(items, async (i) => { + await sleep(delays[i] ?? 0); + return false; // All false + }); + + expect(result).toBe(false); + } + ), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } + ); + }); + + test("predicate is called for each item at least once (when no early true)", async () => { + await fcAssert( + asyncProperty(integer({ min: 1, max: 10 }), async (arrayLength) => { + const items = Array.from({ length: arrayLength }, (_, i) => i); + const called = new Set(); + + await anyTrue(items, async (i) => { + called.add(i); + return false; // All false, so all must be checked + }); + + // All items should have been checked + expect(called.size).toBe(arrayLength); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/promises.test.ts b/packages/cli/test/lib/promises.test.ts new file mode 100644 index 000000000..8817fc534 --- /dev/null +++ b/packages/cli/test/lib/promises.test.ts @@ -0,0 +1,65 @@ +/** + * Promise Utilities Tests + * + * Note: Core behavior invariants (true/false results, error handling, empty arrays) + * are tested via property-based tests in promises.property.test.ts. These tests + * focus on concurrency and timing behavior that property tests cannot easily verify. + */ + +import { setTimeout as sleep } from "node:timers/promises"; +import { describe, expect, test } from "vitest"; +import { anyTrue } from "../../src/lib/promises.js"; + +describe("anyTrue concurrency", () => { + test("starts all predicates concurrently", async () => { + const startTimes: number[] = []; + const startTime = Date.now(); + + await anyTrue([1, 2, 3], async (n) => { + startTimes.push(Date.now() - startTime); + await sleep(50); + return n === 3; + }); + + // All should start within ~10ms of each other (concurrent) + // If sequential, they'd be ~50ms apart + const maxDiff = Math.max(...startTimes) - Math.min(...startTimes); + expect(maxDiff).toBeLessThan(20); + }); + + test("resolves only once even with multiple true values", async () => { + let resolveCount = 0; + + const promise = anyTrue([1, 2, 3], async () => { + await sleep(10); + return true; // All pass + }); + + // Wrap to count resolutions + const result = await promise.then((r) => { + resolveCount += 1; + return r; + }); + + expect(result).toBe(true); + expect(resolveCount).toBe(1); + }); + + test("does not wait for slow false predicates after finding true", async () => { + const startTime = Date.now(); + + const result = await anyTrue([1, 2, 3], async (n) => { + if (n === 1) { + return true; // Fast true + } + await sleep(500); // Slow false + return false; + }); + + const elapsed = Date.now() - startTime; + + expect(result).toBe(true); + // Should resolve well under 500ms since we found true immediately + expect(elapsed).toBeLessThan(100); + }); +}); diff --git a/packages/cli/test/lib/react-native/wrap-call.test.ts b/packages/cli/test/lib/react-native/wrap-call.test.ts new file mode 100644 index 000000000..f7e49da5f --- /dev/null +++ b/packages/cli/test/lib/react-native/wrap-call.test.ts @@ -0,0 +1,147 @@ +/** + * Tests for the React Native Xcode build wrapper (`wrap_call`). + * + * The wrapped Node/Hermes spawn is mocked; the tests assert argument parsing, + * the JSON report contents, and the Hermes debug-id copy. + */ + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { SourceMapReport } from "../../../src/lib/react-native/wrap-call.js"; +import { wrapCall } from "../../../src/lib/react-native/wrap-call.js"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: vi.fn(() => ({ + status: 0, + stdout: "", + stderr: "", + pid: 1, + output: [], + signal: null, + })), + }; +}); + +const spawnMock = vi.mocked(spawnSync); + +let dir: string; +let reportPath: string; +const origArgv = process.argv; + +/** Simulate the RN build script invoking us with these args (non-SEA layout). */ +function setArgs(args: string[]): void { + process.argv = ["node", "bin.js", ...args]; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "rn-wrap-")); + reportPath = join(dir, "report.json"); + spawnMock.mockClear(); +}); +afterEach(() => { + process.argv = origArgv; + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function readReport(): SourceMapReport { + return JSON.parse(readFileSync(reportPath, "utf-8")) as SourceMapReport; +} + +describe("wrapCall", () => { + test("parses a bundle command and adds a --sourcemap-output", () => { + setArgs(["cli.js", "bundle", "--bundle-output", "/build/app.jsbundle"]); + const status = wrapCall({ + SENTRY_RN_SOURCEMAP_REPORT: reportPath, + SENTRY_RN_REAL_NODE_BINARY: "node", + }); + + expect(status).toBe(0); + const report = readReport(); + expect(report.packager_bundle_path).toBe("/build/app.jsbundle"); + expect(report.packager_sourcemap_path).toContain("app.jsbundle.map"); + // The real node was invoked with the injected --sourcemap-output. + const [exe, args] = spawnMock.mock.calls[0]; + expect(exe).toBe("node"); + expect(args).toContain("--sourcemap-output"); + }); + + test("records the Hermes bundle path from -emit-binary", () => { + setArgs(["-emit-binary", "-out", "/build/app.hbc", "/build/app.js"]); + wrapCall({ + SENTRY_RN_SOURCEMAP_REPORT: reportPath, + SENTRY_RN_REAL_NODE_BINARY: "node", + SENTRY_RN_REAL_HERMES_CLI_PATH: "/hermesc", + }); + expect(spawnMock.mock.calls[0][0]).toBe("/hermesc"); + expect(readReport().hermes_bundle_path).toBe("/build/app.hbc"); + }); + + test("copies the debug id into the Hermes combined sourcemap", () => { + const pkgMap = join(dir, "packager.map"); + const hermesMap = join(dir, "hermes.map"); + writeFileSync(pkgMap, JSON.stringify({ version: 3, debugId: "abc-123" })); + writeFileSync(hermesMap, JSON.stringify({ version: 3 })); + // Seed the report with the packager sourcemap path (from a prior bundle call). + writeFileSync( + reportPath, + JSON.stringify({ packager_sourcemap_path: pkgMap }) + ); + + setArgs(["/tools/compose-source-maps.js", "-o", hermesMap]); + wrapCall({ + SENTRY_RN_SOURCEMAP_REPORT: reportPath, + SENTRY_RN_REAL_NODE_BINARY: "node", + }); + + const hermes = JSON.parse(readFileSync(hermesMap, "utf-8")); + expect(hermes.debugId).toBe("abc-123"); + expect(hermes.debug_id).toBe("abc-123"); + expect(readReport().hermes_sourcemap_path).toBe(hermesMap); + }); + + test("reports a non-zero status when the child is killed by a signal", () => { + spawnMock.mockReturnValueOnce({ + status: null, + signal: "SIGTERM", + stdout: "", + stderr: "", + pid: 1, + output: [], + } as unknown as ReturnType); + setArgs(["cli.js", "bundle", "--bundle-output", "/build/app.jsbundle"]); + const status = wrapCall({ + SENTRY_RN_SOURCEMAP_REPORT: reportPath, + SENTRY_RN_REAL_NODE_BINARY: "node", + }); + expect(status).not.toBe(0); + }); + + test("respects SENTRY_RN_NO_DEBUG_ID", () => { + const pkgMap = join(dir, "packager.map"); + const hermesMap = join(dir, "hermes.map"); + writeFileSync(pkgMap, JSON.stringify({ debugId: "abc-123" })); + writeFileSync(hermesMap, JSON.stringify({ version: 3 })); + writeFileSync( + reportPath, + JSON.stringify({ packager_sourcemap_path: pkgMap }) + ); + + setArgs(["/tools/compose-source-maps.js", "-o", hermesMap]); + wrapCall({ + SENTRY_RN_SOURCEMAP_REPORT: reportPath, + SENTRY_RN_REAL_NODE_BINARY: "node", + SENTRY_RN_NO_DEBUG_ID: "1", + }); + + expect( + JSON.parse(readFileSync(hermesMap, "utf-8")).debugId + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/react-native/xcode-env.test.ts b/packages/cli/test/lib/react-native/xcode-env.test.ts new file mode 100644 index 000000000..991de7119 --- /dev/null +++ b/packages/cli/test/lib/react-native/xcode-env.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for Xcode build-environment helpers. + */ + +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + discoverInfoPlist, + expandXcodeVars, + findHermesc, + findNode, + parseInfoPlistStrings, + resolveReleaseAndDist, +} from "../../../src/lib/react-native/xcode-env.js"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFileSync: vi.fn(() => "") }; +}); +const execMock = vi.mocked(execFileSync); + +/** A complete four-key Info.plist XML body. */ +const PLIST_XML = ` + CFBundleNameNAME + CFBundleIdentifierBUNDLE + CFBundleShortVersionStringVERSION + CFBundleVersionBUILD +`; + +const dirs: string[] = []; +beforeEach(() => { + execMock.mockReset(); + execMock.mockReturnValue(""); +}); +afterEach(() => { + while (dirs.length > 0) { + const d = dirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } +}); + +describe("findNode / findHermesc", () => { + test("prefer env overrides", () => { + expect(findNode({ NODE_BINARY: "/usr/bin/node" })).toBe("/usr/bin/node"); + expect(findNode({})).toBe("node"); + expect(findHermesc({ HERMES_CLI_PATH: "/h" })).toBe("/h"); + expect(findHermesc({ PODS_ROOT: "/pods" })).toBe( + "/pods/hermes-engine/destroot/bin/hermesc" + ); + }); +}); + +describe("expandXcodeVars", () => { + test("expands parenthesized and braced references", () => { + const vars = { FOO: "hello world" }; + expect(expandXcodeVars("A$(FOO)B", vars)).toBe("Ahello worldB"); + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal Xcode ${VAR} syntax under test + expect(expandXcodeVars("A${FOO}B", vars)).toBe("Ahello worldB"); + expect(expandXcodeVars("$(MISSING)", vars)).toBe(""); + }); + + test("applies rfc1034identifier and identifier modifiers", () => { + const vars = { FOO_BAR: "a b/c" }; + expect(expandXcodeVars("$(FOO_BAR:rfc1034identifier)", vars)).toBe("a-b-c"); + expect(expandXcodeVars("$(FOO_BAR:identifier)", vars)).toBe("a_b_c"); + }); +}); + +describe("parseInfoPlistStrings", () => { + test("extracts key/string pairs and decodes entities", () => { + const xml = ` + + CFBundleIdentifiercom.example.app + CFBundleShortVersionString + 1.2.3 + CFBundleNameA & B +`; + const parsed = parseInfoPlistStrings(xml); + expect(parsed.CFBundleIdentifier).toBe("com.example.app"); + expect(parsed.CFBundleShortVersionString).toBe("1.2.3"); + expect(parsed.CFBundleName).toBe("A & B"); + }); +}); + +describe("discoverInfoPlist", () => { + test("returns null when not running inside Xcode", async () => { + expect(await discoverInfoPlist({}, "/tmp")).toBeNull(); + }); + + test("reads and expands the Info.plist file", async () => { + const dir = mkdtempSync(join(tmpdir(), "rn-plist-")); + dirs.push(dir); + writeFileSync( + join(dir, "Info.plist"), + ` + CFBundleNameMyApp + CFBundleIdentifiercom.example.$(SUFFIX) + CFBundleShortVersionString2.0.0 + CFBundleVersion42 + ` + ); + const plist = await discoverInfoPlist( + { + XCODE_VERSION_ACTUAL: "1500", + INFOPLIST_FILE: "Info.plist", + SUFFIX: "app", + }, + dir + ); + expect(plist).toEqual({ + name: "MyApp", + bundleId: "com.example.app", + version: "2.0.0", + build: "42", + }); + }); + + test("falls back to build-setting env vars", async () => { + const plist = await discoverInfoPlist( + { + XCODE_VERSION_ACTUAL: "1500", + PRODUCT_NAME: "MyApp", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.app", + MARKETING_VERSION: "1.0.0", + CURRENT_PROJECT_VERSION: "7", + }, + "/tmp" + ); + expect(plist).toMatchObject({ bundleId: "com.example.app", build: "7" }); + }); +}); + +describe("resolveReleaseAndDist", () => { + test("prefers SENTRY_RELEASE/SENTRY_DIST env vars", async () => { + const result = await resolveReleaseAndDist( + { SENTRY_RELEASE: "app@1.0", SENTRY_DIST: "100" }, + "/tmp", + false + ); + expect(result).toEqual({ release: "app@1.0", dist: "100" }); + }); + + test("returns empty when no-auto-release and no env vars", async () => { + expect(await resolveReleaseAndDist({}, "/tmp", true)).toEqual({}); + }); + + test("derives release from Info.plist", async () => { + const result = await resolveReleaseAndDist( + { + XCODE_VERSION_ACTUAL: "1500", + PRODUCT_NAME: "MyApp", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.app", + MARKETING_VERSION: "3.1.0", + CURRENT_PROJECT_VERSION: "55", + }, + "/tmp", + false + ); + expect(result).toEqual({ + dist: "55", + release: "com.example.app@3.1.0+55", + }); + }); + + test("throws when the identity cannot be determined", async () => { + await expect(resolveReleaseAndDist({}, "/tmp", false)).rejects.toThrow( + /Could not determine release/ + ); + }); +}); + +describe("discoverInfoPlist: INFOPLIST_PREPROCESS", () => { + test("runs cc when preprocessing is allowed and requested", async () => { + execMock.mockReturnValue( + PLIST_XML.replace("NAME", "Pre") + .replace("BUNDLE", "com.pre.app") + .replace("VERSION", "9.9") + .replace("BUILD", "99") + ); + const plist = await discoverInfoPlist( + { + XCODE_VERSION_ACTUAL: "1500", + INFOPLIST_FILE: "Info.plist", + INFOPLIST_PREPROCESS: "YES", + INFOPLIST_PREPROCESSOR_DEFINITIONS: "DEBUG=1", + }, + "/tmp", + true + ); + expect(plist).toEqual({ + name: "Pre", + bundleId: "com.pre.app", + version: "9.9", + build: "99", + }); + expect(execMock).toHaveBeenCalledWith( + "cc", + expect.arrayContaining(["-xc", "-P", "-E", "-DDEBUG=1"]), + expect.anything() + ); + }); + + test("does not run cc when preprocessing is not allowed", async () => { + const plist = await discoverInfoPlist( + { + XCODE_VERSION_ACTUAL: "1500", + INFOPLIST_FILE: "Info.plist", + INFOPLIST_PREPROCESS: "YES", + // Fallback env vars so discovery still succeeds. + PRODUCT_NAME: "Fallback", + PRODUCT_BUNDLE_IDENTIFIER: "com.fallback.app", + MARKETING_VERSION: "1.0", + CURRENT_PROJECT_VERSION: "1", + }, + "/does-not-exist", + false + ); + expect(execMock).not.toHaveBeenCalled(); + expect(plist).toMatchObject({ bundleId: "com.fallback.app" }); + }); +}); + +describe("discoverInfoPlist: xcodebuild discovery (outside Xcode)", () => { + test("resolves the release via xcodebuild when not in an Xcode build", async () => { + const dir = mkdtempSync(join(tmpdir(), "rn-xcodeproj-")); + dirs.push(dir); + mkdirSync(join(dir, "MyApp.xcodeproj")); + mkdirSync(join(dir, "MyApp")); + writeFileSync( + join(dir, "MyApp", "Info.plist"), + PLIST_XML.replace("NAME", "MyApp") + .replace("BUNDLE", "com.xcbuild.app") + .replace("VERSION", "4.2.0") + .replace("BUILD", "7") + ); + + execMock.mockImplementation((_cmd: string, args?: readonly string[]) => { + if (args?.includes("-list")) { + return JSON.stringify({ + project: { targets: ["MyApp"], configurations: ["Debug", "Release"] }, + }); + } + if (args?.includes("-showBuildSettings")) { + // CRLF endings must not leak a trailing \r into the parsed values. + return ` INFOPLIST_FILE = MyApp/Info.plist\r\n PROJECT_DIR = ${dir}\r\n`; + } + return ""; + }); + + const plist = await discoverInfoPlist({}, dir); + expect(plist).toEqual({ + name: "MyApp", + bundleId: "com.xcbuild.app", + version: "4.2.0", + build: "7", + }); + // Used the Release configuration. + expect(execMock).toHaveBeenCalledWith( + "xcodebuild", + expect.arrayContaining(["-configuration", "Release"]), + expect.anything() + ); + }); + + test("returns null when there is no .xcodeproj", async () => { + const dir = mkdtempSync(join(tmpdir(), "rn-empty-")); + dirs.push(dir); + expect(await discoverInfoPlist({}, dir)).toBeNull(); + expect(execMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/region.test.ts b/packages/cli/test/lib/region.test.ts new file mode 100644 index 000000000..dcbd1f67b --- /dev/null +++ b/packages/cli/test/lib/region.test.ts @@ -0,0 +1,394 @@ +/** + * Region Resolution Tests + * + * Tests for resolving organization regions in multi-region Sentry support. + */ + +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { + isMultiRegionEnabled, + resolveOrgRegion, +} from "../../src/lib/region.js"; +import { getSentryBaseUrl } from "../../src/lib/sentry-urls.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("region-resolve-"); + +beforeEach(async () => { + // Clear any SENTRY_HOST/SENTRY_URL override for most tests + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + // Set up auth token for API tests + await setAuthToken("test-token"); +}); + +afterEach(() => { + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; +}); + +describe("getSentryBaseUrl", () => { + test("returns sentry.io by default", () => { + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + expect(getSentryBaseUrl()).toBe("https://sentry.io"); + }); + + test("respects SENTRY_URL env var", () => { + process.env.SENTRY_URL = "https://sentry.mycompany.com"; + expect(getSentryBaseUrl()).toBe("https://sentry.mycompany.com"); + }); + + test("respects SENTRY_HOST env var", () => { + process.env.SENTRY_HOST = "https://sentry.mycompany.com"; + expect(getSentryBaseUrl()).toBe("https://sentry.mycompany.com"); + }); + + test("SENTRY_HOST takes precedence over SENTRY_URL", () => { + process.env.SENTRY_HOST = "https://host.example.com"; + process.env.SENTRY_URL = "https://url.example.com"; + expect(getSentryBaseUrl()).toBe("https://host.example.com"); + }); +}); + +describe("isMultiRegionEnabled", () => { + test("returns true for sentry.io (default)", () => { + delete process.env.SENTRY_URL; + expect(isMultiRegionEnabled()).toBe(true); + }); + + test("returns true for *.sentry.io URLs", () => { + process.env.SENTRY_URL = "https://us.sentry.io"; + expect(isMultiRegionEnabled()).toBe(true); + }); + + test("returns true for de.sentry.io", () => { + process.env.SENTRY_URL = "https://de.sentry.io"; + expect(isMultiRegionEnabled()).toBe(true); + }); + + test("returns false for self-hosted URLs", () => { + process.env.SENTRY_URL = "https://sentry.mycompany.com"; + expect(isMultiRegionEnabled()).toBe(false); + }); + + test("returns false for localhost", () => { + process.env.SENTRY_URL = "http://localhost:9000"; + expect(isMultiRegionEnabled()).toBe(false); + }); + + // Security edge cases - ensure proper URL parsing + test("returns false for URL with sentry.io in path (not hostname)", () => { + process.env.SENTRY_URL = "https://evil.com/sentry.io"; + expect(isMultiRegionEnabled()).toBe(false); + }); + + test("returns false for lookalike domain sentry.io.evil.com", () => { + process.env.SENTRY_URL = "https://sentry.io.evil.com"; + expect(isMultiRegionEnabled()).toBe(false); + }); + + test("returns false for domain with sentry.io prefix", () => { + process.env.SENTRY_URL = "https://sentry.io-fake.com"; + expect(isMultiRegionEnabled()).toBe(false); + }); + + test("returns false for invalid URL", () => { + process.env.SENTRY_URL = "not-a-valid-url"; + expect(isMultiRegionEnabled()).toBe(false); + }); + + test("respects SENTRY_HOST for self-hosted", () => { + process.env.SENTRY_HOST = "https://sentry.mycompany.com"; + expect(isMultiRegionEnabled()).toBe(false); + }); + + test("SENTRY_HOST takes precedence over SENTRY_URL", () => { + process.env.SENTRY_HOST = "https://sentry.mycompany.com"; + process.env.SENTRY_URL = "https://us.sentry.io"; + expect(isMultiRegionEnabled()).toBe(false); + }); +}); + +describe("resolveOrgRegion", () => { + test("returns cached region when available", async () => { + setOrgRegion("cached-org", "https://de.sentry.io"); + + const regionUrl = await resolveOrgRegion("cached-org"); + expect(regionUrl).toBe("https://de.sentry.io"); + }); + + test("fetches and caches region from API on cache miss", async () => { + // Import getOrgRegion to verify caching + const { getOrgRegion } = await import("../../src/lib/db/regions.js"); + + // Verify org is not in cache + const before = getOrgRegion("new-org"); + expect(before).toBeUndefined(); + + // Mock fetch to return org with regionUrl + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/new-org/")) { + return new Response( + JSON.stringify({ + id: "123", + slug: "new-org", + name: "New Organization", + links: { + organizationUrl: "https://de.sentry.io/organizations/new-org/", + regionUrl: "https://de.sentry.io", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + try { + const regionUrl = await resolveOrgRegion("new-org"); + + // Should return the region from API + expect(regionUrl).toBe("https://de.sentry.io"); + + // Should have cached the region + const after = getOrgRegion("new-org"); + expect(after).toBe("https://de.sentry.io"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("uses default URL as region when org has no links.regionUrl", async () => { + // Mock fetch to return org without links + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/no-links-org/")) { + return new Response( + JSON.stringify({ + id: "456", + slug: "no-links-org", + name: "Org Without Links", + // No links field + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + try { + const regionUrl = await resolveOrgRegion("no-links-org"); + + // Should fall back to default URL + expect(regionUrl).toBe("https://sentry.io"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("resolves a relative regionUrl against baseUrl", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/relative-region-org/")) { + return new Response( + JSON.stringify({ + id: "789", + slug: "relative-region-org", + name: "Self-hosted Org", + links: { + organizationUrl: "/organizations/relative-region-org/", + // Self-hosted instance returns a relative path instead of an absolute URL + regionUrl: "/", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + try { + const regionUrl = await resolveOrgRegion("relative-region-org"); + // Relative "/" resolves to the base origin, producing an absolute URL + // instead of a broken relative value. + expect(regionUrl).toBe("https://sentry.io"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("falls back to baseUrl when API returns a malformed regionUrl", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/malformed-region-org/")) { + return new Response( + JSON.stringify({ + id: "790", + slug: "malformed-region-org", + name: "Self-hosted Org 2", + links: { + organizationUrl: "/organizations/malformed-region-org/", + regionUrl: "not-a-valid-url", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + try { + // A path-like relative value resolves against the base origin. + const regionUrl = await resolveOrgRegion("malformed-region-org"); + expect(regionUrl).toBe("https://sentry.io"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("falls back to default URL when API call fails", async () => { + // Mock fetch to fail + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("Network error"); + }; + + try { + const regionUrl = await resolveOrgRegion("unknown-org"); + // Should fall back to default + expect(regionUrl).toBe("https://sentry.io"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("uses SENTRY_URL as fallback for self-hosted", async () => { + // The test's beforeEach stored an OAuth row with host = SaaS (since + // SENTRY_URL was unset at store time). Re-scope the stored token to + // the self-hosted host so the fetch-layer guard admits the request. + process.env.SENTRY_URL = "https://sentry.mycompany.com"; + await setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.mycompany.com", + }); + + // Mock fetch to fail (no multi-region on self-hosted) + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("Network error"); + }; + + try { + const regionUrl = await resolveOrgRegion("self-hosted-org"); + expect(regionUrl).toBe("https://sentry.mycompany.com"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("uses cached region on subsequent calls", async () => { + // Pre-populate cache (simulating what happens after a successful API fetch) + setOrgRegion("cached-org-2", "https://de.sentry.io"); + + // First call should use cache + const regionUrl1 = await resolveOrgRegion("cached-org-2"); + expect(regionUrl1).toBe("https://de.sentry.io"); + + // Second call should also use cache + const regionUrl2 = await resolveOrgRegion("cached-org-2"); + expect(regionUrl2).toBe("https://de.sentry.io"); + }); + + test("cache survives across multiple resolve calls", async () => { + // Populate cache with multiple orgs + setOrgRegion("org-a", "https://us.sentry.io"); + setOrgRegion("org-b", "https://de.sentry.io"); + + // Resolve in different order + expect(await resolveOrgRegion("org-b")).toBe("https://de.sentry.io"); + expect(await resolveOrgRegion("org-a")).toBe("https://us.sentry.io"); + expect(await resolveOrgRegion("org-b")).toBe("https://de.sentry.io"); + }); + + test("deduplicates concurrent API calls for the same org", async () => { + let fetchCount = 0; + + // Mock fetch to track call count + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/dedup-org/")) { + fetchCount += 1; + // Small delay to ensure concurrency overlap + await sleep(50); + return new Response( + JSON.stringify({ + id: "789", + slug: "dedup-org", + name: "Dedup Organization", + links: { + organizationUrl: "https://us.sentry.io/organizations/dedup-org/", + regionUrl: "https://us.sentry.io", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + try { + // Fire 5 concurrent calls for the same org + const results = await Promise.all([ + resolveOrgRegion("dedup-org"), + resolveOrgRegion("dedup-org"), + resolveOrgRegion("dedup-org"), + resolveOrgRegion("dedup-org"), + resolveOrgRegion("dedup-org"), + ]); + + // All should return the same result + for (const result of results) { + expect(result).toBe("https://us.sentry.io"); + } + + // Only one fetch should have been made (in-flight dedup) + expect(fetchCount).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/cli/test/lib/release-notes.property.test.ts b/packages/cli/test/lib/release-notes.property.test.ts new file mode 100644 index 000000000..e9c4078aa --- /dev/null +++ b/packages/cli/test/lib/release-notes.property.test.ts @@ -0,0 +1,218 @@ +/** + * Property-Based Tests for Release Notes Parser + * + * Uses fast-check to verify properties that should always hold true + * for the release notes extraction and commit parsing, regardless of input. + */ + +import { + array, + constantFrom, + assert as fcAssert, + nat, + property, + record, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + type ChangeCategory, + extractNightlyTimestamp, + extractSections, + parseCommitMessages, +} from "../../src/lib/release-notes.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// ─────────────────────────── Arbitraries ─────────────────────────────────── + +const VALID_CATEGORIES: readonly ChangeCategory[] = [ + "features", + "fixes", + "performance", +]; + +/** Section headers that should be kept */ +const KEPT_HEADERS = [ + "### New Features ✨", + "### Bug Fixes 🐛", + "### Performance", +]; + +/** Section headers that should be filtered out */ +const FILTERED_HEADERS = [ + "### Internal Changes 🔧", + "### Documentation 📚", + "### Refactoring", + "### Tests", + "### Chores", +]; + +/** Simple description text */ +const descriptionArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz ".split("")), + { minLength: 3, maxLength: 30 } +).map((chars) => chars.join("").trim() || "change"); + +/** Generate a section body with list items */ +const sectionBodyArb = array(descriptionArb, { + minLength: 1, + maxLength: 5, +}).map((items) => items.map((item) => `- ${item}`).join("\n")); + +/** Generate a full release body with mixed kept and filtered sections. + * Sections are concatenated in arbitrary order — extractSections splits + * on ### headings regardless of ordering. */ +const releaseBodyArb = tuple( + array(tuple(constantFrom(...KEPT_HEADERS), sectionBodyArb), { + minLength: 0, + maxLength: 3, + }), + array(tuple(constantFrom(...FILTERED_HEADERS), sectionBodyArb), { + minLength: 0, + maxLength: 2, + }) +).map(([kept, filtered]) => { + // Interleave kept and filtered sections + const all = [...kept, ...filtered]; + return all.map(([header, body]) => `${header}\n\n${body}`).join("\n\n"); +}); + +/** Conventional commit message arbitrary */ +const commitPrefixArb = constantFrom( + "feat", + "fix", + "perf", + "docs", + "refactor", + "chore", + "test", + "ci", + "meta" +); +const commitScopeArb = constantFrom("", "(dashboard)", "(issue)", "(api)"); +const commitMessageArb = tuple( + commitPrefixArb, + commitScopeArb, + descriptionArb +).map(([prefix, scope, desc]) => `${prefix}${scope}: ${desc}`); + +// ─────────────────────────── Tests ───────────────────────────────────────── + +describe("property: extractSections", () => { + test("output categories are always valid", () => { + fcAssert( + property(releaseBodyArb, (body) => { + const sections = extractSections(body); + for (const section of sections) { + expect(VALID_CATEGORIES).toContain(section.category); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("internal changes / docs sections never appear in output", () => { + fcAssert( + property(releaseBodyArb, (body) => { + const sections = extractSections(body); + for (const section of sections) { + // Should never contain Internal Changes or Documentation categories + expect(section.category).not.toBe("internal"); + expect(section.category).not.toBe("docs"); + expect(section.category).not.toBe("documentation"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty body returns empty array", () => { + expect(extractSections("")).toEqual([]); + expect(extractSections(" ")).toEqual([]); + }); + + test("body with only filtered sections returns empty array", () => { + fcAssert( + property( + array(tuple(constantFrom(...FILTERED_HEADERS), sectionBodyArb), { + minLength: 1, + maxLength: 3, + }), + (sections) => { + const body = sections + .map(([header, items]) => `${header}\n\n${items}`) + .join("\n\n"); + expect(extractSections(body)).toEqual([]); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: parseCommitMessages", () => { + test("only feat/fix/perf commits pass through", () => { + fcAssert( + property( + array(record({ commit: record({ message: commitMessageArb }) }), { + minLength: 1, + maxLength: 20, + }), + (commits) => { + const sections = parseCommitMessages(commits); + for (const section of sections) { + expect(VALID_CATEGORIES).toContain(section.category); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("#skip-changelog commits are always filtered", () => { + fcAssert( + property(descriptionArb, (desc) => { + const commits = [ + { commit: { message: `feat: ${desc}\n\n#skip-changelog` } }, + { commit: { message: `fix: ${desc}\n\n#skip-changelog` } }, + { commit: { message: `perf: ${desc}\n\n#skip-changelog` } }, + ]; + const sections = parseCommitMessages(commits); + // All commits have #skip-changelog, so no sections should be produced + expect(sections).toEqual([]); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-conventional commits produce no output", () => { + const sections = parseCommitMessages([ + { commit: { message: "random commit message" } }, + { commit: { message: "another one without prefix" } }, + { commit: { message: "Merge pull request #123" } }, + ]); + expect(sections).toEqual([]); + }); +}); + +describe("property: extractNightlyTimestamp", () => { + test("round-trip: valid nightly versions extract correct timestamp", () => { + fcAssert( + property( + tuple(nat(30), nat(100), nat(100)), + nat({ max: 2_000_000_000 }), + ([major, minor, patch], ts) => { + const version = `${major}.${minor}.${patch}-dev.${ts}`; + expect(extractNightlyTimestamp(version)).toBe(ts); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("stable versions return null", () => { + expect(extractNightlyTimestamp("0.21.0")).toBeNull(); + expect(extractNightlyTimestamp("1.0.0")).toBeNull(); + expect(extractNightlyTimestamp("0.0.1")).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/release-notes.test.ts b/packages/cli/test/lib/release-notes.test.ts new file mode 100644 index 000000000..ffccf4901 --- /dev/null +++ b/packages/cli/test/lib/release-notes.test.ts @@ -0,0 +1,512 @@ +/** + * Unit Tests for Release Notes Parser & Aggregation + * + * Tests core invariants (section extraction, version filtering, truncation) + * that are hard to express as property-based tests due to format specifics. + * + * Core random-input invariants (category validity, filtering, commit parsing) + * are tested via property-based tests in release-notes.property.test.ts. + */ + +import { marked } from "marked"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { UPGRADE_SOURCES } from "../../src/lib/binary.js"; +import { + fetchRecentReleases, + type GitHubRelease, +} from "../../src/lib/delta-upgrade.js"; +import { + buildChangelogSummary, + type ChangeCategory, + countListItems, + extractNightlyTimestamp, + extractSections, + fetchChangelog, + parseCommitMessages, +} from "../../src/lib/release-notes.js"; +import { mockFetch } from "../helpers.js"; + +// ─────────────────────────── Fixtures ────────────────────────────────────── + +/** Realistic release body from 0.21.0 (simplified) */ +const SAMPLE_RELEASE_BODY = `### New Features ✨ + +#### Dashboard + +- Add pagination and glob filtering to dashboard list by @BYK in [#560](https://github.com/getsentry/cli/pull/560) +- Add a full chart rendering engine by @BYK in [#555](https://github.com/getsentry/cli/pull/555) + +#### Other + +- Bidirectional cursor pagination by @BYK in [#564](https://github.com/getsentry/cli/pull/564) +- Add \`sentry sourcemap inject\` and upload commands by @BYK in [#547](https://github.com/getsentry/cli/pull/547) + +### Bug Fixes 🐛 + +#### Dashboard + +- Fix table widget rendering by @BYK in [#584](https://github.com/getsentry/cli/pull/584) + +#### Other + +- Show meaningful message for network errors by @BYK in [#572](https://github.com/getsentry/cli/pull/572) + +### Documentation 📚 + +- Add missing command pages by @sergical in [#569](https://github.com/getsentry/cli/pull/569) + +### Internal Changes 🔧 + +- Use informational-patch input instead of sed hack by @BYK in [#544](https://github.com/getsentry/cli/pull/544) +- Bump Bun from 1.3.9 to 1.3.11 by @BYK in [#552](https://github.com/getsentry/cli/pull/552)`; + +function makeRelease(tagName: string, body?: string): GitHubRelease { + return { tag_name: tagName, assets: [], body }; +} + +// ─────────────────────── extractSections ──────────────────────────────────── + +describe("extractSections", () => { + test("extracts features and fixes, filters docs and internal", () => { + const sections = extractSections(SAMPLE_RELEASE_BODY); + const categories = sections.map((s) => s.category); + + expect(categories).toContain("features"); + expect(categories).toContain("fixes"); + expect(categories).not.toContain("docs" as ChangeCategory); + expect(categories).not.toContain("internal" as ChangeCategory); + }); + + test("features section contains correct entries", () => { + const sections = extractSections(SAMPLE_RELEASE_BODY); + const features = sections.find((s) => s.category === "features"); + expect(features).toBeDefined(); + expect(features!.markdown).toContain("pagination"); + expect(features!.markdown).toContain("sourcemap"); + }); + + test("preserves subheadings (#### scope groups)", () => { + const sections = extractSections(SAMPLE_RELEASE_BODY); + const features = sections.find((s) => s.category === "features"); + expect(features!.markdown).toContain("#### Dashboard"); + expect(features!.markdown).toContain("#### Other"); + }); + + test("empty body returns empty array", () => { + expect(extractSections("")).toEqual([]); + expect(extractSections(" \n ")).toEqual([]); + }); + + test("body with no matching sections returns empty array", () => { + const body = + "### Documentation 📚\n\n- Some doc change\n\n### Internal Changes 🔧\n\n- Some internal change"; + expect(extractSections(body)).toEqual([]); + }); + + test("handles performance section", () => { + const body = + "### Performance\n\n- Optimize query execution\n- Reduce memory usage"; + const sections = extractSections(body); + expect(sections).toHaveLength(1); + expect(sections[0]!.category).toBe("performance"); + }); +}); + +// ──────────────────── buildChangelogSummary ───────────────────────────────── + +describe("buildChangelogSummary", () => { + const releases: GitHubRelease[] = [ + makeRelease("0.21.0", SAMPLE_RELEASE_BODY), + makeRelease( + "0.20.0", + "### New Features ✨\n\n- Feature from 0.20 by @user in [#100](url)\n\n### Bug Fixes 🐛\n\n- Fix from 0.20 by @user in [#101](url)" + ), + makeRelease( + "0.19.0", + "### New Features ✨\n\n- Feature from 0.19 by @user in [#99](url)" + ), + makeRelease("0.18.0", "### Internal Changes 🔧\n\n- Only internal stuff"), + ]; + + test("filters releases within version range", () => { + const summary = buildChangelogSummary(releases, "0.19.0", "0.21.0"); + expect(summary).not.toBeNull(); + // Should include 0.20.0 and 0.21.0 but not 0.19.0 + expect(summary!.sections.length).toBeGreaterThan(0); + + const featuresMarkdown = + summary!.sections.find((s) => s.category === "features")?.markdown ?? ""; + expect(featuresMarkdown).toContain("Feature from 0.20"); + expect(featuresMarkdown).toContain("pagination"); + expect(featuresMarkdown).not.toContain("Feature from 0.19"); + }); + + test("returns null when no releases in range", () => { + expect(buildChangelogSummary(releases, "0.21.0", "0.22.0")).toBeNull(); + }); + + test("returns null when all bodies are empty", () => { + const emptyReleases = [makeRelease("0.21.0"), makeRelease("0.20.0", "")]; + expect(buildChangelogSummary(emptyReleases, "0.19.0", "0.21.0")).toBeNull(); + }); + + test("returns null when only internal changes in range", () => { + // 0.18.0 has only internal changes + expect(buildChangelogSummary(releases, "0.17.0", "0.18.0")).toBeNull(); + }); + + test("strips author attributions from entries", () => { + const summary = buildChangelogSummary(releases, "0.19.0", "0.20.0"); + expect(summary).not.toBeNull(); + const allMarkdown = summary!.sections.map((s) => s.markdown).join("\n"); + expect(allMarkdown).not.toContain("by @user in"); + }); + + test("merges same categories across releases", () => { + const summary = buildChangelogSummary(releases, "0.18.0", "0.21.0"); + expect(summary).not.toBeNull(); + + // Features from both 0.19, 0.20, and 0.21 should be in one section + const features = summary!.sections.filter((s) => s.category === "features"); + expect(features).toHaveLength(1); + }); + + test("sections appear in order: features, fixes, performance", () => { + const summary = buildChangelogSummary(releases, "0.18.0", "0.21.0"); + expect(summary).not.toBeNull(); + + const categories = summary!.sections.map((s) => s.category); + const featureIdx = categories.indexOf("features"); + const fixIdx = categories.indexOf("fixes"); + if (featureIdx >= 0 && fixIdx >= 0) { + expect(featureIdx).toBeLessThan(fixIdx); + } + }); + + test("handles v-prefix in tag names", () => { + const vReleases = [ + makeRelease("v0.21.0", "### New Features ✨\n\n- Something new"), + ]; + const summary = buildChangelogSummary(vReleases, "0.20.0", "0.21.0"); + expect(summary).not.toBeNull(); + expect(summary!.sections).toHaveLength(1); + }); + + test("truncation with maxItems", () => { + const summary = buildChangelogSummary(releases, "0.18.0", "0.21.0", 3); + expect(summary).not.toBeNull(); + expect(summary!.truncated).toBe(true); + expect(summary!.totalItems).toBeLessThanOrEqual(3); + expect(summary!.originalCount).toBeGreaterThan(3); + }); +}); + +// ──────────────────── parseCommitMessages ─────────────────────────────────── + +describe("parseCommitMessages", () => { + test("groups by category correctly", () => { + const commits = [ + { commit: { message: "feat: add new feature" } }, + { commit: { message: "fix: resolve bug" } }, + { commit: { message: "perf: optimize query" } }, + { commit: { message: "docs: update readme" } }, + { commit: { message: "chore: bump deps" } }, + ]; + + const sections = parseCommitMessages(commits); + const categories = sections.map((s) => s.category); + + expect(categories).toContain("features"); + expect(categories).toContain("fixes"); + expect(categories).toContain("performance"); + expect(sections).toHaveLength(3); + }); + + test("extracts scoped commit descriptions", () => { + const commits = [ + { commit: { message: "feat(dashboard): add pagination support" } }, + ]; + + const sections = parseCommitMessages(commits); + expect(sections[0]!.markdown).toContain("add pagination support"); + }); + + test("uses only first line of multi-line messages", () => { + const commits = [ + { + commit: { + message: + "feat: short summary\n\nLong description that should be ignored\n\nAnother paragraph", + }, + }, + ]; + + const sections = parseCommitMessages(commits); + expect(sections[0]!.markdown).toContain("short summary"); + expect(sections[0]!.markdown).not.toContain("Long description"); + }); + + test("filters #skip-changelog commits", () => { + const commits = [ + { commit: { message: "feat: visible change" } }, + { commit: { message: "feat: hidden change\n\n#skip-changelog" } }, + ]; + + const sections = parseCommitMessages(commits); + const allMarkdown = sections.map((s) => s.markdown).join("\n"); + expect(allMarkdown).toContain("visible change"); + expect(allMarkdown).not.toContain("hidden change"); + }); + + test("empty commits produce empty sections", () => { + expect(parseCommitMessages([])).toEqual([]); + }); +}); + +// ──────────────────── extractNightlyTimestamp ─────────────────────────────── + +describe("extractNightlyTimestamp", () => { + test("extracts timestamp from standard nightly format", () => { + expect(extractNightlyTimestamp("0.22.0-dev.1772661724")).toBe( + 1_772_661_724 + ); + }); + + test("returns null for stable versions", () => { + expect(extractNightlyTimestamp("0.21.0")).toBeNull(); + }); + + test("returns null for invalid format", () => { + expect(extractNightlyTimestamp("not-a-version")).toBeNull(); + expect(extractNightlyTimestamp("")).toBeNull(); + }); +}); + +// ──────────────────── countListItems ──────────────────────────────────────── + +describe("countListItems", () => { + test("counts items in a simple list", () => { + const tokens = marked.lexer("- item 1\n- item 2\n- item 3"); + expect(countListItems(tokens)).toBe(3); + }); + + test("returns 0 for non-list content", () => { + const tokens = marked.lexer("Just a paragraph."); + expect(countListItems(tokens)).toBe(0); + }); + + test("returns 0 for empty token array", () => { + expect(countListItems([])).toBe(0); + }); +}); + +describe("fetchChangelog source affinity", () => { + const toolkitSource = UPGRADE_SOURCES[0]!; + const legacySource = UPGRADE_SOURCES[1]!; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("fetches stable releases only from the explicitly selected Toolkit source", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify([ + makeRelease( + "mcp@99.0.0", + "### New Features ✨\n\n- Unrelated Toolkit package release" + ), + makeRelease( + "0.21.0", + "### Bug Fixes 🐛\n\n- Unprefixed Toolkit release" + ), + makeRelease( + "cli@0.21.0", + "### Bug Fixes 🐛\n\n- Keep release stages source-affine" + ), + ]), + { status: 200 } + ); + }); + + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + }); + + expect(changelog?.totalItems).toBe(1); + expect(changelog?.sections[0]?.markdown).not.toContain( + "Unrelated Toolkit package release" + ); + expect(changelog?.sections[0]?.markdown).not.toContain( + "Unprefixed Toolkit release" + ); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/releases?per_page=30", + ]); + expect(requestedUrls.some((url) => url.includes("getsentry/cli"))).toBe( + false + ); + }); + + test("builds a Toolkit changelog from normalized prefetched releases", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify([ + makeRelease( + "cli@0.21.0", + "### Bug Fixes 🐛\n\n- Reuse prefetched releases" + ), + ]), + { status: 200 } + ) + ); + + const releases = await fetchRecentReleases(undefined, toolkitSource); + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: { ...toolkitSource }, + prefetchedReleases: releases, + }); + + expect(changelog?.totalItems).toBe(1); + expect(changelog?.sections[0]?.markdown).toContain( + "Reuse prefetched releases" + ); + }); + + test("rejects normalized releases from another source", async () => { + const releases = await fetchRecentReleases(undefined, toolkitSource); + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: UPGRADE_SOURCES[1], + prefetchedReleases: releases, + }); + + expect(changelog).toBeNull(); + }); + + test("rejects raw prefetched Toolkit releases without fetching", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response("Unexpected", { status: 500 }); + }); + + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + prefetchedReleases: [ + makeRelease("mcp@0.21.0", "- Unrelated MCP release"), + makeRelease( + "cli@0.21.0", + "### Bug Fixes 🐛\n\n- Raw prefetched release" + ), + ] as never, + }); + + expect(changelog).toBeNull(); + expect(requestedUrls).toEqual([]); + }); + + test("rejects unprefixed raw prefetched releases for Toolkit", async () => { + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + prefetchedReleases: [ + makeRelease("0.21.0", "### Bug Fixes 🐛\n\n- Legacy release"), + ] as never, + }); + + expect(changelog).toBeNull(); + }); + + test("excludes semantic prereleases from stable changelogs", async () => { + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: toolkitSource, + prefetchedReleases: [ + makeRelease("cli@0.21.0-dev.1", "- Development release"), + makeRelease("cli@0.21.0", "### Bug Fixes 🐛\n\n- Stable release"), + ] as never, + }); + + expect(changelog).toBeNull(); + }); + + test("fetches stable releases only from the explicitly selected legacy source", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify([ + makeRelease( + "0.21.0", + "### Bug Fixes 🐛\n\n- Keep the legacy bridge working" + ), + ]), + { status: 200 } + ); + }); + + const changelog = await fetchChangelog({ + channel: "stable", + fromVersion: "0.20.0", + toVersion: "0.21.0", + source: legacySource, + }); + + expect(changelog?.totalItems).toBe(1); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/cli/releases?per_page=30", + ]); + }); + + test("fetches nightly commits only from the explicitly selected Toolkit source", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = mockFetch(async (input) => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify([ + { commit: { message: "fix: keep nightly stages affine" } }, + ]), + { status: 200 } + ); + }); + + const changelog = await fetchChangelog({ + channel: "nightly", + fromVersion: "0.21.0-dev.100", + toVersion: "0.21.0-dev.200", + source: toolkitSource, + }); + + expect(changelog?.totalItems).toBe(1); + expect(requestedUrls).toEqual([ + "https://api.github.com/repos/getsentry/toolkit/commits?sha=main&since=1970-01-01T00:01:41.000Z&until=1970-01-01T00:03:21.000Z&per_page=100", + ]); + expect(requestedUrls.some((url) => url.includes("getsentry/cli"))).toBe( + false + ); + }); +}); diff --git a/packages/cli/test/lib/release-parse.property.test.ts b/packages/cli/test/lib/release-parse.property.test.ts new file mode 100644 index 000000000..f43198be1 --- /dev/null +++ b/packages/cli/test/lib/release-parse.property.test.ts @@ -0,0 +1,180 @@ +import { array, constantFrom, assert as fcAssert, property } from "fast-check"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + parseReleaseArg, + resolveReleaseTarget, +} from "../../src/commands/release/parse.js"; +import { ContextError, ValidationError } from "../../src/lib/errors.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +vi.mock("../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../src/lib/resolve-target.js"; + +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + +const simpleSlugArb = array(constantFrom(...slugChars.split("")), { + minLength: 1, + maxLength: 15, +}).map((chars) => chars.join("")); + +const slugWithHyphensArb = array(constantFrom(...`${slugChars}-`.split("")), { + minLength: 2, + maxLength: 20, +}) + .map((chars) => chars.join("")) + .filter((s) => !(s.startsWith("-") || s.endsWith("-") || s.includes("--"))); + +const versionArb = array( + constantFrom(..."0123456789.abcdefghijklmnopqrstuvwxyz-+@".split("")), + { minLength: 1, maxLength: 20 } +).map((chars) => chars.join("")); + +describe("property: parseReleaseArg", () => { + test("round-trip: org/version → orgSlug + '/' + version === input", () => { + fcAssert( + property(slugWithHyphensArb, versionArb, (slug, version) => { + const input = `${slug}/${version}`; + const result = parseReleaseArg(input, "test"); + if (result.orgSlug) { + expect(`${result.orgSlug}/${result.version}`).toBe(input); + } else { + expect(result.version).toBe(input); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("version with @ is never split into org", () => { + fcAssert( + property( + versionArb.filter((v) => v.includes("@")), + (version) => { + const result = parseReleaseArg(version, "test"); + // The @ in the prefix would make it not match SLUG_RE + // so the whole string should be the version + expect(result.version).toBe(version); + expect(result.orgSlug).toBeUndefined(); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("valid slug prefix always extracts org", () => { + fcAssert( + property( + simpleSlugArb, + versionArb.filter((v) => v.length > 0), + (slug, version) => { + const input = `${slug}/${version}`; + const result = parseReleaseArg(input, "test"); + expect(result.orgSlug).toBe(slug); + expect(result.version).toBe(version); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("plain version without slash returns no org", () => { + fcAssert( + property( + versionArb.filter((v) => !v.includes("/") && v.length > 0), + (version) => { + const result = parseReleaseArg(version, "test"); + expect(result.orgSlug).toBeUndefined(); + expect(result.version).toBe(version); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("unit: parseReleaseArg edge cases", () => { + test("empty string throws ValidationError", () => { + expect(() => parseReleaseArg("", "test")).toThrow(ValidationError); + }); + + test("sentry-cli@0.24.0 is a plain version (no org)", () => { + const result = parseReleaseArg("sentry-cli@0.24.0", "test"); + expect(result.version).toBe("sentry-cli@0.24.0"); + expect(result.orgSlug).toBeUndefined(); + }); + + test("my-org/1.0.0 extracts org and version", () => { + const result = parseReleaseArg("my-org/1.0.0", "test"); + expect(result.orgSlug).toBe("my-org"); + expect(result.version).toBe("1.0.0"); + }); + + test("my-org/sentry-cli@0.24.0 extracts org and version with @", () => { + const result = parseReleaseArg("my-org/sentry-cli@0.24.0", "test"); + expect(result.orgSlug).toBe("my-org"); + expect(result.version).toBe("sentry-cli@0.24.0"); + }); + + test("version with leading slash treated as plain version", () => { + const result = parseReleaseArg("/1.0.0", "test"); + expect(result.version).toBe("/1.0.0"); + expect(result.orgSlug).toBeUndefined(); + }); +}); + +describe("unit: resolveReleaseTarget", () => { + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + resolveOrgSpy.mockRestore(); + }); + + const USAGE = "sentry release archive [/]"; + + test("resolves version and org from a target", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + const result = await resolveReleaseTarget("my-org/1.0.0", USAGE, "/tmp"); + expect(result).toEqual({ version: "1.0.0", org: "my-org" }); + expect(resolveOrgSpy).toHaveBeenCalledWith({ org: "my-org", cwd: "/tmp" }); + }); + + test("forwards detectedFrom from the resolved org", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org", detectedFrom: "DSN" }); + const result = await resolveReleaseTarget("1.0.0", USAGE, "/tmp"); + expect(result.detectedFrom).toBe("DSN"); + }); + + test("throws ContextError when target is undefined", async () => { + await expect( + resolveReleaseTarget(undefined, USAGE, "/tmp") + ).rejects.toThrow(ContextError); + }); + + test("throws ContextError when target is whitespace", async () => { + await expect(resolveReleaseTarget(" ", USAGE, "/tmp")).rejects.toThrow( + "Release version" + ); + }); + + test("throws ContextError when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + await expect(resolveReleaseTarget("1.0.0", USAGE, "/tmp")).rejects.toThrow( + ContextError + ); + }); +}); diff --git a/packages/cli/test/lib/replay-duration.test.ts b/packages/cli/test/lib/replay-duration.test.ts new file mode 100644 index 000000000..6459caf60 --- /dev/null +++ b/packages/cli/test/lib/replay-duration.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; + +import { + formatDurationCompact, + formatDurationCompactMs, + formatDurationVerbose, +} from "../../src/lib/formatters/time-utils.js"; + +describe("formatDurationCompact", () => { + test("formats short durations", () => { + expect(formatDurationCompact(59)).toBe("59s"); + expect(formatDurationCompact(60)).toBe("1m"); + expect(formatDurationCompact(125)).toBe("2m 5s"); + }); + + test("formats long durations", () => { + expect(formatDurationCompact(3600)).toBe("1h"); + expect(formatDurationCompact(3665)).toBe("1h 1m"); + expect(formatDurationCompact(90_061)).toBe("1d 1h"); + }); + + test("handles missing durations", () => { + expect(formatDurationCompact(null)).toBe("—"); + expect(formatDurationCompact(undefined)).toBe("—"); + }); +}); + +describe("formatDurationCompactMs", () => { + test("converts ms to seconds and formats compactly", () => { + expect(formatDurationCompactMs(5000)).toBe("5s"); + expect(formatDurationCompactMs(125_000)).toBe("2m 5s"); + expect(formatDurationCompactMs(3_665_000)).toBe("1h 1m"); + }); + + test("handles sub-second durations", () => { + expect(formatDurationCompactMs(0)).toBe("0s"); + expect(formatDurationCompactMs(499)).toBe("0s"); + expect(formatDurationCompactMs(500)).toBe("1s"); + }); +}); + +describe("formatDurationVerbose", () => { + test("formats short durations", () => { + expect(formatDurationVerbose(1)).toBe("1 second"); + expect(formatDurationVerbose(125)).toBe("2 minutes and 5 seconds"); + }); + + test("formats long durations", () => { + expect(formatDurationVerbose(3600)).toBe("1 hour"); + expect(formatDurationVerbose(3665)).toBe("1 hour and 1 minute"); + expect(formatDurationVerbose(90_061)).toBe("1 day and 1 hour"); + }); +}); diff --git a/packages/cli/test/lib/replay-search.test.ts b/packages/cli/test/lib/replay-search.test.ts new file mode 100644 index 000000000..743f39515 --- /dev/null +++ b/packages/cli/test/lib/replay-search.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { + getReplayRequestFields, + isSupportedReplayField, +} from "../../src/lib/replay-search.js"; + +describe("getReplayRequestFields", () => { + test("normalizes replay field aliases for API requests", () => { + expect(getReplayRequestFields(["url", "trace_id"])).toEqual([ + "id", + "urls", + "trace_ids", + ]); + }); + + test("requests backing array fields for convenience replay columns", () => { + expect( + getReplayRequestFields([ + "error_id", + "info_id", + "release", + "screen", + "warning_id", + ]) + ).toEqual([ + "id", + "error_ids", + "info_ids", + "releases", + "urls", + "warning_ids", + ]); + }); +}); + +describe("isSupportedReplayField", () => { + test("does not expose replay detail-only fields in replay explore", () => { + expect(isSupportedReplayField("replay_type")).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/resolve-effective-org.test.ts b/packages/cli/test/lib/resolve-effective-org.test.ts new file mode 100644 index 000000000..467710be5 --- /dev/null +++ b/packages/cli/test/lib/resolve-effective-org.test.ts @@ -0,0 +1,305 @@ +/** + * Tests for resolveEffectiveOrg and DSN org ID resolution. + * + * Covers the offline cache lookup path that resolves DSN-style org + * identifiers (e.g., `o1081365`) to real org slugs using the local + * org_regions cache. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + getOrgByNumericId, + getOrgRegion, + setOrgRegion, + setOrgRegions, +} from "../../src/lib/db/regions.js"; +import { logger } from "../../src/lib/logger.js"; +import { resolveEffectiveOrg } from "../../src/lib/region.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("resolve-effective-org-"); + +// getOrgByNumericId (DB layer) + +describe("getOrgByNumericId", () => { + test("returns slug and regionUrl when org_id matches", async () => { + setOrgRegions([ + { slug: "my-org", regionUrl: "https://us.sentry.io", orgId: "1081365" }, + ]); + + const result = getOrgByNumericId("1081365"); + expect(result).toEqual({ + slug: "my-org", + regionUrl: "https://us.sentry.io", + }); + }); + + test("returns undefined when no org_id matches", async () => { + setOrgRegions([ + { slug: "my-org", regionUrl: "https://us.sentry.io", orgId: "1081365" }, + ]); + + const result = getOrgByNumericId("9999999"); + expect(result).toBeUndefined(); + }); + + test("returns undefined when org_id column is null", async () => { + setOrgRegion("my-org", "https://us.sentry.io"); + + const result = getOrgByNumericId("1081365"); + expect(result).toBeUndefined(); + }); + + test("returns correct org when multiple orgs exist", async () => { + setOrgRegions([ + { slug: "org-a", regionUrl: "https://us.sentry.io", orgId: "111" }, + { slug: "org-b", regionUrl: "https://de.sentry.io", orgId: "222" }, + { slug: "org-c", regionUrl: "https://us.sentry.io", orgId: "333" }, + ]); + + const result = getOrgByNumericId("222"); + expect(result).toEqual({ + slug: "org-b", + regionUrl: "https://de.sentry.io", + }); + }); +}); + +// setOrgRegions with orgId + +describe("setOrgRegions with orgId", () => { + test("stores org_id and allows lookup by numeric ID", async () => { + setOrgRegions([ + { slug: "acme", regionUrl: "https://us.sentry.io", orgId: "42" }, + ]); + + const region = getOrgRegion("acme"); + expect(region).toBe("https://us.sentry.io"); + + const byId = getOrgByNumericId("42"); + expect(byId).toEqual({ slug: "acme", regionUrl: "https://us.sentry.io" }); + }); + + test("handles entries without orgId", async () => { + setOrgRegions([{ slug: "no-id-org", regionUrl: "https://de.sentry.io" }]); + + const region = getOrgRegion("no-id-org"); + expect(region).toBe("https://de.sentry.io"); + + const byId = getOrgByNumericId("no-id-org"); + expect(byId).toBeUndefined(); + }); + + test("upserts update region on slug conflict", async () => { + setOrgRegions([ + { slug: "my-org", regionUrl: "https://us.sentry.io", orgId: "100" }, + ]); + + setOrgRegions([ + { slug: "my-org", regionUrl: "https://de.sentry.io", orgId: "100" }, + ]); + + const region = getOrgRegion("my-org"); + expect(region).toBe("https://de.sentry.io"); + + const byId = getOrgByNumericId("100"); + expect(byId?.slug).toBe("my-org"); + }); + + test("empty entries array is a no-op", async () => { + setOrgRegions([]); + }); +}); + +// resolveEffectiveOrg — cache-hit paths (no API calls needed) + +describe("resolveEffectiveOrg", () => { + test("returns orgSlug when slug is already cached", async () => { + setOrgRegion("my-org", "https://us.sentry.io"); + + const result = await resolveEffectiveOrg("my-org"); + expect(result).toBe("my-org"); + }); + + test("resolves DSN-style org via cached numeric ID", async () => { + setOrgRegions([ + { + slug: "acme-corp", + regionUrl: "https://us.sentry.io", + orgId: "1081365", + }, + ]); + + const result = await resolveEffectiveOrg("o1081365"); + expect(result).toBe("acme-corp"); + }); + + test("resolves another DSN org ID pattern", async () => { + setOrgRegions([ + { slug: "test-org", regionUrl: "https://de.sentry.io", orgId: "42" }, + ]); + + const result = await resolveEffectiveOrg("o42"); + expect(result).toBe("test-org"); + }); + + test("resolves large numeric ID", async () => { + setOrgRegions([ + { + slug: "big-org", + regionUrl: "https://us.sentry.io", + orgId: "9999999999", + }, + ]); + + const result = await resolveEffectiveOrg("o9999999999"); + expect(result).toBe("big-org"); + }); +}); + +// resolveEffectiveOrg — API refresh paths + +describe("resolveEffectiveOrg with API refresh", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + /** + * Create a fetch mock that simulates the listOrganizations API chain. + * Returns a single region + single org with the given slug and ID. + */ + function mockListOrgsApi(orgSlug: string, orgId: string, regionUrl: string) { + globalThis.fetch = mockFetch( + async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // /users/me/regions/ → return one region + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: regionUrl }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // /organizations/ → return the org + if (url.includes("/organizations/")) { + return new Response( + JSON.stringify([ + { + slug: orgSlug, + id: orgId, + name: orgSlug, + links: { + organizationUrl: `${regionUrl}/organizations/${orgSlug}/`, + regionUrl, + }, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response("Not found", { status: 404 }); + } + ); + } + + test("resolves DSN org after API refresh when cache is cold", async () => { + mockListOrgsApi("fresh-org", "5555", "https://us.sentry.io"); + + const result = await resolveEffectiveOrg("o5555"); + expect(result).toBe("fresh-org"); + }); + + test("resolves slug after API refresh populates cache", async () => { + mockListOrgsApi("discovered-org", "777", "https://de.sentry.io"); + + const result = await resolveEffectiveOrg("discovered-org"); + expect(result).toBe("discovered-org"); + }); + + test("returns orgSlug when not found even after API refresh", async () => { + mockListOrgsApi("other-org", "999", "https://us.sentry.io"); + + const result = await resolveEffectiveOrg("nonexistent-org"); + expect(result).toBe("nonexistent-org"); + }); + + test("returns orgSlug when not authenticated (no token)", async () => { + // AuthError is thrown when there's no valid auth token. + // Clear the token so refreshToken() throws AuthError. + const { clearAuth } = await import("../../src/lib/db/auth.js"); + await clearAuth(); + + // Set a silent fetch mock to prevent preload warnings. + // Without auth, resolveEffectiveOrg tries an API refresh that fails, + // then falls back to the original slug. + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Unauthorized" }), { + status: 401, + }) + ); + + const result = await resolveEffectiveOrg("o1081365"); + expect(result).toBe("o1081365"); + }); + + test("logs when resolveOrgRegion throws for a slug", async () => { + const { clearAuth } = await import("../../src/lib/db/auth.js"); + await clearAuth(); + + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + const savedSentryToken = process.env.SENTRY_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + + const debugSpy = vi.spyOn(logger, "debug"); + try { + const result = await resolveEffectiveOrg("missing-org"); + expect(result).toBe("missing-org"); + expect(debugSpy).toHaveBeenCalledWith( + "resolveOrgRegion failed for 'missing-org', using raw slug", + expect.anything() + ); + } finally { + debugSpy.mockRestore(); + if (savedAuthToken === undefined) { + delete process.env.SENTRY_AUTH_TOKEN; + } else { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + if (savedSentryToken === undefined) { + delete process.env.SENTRY_TOKEN; + } else { + process.env.SENTRY_TOKEN = savedSentryToken; + } + } + }); + + test("passes through non-DSN slugs starting with o", async () => { + mockListOrgsApi("organic", "100", "https://us.sentry.io"); + + const result = await resolveEffectiveOrg("organic"); + expect(result).toBe("organic"); + }); + + test("passes through bare numeric IDs without o prefix", async () => { + mockListOrgsApi("my-org", "1081365", "https://us.sentry.io"); + + // "1081365" is not DSN-style (no "o" prefix) — won't match slug after refresh + const result = await resolveEffectiveOrg("1081365"); + expect(result).toBe("1081365"); + }); +}); diff --git a/packages/cli/test/lib/resolve-target-listing.test.ts b/packages/cli/test/lib/resolve-target-listing.test.ts new file mode 100644 index 000000000..ac066eb67 --- /dev/null +++ b/packages/cli/test/lib/resolve-target-listing.test.ts @@ -0,0 +1,770 @@ +/** + * Tests for new resolve-target listing functions + * + * Tests for resolveOrgsForListing, resolveProjectBoundTarget, and + * resolveProjectBoundFromArg added in the pagination PR. + * Uses spyOn to mock dependencies without real HTTP calls. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../src/lib/api-client.js"; +import { DEFAULT_SENTRY_URL } from "../../src/lib/constants.js"; + +vi.mock("../../src/lib/db/defaults.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as defaults from "../../src/lib/db/defaults.js"; + +vi.mock("../../src/lib/db/auth.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as auth from "../../src/lib/db/auth.js"; +import { setOrgRegion, setOrgRegions } from "../../src/lib/db/regions.js"; +import { ContextError, ResolutionError } from "../../src/lib/errors.js"; + +vi.mock("../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTargetModule from "../../src/lib/resolve-target.js"; +import { + classifyProjectSearchTarget, + projectSearchNotFoundSuggestions, + resolveOrgOnlyTarget, + resolveOrgOptionalTarget, + resolveOrgProjectOrGuide, + resolveOrgsForListing, + resolveProjectBoundFromArg, + resolveProjectBoundTarget, + resolveProjectBoundTargets, +} from "../../src/lib/resolve-target.js"; + +const CWD = "/tmp/test-project"; + +describe("classifyProjectSearchTarget", () => { + let findProjectsBySlugSpy: ReturnType; + let listProjectsSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + listProjectsSpy = vi.spyOn(apiClient, "listProjects"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + listProjectsSpy.mockRestore(); + }); + + test("prefers an exact project over an organization with the same slug", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { + id: "1", + slug: "acme", + name: "Acme Project", + orgSlug: "other-org", + }, + ], + orgs: [ + { slug: "acme", name: "Acme Org" }, + { slug: "other-org", name: "Other Org" }, + ], + }); + + const result = await classifyProjectSearchTarget({ + type: "project-search", + projectSlug: "acme", + }); + + expect(result.kind).toBe("projects"); + }); + + test("returns an exact organization only after the project search misses", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme", name: "Acme Org" }], + }); + + const result = await classifyProjectSearchTarget({ + type: "project-search", + projectSlug: "acme", + }); + + expect(result).toMatchObject({ kind: "organization", org: "acme" }); + }); + + test("recovers one fuzzy project after exact project and org misses", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme", name: "Acme Org" }], + }); + listProjectsSpy.mockResolvedValue([ + { id: "1", slug: "app-frontend", name: "App Frontend" }, + ]); + + const result = await classifyProjectSearchTarget({ + type: "project-search", + projectSlug: "app-front", + }); + + expect(result).toMatchObject({ + kind: "fuzzy-project", + org: "acme", + project: "app-frontend", + }); + }); + + test("returns not-found after project, organization, and fuzzy misses", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme", name: "Acme Org" }], + }); + listProjectsSpy.mockResolvedValue([]); + + const result = await classifyProjectSearchTarget({ + type: "project-search", + projectSlug: "missing", + }); + + expect(result.kind).toBe("not-found"); + }); +}); + +describe("projectSearchNotFoundSuggestions", () => { + test.each([ + { + name: "preserves fuzzy suggestions", + scopedOrg: undefined, + suggestions: ["Similar projects: 'acme/frontend'"], + expected: ["Similar projects: 'acme/frontend'"], + }, + { + name: "describes a scoped organization miss", + scopedOrg: "acme", + suggestions: [], + expected: [ + "No project with this name found in organization 'acme'", + "Check the organization slug or try: sentry project list acme/", + ], + }, + { + name: "describes an unscoped miss", + scopedOrg: undefined, + suggestions: [], + expected: [ + "No project with this slug found in any accessible organization", + ], + }, + ])("$name", ({ scopedOrg, suggestions, expected }) => { + expect( + projectSearchNotFoundSuggestions({ + kind: "not-found", + displaySlug: "missing", + scopedOrg, + suggestions, + }) + ).toEqual(expected); + }); +}); + +// --------------------------------------------------------------------------- +// resolveOrgsForListing +// --------------------------------------------------------------------------- + +describe("resolveOrgsForListing", () => { + let getDefaultOrganizationSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + + beforeEach(() => { + getDefaultOrganizationSpy = vi.spyOn(defaults, "getDefaultOrganization"); + resolveAllTargetsSpy = vi.spyOn(resolveTargetModule, "resolveAllTargets"); + + getDefaultOrganizationSpy.mockReturnValue(null); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + }); + + afterEach(() => { + getDefaultOrganizationSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + }); + + test("returns explicit org when orgFlag is provided", async () => { + const result = await resolveOrgsForListing("my-org", CWD); + expect(result.orgs).toEqual(["my-org"]); + // Should not consult defaults or DSN when explicit org given + expect(getDefaultOrganizationSpy).not.toHaveBeenCalled(); + }); + + test("returns default org when no orgFlag and default exists", async () => { + getDefaultOrganizationSpy.mockReturnValue("default-org"); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.orgs).toEqual(["default-org"]); + }); + + // Skip: resolveOrgsForListing calls resolveAllTargets internally (same-file). + // vi.spyOn on the export doesn't intercept same-file calls in vitest. + // These tests are covered by the Bun test suite. + // biome-ignore lint/suspicious/noSkippedTests: vitest can't intercept same-file internal calls + test.skip("returns unique orgs from DSN detection when no default", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { org: "org-a", project: "proj-1" }, + { org: "org-a", project: "proj-2" }, // same org, different project + { org: "org-b", project: "proj-3" }, + ], + }); + + const result = await resolveOrgsForListing(undefined, CWD); + // Should deduplicate orgs + expect(result.orgs).toEqual(["org-a", "org-b"]); + }); + + test("returns empty orgs when no detection results", async () => { + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.orgs).toEqual([]); + }); + + // biome-ignore lint/suspicious/noSkippedTests: vitest can't intercept same-file internal calls + test.skip("propagates footer from DSN detection", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { org: "org-a", project: "proj-1" }, + { org: "org-b", project: "proj-2" }, + ], + footer: "Found 2 projects", + }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.footer).toBe("Found 2 projects"); + }); + + // biome-ignore lint/suspicious/noSkippedTests: vitest can't intercept same-file internal calls + test.skip("propagates skippedSelfHosted from DSN detection", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [{ org: "org-a", project: "proj-1" }], + skippedSelfHosted: 2, + }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.skippedSelfHosted).toBe(2); + }); + + // biome-ignore lint/suspicious/noSkippedTests: vitest can't intercept same-file internal calls + test.skip("returns empty orgs and propagates skippedSelfHosted when targets empty but DSNs found", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [], + skippedSelfHosted: 3, + }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.orgs).toEqual([]); + expect(result.skippedSelfHosted).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// resolveProjectBoundTarget +// --------------------------------------------------------------------------- + +describe("resolveProjectBoundTarget", () => { + let findProjectsBySlugSpy: ReturnType; + let resolveOrgAndProjectSpy: ReturnType; + let listOrganizationsSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + resolveOrgAndProjectSpy = vi.spyOn( + resolveTargetModule, + "resolveOrgAndProject" + ); + // No accessible orgs by default so the authenticated account fallback in + // resolveOrgProjectOrGuide can't resolve and makes no real HTTP calls. + listOrganizationsSpy = vi + .spyOn(apiClient, "listOrganizations") + .mockResolvedValue([]); + // Pre-populate org region cache so resolveEffectiveOrg doesn't fetch + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + resolveOrgAndProjectSpy.mockRestore(); + listOrganizationsSpy.mockRestore(); + }); + + test("returns org and project for explicit type", async () => { + const parsed = { + type: "explicit" as const, + org: "my-org", + project: "my-proj", + }; + + const result = await resolveProjectBoundTarget(parsed, CWD, "trace list"); + expect(result).toEqual({ org: "my-org", project: "my-proj" }); + expect(findProjectsBySlugSpy).not.toHaveBeenCalled(); + }); + + test("throws ContextError for org-all type", async () => { + const parsed = { type: "org-all" as const, org: "my-org" }; + + await expect( + resolveProjectBoundTarget(parsed, CWD, "trace list") + ).rejects.toThrow(ContextError); + }); + + test("resolves project-search when single match found", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [{ orgSlug: "found-org", slug: "my-proj", name: "My Project" }], + orgs: [], + }); + + const parsed = { type: "project-search" as const, projectSlug: "my-proj" }; + + const result = await resolveProjectBoundTarget(parsed, CWD, "trace list"); + expect(result).toMatchObject({ org: "found-org", project: "my-proj" }); + expect(result.projectData).toBeDefined(); + }); + + test("throws ResolutionError for project-search when no match", async () => { + findProjectsBySlugSpy.mockResolvedValue({ projects: [], orgs: [] }); + + const parsed = { + type: "project-search" as const, + projectSlug: "nonexistent", + }; + + await expect( + resolveProjectBoundTarget(parsed, CWD, "trace list") + ).rejects.toThrow(ResolutionError); + }); + + test("throws ResolutionError for project-search when multiple matches", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-a", slug: "my-proj", name: "My Project" }, + { orgSlug: "org-b", slug: "my-proj", name: "My Project" }, + ], + orgs: [], + }); + + const parsed = { type: "project-search" as const, projectSlug: "my-proj" }; + + await expect( + resolveProjectBoundTarget(parsed, CWD, "trace list") + ).rejects.toThrow(ResolutionError); + }); + + // Skip: same-file internal call — resolveProjectBoundTarget → resolveAllTargets + // biome-ignore lint/suspicious/noSkippedTests: vitest can't intercept same-file internal calls + test.skip("resolves auto-detect when DSN detection succeeds", async () => { + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "detected-org", + project: "detected-proj", + orgDisplay: "Detected Org", + projectDisplay: "Detected Project", + }); + + const parsed = { type: "auto-detect" as const }; + + const result = await resolveProjectBoundTarget(parsed, CWD, "log list"); + expect(result).toEqual({ org: "detected-org", project: "detected-proj" }); + }); + + test("throws ContextError for auto-detect when no target found", async () => { + resolveOrgAndProjectSpy.mockResolvedValue(null); + + const parsed = { type: "auto-detect" as const }; + + await expect( + resolveProjectBoundTarget(parsed, CWD, "log list") + ).rejects.toThrow(ContextError); + }); + + test("error message for org-all includes command name and project hint", async () => { + const parsed = { type: "org-all" as const, org: "sentry" }; + + try { + await resolveProjectBoundTarget(parsed, CWD, "trace list"); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ContextError); + expect((err as Error).message).toContain("trace list"); + expect((err as Error).message).toContain("sentry"); + } + }); + + test("throws ResolutionError when project-search slug matches an organization", async () => { + // Lines 1017-1023: slug matches an org but no project → org-as-project error + findProjectsBySlugSpy.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme-corp", name: "Acme Corp" }], + }); + + const parsed = { + type: "project-search" as const, + projectSlug: "acme-corp", + }; + + try { + await resolveProjectBoundTarget(parsed, CWD, "trace list"); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ResolutionError); + const msg = (err as ResolutionError).message; + expect(msg).toContain("is an organization, not a project"); + expect(msg).toContain("acme-corp/"); + expect(msg).toContain("sentry project list acme-corp/"); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveProjectBoundFromArg +// --------------------------------------------------------------------------- + +describe("resolveProjectBoundFromArg", () => { + let findProjectsBySlugSpy: ReturnType; + let resolveOrgAndProjectSpy: ReturnType; + + beforeEach(async () => { + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + resolveOrgAndProjectSpy = vi.spyOn( + resolveTargetModule, + "resolveOrgAndProject" + ); + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + resolveOrgAndProjectSpy.mockRestore(); + }); + + test("resolves 'org/project' string to explicit target", async () => { + const result = await resolveProjectBoundFromArg( + "my-org/my-proj", + CWD, + "trace list" + ); + expect(result).toEqual({ org: "my-org", project: "my-proj" }); + }); + + test("resolves bare project slug string via project-search", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [{ orgSlug: "found-org", slug: "my-proj", name: "My Project" }], + orgs: [], + }); + + const result = await resolveProjectBoundFromArg("my-proj", CWD, "log list"); + expect(result).toMatchObject({ org: "found-org", project: "my-proj" }); + expect(result.projectData).toBeDefined(); + }); + + test("throws ContextError for 'org/' (org-all) string", async () => { + await expect( + resolveProjectBoundFromArg("sentry/", CWD, "trace list") + ).rejects.toThrow(ContextError); + }); + + // Skip: same-file internal call — resolveProjectBoundFromArg → resolveAllTargets + // biome-ignore lint/suspicious/noSkippedTests: vitest can't intercept same-file internal calls + test.skip("resolves undefined to auto-detect", async () => { + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "auto-org", + project: "auto-proj", + orgDisplay: "Auto Org", + projectDisplay: "Auto Project", + }); + + const result = await resolveProjectBoundFromArg( + undefined, + CWD, + "trace list" + ); + expect(result).toEqual({ org: "auto-org", project: "auto-proj" }); + }); +}); + +// --------------------------------------------------------------------------- +// resolveProjectBoundTargets — org scoping & DSN-style org resolution +// --------------------------------------------------------------------------- + +describe("resolveProjectBoundTargets", () => { + let getProjectSpy: ReturnType; + let listProjectsSpy: ReturnType; + let findProjectsBySlugSpy: ReturnType; + + const OPTS = { cwd: CWD, usageHint: "sentry issue list /" }; + + beforeEach(() => { + getProjectSpy = vi.spyOn(apiClient, "getProject"); + listProjectsSpy = vi.spyOn(apiClient, "listProjects"); + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + // Seed both a normal slug and a DSN-style numeric ID mapping so + // resolveEffectiveOrg resolves from cache without hitting the API. + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + setOrgRegions([ + { slug: "real-org", regionUrl: DEFAULT_SENTRY_URL, orgId: "1081365" }, + ]); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + listProjectsSpy.mockRestore(); + findProjectsBySlugSpy.mockRestore(); + }); + + test("explicit: resolves DSN-style org id to the real slug", async () => { + getProjectSpy.mockResolvedValue({ id: "42", slug: "my-proj" }); + + const result = await resolveProjectBoundTargets( + { type: "explicit", org: "o1081365", project: "my-proj" }, + OPTS + ); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]).toMatchObject({ + org: "real-org", + project: "my-proj", + projectId: 42, + }); + // The API lookup must use the resolved slug, not the raw DSN id. + expect(getProjectSpy).toHaveBeenCalledWith("real-org", "my-proj"); + }); + + test("org-all: resolves DSN-style org id before listing projects", async () => { + listProjectsSpy.mockResolvedValue([ + { id: "1", slug: "p1", name: "P1" }, + { id: "2", slug: "p2", name: "P2" }, + ]); + + const result = await resolveProjectBoundTargets( + { type: "org-all", org: "o1081365" }, + OPTS + ); + + expect(listProjectsSpy).toHaveBeenCalledWith("real-org"); + expect(result.targets).toHaveLength(2); + for (const t of result.targets) { + expect(t.org).toBe("real-org"); + } + }); + + test("project-search: scopes recovery to explicit org, ignoring a same-slug project in another org", async () => { + // A bare-slug lookup fans out across orgs and finds the slug in BOTH + // org-a (the requested scope) and org-b. The result must only include + // the org-a match — never leak org-b. + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-b", slug: "my-proj", name: "My Project" }, + { orgSlug: "my-org", slug: "my-proj", name: "My Project" }, + ], + orgs: [ + { slug: "org-b", name: "Org B" }, + { slug: "my-org", name: "My Org" }, + ], + }); + + const result = await resolveProjectBoundTargets( + { type: "project-search", projectSlug: "my-proj", org: "my-org" }, + OPTS + ); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]).toMatchObject({ + org: "my-org", + project: "my-proj", + }); + }); + + test("project-search: uses the calling command's usage hint when not found", async () => { + findProjectsBySlugSpy.mockResolvedValue({ projects: [], orgs: [] }); + + try { + await resolveProjectBoundTargets( + { type: "project-search", projectSlug: "missing" }, + OPTS + ); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ResolutionError); + expect((error as ResolutionError).hint).toBe(OPTS.usageHint); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveOrgProjectOrGuide (authenticated account fallback) +// --------------------------------------------------------------------------- + +describe("resolveOrgProjectOrGuide", () => { + let isAuthenticatedSpy: ReturnType; + let listOrganizationsSpy: ReturnType; + let listProjectsSpy: ReturnType; + let getDefaultOrgSpy: ReturnType; + let getDefaultProjectSpy: ReturnType; + + beforeEach(() => { + isAuthenticatedSpy = vi.spyOn(auth, "isAuthenticated"); + listOrganizationsSpy = vi.spyOn(apiClient, "listOrganizations"); + listProjectsSpy = vi.spyOn(apiClient, "listProjects"); + // Force the cascade to miss config defaults so it reaches the account + // fallback (CWD has no DSN/config either). + getDefaultOrgSpy = vi + .spyOn(defaults, "getDefaultOrganization") + .mockReturnValue(null); + getDefaultProjectSpy = vi + .spyOn(defaults, "getDefaultProject") + .mockReturnValue(null); + }); + + afterEach(() => { + isAuthenticatedSpy.mockRestore(); + listOrganizationsSpy.mockRestore(); + listProjectsSpy.mockRestore(); + getDefaultOrgSpy.mockRestore(); + getDefaultProjectSpy.mockRestore(); + }); + + test("auto-selects the sole org/project for a single-org account", async () => { + isAuthenticatedSpy.mockReturnValue(true); + listOrganizationsSpy.mockResolvedValue([ + { slug: "solo", name: "Solo Inc" }, + ]); + listProjectsSpy.mockResolvedValue([ + { id: "42", slug: "only-proj", name: "Only Project" }, + ]); + + const result = await resolveOrgProjectOrGuide({ + cwd: CWD, + usageHint: "sentry issue list /", + interactive: false, + }); + + expect(result).toMatchObject({ org: "solo", project: "only-proj" }); + }); + + test("throws a ContextError listing accessible orgs for a multi-org account", async () => { + isAuthenticatedSpy.mockReturnValue(true); + listOrganizationsSpy.mockResolvedValue([ + { slug: "org-a", name: "Org A" }, + { slug: "org-b", name: "Org B" }, + ]); + + try { + await resolveOrgProjectOrGuide({ + cwd: CWD, + usageHint: "sentry issue list /", + interactive: false, + }); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ContextError); + expect((err as Error).message).toContain("org-a"); + expect((err as Error).message).toContain("org-b"); + } + }); + + test("throws a generic ContextError when unauthenticated", async () => { + isAuthenticatedSpy.mockReturnValue(false); + + await expect( + resolveOrgProjectOrGuide({ + cwd: CWD, + usageHint: "sentry issue list /", + interactive: false, + }) + ).rejects.toBeInstanceOf(ContextError); + // Unauthenticated path must not attempt to list orgs. + expect(listOrganizationsSpy).not.toHaveBeenCalled(); + }); +}); + +describe("resolveOrgOptionalTarget bare org slug", () => { + let findProjectsBySlugSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + }); + + test("uses the organization when no project has that slug", async () => { + findProjectsBySlugSpy.mockResolvedValue({ + projects: [], + orgs: [{ slug: "acme-corp", name: "Acme Corp" }], + }); + + const result = await resolveOrgOptionalTarget( + { type: "project-search", projectSlug: "acme-corp" }, + CWD, + "explore" + ); + + expect(result).toEqual({ org: "acme-corp" }); + expect(result.project).toBeUndefined(); + expect(findProjectsBySlugSpy).toHaveBeenCalledTimes(1); + }); + + test("org-only mode preserves its usage hint when a bare target is missing", async () => { + findProjectsBySlugSpy.mockResolvedValue({ projects: [], orgs: [] }); + const usageHint = "sentry alert metrics create /"; + + try { + await resolveOrgOnlyTarget( + { type: "project-search", projectSlug: "missing" }, + CWD, + "alert metrics create", + usageHint + ); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ResolutionError); + expect((error as ResolutionError).hint).toBe(usageHint); + } + }); +}); diff --git a/packages/cli/test/lib/resolve-target.mocked.test.ts b/packages/cli/test/lib/resolve-target.mocked.test.ts new file mode 100644 index 000000000..1f2adf56c --- /dev/null +++ b/packages/cli/test/lib/resolve-target.mocked.test.ts @@ -0,0 +1,1158 @@ +/** + * Unit tests for resolve-target utilities with mocked collaborators. + * + * These tests use `vi.mock()` to stub out every dependency (DB caches, + * DSN detection, api-client). This gives tight control over return values + * at each decision point, at the cost of not exercising the real DB/HTTP + * paths — those are covered by `resolve-target.test.ts` (real DB + mocked + * fetch). + * + * Keeping this as a separate file from `resolve-target.test.ts` so the two + * mocking strategies don't cross-contaminate: `bun test --isolate` gives + * each file a fresh module graph, so the mocks here don't leak into the + * real-DB integration tests. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Import the real formatMultipleProjectsFooter from its source file (not the +// barrel dsn/index.js), then pass it through the mock below so the mocked +// barrel still returns the real implementation for this export. +import { formatMultipleProjectsFooter } from "../../src/lib/dsn/errors.js"; + +// ============================================================================ +// Mock Setup - All dependency modules mocked before importing resolve-target +// ============================================================================ + +// Mock functions we'll control in tests +const { + mockGetDefaultOrganization, + mockGetDefaultProject, + mockDetectDsn, + mockDetectAllDsns, + mockFindProjectRoot, + mockGetDsnSourceDescription, + mockGetCachedProject, + mockSetCachedProject, + mockGetCachedProjectByDsnKey, + mockSetCachedProjectByDsnKey, + mockGetCachedDsn, + mockSetCachedDsn, + mockGetProject, + mockFindProjectByDsnKey, + mockFindProjectsByPattern, + mockListOrganizationsUncached, + mockGetOrgByNumericId, +} = vi.hoisted(() => ({ + mockGetDefaultOrganization: vi.fn(() => null), + mockGetDefaultProject: vi.fn(() => null), + mockDetectDsn: vi.fn(() => Promise.resolve(null)), + mockDetectAllDsns: vi.fn(() => + Promise.resolve({ + primary: null, + all: [], + hasMultiple: false, + fingerprint: "", + }) + ), + mockFindProjectRoot: vi.fn(() => + Promise.resolve({ + projectRoot: "/test/project", + detectedFrom: "package.json", + }) + ), + mockGetDsnSourceDescription: vi.fn(() => "SENTRY_DSN environment variable"), + mockGetCachedProject: vi.fn(() => null), + mockSetCachedProject: vi.fn(() => { + /* no-op */ + }), + mockGetCachedProjectByDsnKey: vi.fn(() => null), + mockSetCachedProjectByDsnKey: vi.fn(() => { + /* no-op */ + }), + mockGetCachedDsn: vi.fn(() => null), + mockSetCachedDsn: vi.fn(() => { + /* no-op */ + }), + mockGetProject: vi.fn(() => Promise.resolve({ slug: "test", name: "Test" })), + mockFindProjectByDsnKey: vi.fn(() => Promise.resolve(null)), + mockFindProjectsByPattern: vi.fn(() => Promise.resolve([])), + mockListOrganizationsUncached: vi.fn(() => Promise.resolve([])), + mockGetOrgByNumericId: vi.fn( + () => undefined as { slug: string; regionUrl: string } | undefined + ), +})); + +// Mock all dependency modules +vi.mock("../../src/lib/db/defaults.js", () => ({ + getDefaultOrganization: mockGetDefaultOrganization, + getDefaultProject: mockGetDefaultProject, +})); + +// Bun's vi.mock() replaces the ENTIRE barrel module. Since resolve-target.ts +// imports formatMultipleProjectsFooter from dsn/index.js, we must include it here. +// We pass through the real function (imported above from dsn/errors.js) rather than +// a stub, because Bun leaks vi.mock() state across test files in the same run +// and a simplified stub would break tests in dsn/errors.test.ts. +vi.mock("../../src/lib/dsn/index.js", () => ({ + detectDsn: mockDetectDsn, + detectAllDsns: mockDetectAllDsns, + findProjectRoot: mockFindProjectRoot, + getDsnSourceDescription: mockGetDsnSourceDescription, + formatMultipleProjectsFooter, +})); + +vi.mock("../../src/lib/db/project-cache.js", () => ({ + getCachedProject: mockGetCachedProject, + setCachedProject: mockSetCachedProject, + getCachedProjectByDsnKey: mockGetCachedProjectByDsnKey, + setCachedProjectByDsnKey: mockSetCachedProjectByDsnKey, +})); + +vi.mock("../../src/lib/db/dsn-cache.js", () => ({ + getCachedDsn: mockGetCachedDsn, + setCachedDsn: mockSetCachedDsn, +})); + +vi.mock("../../src/lib/db/regions.js", () => ({ + getOrgByNumericId: mockGetOrgByNumericId, + getOrgRegion: vi.fn(() => { + /* returns undefined */ + }), + setOrgRegion: vi.fn(() => { + /* no-op */ + }), + setOrgRegions: vi.fn(() => { + /* no-op */ + }), + clearOrgRegions: vi.fn(() => { + /* no-op */ + }), + getAllOrgRegions: vi.fn(() => new Map()), + getCachedOrganizations: vi.fn(() => []), + getCachedOrgRole: vi.fn(() => { + /* returns undefined */ + }), + disableOrgCache: vi.fn(() => { + /* no-op */ + }), + enableOrgCache: vi.fn(() => { + /* no-op */ + }), +})); + +vi.mock("../../src/lib/api-client.js", () => ({ + getProject: mockGetProject, + findProjectByDsnKey: mockFindProjectByDsnKey, + findProjectsByPattern: mockFindProjectsByPattern, + findProjectsBySlug: vi.fn(() => Promise.resolve({ projects: [], orgs: [] })), + listOrganizations: vi.fn(() => Promise.resolve([])), + listOrganizationsUncached: mockListOrganizationsUncached, + listProjects: vi.fn(() => Promise.resolve([])), + resolveOrgDisplayName: vi.fn((slug: string, name?: string) => name ?? slug), +})); + +import { ContextError } from "../../src/lib/errors.js"; +// Now import the module under test (after mocks are set up) +import { + resolveAllTargets, + resolveFromDsn, + resolveOrg, + resolveOrgAndProject, + resolveOrgsForListing, +} from "../../src/lib/resolve-target.js"; + +/** Reset all mocks between tests */ +function resetAllMocks() { + mockGetDefaultOrganization.mockReset(); + mockGetDefaultProject.mockReset(); + mockDetectDsn.mockReset(); + mockDetectAllDsns.mockReset(); + mockFindProjectRoot.mockReset(); + mockGetDsnSourceDescription.mockReset(); + mockGetCachedProject.mockReset(); + mockSetCachedProject.mockReset(); + mockGetCachedProjectByDsnKey.mockReset(); + mockSetCachedProjectByDsnKey.mockReset(); + mockGetCachedDsn.mockReset(); + mockSetCachedDsn.mockReset(); + mockGetProject.mockReset(); + mockFindProjectByDsnKey.mockReset(); + mockFindProjectsByPattern.mockReset(); + mockListOrganizationsUncached.mockReset(); + mockGetOrgByNumericId.mockReset(); + + // Set sensible defaults + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectDsn.mockResolvedValue(null); + mockDetectAllDsns.mockResolvedValue({ + primary: null, + all: [], + hasMultiple: false, + fingerprint: "", + }); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/test/project", + detectedFrom: "package.json", + }); + mockGetDsnSourceDescription.mockReturnValue( + "SENTRY_DSN environment variable" + ); + mockGetCachedProject.mockReturnValue(null); + mockGetCachedProjectByDsnKey.mockReturnValue(null); + mockGetCachedDsn.mockReturnValue(null); + mockFindProjectsByPattern.mockResolvedValue([]); + mockListOrganizationsUncached.mockResolvedValue([]); + mockGetOrgByNumericId.mockReturnValue(undefined); +} + +// ============================================================================ +// resolveOrg Tests +// ============================================================================ + +describe("resolveOrg", () => { + beforeEach(() => { + resetAllMocks(); + }); + + test("returns org from CLI flag when provided", async () => { + const result = await resolveOrg({ org: "my-org", cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("my-org"); + // Should not call any other resolution methods + expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); + expect(mockDetectDsn).not.toHaveBeenCalled(); + }); + + test("returns org from config defaults when no CLI flag", async () => { + mockGetDefaultOrganization.mockReturnValue("default-org"); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("default-org"); + expect(mockGetDefaultOrganization).toHaveBeenCalled(); + expect(mockDetectDsn).not.toHaveBeenCalled(); + }); + + test("falls back to DSN detection when no flag or defaults", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + }); + mockGetCachedProject.mockReturnValue({ + orgSlug: "cached-org", + orgName: "Cached Organization", + projectSlug: "project", + projectName: "Project", + }); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("cached-org"); + expect(mockGetDefaultOrganization).toHaveBeenCalled(); + expect(mockDetectDsn).toHaveBeenCalled(); + }); + + test("returns numeric orgId when DSN detected but no cache", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + }); + mockGetCachedProject.mockReturnValue(null); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("123"); + }); + + test("normalizes numeric orgId to slug via DB cache", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o1169445.ingest.us.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o1169445.ingest.us.sentry.io", + projectId: "456", + orgId: "1169445", + source: "env", + }); + mockGetCachedProject.mockReturnValue(null); + mockGetOrgByNumericId.mockReturnValue({ + slug: "my-org", + regionUrl: "https://us.sentry.io", + }); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("my-org"); + expect(mockGetOrgByNumericId).toHaveBeenCalledWith("1169445"); + }); + + test("normalizes numeric orgId via API when DB cache misses", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o1169445.ingest.us.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o1169445.ingest.us.sentry.io", + projectId: "456", + orgId: "1169445", + source: "env", + }); + mockGetCachedProject.mockReturnValue(null); + + // First call returns undefined (cache miss), second call returns slug + // (after listOrganizationsUncached populates the cache) + let callCount = 0; + mockGetOrgByNumericId.mockImplementation(() => { + callCount += 1; + if (callCount >= 2) { + return { slug: "my-org", regionUrl: "https://us.sentry.io" }; + } + return; + }); + mockListOrganizationsUncached.mockResolvedValue([]); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("my-org"); + expect(mockListOrganizationsUncached).toHaveBeenCalled(); + }); + + test("skips normalization for non-numeric org slug from DSN cache", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + }); + mockGetCachedProject.mockReturnValue({ + orgSlug: "cached-org", + orgName: "Cached Organization", + projectSlug: "project", + projectName: "Project", + }); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("cached-org"); + // getOrgByNumericId should not be called because "cached-org" is not all digits + expect(mockGetOrgByNumericId).not.toHaveBeenCalled(); + }); + + test("returns null when no org found from any source", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockResolvedValue(null); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).toBeNull(); + }); + + test("returns null when DSN has no orgId", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "sentry.io", + projectId: "456", + // No orgId - self-hosted DSN + source: "env", + }); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).toBeNull(); + }); + + test("returns null when DSN detection throws", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockDetectDsn.mockRejectedValue(new Error("Detection failed")); + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).toBeNull(); + }); +}); + +// ============================================================================ +// resolveFromDsn Tests +// ============================================================================ + +describe("resolveFromDsn", () => { + beforeEach(() => { + resetAllMocks(); + }); + + test("returns null when no DSN detected", async () => { + mockDetectDsn.mockResolvedValue(null); + + const result = await resolveFromDsn("/test"); + + expect(result).toBeNull(); + }); + + test("returns null when DSN has no orgId", async () => { + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "sentry.io", + projectId: "456", + source: "env", + }); + + const result = await resolveFromDsn("/test"); + + expect(result).toBeNull(); + }); + + test("returns null when DSN has no projectId", async () => { + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + orgId: "123", + source: "env", + }); + + const result = await resolveFromDsn("/test"); + + expect(result).toBeNull(); + }); + + test("returns cached project info when available", async () => { + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + sourcePath: "/test/.env", + }); + mockGetCachedProject.mockReturnValue({ + orgSlug: "cached-org", + orgName: "Cached Organization", + projectSlug: "cached-project", + projectName: "Cached Project", + }); + + const result = await resolveFromDsn("/test"); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("cached-org"); + expect(result?.project).toBe("cached-project"); + expect(result?.orgDisplay).toBe("Cached Organization"); + expect(result?.projectDisplay).toBe("Cached Project"); + expect(mockGetCachedProject).toHaveBeenCalledWith("123", "456"); + }); + + test("uses resolution surfaced by the detector without any API call", async () => { + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + resolved: { + orgSlug: "detected-org", + orgName: "Detected Organization", + projectSlug: "detected-project", + projectName: "Detected Project", + }, + }); + + const result = await resolveFromDsn("/test"); + + expect(result?.org).toBe("detected-org"); + expect(result?.project).toBe("detected-project"); + expect(result?.orgDisplay).toBe("Detected Organization"); + expect(result?.projectDisplay).toBe("Detected Project"); + // DSN already encodes identity — no discovery call and no cache lookup. + expect(mockGetProject).not.toHaveBeenCalled(); + expect(mockGetCachedProject).not.toHaveBeenCalled(); + }); + + test("skips org/project discovery on cache miss when DSN encodes identity", async () => { + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + sourcePath: "/test/.env", + }); + mockGetCachedProject.mockReturnValue(null); + + const result = await resolveFromDsn("/test"); + + // No getProject discovery call — the numeric IDs from the DSN are used + // directly (the API accepts them as org/project identifiers). + expect(mockGetProject).not.toHaveBeenCalled(); + expect(result?.org).toBe("123"); + expect(result?.project).toBe("456"); + expect(result?.projectId).toBe(456); + }); + + test("enriches org slug from the local regions cache without an API call", async () => { + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o1169445.ingest.us.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o1169445.ingest.us.sentry.io", + projectId: "456", + orgId: "1169445", + source: "env", + }); + mockGetCachedProject.mockReturnValue(null); + mockGetOrgByNumericId.mockReturnValue({ + slug: "my-org", + regionUrl: "https://us.sentry.io", + }); + + const result = await resolveFromDsn("/test"); + + expect(result?.org).toBe("my-org"); + expect(result?.orgDisplay).toBe("my-org"); + expect(result?.project).toBe("456"); + expect(mockGetProject).not.toHaveBeenCalled(); + expect(mockGetOrgByNumericId).toHaveBeenCalledWith("1169445"); + }); +}); + +// ============================================================================ +// resolveOrgAndProject Tests +// ============================================================================ + +describe("resolveOrgAndProject", () => { + beforeEach(() => { + resetAllMocks(); + }); + + test("returns target from CLI flags when both provided", async () => { + const result = await resolveOrgAndProject({ + org: "my-org", + project: "my-project", + cwd: "/test", + }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("my-org"); + expect(result?.project).toBe("my-project"); + expect(result?.orgDisplay).toBe("my-org"); + expect(result?.projectDisplay).toBe("my-project"); + // Should not call any detection methods + expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); + expect(mockDetectDsn).not.toHaveBeenCalled(); + }); + + test("throws ContextError when only org provided", async () => { + await expect( + resolveOrgAndProject({ org: "my-org", cwd: "/test" }) + ).rejects.toThrow(ContextError); + }); + + test("throws ContextError when only project provided", async () => { + await expect( + resolveOrgAndProject({ project: "my-project", cwd: "/test" }) + ).rejects.toThrow(ContextError); + }); + + test("returns target from config defaults when no flags", async () => { + mockGetDefaultOrganization.mockReturnValue("default-org"); + mockGetDefaultProject.mockReturnValue("default-project"); + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("default-org"); + expect(result?.project).toBe("default-project"); + expect(mockGetDefaultOrganization).toHaveBeenCalled(); + expect(mockGetDefaultProject).toHaveBeenCalled(); + }); + + test("falls back to DSN detection when no flags or defaults", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectDsn.mockResolvedValue({ + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + }); + mockGetCachedProject.mockReturnValue({ + orgSlug: "dsn-org", + orgName: "DSN Org", + projectSlug: "dsn-project", + projectName: "DSN Project", + }); + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("dsn-org"); + expect(result?.project).toBe("dsn-project"); + }); + + test("falls back to directory inference when DSN detection fails", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectDsn.mockResolvedValue(null); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/home/user/my-project", + detectedFrom: "package.json", + }); + mockFindProjectsByPattern.mockResolvedValue([ + { + id: "789", + slug: "my-project", + name: "My Project", + orgSlug: "inferred-org", + organization: { id: "1", slug: "inferred-org", name: "Inferred Org" }, + }, + ]); + + const result = await resolveOrgAndProject({ cwd: "/home/user/my-project" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("inferred-org"); + expect(result?.project).toBe("my-project"); + }); + + test("returns null when no resolution method succeeds", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectDsn.mockResolvedValue(null); + // Short directory name that won't match + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/home/user/ab", + detectedFrom: "package.json", + }); + mockFindProjectsByPattern.mockResolvedValue([]); + + const result = await resolveOrgAndProject({ cwd: "/home/user/ab" }); + + expect(result).toBeNull(); + }); +}); + +// ============================================================================ +// resolveAllTargets Tests +// ============================================================================ + +describe("resolveAllTargets", () => { + beforeEach(() => { + resetAllMocks(); + }); + + test("returns single target from CLI flags", async () => { + const result = await resolveAllTargets({ + org: "my-org", + project: "my-project", + cwd: "/test", + }); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0].org).toBe("my-org"); + expect(result.targets[0].project).toBe("my-project"); + }); + + test("throws ContextError when only org provided", async () => { + await expect( + resolveAllTargets({ org: "my-org", cwd: "/test" }) + ).rejects.toThrow(ContextError); + }); + + test("returns single target from config defaults", async () => { + mockGetDefaultOrganization.mockReturnValue("default-org"); + mockGetDefaultProject.mockReturnValue("default-project"); + + const result = await resolveAllTargets({ cwd: "/test" }); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0].org).toBe("default-org"); + expect(result.targets[0].project).toBe("default-project"); + }); + + test("resolves multiple DSNs in monorepo", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectAllDsns.mockResolvedValue({ + primary: { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env-file", + sourcePath: "/test/monorepo/packages/frontend/.env", + }, + all: [ + { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env-file", + sourcePath: "/test/monorepo/packages/frontend/.env", + }, + { + raw: "https://def@o123.ingest.sentry.io/789", + protocol: "https", + publicKey: "def", + host: "o123.ingest.sentry.io", + projectId: "789", + orgId: "123", + source: "env-file", + sourcePath: "/test/monorepo/packages/backend/.env", + }, + ], + hasMultiple: true, + fingerprint: "abc-def", + }); + mockGetCachedProject + .mockReturnValueOnce({ + orgSlug: "my-org", + orgName: "My Org", + projectSlug: "frontend", + projectName: "Frontend", + }) + .mockReturnValueOnce({ + orgSlug: "my-org", + orgName: "My Org", + projectSlug: "backend", + projectName: "Backend", + }); + mockGetDsnSourceDescription + .mockReturnValueOnce("packages/frontend/.env") + .mockReturnValueOnce("packages/backend/.env"); + + const result = await resolveAllTargets({ cwd: "/test/monorepo" }); + + expect(result.targets).toHaveLength(2); + expect(result.targets[0].org).toBe("my-org"); + expect(result.targets[0].project).toBe("frontend"); + expect(result.targets[1].org).toBe("my-org"); + expect(result.targets[1].project).toBe("backend"); + }); + + test("deduplicates targets with same org+project", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectAllDsns.mockResolvedValue({ + primary: { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env-file", + sourcePath: "/test/.env", + }, + all: [ + { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env-file", + sourcePath: "/test/.env", + }, + { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + // Same DSN from different source + }, + ], + hasMultiple: true, + fingerprint: "abc-abc", + }); + mockGetCachedProject.mockReturnValue({ + orgSlug: "my-org", + orgName: "My Org", + projectSlug: "my-project", + projectName: "My Project", + }); + + const result = await resolveAllTargets({ cwd: "/test" }); + + // Should be deduplicated to single target + expect(result.targets).toHaveLength(1); + }); + + test("falls back to directory inference when no DSNs detected", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectAllDsns.mockResolvedValue({ + primary: null, + all: [], + hasMultiple: false, + fingerprint: "", + }); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/home/user/my-app", + detectedFrom: "package.json", + }); + mockFindProjectsByPattern.mockResolvedValue([ + { + id: "789", + slug: "my-app", + name: "My App", + orgSlug: "inferred-org", + organization: { id: "1", slug: "inferred-org", name: "Inferred Org" }, + }, + ]); + + const result = await resolveAllTargets({ cwd: "/home/user/my-app" }); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0].org).toBe("inferred-org"); + expect(result.targets[0].project).toBe("my-app"); + }); + + test("returns empty targets when a public-key DSN cannot be resolved", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + // Self-hosted / public-key-only DSN (no orgId) — the DSN does not encode + // org identity, so a lookup is required and can fail. + mockDetectAllDsns.mockResolvedValue({ + primary: { + raw: "https://abc@sentry.example.com/456", + protocol: "https", + publicKey: "abc", + host: "sentry.example.com", + projectId: "456", + source: "env", + }, + all: [ + { + raw: "https://abc@sentry.example.com/456", + protocol: "https", + publicKey: "abc", + host: "sentry.example.com", + projectId: "456", + source: "env", + }, + ], + hasMultiple: false, + fingerprint: "", + }); + mockGetCachedProjectByDsnKey.mockReturnValue(null); + // findProjectByDsnKey returns null (project not found) + mockFindProjectByDsnKey.mockResolvedValue(null); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/a", + detectedFrom: "package.json", + }); + + const result = await resolveAllTargets({ cwd: "/test" }); + + expect(result.targets).toHaveLength(0); + }); + + test("skips discovery for orgId DSNs and resolves from numeric IDs", async () => { + mockGetDefaultOrganization.mockReturnValue(null); + mockGetDefaultProject.mockReturnValue(null); + mockDetectAllDsns.mockResolvedValue({ + primary: { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + }, + all: [ + { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env", + }, + ], + hasMultiple: false, + fingerprint: "", + }); + mockGetCachedProject.mockReturnValue(null); + + const result = await resolveAllTargets({ cwd: "/test" }); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0].org).toBe("123"); + expect(result.targets[0].project).toBe("456"); + // The DSN already identifies the target — no discovery API call. + expect(mockGetProject).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// Environment Variable Resolution Tests (SENTRY_ORG / SENTRY_PROJECT) +// ============================================================================ + +describe("env var resolution: SENTRY_ORG + SENTRY_PROJECT", () => { + beforeEach(() => { + resetAllMocks(); + }); + + afterEach(() => { + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + // --- resolveOrg --- + + test("resolveOrg: returns org from SENTRY_ORG when no CLI flag", async () => { + process.env.SENTRY_ORG = "env-org"; + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("env-org"); + // Env vars take priority over config defaults + expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); + }); + + test("resolveOrg: CLI flag takes priority over env var", async () => { + process.env.SENTRY_ORG = "env-org"; + + const result = await resolveOrg({ org: "flag-org", cwd: "/test" }); + + expect(result?.org).toBe("flag-org"); + }); + + test("resolveOrg: SENTRY_PROJECT=org/project combo provides org", async () => { + process.env.SENTRY_PROJECT = "combo-org/combo-project"; + + const result = await resolveOrg({ cwd: "/test" }); + + expect(result?.org).toBe("combo-org"); + }); + + test("resolveOrg: combo SENTRY_PROJECT overrides SENTRY_ORG", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "combo-org/combo-project"; + + const result = await resolveOrg({ cwd: "/test" }); + + // The combo form should win because resolveFromEnvVars returns combo-org + expect(result?.org).toBe("combo-org"); + }); + + // --- resolveOrgAndProject --- + + test("resolveOrgAndProject: uses SENTRY_ORG + SENTRY_PROJECT", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("env-org"); + expect(result?.project).toBe("env-project"); + expect(result?.detectedFrom).toContain("env var"); + // Should not fall through to defaults or DSN detection + expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); + expect(mockDetectDsn).not.toHaveBeenCalled(); + }); + + test("resolveOrgAndProject: SENTRY_PROJECT=org/project combo works", async () => { + process.env.SENTRY_PROJECT = "my-org/my-project"; + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + expect(result).not.toBeNull(); + expect(result?.org).toBe("my-org"); + expect(result?.project).toBe("my-project"); + expect(result?.detectedFrom).toContain("SENTRY_PROJECT"); + }); + + test("resolveOrgAndProject: CLI flags take priority over env vars", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + + const result = await resolveOrgAndProject({ + org: "flag-org", + project: "flag-project", + cwd: "/test", + }); + + expect(result?.org).toBe("flag-org"); + expect(result?.project).toBe("flag-project"); + }); + + test("resolveOrgAndProject: SENTRY_ORG alone (no project) falls through", async () => { + process.env.SENTRY_ORG = "env-org"; + // No SENTRY_PROJECT — resolveFromEnvVars returns org-only, but + // resolveOrgAndProject requires project, so it falls through + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + // Falls through to defaults (which are null), then DSN, then dir inference + expect(result).toBeNull(); + }); + + test("resolveOrgAndProject: env vars beat config defaults", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + mockGetDefaultOrganization.mockReturnValue("default-org"); + mockGetDefaultProject.mockReturnValue("default-project"); + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + expect(result?.org).toBe("env-org"); + expect(result?.project).toBe("env-project"); + // Config defaults should not have been checked + expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); + }); + + // --- resolveAllTargets --- + + test("resolveAllTargets: uses SENTRY_ORG + SENTRY_PROJECT", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + + const result = await resolveAllTargets({ cwd: "/test" }); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]?.org).toBe("env-org"); + expect(result.targets[0]?.project).toBe("env-project"); + expect(result.targets[0]?.detectedFrom).toContain("env var"); + }); + + test("resolveAllTargets: SENTRY_PROJECT=org/project combo", async () => { + process.env.SENTRY_PROJECT = "combo-org/combo-proj"; + + const result = await resolveAllTargets({ cwd: "/test" }); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]?.org).toBe("combo-org"); + expect(result.targets[0]?.project).toBe("combo-proj"); + }); + + test("resolveAllTargets: env vars beat config defaults", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + mockGetDefaultOrganization.mockReturnValue("default-org"); + mockGetDefaultProject.mockReturnValue("default-project"); + + const result = await resolveAllTargets({ cwd: "/test" }); + + expect(result.targets[0]?.org).toBe("env-org"); + expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); + }); + + // --- resolveOrgsForListing --- + + test("resolveOrgsForListing: returns org from SENTRY_ORG when no flag or defaults", async () => { + process.env.SENTRY_ORG = "env-org"; + + const result = await resolveOrgsForListing(undefined, "/test"); + + expect(result.orgs).toEqual(["env-org"]); + }); + + // --- Edge cases --- + + test("whitespace-only env vars are ignored", async () => { + process.env.SENTRY_ORG = " "; + process.env.SENTRY_PROJECT = " "; + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + // Should fall through — empty after trim + expect(result).toBeNull(); + }); + + test("SENTRY_PROJECT with trailing slash is treated as invalid combo", async () => { + process.env.SENTRY_PROJECT = "my-org/"; + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + // slash present but empty project — falls through entirely + expect(result).toBeNull(); + }); + + test("SENTRY_PROJECT with leading slash is treated as invalid combo", async () => { + process.env.SENTRY_PROJECT = "/my-project"; + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + // slash present but empty org — falls through entirely + expect(result).toBeNull(); + }); + + test("malformed SENTRY_PROJECT with slash does not leak slash into project slug", async () => { + // Regression: SENTRY_PROJECT="org/" + SENTRY_ORG="my-org" must NOT + // produce project="org/" — the malformed combo should be discarded + // and only the org from SENTRY_ORG should be returned. + process.env.SENTRY_ORG = "my-org"; + process.env.SENTRY_PROJECT = "other-org/"; + + const result = await resolveOrgAndProject({ cwd: "/test" }); + + // Should fall through because SENTRY_PROJECT is a malformed combo. + // resolveFromEnvVars returns org-only from SENTRY_ORG, but + // resolveOrgAndProject requires a project, so result is null. + expect(result).toBeNull(); + }); + + test("malformed SENTRY_PROJECT with slash still provides org via SENTRY_ORG", async () => { + process.env.SENTRY_ORG = "my-org"; + process.env.SENTRY_PROJECT = "other-org/"; + + const result = await resolveOrg({ cwd: "/test" }); + + // Malformed combo discards SENTRY_PROJECT but SENTRY_ORG is still used + expect(result?.org).toBe("my-org"); + }); +}); diff --git a/packages/cli/test/lib/resolve-target.test.ts b/packages/cli/test/lib/resolve-target.test.ts new file mode 100644 index 000000000..ad5b53a80 --- /dev/null +++ b/packages/cli/test/lib/resolve-target.test.ts @@ -0,0 +1,961 @@ +/** + * Tests for resolve-target utilities + * + * Property-based and unit tests for pure functions in the resolve-target module. + * Integration tests for async resolution functions are in e2e tests due to + * the complexity of mocking module dependencies in Bun's test environment. + */ + +import { array, constantFrom, assert as fcAssert, property } from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { parseOrgProjectArg } from "../../src/lib/arg-parsing.js"; +import { DEFAULT_SENTRY_URL } from "../../src/lib/constants.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + cacheProjectsForOrg, + clearProjectCache, + getCachedProjectBySlug, +} from "../../src/lib/db/project-cache.js"; +import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { + AuthError, + ContextError, + ResolutionError, +} from "../../src/lib/errors.js"; +import { + fetchProjectId, + isValidDirNameForInference, + resolveAllTargets, + resolveLogProjectId, + resolveOrg, + resolveOrgAndProject, + resolveOrgOptionalTarget, + resolveOrgsForListing, + toNumericId, + tryFuzzyProjectRecovery, +} from "../../src/lib/resolve-target.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +// ============================================================================ +// Arbitraries for Property-Based Testing +// ============================================================================ + +/** Characters valid in directory names (no leading dot) */ +const dirNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-_"; + +/** Generate valid directory names (2+ chars, alphanumeric with hyphens/underscores) */ +const validDirNameArb = array(constantFrom(...dirNameChars.split("")), { + minLength: 2, + maxLength: 30, +}).map((chars) => chars.join("")); + +/** Generate single characters */ +const singleCharArb = constantFrom(...dirNameChars.split("")); + +// ============================================================================ +// Property Tests for isValidDirNameForInference +// ============================================================================ + +describe("property: isValidDirNameForInference", () => { + test("rejects empty string", () => { + expect(isValidDirNameForInference("")).toBe(false); + }); + + test("rejects single characters", () => { + fcAssert( + property(singleCharArb, (char) => { + expect(isValidDirNameForInference(char)).toBe(false); + }), + { numRuns: 50 } + ); + }); + + test("rejects names starting with dot (hidden directories)", () => { + fcAssert( + property(validDirNameArb, (suffix) => { + // .anything should be rejected - hidden directories are not valid + const name = `.${suffix}`; + expect(isValidDirNameForInference(name)).toBe(false); + }), + { numRuns: 100 } + ); + }); + + test("accepts valid directory names (2+ chars, not starting with dot)", () => { + fcAssert( + property(validDirNameArb, (name) => { + // Valid names with 2+ chars that don't start with dot should be accepted + expect(isValidDirNameForInference(name)).toBe(true); + }), + { numRuns: 100 } + ); + }); +}); + +// ============================================================================ +// Example-Based Tests for Edge Cases and Documentation +// ============================================================================ + +describe("isValidDirNameForInference edge cases", () => { + test("real-world valid names", () => { + expect(isValidDirNameForInference("cli")).toBe(true); + expect(isValidDirNameForInference("my-project")).toBe(true); + expect(isValidDirNameForInference("sentry-cli")).toBe(true); + expect(isValidDirNameForInference("frontend")).toBe(true); + expect(isValidDirNameForInference("my_app")).toBe(true); + }); + + test("hidden directories are rejected", () => { + expect(isValidDirNameForInference(".env")).toBe(false); + expect(isValidDirNameForInference(".git")).toBe(false); + expect(isValidDirNameForInference(".config")).toBe(false); + expect(isValidDirNameForInference(".")).toBe(false); + expect(isValidDirNameForInference("..")).toBe(false); + }); + + test("two-character names are the minimum", () => { + expect(isValidDirNameForInference("ab")).toBe(true); + expect(isValidDirNameForInference("a1")).toBe(true); + expect(isValidDirNameForInference("--")).toBe(true); + }); +}); + +// ============================================================================ +// toNumericId — pure function for ID coercion +// ============================================================================ + +describe("toNumericId", () => { + test("returns undefined for undefined", () => { + expect(toNumericId(undefined)).toBeUndefined(); + }); + + test("returns undefined for null", () => { + expect(toNumericId(null)).toBeUndefined(); + }); + + test("converts string number to number", () => { + expect(toNumericId("123")).toBe(123); + }); + + test("returns number as-is", () => { + expect(toNumericId(123)).toBe(123); + }); + + test("returns undefined for string '0' (not a valid Sentry ID)", () => { + expect(toNumericId("0")).toBeUndefined(); + }); + + test("returns undefined for numeric 0 (not a valid Sentry ID)", () => { + expect(toNumericId(0)).toBeUndefined(); + }); + + test("returns undefined for negative numbers (not valid Sentry IDs)", () => { + expect(toNumericId(-1)).toBeUndefined(); + }); + + test("returns undefined for non-integer floats", () => { + expect(toNumericId(1.5)).toBeUndefined(); + }); + + test("returns undefined for empty string", () => { + expect(toNumericId("")).toBeUndefined(); + }); + + test("returns undefined for non-numeric string", () => { + expect(toNumericId("abc")).toBeUndefined(); + }); + + test("returns undefined for negative numbers", () => { + expect(toNumericId(-1)).toBeUndefined(); + expect(toNumericId("-5")).toBeUndefined(); + }); + + test("returns undefined for Infinity", () => { + expect(toNumericId(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(toNumericId(Number.NEGATIVE_INFINITY)).toBeUndefined(); + expect(toNumericId("Infinity")).toBeUndefined(); + }); +}); + +// ============================================================================ +// Environment Variable Resolution (SENTRY_ORG / SENTRY_PROJECT) +// +// These tests call the REAL resolve functions with env vars set. +// When both SENTRY_ORG and SENTRY_PROJECT are provided, the resolve +// functions short-circuit at step 2 and never reach DB/DSN/API calls, +// so no mocking is needed. +// ============================================================================ + +describe("Environment variable resolution (SENTRY_ORG / SENTRY_PROJECT)", () => { + useTestConfigDir("test-resolve-target-"); + + // Silence unmocked fetch calls from resolution cascade fall-through. + // Tests that set valid env vars short-circuit before fetch; tests that + // fall through (empty/whitespace env vars) trigger DSN detection and + // directory inference which call the API. A silent 404 prevents preload + // warnings while preserving the catch-and-continue behavior. + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }) + ); + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + // --- resolveOrg --- + + test("resolveOrg: returns org from SENTRY_ORG", async () => { + process.env.SENTRY_ORG = "test-org"; + const result = await resolveOrg({ cwd: "/tmp" }); + expect(result?.org).toBe("test-org"); + }); + + test("resolveOrg: SENTRY_PROJECT=org/project combo extracts org", async () => { + process.env.SENTRY_PROJECT = "combo-org/combo-project"; + const result = await resolveOrg({ cwd: "/tmp" }); + expect(result?.org).toBe("combo-org"); + }); + + test("resolveOrg: CLI flag takes priority over env var", async () => { + process.env.SENTRY_ORG = "env-org"; + const result = await resolveOrg({ org: "flag-org", cwd: "/tmp" }); + expect(result?.org).toBe("flag-org"); + }); + + // --- resolveOrgAndProject --- + + test("resolveOrgAndProject: returns from SENTRY_ORG + SENTRY_PROJECT", async () => { + process.env.SENTRY_ORG = "test-org"; + process.env.SENTRY_PROJECT = "test-project"; + const result = await resolveOrgAndProject({ cwd: "/tmp" }); + expect(result?.org).toBe("test-org"); + expect(result?.project).toBe("test-project"); + expect(result?.detectedFrom).toContain("env var"); + }); + + test("resolveOrgAndProject: SENTRY_PROJECT combo notation", async () => { + process.env.SENTRY_PROJECT = "my-org/my-project"; + const result = await resolveOrgAndProject({ cwd: "/tmp" }); + expect(result?.org).toBe("my-org"); + expect(result?.project).toBe("my-project"); + expect(result?.detectedFrom).toContain("SENTRY_PROJECT"); + }); + + test("resolveOrgAndProject: env vars override config defaults", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + const result = await resolveOrgAndProject({ cwd: "/tmp" }); + expect(result?.org).toBe("env-org"); + expect(result?.project).toBe("env-project"); + }); + + test("resolveOrgAndProject: CLI flags override env vars", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + const result = await resolveOrgAndProject({ + org: "flag-org", + project: "flag-project", + cwd: "/tmp", + }); + expect(result?.org).toBe("flag-org"); + expect(result?.project).toBe("flag-project"); + // Explicit path no longer fetches projectId + expect(result?.projectId).toBeUndefined(); + }); + + test("resolveOrgAndProject: ignores empty/whitespace-only values", async () => { + process.env.SENTRY_ORG = " "; + process.env.SENTRY_PROJECT = ""; + // Both empty after trim — should not use env vars + // This will fall through and return null since /tmp has no DSN + const result = await resolveOrgAndProject({ cwd: "/tmp" }); + // Should return null or a result that's not from env vars + if (result) { + expect(result.detectedFrom ?? "").not.toContain("env var"); + } + }); + + test("resolveOrgAndProject: SENTRY_ORG alone not enough for org+project", async () => { + process.env.SENTRY_ORG = "my-org"; + // No SENTRY_PROJECT — resolveFromEnvVars returns org-only + // resolveOrgAndProject needs project, so env vars don't satisfy it + const result = await resolveOrgAndProject({ cwd: "/tmp" }); + // If result exists (from DSN or dir inference), it should not claim env var source + if (result) { + expect(result.detectedFrom ?? "").not.toContain("SENTRY_ORG env var"); + } + }); + + test("resolveOrgAndProject: trailing slash in combo is ignored (no project)", async () => { + process.env.SENTRY_PROJECT = "my-org/"; + process.env.SENTRY_ORG = "other-org"; + // Malformed combo — slash present but empty project + // Should fall through; SENTRY_ORG provides org-only + const result = await resolveOrgAndProject({ cwd: "/tmp" }); + // No project from env vars, so result should not have env-var detectedFrom + if (result) { + expect(result.project).not.toContain("/"); + } + }); + + // --- resolveAllTargets --- + + test("resolveAllTargets: returns target from SENTRY_ORG + SENTRY_PROJECT", async () => { + process.env.SENTRY_ORG = "test-org"; + process.env.SENTRY_PROJECT = "test-project"; + const result = await resolveAllTargets({ cwd: "/tmp" }); + expect(result.targets).toHaveLength(1); + expect(result.targets[0]?.org).toBe("test-org"); + expect(result.targets[0]?.project).toBe("test-project"); + }); + + test("resolveAllTargets: env vars override config defaults", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + const result = await resolveAllTargets({ cwd: "/tmp" }); + expect(result.targets[0]?.org).toBe("env-org"); + }); + + test("resolveAllTargets: CLI flags override env vars", async () => { + process.env.SENTRY_ORG = "env-org"; + process.env.SENTRY_PROJECT = "env-project"; + const result = await resolveAllTargets({ + org: "flag-org", + project: "flag-project", + cwd: "/tmp", + }); + expect(result.targets[0]?.org).toBe("flag-org"); + expect(result.targets[0]?.project).toBe("flag-project"); + // Explicit path no longer fetches projectId + expect(result.targets[0]?.projectId).toBeUndefined(); + }); + + // --- resolveOrgsForListing --- + + test("resolveOrgsForListing: returns org from env vars when no flag/defaults", async () => { + process.env.SENTRY_ORG = "env-org"; + const result = await resolveOrgsForListing(undefined, "/tmp"); + expect(result.orgs).toContain("env-org"); + }); +}); + +// ============================================================================ +// fetchProjectId — async project ID lookup with error handling +// ============================================================================ + +describe("fetchProjectId", () => { + useTestConfigDir("test-fetchProjectId-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns numeric project ID on success", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/api/0/projects/test-org/test-project/")) { + return Response.json({ id: "456", slug: "test-project" }); + } + return new Response("Not found", { status: 404 }); + }); + + const result = await fetchProjectId("test-org", "test-project"); + expect(result).toBe(456); + }); + + test("throws ResolutionError on 404", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }) + ); + + await expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( + ResolutionError + ); + }); + + test("includes similar project suggestions on 404 when projects exist", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + + // Mock: getProject returns 404, but listProjects returns available projects. + // The two calls hit different URL patterns. + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + const url = req.url; + + // listProjects → GET /api/0/organizations//projects/ + if (url.includes("/organizations/test-org/projects")) { + return Response.json([ + { id: "1", slug: "test-project-api", name: "Test Project API" }, + { id: "2", slug: "test-project-web", name: "Test Project Web" }, + { id: "3", slug: "unrelated", name: "Unrelated" }, + ]); + } + + // getProject → GET /api/0/projects/// → 404 + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }); + + try { + await fetchProjectId("test-org", "test-project"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ResolutionError); + const msg = (error as ResolutionError).message; + // Should include fuzzy-matched similar projects + expect(msg).toContain("test-project-api"); + expect(msg).toContain("test-project-web"); + // Should not include unrelated projects (Levenshtein distance too high) + expect(msg).not.toContain("unrelated"); + // Should suggest listing projects + expect(msg).toContain("sentry project list test-org/"); + } + }); + + test("includes numeric project ID hint on 404 for all-digit slug", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }) + ); + + try { + await fetchProjectId("test-org", "6775615880"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ResolutionError); + const msg = (error as ResolutionError).message; + expect(msg).toContain("not numeric project IDs"); + expect(msg).toContain("sentry project list test-org/"); + expect(msg.match(/sentry project list test-org\//g)).toHaveLength(1); + } + }); + + test("includes project list suggestion even when listProjects fails", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + + // Mock: all requests return 404 (both getProject and listProjects fail) + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }) + ); + + try { + await fetchProjectId("test-org", "test-project"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ResolutionError); + const msg = (error as ResolutionError).message; + // No similar projects (listProjects also 404'd), but should still + // suggest the project list command + expect(msg).toContain("sentry project list test-org/"); + expect(msg).not.toContain("Similar projects:"); + } + }); + + test("rethrows AuthError when not authenticated", async () => { + // No auth token set — refreshToken() will throw AuthError + const saved = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + + try { + await expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( + AuthError + ); + } finally { + if (saved !== undefined) { + process.env.SENTRY_AUTH_TOKEN = saved; + } + } + }); + + test("returns undefined on transient server error", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Internal error" }), { + status: 500, + }) + ); + + const result = await fetchProjectId("test-org", "test-project"); + expect(result).toBeUndefined(); + }); + + test("returns cached project ID without hitting the API", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + clearProjectCache(); + // Seed the cache via cacheProjectsForOrg (the `list:` key path) as + // `listProjects()` does in production. + cacheProjectsForOrg("test-org", "Test Org", [ + { id: "999", slug: "cached-project", name: "Cached Project" }, + ]); + + let apiCalled = false; + globalThis.fetch = mockFetch(async () => { + apiCalled = true; + return new Response("should not be called", { status: 500 }); + }); + + const result = await fetchProjectId("test-org", "cached-project"); + expect(result).toBe(999); + expect(apiCalled).toBe(false); + }); + + test("writes the response to the project cache on a cache miss", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + clearProjectCache(); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/api/0/projects/test-org/fresh-project/")) { + return Response.json({ + id: "1234", + slug: "fresh-project", + name: "Fresh Project", + organization: { + id: "500", + slug: "test-org", + name: "Test Org", + }, + }); + } + return new Response("Not found", { status: 404 }); + }); + + await fetchProjectId("test-org", "fresh-project"); + + // After the call, the cache should be populated so a subsequent call + // skips the API entirely. + const cached = getCachedProjectBySlug("test-org", "fresh-project"); + expect(cached).toBeDefined(); + expect(cached?.projectId).toBe("1234"); + expect(cached?.projectName).toBe("Fresh Project"); + }); + + test("falls through to API when the cache has no projectId (legacy rows)", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + clearProjectCache(); + // Seed a cache entry WITHOUT a projectId (mirrors pre-schema-v7 rows). + const { setCachedProject } = await import( + "../../src/lib/db/project-cache.js" + ); + setCachedProject("org-id", "proj-id", { + orgSlug: "test-org", + orgName: "Test Org", + projectSlug: "legacy-project", + projectName: "Legacy", + }); + + let apiCalled = false; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/api/0/projects/test-org/legacy-project/")) { + apiCalled = true; + return Response.json({ id: "777", slug: "legacy-project" }); + } + return new Response("Not found", { status: 404 }); + }); + + const result = await fetchProjectId("test-org", "legacy-project"); + expect(result).toBe(777); + expect(apiCalled).toBe(true); + }); +}); + +// ============================================================================ +// resolveLogProjectId — slug→id resolution tolerant of transient failures, +// used by log list/view to scope by the numeric `project` param (#1317). +// ============================================================================ + +describe("resolveLogProjectId", () => { + useTestConfigDir("test-resolveLogProjectId-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns an all-digit slug as a numeric ID without hitting the API", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + let apiCalled = false; + globalThis.fetch = mockFetch(async () => { + apiCalled = true; + return new Response("should not be called", { status: 500 }); + }); + + const result = await resolveLogProjectId("test-org", "6775615880"); + expect(result).toBe(6_775_615_880); + expect(apiCalled).toBe(false); + }); + + test("resolves a slug to its numeric project ID", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + clearProjectCache(); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/api/0/projects/test-org/my-project/")) { + return Response.json({ id: "456", slug: "my-project" }); + } + return new Response("Not found", { status: 404 }); + }); + + const result = await resolveLogProjectId("test-org", "my-project"); + expect(result).toBe(456); + }); + + test("returns undefined on a transient server error (falls back to slug scoping)", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + clearProjectCache(); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Internal error" }), { + status: 500, + }) + ); + + const result = await resolveLogProjectId("test-org", "my-project"); + expect(result).toBeUndefined(); + }); + + test("re-throws AuthError instead of swallowing it", async () => { + const saved = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + + try { + await expect( + resolveLogProjectId("test-org", "my-project") + ).rejects.toThrow(AuthError); + } finally { + if (saved !== undefined) { + process.env.SENTRY_AUTH_TOKEN = saved; + } + } + }); + + test("re-throws ResolutionError on a genuine 404", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + clearProjectCache(); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }) + ); + + await expect( + resolveLogProjectId("test-org", "missing-project") + ).rejects.toThrow(ResolutionError); + }); +}); + +// ============================================================================ +// tryFuzzyProjectRecovery +// +// Tests the discriminated-result fuzzy recovery function. Mocks +// globalThis.fetch to control which projects the API returns per org. +// ============================================================================ + +describe("tryFuzzyProjectRecovery", () => { + useTestConfigDir("test-fuzzy-recovery-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + /** + * Build a mock fetch that responds to list-projects endpoints. + * + * @param projectsByOrg - Map of org slug → list of project slugs + */ + function mockListProjects( + projectsByOrg: Record + ): typeof fetch { + return mockFetch(async (input, init) => { + const req = new Request(input, init); + const url = req.url; + // Match /organizations//projects/ + const orgMatch = url.match(/\/organizations\/([^/]+)\/projects/); + if (orgMatch) { + const org = orgMatch[1] as string; + const slugs = projectsByOrg[org]; + if (slugs) { + const projects = slugs.map((slug, i) => ({ + id: String(i + 1), + slug, + name: slug, + })); + return Response.json(projects); + } + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }); + } + + test("returns 'match' when exactly one similar project exists", async () => { + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockListProjects({ + "test-org": ["app-frontend", "app-backend", "admin-panel"], + }); + + const result = await tryFuzzyProjectRecovery("app-front", [ + { slug: "test-org" }, + ]); + expect(result.kind).toBe("match"); + if (result.kind === "match") { + expect(result.project).toBe("app-frontend"); + expect(result.org).toBe("test-org"); + } + }); + + test("returns 'suggestions' when multiple similar projects exist", async () => { + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockListProjects({ + "test-org": ["app-frontend", "app-backend", "app-worker"], + }); + + const result = await tryFuzzyProjectRecovery("app", [{ slug: "test-org" }]); + expect(result.kind).toBe("suggestions"); + if (result.kind === "suggestions") { + expect(result.suggestions.length).toBeGreaterThan(0); + expect(result.suggestions[0]).toContain("test-org"); + } + }); + + test("returns 'none' when no similar projects exist", async () => { + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockListProjects({ + "test-org": ["completely-different-name"], + }); + + const result = await tryFuzzyProjectRecovery("zzz-no-match-zzz", [ + { slug: "test-org" }, + ]); + expect(result.kind).toBe("none"); + }); + + test("returns 'none' when all orgs fail to respond", async () => { + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Error" }), { status: 500 }) + ); + + const result = await tryFuzzyProjectRecovery("some-slug", [ + { slug: "test-org" }, + ]); + expect(result.kind).toBe("none"); + }); + + test("returns 'none' with empty orgs list", async () => { + const result = await tryFuzzyProjectRecovery("anything", []); + expect(result.kind).toBe("none"); + }); + + test("searches across multiple orgs", async () => { + setOrgRegion("org-alpha", DEFAULT_SENTRY_URL); + setOrgRegion("org-beta", DEFAULT_SENTRY_URL); + globalThis.fetch = mockListProjects({ + "org-alpha": ["web-client", "api-server"], + "org-beta": ["mobile-app", "web-portal"], + }); + + const result = await tryFuzzyProjectRecovery("web-portal", [ + { slug: "org-alpha" }, + { slug: "org-beta" }, + ]); + expect(result.kind).toBe("match"); + if (result.kind === "match") { + expect(result.project).toBe("web-portal"); + expect(result.org).toBe("org-beta"); + } + }); + + test("deduplicates slugs across orgs for fuzzy matching", async () => { + setOrgRegion("org-one", DEFAULT_SENTRY_URL); + setOrgRegion("org-two", DEFAULT_SENTRY_URL); + // Same project slug in two orgs — should return suggestions, not crash + globalThis.fetch = mockListProjects({ + "org-one": ["shared-service"], + "org-two": ["shared-service"], + }); + + const result = await tryFuzzyProjectRecovery("shared-servic", [ + { slug: "org-one" }, + { slug: "org-two" }, + ]); + // Both orgs have the matching slug, so it returns suggestions (not a + // single match) since there are 2 org-qualified entries + expect(result.kind).toBe("suggestions"); + }); + + test("gracefully handles partial org failures", async () => { + setOrgRegion("good-org", DEFAULT_SENTRY_URL); + setOrgRegion("bad-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + const url = req.url; + if (url.includes("good-org")) { + return Response.json([ + { id: "1", slug: "target-project", name: "target-project" }, + ]); + } + // bad-org returns error + return new Response(JSON.stringify({ detail: "Error" }), { status: 500 }); + }); + + const result = await tryFuzzyProjectRecovery("target-project", [ + { slug: "good-org" }, + { slug: "bad-org" }, + ]); + expect(result.kind).toBe("match"); + if (result.kind === "match") { + expect(result.project).toBe("target-project"); + expect(result.org).toBe("good-org"); + } + }); +}); + +// ============================================================================ +// resolveOrgOptionalTarget — org-optional resolution for commands +// that accept org-all mode (e.g., sentry explore) +// ============================================================================ + +describe("resolveOrgOptionalTarget", () => { + const getConfigDir = useTestConfigDir("test-resolve-optional-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + // Silence unmocked fetch calls — resolveEffectiveOrg catches errors and + // returns the original slug, so a 404 is sufficient. + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }) + ); + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + test("org-all mode returns org without project", async () => { + const parsed = parseOrgProjectArg("myorg/"); + expect(parsed.type).toBe("org-all"); + + const result = await resolveOrgOptionalTarget( + parsed, + getConfigDir(), + "explore" + ); + expect(result.org).toBe("myorg"); + expect(result.project).toBeUndefined(); + }); + + test("explicit mode returns both org and project", async () => { + const parsed = parseOrgProjectArg("myorg/myproject"); + expect(parsed.type).toBe("explicit"); + + const result = await resolveOrgOptionalTarget( + parsed, + getConfigDir(), + "explore" + ); + expect(result.org).toBe("myorg"); + expect(result.project).toBe("myproject"); + }); + + test("auto-detect mode returns org only when SENTRY_ORG is set", async () => { + process.env.SENTRY_ORG = "env-org"; + const parsed = parseOrgProjectArg(undefined); + expect(parsed.type).toBe("auto-detect"); + + const result = await resolveOrgOptionalTarget( + parsed, + getConfigDir(), + "explore" + ); + expect(result.org).toBe("env-org"); + expect(result.project).toBeUndefined(); + }); + + test("auto-detect mode throws ContextError when nothing resolves", async () => { + // No env vars, no defaults, no DSN — resolveOrg returns null + const parsed = parseOrgProjectArg(undefined); + + await expect( + resolveOrgOptionalTarget(parsed, getConfigDir(), "explore") + ).rejects.toThrow(ContextError); + }); + + test("auto-detect ContextError mentions the command name", async () => { + const parsed = parseOrgProjectArg(undefined); + + try { + await resolveOrgOptionalTarget(parsed, getConfigDir(), "explore"); + // Should not reach here + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(ContextError); + expect((err as ContextError).message).toContain("Organization"); + expect((err as ContextError).command).toContain("explore"); + } + }); +}); diff --git a/packages/cli/test/lib/resolve-team.test.ts b/packages/cli/test/lib/resolve-team.test.ts new file mode 100644 index 000000000..5c98c9415 --- /dev/null +++ b/packages/cli/test/lib/resolve-team.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for resolveOrCreateTeam error handling. Mocks the teams API so + * listTeams failures can be exercised without real HTTP calls. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../src/lib/api/teams.js"); +vi.mock("../../src/lib/api/organizations.js"); + +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as teamsApi from "../../src/lib/api/teams.js"; +import { ApiError, ResolutionError } from "../../src/lib/errors.js"; +import { resolveOrCreateTeam } from "../../src/lib/resolve-team.js"; + +describe("resolveOrCreateTeam", () => { + const listTeamsSpy = vi.mocked(teamsApi.listTeams); + + afterEach(() => { + listTeamsSpy.mockReset(); + }); + + test("re-throws the original ApiError when listTeams returns 401", async () => { + // member-disabled-over-limit and other 401s must keep their enriched detail + // instead of being flattened into a generic ResolutionError. + const apiError = new ApiError( + "Failed to list teams", + 401, + "Your account is disabled in this organization because it is over its member limit." + ); + listTeamsSpy.mockRejectedValueOnce(apiError); + + const error = await resolveOrCreateTeam("chisme", { + usageHint: "sentry init", + }).catch((e) => e); + + expect(error).toBe(apiError); + expect(error).toBeInstanceOf(ApiError); + expect(error).not.toBeInstanceOf(ResolutionError); + expect(error.status).toBe(401); + expect(error.detail).toContain("over its member limit"); + }); +}); diff --git a/packages/cli/test/lib/response-cache.property.test.ts b/packages/cli/test/lib/response-cache.property.test.ts new file mode 100644 index 000000000..74776715e --- /dev/null +++ b/packages/cli/test/lib/response-cache.property.test.ts @@ -0,0 +1,246 @@ +/** + * Property-Based Tests for Response Cache + * + * Verifies properties of cache key generation, URL normalization, + * and URL classification that should hold for any valid input. + */ + +import { + array, + constantFrom, + assert as fcAssert, + property, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + buildCacheKey, + classifyUrl, + normalizeUrl, +} from "../../src/lib/response-cache.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Generate valid HTTP methods */ +const methodArb = constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"); + +/** Generate simple path segments */ +const pathSegmentArb = string({ minLength: 1, maxLength: 20 }).filter((s) => + /^[a-zA-Z0-9_-]+$/.test(s) +); + +/** Generate URL-like strings with paths and query params */ +const sentryUrlArb = tuple( + constantFrom( + "https://us.sentry.io", + "https://de.sentry.io", + "https://sentry.io" + ), + array(pathSegmentArb, { minLength: 1, maxLength: 5 }), + array( + tuple( + string({ minLength: 1, maxLength: 10 }).filter((s) => + /^[a-zA-Z]+$/.test(s) + ), + string({ minLength: 1, maxLength: 20 }).filter((s) => + /^[a-zA-Z0-9]+$/.test(s) + ) + ), + { minLength: 0, maxLength: 4 } + ) +).map(([base, paths, params]) => { + const pathStr = `/api/0/${paths.join("/")}`; + const query = + params.length > 0 + ? `?${params.map(([k, v]) => `${k}=${v}`).join("&")}` + : ""; + return `${base}${pathStr}${query}`; +}); + +// --------------------------------------------------------------------------- +// Tests: buildCacheKey +// --------------------------------------------------------------------------- + +describe("property: buildCacheKey", () => { + test("produces a 64-char hex string (SHA-256)", () => { + fcAssert( + property(methodArb, sentryUrlArb, (method, url) => { + expect(buildCacheKey(method, url)).toMatch(/^[0-9a-f]{64}$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic — same inputs produce same key", () => { + fcAssert( + property(methodArb, sentryUrlArb, (method, url) => { + expect(buildCacheKey(method, url)).toBe(buildCacheKey(method, url)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("different methods produce different keys for same URL", () => { + fcAssert( + property(sentryUrlArb, (url) => { + expect(buildCacheKey("GET", url)).not.toBe(buildCacheKey("POST", url)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("query param order does not affect the key", () => { + fcAssert( + property( + constantFrom("https://us.sentry.io", "https://de.sentry.io"), + pathSegmentArb, + (base, path) => { + const url1 = `${base}/api/0/${path}?a=1&b=2&c=3`; + const url2 = `${base}/api/0/${path}?c=3&a=1&b=2`; + expect(buildCacheKey("GET", url1)).toBe(buildCacheKey("GET", url2)); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("method comparison is case-insensitive", () => { + fcAssert( + property(sentryUrlArb, (url) => { + expect(buildCacheKey("get", url)).toBe(buildCacheKey("GET", url)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: normalizeUrl +// --------------------------------------------------------------------------- + +describe("property: normalizeUrl", () => { + test("sorts query parameters alphabetically", () => { + const normalized = normalizeUrl("GET", "https://sentry.io/api?z=1&a=2&m=3"); + expect(normalized).toBe("GET|https://sentry.io/api?a=2&m=3&z=1"); + }); + + test("uppercases the method", () => { + fcAssert( + property( + constantFrom("get", "post", "put", "delete"), + sentryUrlArb, + (method, url) => { + const normalized = normalizeUrl(method, url); + expect(normalized.startsWith(method.toUpperCase())).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("produces pipe-separated method|url format", () => { + fcAssert( + property(methodArb, sentryUrlArb, (method, url) => { + const normalized = normalizeUrl(method, url); + expect(normalized).toContain("|"); + const [m] = normalized.split("|", 1); + expect(m).toBe(method.toUpperCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: classifyUrl +// --------------------------------------------------------------------------- + +describe("property: classifyUrl", () => { + test("always returns a valid tier", () => { + fcAssert( + property(sentryUrlArb, (url) => { + const tier = classifyUrl(url); + expect(["immutable", "stable", "volatile", "no-cache"]).toContain(tier); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("event detail URLs are immutable", () => { + const urls = [ + "https://us.sentry.io/api/0/projects/myorg/myproject/events/abc123/", + "https://sentry.io/api/0/projects/org/proj/events/deadbeef/?full=true", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("immutable"); + } + }); + + test("trace URLs with 32-char hex IDs are immutable", () => { + const traceId = "a".repeat(32); + const url = `https://us.sentry.io/api/0/organizations/myorg/trace/${traceId}/`; + expect(classifyUrl(url)).toBe("immutable"); + }); + + test("trace-items (span detail) URLs are immutable", () => { + const url = + "https://us.sentry.io/api/0/projects/org/proj/trace-items/a1b2c3d4e5f67890/?trace_id=abc&item_type=spans"; + expect(classifyUrl(url)).toBe("immutable"); + }); + + test("issue URLs are volatile (lists and detail views)", () => { + const urls = [ + "https://us.sentry.io/api/0/projects/org/proj/issues/", + "https://us.sentry.io/api/0/projects/org/proj/issues/?query=is:unresolved", + "https://us.sentry.io/api/0/issues/12345/", + "https://sentry.io/api/0/issues/67890/?format=json", + "https://us.sentry.io/api/0/organizations/org/issues/12345/hashes/", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("volatile"); + } + }); + + test("dataset=logs URLs are volatile", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/events/?dataset=logs&query=foo"; + expect(classifyUrl(url)).toBe("volatile"); + }); + + test("dataset=spans URLs are volatile", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/events/?dataset=spans"; + expect(classifyUrl(url)).toBe("volatile"); + }); + + test("autofix URLs are no-cache", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/org/issues/123/autofix/", + "https://sentry.io/api/0/organizations/org/issues/456/autofix/?format=json", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("no-cache"); + } + }); + + test("root-cause URLs are no-cache", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/issues/123/root-cause/"; + expect(classifyUrl(url)).toBe("no-cache"); + }); + + test("org/project/team list URLs default to stable", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/", + "https://us.sentry.io/api/0/organizations/myorg/projects/", + "https://us.sentry.io/api/0/organizations/myorg/teams/", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("stable"); + } + }); +}); diff --git a/packages/cli/test/lib/response-cache.test.ts b/packages/cli/test/lib/response-cache.test.ts new file mode 100644 index 000000000..26f347a1c --- /dev/null +++ b/packages/cli/test/lib/response-cache.test.ts @@ -0,0 +1,622 @@ +/** + * Unit Tests for Response Cache + * + * Tests the cache lifecycle: store, retrieve, expire, clear, and bypass. + * Uses isolated temp directories per test to avoid interference. + */ + +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + buildCacheKey, + clearResponseCache, + disableResponseCache, + getCachedResponse, + invalidateCachedResponsesMatching, + normalizeUrl, + resetCacheState, + storeCachedResponse, +} from "../../src/lib/response-cache.js"; +import { useTestConfigDir } from "../helpers.js"; + +const getConfigDir = useTestConfigDir("response-cache-"); + +// Reset cache disabled state between tests +let savedNoCache: string | undefined; + +beforeEach(() => { + savedNoCache = process.env.SENTRY_NO_CACHE; + delete process.env.SENTRY_NO_CACHE; + resetCacheState(); +}); + +afterEach(() => { + if (savedNoCache !== undefined) { + process.env.SENTRY_NO_CACHE = savedNoCache; + } else { + delete process.env.SENTRY_NO_CACHE; + } + resetCacheState(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a mock Response with JSON body and optional headers */ +function mockResponse( + body: unknown, + status = 200, + headers: Record = {} +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + ...headers, + }, + }); +} + +const TEST_URL = "https://us.sentry.io/api/0/organizations/myorg/projects/"; +const TEST_METHOD = "GET"; +const TEST_BODY = { data: [{ id: 1, name: "test" }] }; + +// --------------------------------------------------------------------------- +// Store and Retrieve +// --------------------------------------------------------------------------- + +describe("store and retrieve", () => { + test("round-trip: store then retrieve returns same body", async () => { + const response = mockResponse(TEST_BODY); + await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeDefined(); + expect(cached!.status).toBe(200); + + const cachedBody = await cached!.json(); + expect(cachedBody).toEqual(TEST_BODY); + }); + + test("preserves Link header for pagination", async () => { + const linkHeader = + '; rel="next"'; + const response = mockResponse(TEST_BODY, 200, { link: linkHeader }); + await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeDefined(); + expect(cached!.headers.get("link")).toBe(linkHeader); + }); + + test("cache miss returns undefined", async () => { + const cached = await getCachedResponse( + TEST_METHOD, + "https://us.sentry.io/api/0/organizations/nonexistent/projects/", + {} + ); + expect(cached).toBeUndefined(); + }); + + test("different URLs produce different cache entries", async () => { + const url1 = "https://us.sentry.io/api/0/organizations/org1/projects/"; + const url2 = "https://us.sentry.io/api/0/organizations/org2/projects/"; + const body1 = { data: "org1" }; + const body2 = { data: "org2" }; + + await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse(body1)); + await storeCachedResponse(TEST_METHOD, url2, {}, mockResponse(body2)); + + const cached1 = await getCachedResponse(TEST_METHOD, url1, {}); + const cached2 = await getCachedResponse(TEST_METHOD, url2, {}); + + expect(await cached1!.json()).toEqual(body1); + expect(await cached2!.json()).toEqual(body2); + }); + + test("query param order does not affect cache lookup", async () => { + const url1 = "https://us.sentry.io/api/0/orgs/?a=1&b=2"; + const url2 = "https://us.sentry.io/api/0/orgs/?b=2&a=1"; + + await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse(TEST_BODY)); + + const cached = await getCachedResponse(TEST_METHOD, url2, {}); + expect(cached).toBeDefined(); + expect(await cached!.json()).toEqual(TEST_BODY); + }); +}); + +// --------------------------------------------------------------------------- +// Method isolation +// --------------------------------------------------------------------------- + +describe("method isolation", () => { + test("only GET requests are cached", async () => { + await storeCachedResponse("POST", TEST_URL, {}, mockResponse(TEST_BODY)); + + const cached = await getCachedResponse("POST", TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("GET lookup does not return POST-stored data", async () => { + // This is already guaranteed since POST doesn't store, but test explicitly + await storeCachedResponse("GET", TEST_URL, {}, mockResponse(TEST_BODY)); + + // GET should find it + const getResult = await getCachedResponse("GET", TEST_URL, {}); + expect(getResult).toBeDefined(); + + // POST should not even look + const postResult = await getCachedResponse("POST", TEST_URL, {}); + expect(postResult).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Non-2xx responses +// --------------------------------------------------------------------------- + +describe("non-2xx responses", () => { + test("4xx responses are not cached", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse({ detail: "not found" }, 404) + ); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("5xx responses are not cached", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse({ detail: "server error" }, 500) + ); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Cache-Control: no-store +// --------------------------------------------------------------------------- + +describe("Cache-Control: no-store", () => { + test("responses with no-store are not cached", async () => { + const response = mockResponse(TEST_BODY, 200, { + "cache-control": "no-store", + }); + await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// clearResponseCache +// --------------------------------------------------------------------------- + +describe("clearResponseCache", () => { + test("removes all cached entries", async () => { + const url1 = "https://us.sentry.io/api/0/orgs/a/projects/"; + const url2 = "https://us.sentry.io/api/0/orgs/b/projects/"; + + await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse({ a: 1 })); + await storeCachedResponse(TEST_METHOD, url2, {}, mockResponse({ b: 2 })); + + // Verify entries exist + expect(await getCachedResponse(TEST_METHOD, url1, {})).toBeDefined(); + + await clearResponseCache(); + + // Verify all cleared + expect(await getCachedResponse(TEST_METHOD, url1, {})).toBeUndefined(); + expect(await getCachedResponse(TEST_METHOD, url2, {})).toBeUndefined(); + }); + + test("is idempotent — clearing empty cache does not throw", async () => { + await clearResponseCache(); + await clearResponseCache(); + // No error + }); +}); + +// --------------------------------------------------------------------------- +// Cache bypass +// --------------------------------------------------------------------------- + +describe("cache bypass", () => { + test("SENTRY_NO_CACHE=1 bypasses cache reads", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + process.env.SENTRY_NO_CACHE = "1"; + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("SENTRY_NO_CACHE=1 bypasses cache writes", async () => { + process.env.SENTRY_NO_CACHE = "1"; + + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + // Remove the bypass to verify nothing was written + delete process.env.SENTRY_NO_CACHE; + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("--fresh bypasses cache reads", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + disableResponseCache(); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("--fresh still allows cache writes", async () => { + disableResponseCache(); + + const freshBody = { data: "fresh" }; + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(freshBody) + ); + + // Re-enable cache reads to verify the write succeeded + resetCacheState(); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeDefined(); + expect(await cached!.json()).toEqual(freshBody); + }); + + test("--fresh round-trip: stale entry is replaced by fresh response", async () => { + const staleBody = { data: "stale" }; + const freshBody = { data: "fresh" }; + + // Store initial stale entry + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(staleBody) + ); + + // Activate --fresh: reads are bypassed, but writes still go through + disableResponseCache(); + + // Verify stale entry is not served + const duringFresh = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(duringFresh).toBeUndefined(); + + // Store fresh response (overwrites the stale entry) + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(freshBody) + ); + + // Re-enable cache reads (simulates next invocation without --fresh) + resetCacheState(); + + // Verify fresh data is served from cache + const afterFresh = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(afterFresh).toBeDefined(); + expect(await afterFresh!.json()).toEqual(freshBody); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeUrl +// --------------------------------------------------------------------------- + +describe("normalizeUrl", () => { + test("sorts query params alphabetically", () => { + const result = normalizeUrl( + "GET", + "https://sentry.io/api/0/issues/?b=2&a=1" + ); + expect(result).toBe("GET|https://sentry.io/api/0/issues/?a=1&b=2"); + }); + + test("uppercases the method", () => { + const result = normalizeUrl("get", "https://sentry.io/api/0/issues/"); + expect(result).toMatch(/^GET\|/); + }); + + test("throws on invalid URLs", () => { + expect(() => normalizeUrl("GET", "not-a-valid-url")).toThrow(); + }); + + test("handles self-hosted URLs with unusual schemes", () => { + const result = normalizeUrl( + "GET", + "https://sentry.mycompany.internal/api/0/issues/" + ); + expect(result).toBe("GET|https://sentry.mycompany.internal/api/0/issues/"); + }); +}); + +// --------------------------------------------------------------------------- +// buildCacheKey +// --------------------------------------------------------------------------- + +describe("buildCacheKey", () => { + test("produces a 64-char hex string", () => { + expect(buildCacheKey("GET", TEST_URL)).toMatch(/^[0-9a-f]{64}$/); + }); + + test("is deterministic", () => { + expect(buildCacheKey("GET", TEST_URL)).toBe(buildCacheKey("GET", TEST_URL)); + }); + + test("different methods produce different keys", () => { + expect(buildCacheKey("GET", TEST_URL)).not.toBe( + buildCacheKey("POST", TEST_URL) + ); + }); + + test("different identities produce different keys for the same URL", () => { + // Switching accounts must route reads/writes through a different + // namespace so users never see each other's cached data. + setAuthToken("alice_access", 3600, "alice_refresh"); + const aliceKey = buildCacheKey("GET", TEST_URL); + setAuthToken("bob_access", 3600, "bob_refresh"); + const bobKey = buildCacheKey("GET", TEST_URL); + expect(aliceKey).not.toBe(bobKey); + }); +}); + +// --------------------------------------------------------------------------- +// Invalid URL handling (CLI-GC) +// --------------------------------------------------------------------------- + +describe("invalid URL handling", () => { + test("getCachedResponse skips cache for malformed URLs", async () => { + const result = await getCachedResponse("GET", "not-a-valid-url", {}); + expect(result).toBeUndefined(); + }); + + test("storeCachedResponse skips cache for malformed URLs", async () => { + // Should not throw — just silently skip + await storeCachedResponse( + "GET", + "not-a-valid-url", + {}, + mockResponse({ ok: true }) + ); + }); +}); + +// --------------------------------------------------------------------------- +// No-cache tier (polling endpoints) +// --------------------------------------------------------------------------- + +describe("no-cache tier", () => { + test("autofix URLs are not cached", async () => { + const autofixUrl = + "https://us.sentry.io/api/0/organizations/myorg/issues/123/autofix/"; + await storeCachedResponse( + TEST_METHOD, + autofixUrl, + {}, + mockResponse({ autofix: { status: "PROCESSING" } }) + ); + + const cached = await getCachedResponse(TEST_METHOD, autofixUrl, {}); + expect(cached).toBeUndefined(); + }); + + test("root-cause URLs are not cached", async () => { + const rootCauseUrl = + "https://us.sentry.io/api/0/organizations/myorg/issues/123/root-cause/"; + await storeCachedResponse( + TEST_METHOD, + rootCauseUrl, + {}, + mockResponse({ cause: "something" }) + ); + + const cached = await getCachedResponse(TEST_METHOD, rootCauseUrl, {}); + expect(cached).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// File structure +// --------------------------------------------------------------------------- + +describe("file structure", () => { + test("creates cache directory under config dir", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + const cacheDir = join(getConfigDir(), "cache", "responses"); + const files = await readdir(cacheDir); + expect(files.length).toBe(1); + expect(files[0]).toMatch(/^[0-9a-f]{64}\.json$/); + }); + + test("atomic write leaves no temp file behind on success", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + const cacheDir = join(getConfigDir(), "cache", "responses"); + const files = await readdir(cacheDir); + // Exactly the final .json file — the temp file was renamed into place. + expect(files.every((f) => f.endsWith(".json"))).toBe(true); + expect(files.some((f) => f.endsWith(".tmp"))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Atomic write / torn-read regression (getsentry/cli#1056) +// +// A write fires cleanupCache() fire-and-forget at 10% probability. Before +// atomic writes, a second write to the same key could be read mid-overwrite by +// that cleanup sweep, fail to JSON.parse, and be deleted as "corrupted" — +// losing a valid entry. This loop exercises the exact store→overwrite→read +// sequence many times so the probabilistic cleanup is virtually guaranteed to +// fire; every iteration must still serve the fresh value. +// --------------------------------------------------------------------------- + +describe("atomic write regression", () => { + test("repeated overwrite-then-read never loses the entry", async () => { + for (let i = 0; i < 50; i++) { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse({ data: `stale-${i}` }) + ); + const freshBody = { data: `fresh-${i}` }; + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(freshBody) + ); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeDefined(); + expect(await cached!.json()).toEqual(freshBody); + } + }); +}); + +// --------------------------------------------------------------------------- +// Prefix-based invalidation + identity isolation (getsentry/cli#788 follow-up) +// --------------------------------------------------------------------------- + +describe("invalidateCachedResponsesMatching", () => { + const ORG_PREFIX = "https://us.sentry.io/api/0/organizations/myorg/projects/"; + const ORG_LIST_URL = `${ORG_PREFIX}?cursor=abc`; + const OTHER_PREFIX = + "https://us.sentry.io/api/0/organizations/other-org/projects/"; + + test("removes entries whose URL matches the prefix", async () => { + await storeCachedResponse( + "GET", + ORG_LIST_URL, + {}, + mockResponse({ matched: true }) + ); + await storeCachedResponse( + "GET", + `${OTHER_PREFIX}?cursor=def`, + {}, + mockResponse({ matched: false }) + ); + + await invalidateCachedResponsesMatching(ORG_PREFIX); + + // The matching entry is gone; the other org's entry survives. + expect(await getCachedResponse("GET", ORG_LIST_URL, {})).toBeUndefined(); + const survivor = await getCachedResponse( + "GET", + `${OTHER_PREFIX}?cursor=def`, + {} + ); + expect(survivor).toBeDefined(); + }); + + test("does not delete entries belonging to a different identity", async () => { + // A writes a cache entry, B sweeps the same URL prefix; A's entry + // must survive because B can only see its own identity's files. + setAuthToken("identity-a", 3600, "refresh-a"); + await storeCachedResponse( + "GET", + ORG_LIST_URL, + {}, + mockResponse({ owner: "a" }) + ); + expect(await getCachedResponse("GET", ORG_LIST_URL, {})).toBeDefined(); + + setAuthToken("identity-b", 3600, "refresh-b"); + await invalidateCachedResponsesMatching(ORG_PREFIX); + + setAuthToken("identity-a", 3600, "refresh-a"); + expect(await getCachedResponse("GET", ORG_LIST_URL, {})).toBeDefined(); + }); + + test("is a no-op when the cache dir does not exist", async () => { + await invalidateCachedResponsesMatching(ORG_PREFIX); + }); +}); + +// --------------------------------------------------------------------------- +// Regression: prefix-sweep must catch query-string variants +// (sentry-bot finding on #788 — `getIssue` caches under +// `/issues/{id}/?collapse=stats&...` so exact-match invalidation of +// `/issues/{id}/` would silently fail to clear the stale entry.) +// --------------------------------------------------------------------------- + +describe("invalidateCachedResponsesMatching with query params", () => { + const DETAIL_BASE = + "https://us.sentry.io/api/0/organizations/acme/issues/12345/"; + + test("clears entries cached with varying query parameters", async () => { + await storeCachedResponse( + "GET", + `${DETAIL_BASE}?collapse=stats&collapse=lifetime`, + {}, + mockResponse({ id: "12345" }) + ); + expect( + await getCachedResponse( + "GET", + `${DETAIL_BASE}?collapse=stats&collapse=lifetime`, + {} + ) + ).toBeDefined(); + + // Mutation-side invalidator uses the base URL (no params). + await invalidateCachedResponsesMatching(DETAIL_BASE); + + expect( + await getCachedResponse( + "GET", + `${DETAIL_BASE}?collapse=stats&collapse=lifetime`, + {} + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/route-subcommands.test.ts b/packages/cli/test/lib/route-subcommands.test.ts new file mode 100644 index 000000000..2d9f4960c --- /dev/null +++ b/packages/cli/test/lib/route-subcommands.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for interceptSubcommand in list-command.ts. + */ + +import { describe, expect, test } from "vitest"; +import { interceptSubcommand } from "../../src/lib/list-command.js"; + +function makeStderr(): { write(s: string): void; output: string } { + let output = ""; + return { + write(s: string) { + output += s; + }, + get output() { + return output; + }, + }; +} + +describe("interceptSubcommand", () => { + // Skip: interceptSubcommand relies on require("../app.js") to load the + // Stricli route map, which fails under vitest because Node's CJS require + // can't resolve .js→.ts for transitive ESM imports. The try-catch in + // getSubcommandsForRoute gracefully degrades to empty sets in test. + // biome-ignore lint/suspicious/noSkippedTests: require("../app.js") fails in vitest — CJS can't resolve .js→.ts + test.skip("returns undefined and writes hint for known subcommand", () => { + const stderr = makeStderr(); + const result = interceptSubcommand("list", stderr, "project"); + expect(result).toBeUndefined(); + expect(stderr.output).toContain("Tip:"); + expect(stderr.output).toContain("sentry project list"); + }); + + test("returns target unchanged for normal project names", () => { + const stderr = makeStderr(); + const result = interceptSubcommand("my-project", stderr, "project"); + expect(result).toBe("my-project"); + expect(stderr.output).toBe(""); + }); + + test("returns target unchanged for org/project patterns", () => { + const stderr = makeStderr(); + const result = interceptSubcommand("sentry/cli", stderr, "issue"); + expect(result).toBe("sentry/cli"); + expect(stderr.output).toBe(""); + }); + + test("returns undefined/empty unchanged (no hint)", () => { + const stderr = makeStderr(); + expect(interceptSubcommand(undefined, stderr, "project")).toBeUndefined(); + expect(stderr.output).toBe(""); + + expect(interceptSubcommand("", stderr, "project")).toBe(""); + expect(stderr.output).toBe(""); + }); + + // Skip: same reason as above — require("../app.js") fails in vitest + // biome-ignore lint/suspicious/noSkippedTests: require("../app.js") fails in vitest — CJS can't resolve .js→.ts + test.skip("hint includes the route name and subcommand", () => { + const stderr = makeStderr(); + interceptSubcommand("view", stderr, "issue"); + expect(stderr.output).toContain("sentry issue view"); + }); + + // Skip: same reason as above — require("../app.js") fails in vitest + // biome-ignore lint/suspicious/noSkippedTests: require("../app.js") fails in vitest — CJS can't resolve .js→.ts + test.skip("handles 'explain' and 'plan' subcommands for issue route", () => { + const stderr1 = makeStderr(); + expect(interceptSubcommand("explain", stderr1, "issue")).toBeUndefined(); + expect(stderr1.output).toContain("sentry issue explain"); + + const stderr2 = makeStderr(); + expect(interceptSubcommand("plan", stderr2, "issue")).toBeUndefined(); + expect(stderr2.output).toContain("sentry issue plan"); + }); + + test("does not intercept subcommands from unrelated routes", () => { + const stderr = makeStderr(); + // "explain" is a subcommand of "issue" but not "project" + const result = interceptSubcommand("explain", stderr, "project"); + expect(result).toBe("explain"); + expect(stderr.output).toBe(""); + }); + + test("only intercepts subcommands of the specified route", () => { + const stderr = makeStderr(); + // "login" is a subcommand of "auth", not "project" + const result = interceptSubcommand("login", stderr, "project"); + expect(result).toBe("login"); + expect(stderr.output).toBe(""); + }); +}); diff --git a/packages/cli/test/lib/run-commands.mocked.test.ts b/packages/cli/test/lib/run-commands.mocked.test.ts new file mode 100644 index 000000000..d119f31ee --- /dev/null +++ b/packages/cli/test/lib/run-commands.mocked.test.ts @@ -0,0 +1,86 @@ +/** + * Unit tests for the run-commands tool. + * + * Kept as a mocked sibling file because vi.mock() on @sentry/node-core/light + * must precede all module imports to take effect. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { RunCommandsPayload } from "../../src/lib/init/types.js"; + +// ============================================================================ +// Mock Setup — must precede all imports of the module under test +// ============================================================================ + +type Breadcrumb = { + level: string; + message: string; + data: { exitCode: number; stdout: string; stderr: string; cwd: string }; +}; + +const { breadcrumbs } = vi.hoisted(() => ({ + breadcrumbs: [] as Breadcrumb[], +})); + +vi.mock("@sentry/node-core/light", () => ({ + addBreadcrumb: (crumb: Breadcrumb) => breadcrumbs.push(crumb), +})); + +// Import AFTER mock setup +import { runCommands } from "../../src/lib/init/tools/run-commands.js"; + +// ============================================================================ +// Helpers +// ============================================================================ + +function makePayload(commands: string[], cwd = "/tmp"): RunCommandsPayload { + return { type: "tool", operation: "run-commands", cwd, params: { commands } }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +beforeEach(() => breadcrumbs.splice(0)); + +describe("runCommands breadcrumb on failure", () => { + test("emits an error breadcrumb with stderr when a command exits non-zero", async () => { + // `ls /nonexistent-sentry-test-path` exits 1 on macOS/Linux with a + // "No such file" message on stderr — a reliable non-zero exit without + // requiring shell built-ins. + const result = await runCommands( + makePayload(["ls /nonexistent-sentry-test-path-xyz"]), + { dryRun: false } + ); + + expect(result.ok).toBe(false); + expect(breadcrumbs).toHaveLength(1); + + const crumb = breadcrumbs[0]; + expect(crumb.level).toBe("error"); + expect(crumb.message).toContain("ls"); + expect(crumb.data.exitCode).not.toBe(0); + expect(typeof crumb.data.stdout).toBe("string"); + expect(typeof crumb.data.stderr).toBe("string"); + expect(crumb.data.cwd).toBe("/tmp"); + }); + + test("does not emit a breadcrumb when the command succeeds", async () => { + const result = await runCommands(makePayload(["echo hello"]), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(breadcrumbs).toHaveLength(0); + }); + + test("does not emit a breadcrumb in dry-run mode", async () => { + const result = await runCommands( + makePayload(["ls /nonexistent-sentry-test-path-xyz"]), + { dryRun: true } + ); + + expect(result.ok).toBe(true); + expect(breadcrumbs).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/lib/safe-read.test.ts b/packages/cli/test/lib/safe-read.test.ts new file mode 100644 index 000000000..eecc77578 --- /dev/null +++ b/packages/cli/test/lib/safe-read.test.ts @@ -0,0 +1,343 @@ +/** + * FIFO-safety tests for the `safeReadFile` helper and the migrated + * call sites. Parallels `test/lib/dsn/fifo-safety.test.ts` (added by + * PR #806) — extends coverage to the non-DSN read paths addressed by + * Group D unification. + */ + +import { execSync } from "node:child_process"; +import fs, { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { MAX_FILE_BYTES } from "../../src/lib/init/constants.js"; +import { applyPatchset } from "../../src/lib/init/tools/apply-patchset.js"; +import { readFiles } from "../../src/lib/init/tools/read-files.js"; +import type { DirEntry } from "../../src/lib/init/types.js"; +import { preReadCommonFiles } from "../../src/lib/init/workflow-inputs.js"; +import { safeReadFile } from "../../src/lib/safe-read.js"; +import { + clearSentryCliRcCache, + loadSentryCliRc, +} from "../../src/lib/sentryclirc.js"; + +/** Create a FIFO (named pipe) at the given path using mkfifo(1). */ +function createFifo(path: string): void { + execSync(`mkfifo ${JSON.stringify(path)}`); +} + +describe("safeReadFile", () => { + let dir: string; + + beforeEach(() => { + dir = join( + tmpdir(), + `safe-read-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(dir, { recursive: true }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("reads regular file content", async () => { + const path = join(dir, "config.ini"); + writeFileSync(path, "[defaults]\norg=acme\n"); + expect(await safeReadFile(path, "test")).toBe("[defaults]\norg=acme\n"); + }); + + test("returns null for missing files", async () => { + expect(await safeReadFile(join(dir, "missing"), "test")).toBeNull(); + }); + + test("returns null for FIFOs instead of blocking", async () => { + const fifo = join(dir, "pipe.ini"); + createFifo(fifo); + // If the guard weren't in place this would hang indefinitely. + // Bun's test timeout would eventually fail the test, but we + // should get a clean `null` return almost immediately. + expect(await safeReadFile(fifo, "test")).toBeNull(); + }); + + test("returns null for symlinks to FIFOs (1Password pattern)", async () => { + const fifo = join(dir, "pipe"); + const link = join(dir, "linked.env"); + createFifo(fifo); + execSync(`ln -s ${JSON.stringify(fifo)} ${JSON.stringify(link)}`); + expect(await safeReadFile(link, "test")).toBeNull(); + }); + + test("returns null for directories", async () => { + const subdir = join(dir, "sub"); + mkdirSync(subdir); + expect(await safeReadFile(subdir, "test")).toBeNull(); + }); +}); + +describe("sentryclirc FIFO safety", () => { + let dir: string; + let originalHome: string | undefined; + let originalSentryConfigDir: string | undefined; + + beforeEach(() => { + // Clear both the load cache AND the cached global-paths singleton + // so this describe block sees the overridden env vars below. + clearSentryCliRcCache(); + dir = join( + tmpdir(), + `sentryclirc-fifo-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(dir, { recursive: true }); + // Point both global fallback locations into `dir` so the loader + // can't reach the real user's `$HOME/.sentryclirc`. + originalHome = process.env.HOME; + originalSentryConfigDir = process.env.SENTRY_CONFIG_DIR; + process.env.HOME = dir; + process.env.SENTRY_CONFIG_DIR = dir; + }); + + afterEach(() => { + process.env.HOME = originalHome; + process.env.SENTRY_CONFIG_DIR = originalSentryConfigDir; + rmSync(dir, { recursive: true, force: true }); + // Reset again so later test files don't inherit stale globalPaths + // pointing at the now-deleted temp dir. + clearSentryCliRcCache(); + }); + + test("skips a `.sentryclirc` FIFO in the project tree without hanging", async () => { + // Simulate a 1Password-managed `.sentryclirc` (unusual but + // possible). The walk-up should emit a null and move on — not + // hang on the FIFO read. + const cwd = join(dir, "project"); + mkdirSync(cwd); + createFifo(join(cwd, ".sentryclirc")); + + const config = await loadSentryCliRc(cwd); + // No config values resolved, but the function returned cleanly. + expect(config.org).toBeUndefined(); + expect(config.project).toBeUndefined(); + }); +}); + +describe("init read-files FIFO safety", () => { + let dir: string; + + beforeEach(() => { + dir = join( + tmpdir(), + `readfiles-fifo-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(dir, { recursive: true }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("returns a structured error for a FIFO path instead of hanging", async () => { + writeFileSync(join(dir, "real.ts"), "export {};\n"); + createFifo(join(dir, ".env")); + + const result = await readFiles({ + type: "tool", + operation: "read-files", + cwd: dir, + params: { paths: ["real.ts", ".env"], resultVersion: 2 }, + }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + files: { + ".env": { error: "not-file", status: "error" }, + "real.ts": { + content: "export {};\n", + status: "ok", + truncated: false, + }, + }, + version: 2, + }); + }); + + test("returns a structured error for a symlink to a FIFO", async () => { + // 1Password's `.env` integration uses a symlink → FIFO to stream + // secrets. `stat` follows the symlink so `isFile()` is false on + // the FIFO target, correctly rejected by the guard. + const fifo = join(dir, ".env-pipe"); + const link = join(dir, ".env"); + createFifo(fifo); + execSync(`ln -s ${JSON.stringify(fifo)} ${JSON.stringify(link)}`); + + const result = await readFiles({ + type: "tool", + operation: "read-files", + cwd: dir, + params: { paths: [".env"], resultVersion: 2 }, + }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + files: { ".env": { error: "not-file", status: "error" } }, + version: 2, + }); + }); +}); + +describe("init apply-patchset FIFO safety", () => { + let dir: string; + + beforeEach(() => { + dir = join( + tmpdir(), + `applypatch-fifo-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(dir, { recursive: true }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("returns a targeted error for a FIFO target instead of hanging", async () => { + createFifo(join(dir, "config.ts")); + + const result = await applyPatchset( + { + type: "tool", + operation: "apply-patchset", + cwd: dir, + params: { + patches: [ + { + action: "modify", + path: "config.ts", + edits: [{ oldString: "foo", newString: "bar" }], + }, + ], + }, + }, + { dryRun: false, authToken: undefined } + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not a (?:readable )?regular file/); + }); + + test("returns a targeted error for a symlink to a FIFO", async () => { + const fifo = join(dir, "config.pipe"); + const link = join(dir, "config.ts"); + createFifo(fifo); + execSync(`ln -s ${JSON.stringify(fifo)} ${JSON.stringify(link)}`); + + const result = await applyPatchset( + { + type: "tool", + operation: "apply-patchset", + cwd: dir, + params: { + patches: [ + { + action: "modify", + path: "config.ts", + edits: [{ oldString: "foo", newString: "bar" }], + }, + ], + }, + }, + { dryRun: false, authToken: undefined } + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not a (?:readable )?regular file/); + }); +}); + +describe("workflow-inputs preReadCommonFiles FIFO safety", () => { + let dir: string; + + beforeEach(() => { + dir = join( + tmpdir(), + `preread-fifo-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(dir, { recursive: true }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("returns null entry for a FIFO-backed common-config file", async () => { + writeFileSync(join(dir, "package.json"), '{"name":"x"}'); + // `tsconfig.json` is in `COMMON_CONFIG_FILES` but exists here as + // a FIFO. Without the `stat.isFile()` guard the read would hang. + createFifo(join(dir, "tsconfig.json")); + + const listing: DirEntry[] = [ + { name: "package.json", path: "package.json", type: "file" }, + { name: "tsconfig.json", path: "tsconfig.json", type: "file" }, + ]; + const cache = await preReadCommonFiles(dir, listing); + expect(cache["package.json"]).toBe('{"name":"x"}'); + expect(cache["tsconfig.json"]).toBeNull(); + }); + + test("rejects a common-config alias to sensitive metadata", async () => { + writeFileSync( + join(dir, ".netrc"), + "machine example.test login user password secret\n" + ); + symlinkSync(".netrc", join(dir, "package.json")); + + const listing: DirEntry[] = [ + { name: "package.json", path: "package.json", type: "file" }, + ]; + + const cache = await preReadCommonFiles(dir, listing); + expect(cache["package.json"]).toBeNull(); + }); + + test("skips an oversized common config instead of truncating it", async () => { + writeFileSync(join(dir, "package.json"), "x".repeat(MAX_FILE_BYTES + 1)); + const listing: DirEntry[] = [ + { name: "package.json", path: "package.json", type: "file" }, + ]; + + const cache = await preReadCommonFiles(dir, listing); + expect(cache).not.toHaveProperty("package.json"); + }); + + test("fills the common-config buffer after short descriptor reads", async () => { + const filePath = join(dir, "package.json"); + const content = '{"name":"short-read-project"}\n'; + writeFileSync(filePath, content); + const actualHandle = await fs.promises.open(filePath, "r"); + const shortRead = vi.fn( + ( + buffer: Buffer, + offset: number, + length: number, + position: number | null + ) => actualHandle.read(buffer, offset, Math.min(length, 3), position) + ); + const openSpy = vi.spyOn(fs.promises, "open").mockResolvedValue({ + close: vi.fn().mockResolvedValue(undefined), + read: shortRead, + stat: () => actualHandle.stat(), + } as unknown as fs.promises.FileHandle); + + try { + const cache = await preReadCommonFiles(dir, [ + { name: "package.json", path: "package.json", type: "file" }, + ]); + + expect(cache["package.json"]).toBe(content); + expect(shortRead.mock.calls.length).toBeGreaterThan(1); + } finally { + openSpy.mockRestore(); + await actualHandle.close(); + } + }); +}); diff --git a/packages/cli/test/lib/scan/binary.property.test.ts b/packages/cli/test/lib/scan/binary.property.test.ts new file mode 100644 index 000000000..3ffa04b2f --- /dev/null +++ b/packages/cli/test/lib/scan/binary.property.test.ts @@ -0,0 +1,76 @@ +/** + * Property tests for binary classification. + * + * We pin the core invariant: `isLikelyBinary(buf)` is equivalent to + * "the first 8 KB of `buf` contains a 0x00 byte." Everything else in + * `binary.ts` is derived from that predicate. + */ + +import { assert as fcAssert, integer, property, uint8Array } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { isLikelyBinary } from "../../../src/lib/scan/binary.js"; +import { BINARY_SNIFF_BYTES } from "../../../src/lib/scan/constants.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Reference implementation: scan the first `BINARY_SNIFF_BYTES` for NUL. */ +function referenceHasNul(buf: Uint8Array): boolean { + const end = Math.min(buf.length, BINARY_SNIFF_BYTES); + for (let i = 0; i < end; i += 1) { + if (buf[i] === 0) { + return true; + } + } + return false; +} + +describe("property: isLikelyBinary", () => { + test("matches reference impl on random buffers", () => { + fcAssert( + property(uint8Array({ minLength: 0, maxLength: 9000 }), (buf) => { + expect(isLikelyBinary(buf)).toBe(referenceHasNul(buf)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent: repeated calls produce the same answer", () => { + fcAssert( + property(uint8Array({ minLength: 0, maxLength: 2048 }), (buf) => { + const a = isLikelyBinary(buf); + const b = isLikelyBinary(buf); + expect(a).toBe(b); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("inserting a NUL inside the sniff window forces binary", () => { + fcAssert( + property( + uint8Array({ minLength: 1, maxLength: BINARY_SNIFF_BYTES }), + integer({ min: 0 }), + (buf, offsetSeed) => { + const buf2 = new Uint8Array(buf); + const idx = offsetSeed % buf2.length; + buf2[idx] = 0; + expect(isLikelyBinary(buf2)).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buffers of NUL-free bytes inside the window classify as text", () => { + fcAssert( + property( + uint8Array({ minLength: 0, maxLength: BINARY_SNIFF_BYTES }), + (buf) => { + // Strip NULs by flipping each to 1. + const clean = buf.map((b) => (b === 0 ? 1 : b)); + expect(isLikelyBinary(clean)).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/scan/binary.test.ts b/packages/cli/test/lib/scan/binary.test.ts new file mode 100644 index 000000000..e1cea3f5a --- /dev/null +++ b/packages/cli/test/lib/scan/binary.test.ts @@ -0,0 +1,169 @@ +/** + * Unit tests for `src/lib/scan/binary.ts`. + * + * Covers the three entry points: + * - `isLikelyBinary(head)` — pure NUL-byte sniff on a buffer. + * - `classifyByExtension(path, textExtensions)` — O(1) fast path. + * - `readHeadAndSniff(path)` — opens a real file and runs both. + * + * We also pin a few known limitations as assertions so they don't + * silently change (UTF-16 misclassified as binary; empty file = text). + */ + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + classifyByExtension, + isLikelyBinary, + readHeadAndSniff, +} from "../../../src/lib/scan/binary.js"; +import { TEXT_EXTENSIONS } from "../../../src/lib/scan/constants.js"; + +const TMP = mkdtempSync(join(tmpdir(), "scan-binary-test-")); + +afterAll(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe("isLikelyBinary", () => { + test("empty buffer is text", () => { + expect(isLikelyBinary(new Uint8Array(0))).toBe(false); + }); + + test("ASCII text is text", () => { + const buf = new TextEncoder().encode("hello world\nfoo bar\n"); + expect(isLikelyBinary(buf)).toBe(false); + }); + + test("UTF-8 with CJK is text", () => { + const buf = new TextEncoder().encode("こんにちは世界\n"); + expect(isLikelyBinary(buf)).toBe(false); + }); + + test("single NUL at offset 0 classifies as binary", () => { + expect(isLikelyBinary(Uint8Array.of(0, 1, 2, 3))).toBe(true); + }); + + test("NUL byte anywhere in first 8 KB classifies as binary", () => { + const buf = new Uint8Array(8000); + buf.fill(0x41); // 'A' + buf[7999] = 0; + expect(isLikelyBinary(buf)).toBe(true); + }); + + test("NUL beyond 8 KB is ignored (sniff window bounded)", () => { + const buf = new Uint8Array(10_000); + buf.fill(0x41); + buf[9000] = 0; + expect(isLikelyBinary(buf)).toBe(false); + }); + + test("documented limitation: UTF-16LE text misclassified as binary", () => { + // "A" in UTF-16LE is 0x41 0x00 — the 0x00 triggers the sniff. + const buf = Uint8Array.of(0x41, 0x00, 0x42, 0x00, 0x43, 0x00); + expect(isLikelyBinary(buf)).toBe(true); + }); +}); + +describe("classifyByExtension", () => { + test("known text extensions return {isBinary: false}", () => { + expect(classifyByExtension("/a/b/c.ts", TEXT_EXTENSIONS)).toEqual({ + isBinary: false, + }); + expect(classifyByExtension("/a/b/c.JSON", TEXT_EXTENSIONS)).toEqual({ + isBinary: false, + }); + }); + + test("known-binary extensions return isBinary:true (no sniff)", () => { + expect(classifyByExtension("/a/b/c.png", TEXT_EXTENSIONS)).toEqual({ + isBinary: true, + }); + expect(classifyByExtension("/a/b/c.woff", TEXT_EXTENSIONS)).toEqual({ + isBinary: true, + }); + expect(classifyByExtension("/a/b/c.pdf", TEXT_EXTENSIONS)).toEqual({ + isBinary: true, + }); + expect(classifyByExtension("/a/b/c.wasm", TEXT_EXTENSIONS)).toEqual({ + isBinary: true, + }); + // Case-insensitive — common for screenshot exports etc. + expect(classifyByExtension("/a/b/c.PNG", TEXT_EXTENSIONS)).toEqual({ + isBinary: true, + }); + }); + + test("ambiguous extensions return null (caller must sniff)", () => { + // `.svg` is XML text, NOT in BINARY_EXTENSIONS. + expect(classifyByExtension("/a/b/c.svg", TEXT_EXTENSIONS)).toBeNull(); + // `.log` / `.lock` / `.map` — usually text, unsafe to presume. + expect(classifyByExtension("/a/b/c.log", TEXT_EXTENSIONS)).toBeNull(); + expect(classifyByExtension("/a/b/c.lock", TEXT_EXTENSIONS)).toBeNull(); + expect(classifyByExtension("/a/b/c.map", TEXT_EXTENSIONS)).toBeNull(); + // Generic binary-ish extensions that are often text — we rely + // on the NUL-sniff for these. + expect(classifyByExtension("/a/b/c.bin", TEXT_EXTENSIONS)).toBeNull(); + expect(classifyByExtension("/a/b/c.dat", TEXT_EXTENSIONS)).toBeNull(); + expect(classifyByExtension("/a/b/c.dump", TEXT_EXTENSIONS)).toBeNull(); + // `.obj` is shared with Wavefront OBJ (text 3D model format). + expect(classifyByExtension("/a/b/c.obj", TEXT_EXTENSIONS)).toBeNull(); + // Wholly unknown extension. + expect(classifyByExtension("/a/b/c.xyz", TEXT_EXTENSIONS)).toBeNull(); + }); + + test("no-extension files return null", () => { + expect(classifyByExtension("/a/b/Makefile", TEXT_EXTENSIONS)).toBeNull(); + expect( + classifyByExtension("/a/b/.sentryclirc", TEXT_EXTENSIONS) + ).toBeNull(); + }); +}); + +describe("readHeadAndSniff", () => { + let textPath: string; + let binaryPath: string; + let emptyPath: string; + let smallPath: string; + + beforeAll(() => { + textPath = join(TMP, "hello.txt"); + binaryPath = join(TMP, "blob.bin"); + emptyPath = join(TMP, "empty.bin"); + smallPath = join(TMP, "tiny.txt"); + writeFileSync(textPath, "hello world\nfoo bar\nanother line\n", "utf8"); + const bin = new Uint8Array(1024); + for (let i = 0; i < bin.length; i += 1) { + bin[i] = 0x41; + } + bin[16] = 0; // match the fixture generator convention + writeFileSync(binaryPath, bin); + writeFileSync(emptyPath, new Uint8Array(0)); + writeFileSync(smallPath, "ok"); + }); + + test("text file classified as text with a non-empty head", async () => { + const { head, isBinary } = await readHeadAndSniff(textPath); + expect(isBinary).toBe(false); + expect(head.length).toBeGreaterThan(0); + }); + + test("binary file classified as binary", async () => { + const { head, isBinary } = await readHeadAndSniff(binaryPath); + expect(isBinary).toBe(true); + expect(head.length).toBe(1024); + }); + + test("empty file sniffs to head.length 0 and is text", async () => { + const { head, isBinary } = await readHeadAndSniff(emptyPath); + expect(head.length).toBe(0); + expect(isBinary).toBe(false); + }); + + test("tiny files yield a correctly-sized head buffer", async () => { + const { head } = await readHeadAndSniff(smallPath); + expect(head.length).toBe(2); + }); +}); diff --git a/packages/cli/test/lib/scan/concurrent.test.ts b/packages/cli/test/lib/scan/concurrent.test.ts new file mode 100644 index 000000000..ce3164975 --- /dev/null +++ b/packages/cli/test/lib/scan/concurrent.test.ts @@ -0,0 +1,209 @@ +/** + * Unit tests for `src/lib/scan/concurrent.ts`. + * + * Pins down the invariants grep + DSN scanner rely on: + * 1. Bounded parallelism — never more than `concurrency` tasks in flight. + * 2. Early-exit via `onResult.done: true` halts queued tasks. + * 3. AbortSignal propagates through both helpers. + * 4. Streaming variant yields in completion order and buffers correctly. + * 5. Consumer-initiated `break` halts the producer cleanly. + */ + +import { describe, expect, test } from "vitest"; +import { + mapFilesConcurrent, + mapFilesConcurrentStream, +} from "../../../src/lib/scan/concurrent.js"; + +/** Helper: emit n items through an async generator. */ +async function* emitRange(n: number): AsyncGenerator { + for (let i = 0; i < n; i += 1) { + yield i; + } +} + +describe("mapFilesConcurrent — gather variant", () => { + test("runs fn on every item and collects results", async () => { + const out = await mapFilesConcurrent(emitRange(5), async (i) => i * 2); + expect(out.sort((a, b) => a - b)).toEqual([0, 2, 4, 6, 8]); + }); + + test("null return values are filtered out", async () => { + const out = await mapFilesConcurrent( + emitRange(5), + async (i) => (i % 2 === 0 ? i : null) + ); + expect(out.sort((a, b) => a - b)).toEqual([0, 2, 4]); + }); + + test("respects concurrency limit", async () => { + let inFlight = 0; + let maxSeen = 0; + await mapFilesConcurrent( + emitRange(20), + async () => { + inFlight += 1; + maxSeen = Math.max(maxSeen, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight -= 1; + return 1; + }, + { concurrency: 3 } + ); + expect(maxSeen).toBeLessThanOrEqual(3); + expect(maxSeen).toBeGreaterThanOrEqual(1); + }); + + test("onResult done:true raises early-exit flag", async () => { + let processed = 0; + await mapFilesConcurrent( + emitRange(100), + async (i) => { + processed += 1; + return i; + }, + { + onResult: (result) => ({ done: result === 3 }), + } + ); + // With concurrency 50 and `done` on hit 4, we expect well under + // all 100 items to have been processed before the flag stopped + // further queueing. Loose bound — just assert we didn't process + // every single item. + expect(processed).toBeLessThan(100); + expect(processed).toBeGreaterThanOrEqual(1); + }); + + test("aborted signal throws AbortError synchronously on next iteration", async () => { + const controller = new AbortController(); + controller.abort(); + let threw: unknown = null; + try { + await mapFilesConcurrent(emitRange(10), async (i) => i, { + signal: controller.signal, + }); + } catch (error) { + threw = error; + } + expect(threw).toBeInstanceOf(DOMException); + expect((threw as DOMException).name).toBe("AbortError"); + }); +}); + +describe("mapFilesConcurrentStream — streaming variant", () => { + test("yields every non-null entry from fn's result arrays", async () => { + const collected: number[] = []; + for await (const item of mapFilesConcurrentStream( + emitRange(5), + async (i) => [i, i + 10] + )) { + collected.push(item); + } + // 5 items * 2 entries = 10 total, any order. + expect(collected.length).toBe(10); + expect(collected.sort((a, b) => a - b)).toEqual([ + 0, 1, 2, 3, 4, 10, 11, 12, 13, 14, + ]); + }); + + test("null or empty array skips emission", async () => { + const collected: number[] = []; + for await (const item of mapFilesConcurrentStream( + emitRange(5), + async (i) => (i % 2 === 0 ? [i] : null) + )) { + collected.push(item); + } + expect(collected.sort((a, b) => a - b)).toEqual([0, 2, 4]); + }); + + test("consumer break halts the producer cleanly", async () => { + let produced = 0; + const consumed: number[] = []; + for await (const item of mapFilesConcurrentStream( + emitRange(100), + async (i) => { + produced += 1; + return [i]; + }, + { concurrency: 4 } + )) { + consumed.push(item); + if (consumed.length === 2) { + break; + } + } + expect(consumed.length).toBe(2); + // Producer ran at most a handful of extras past the break point + // (the queue size + in-flight tasks). Don't pin an exact number — + // that's flaky. Just assert it's well short of all 100. + expect(produced).toBeLessThan(100); + }); + + test("signal propagates in stream variant when provided", async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 5); + const iter = mapFilesConcurrentStream( + slowRange(200), + async (i) => { + await new Promise((r) => setTimeout(r, 20)); + return [i]; + }, + { signal: controller.signal, concurrency: 2 } + ); + let threw: unknown = null; + try { + for await (const _ of iter) { + // drain + } + } catch (error) { + threw = error; + } + expect(threw).toBeInstanceOf(DOMException); + expect((threw as DOMException).name).toBe("AbortError"); + }); + + test("producer errors surface even when consumer breaks early", async () => { + // Regression test for PR 791 review finding: the producer-error + // rethrow was placed after the generator's `try/finally`. When a + // consumer `break`s early, the runtime's `return()` resolves the + // iterator after the `finally` runs but does NOT execute code + // after the try block — so a producer error would be silently + // swallowed. The fix moves the rethrow inside the `finally`. + const erroringSource = (async function* () { + yield 1; + yield 2; + throw new Error("producer exploded"); + })(); + + let caught: unknown = null; + try { + for await (const _ of mapFilesConcurrentStream( + erroringSource, + async (i) => [i * 2], + { concurrency: 1 } + )) { + // break immediately — the error hasn't been observed yet. + break; + } + } catch (error) { + caught = error; + } + // Without the fix: caught stays null (error lost). + // With the fix: the error propagates via the generator's + // `return()` -> finally path and surfaces at the consumer. + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe("producer exploded"); + }); +}); + +/** + * Slow generator that yields one item every 1ms — gives the signal + * time to fire mid-stream. + */ +async function* slowRange(n: number): AsyncGenerator { + for (let i = 0; i < n; i += 1) { + await new Promise((r) => setTimeout(r, 1)); + yield i; + } +} diff --git a/packages/cli/test/lib/scan/constants.property.test.ts b/packages/cli/test/lib/scan/constants.property.test.ts new file mode 100644 index 000000000..b9d3f1409 --- /dev/null +++ b/packages/cli/test/lib/scan/constants.property.test.ts @@ -0,0 +1,133 @@ +/** + * Property tests for shared constants in `src/lib/scan/constants.ts`. + * + * `normalizePath` is trivially identity on POSIX and a regex replace on + * Windows. We use property-based tests anyway because the identity case + * is easy to silently break (e.g., someone switches to `path.normalize` + * which collapses `a/b/../c` to `a/c` — that would not be idempotent). + */ + +import path from "node:path"; +import { + constantFrom, + assert as fcAssert, + property, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + isMonorepoPackageDir, + MONOREPO_ROOTS, + normalizePath, +} from "../../../src/lib/scan/constants.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +describe("property: normalizePath", () => { + test("idempotent", () => { + fcAssert( + property(string(), (input) => { + const once = normalizePath(input); + const twice = normalizePath(once); + expect(twice).toBe(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("no backslashes in output on non-POSIX platforms", () => { + if (path.sep === path.posix.sep) { + // Identity on POSIX — backslashes are a valid filename character + // there, so preserving them is the correct behavior. + return; + } + fcAssert( + property(string(), (input) => { + expect(normalizePath(input).includes("\\")).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("POSIX: identity", () => { + if (path.sep !== path.posix.sep) { + return; + } + fcAssert( + property(string(), (input) => { + expect(normalizePath(input)).toBe(input); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: isMonorepoPackageDir", () => { + const segmentArb = string({ minLength: 1, maxLength: 15 }).filter( + (s) => !s.includes("/") && s.length > 0 + ); + + test("any 2-segment path with MONOREPO_ROOTS first is a package dir", () => { + fcAssert( + property(constantFrom(...MONOREPO_ROOTS), segmentArb, (root, pkg) => { + const rel = `${root}/${pkg}`; + expect(isMonorepoPackageDir(rel)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("single-segment paths are never package dirs", () => { + fcAssert( + property(segmentArb, (seg) => { + expect(isMonorepoPackageDir(seg)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("3-segment paths are never package dirs even with monorepo root", () => { + fcAssert( + property( + constantFrom(...MONOREPO_ROOTS), + tuple(segmentArb, segmentArb), + (root, [b, c]) => { + expect(isMonorepoPackageDir(`${root}/${b}/${c}`)).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("2-segment paths with non-monorepo first segment are not package dirs", () => { + // Filter out any MONOREPO_ROOTS member so we exercise the negative case. + const monorepoSet = new Set(MONOREPO_ROOTS); + fcAssert( + property(segmentArb, segmentArb, (first, second) => { + if (monorepoSet.has(first)) { + return; // skip — tested above + } + expect(isMonorepoPackageDir(`${first}/${second}`)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("DEFAULT_SKIP_DIRS vs DSN_ADDITIONAL_SKIP_DIRS", () => { + test("no overlap between the two skip lists", () => { + // Overlapping entries would be a code-smell: either they move into + // DEFAULT_SKIP_DIRS (and DSN_ADDITIONAL_SKIP_DIRS drops them) or + // they're DSN-specific (and shouldn't be in both). + const fn = async () => { + const { DEFAULT_SKIP_DIRS, DSN_ADDITIONAL_SKIP_DIRS } = await import( + "../../../src/lib/scan/constants.js" + ); + const base = new Set(DEFAULT_SKIP_DIRS); + for (const extra of DSN_ADDITIONAL_SKIP_DIRS) { + expect(base.has(extra)).toBe(false); + } + }; + return fn(); + }); +}); diff --git a/packages/cli/test/lib/scan/glob.test.ts b/packages/cli/test/lib/scan/glob.test.ts new file mode 100644 index 000000000..c8159cd79 --- /dev/null +++ b/packages/cli/test/lib/scan/glob.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for `src/lib/scan/glob.ts` (`globFiles`, `collectGlob`). + * + * Pin down the picomatch-backed semantics the init wizard's + * fs-fallback (and rg) expose: + * + * - `*.ts` (no `/`) matches basename anywhere in tree. + * - `src/*.ts` (with `/`) matches against the relative path. + * - `**\/*.ts` matches `.ts` anywhere. + * - Multiple patterns OR. + * - `exclude` suppresses. + * - `maxResults` caps + sets `truncated: true`. + * - `path` narrows the walk root and yields cwd-relative paths. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import { collectGlob } from "../../../src/lib/scan/glob.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "scan-glob-test-")); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +function makeSandbox(layout: Record): { + cwd: string; + cleanup: () => void; +} { + const cwd = mkdtempSync(join(ROOT, "box-")); + for (const [rel, content] of Object.entries(layout)) { + const abs = join(cwd, rel); + const parent = abs.slice(0, abs.lastIndexOf("/")); + mkdirSync(parent, { recursive: true }); + writeFileSync(abs, content, "utf8"); + } + return { cwd, cleanup: () => rmSync(cwd, { recursive: true, force: true }) }; +} + +describe("collectGlob — pattern semantics", () => { + test("bare `*.ext` matches basename anywhere in tree", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "src/b.ts": "y", + "deep/sub/c.ts": "z", + "d.md": "w", + }); + try { + const { files } = await collectGlob({ cwd, patterns: "*.ts" }); + expect(files).toEqual(["a.ts", "deep/sub/c.ts", "src/b.ts"]); + } finally { + cleanup(); + } + }); + + test("`src/*.ts` with `/` matches only directly under src/", async () => { + const { cwd, cleanup } = makeSandbox({ + "src/a.ts": "x", + "src/b.ts": "y", + "src/deep/c.ts": "z", + "other/d.ts": "w", + }); + try { + const { files } = await collectGlob({ cwd, patterns: "src/*.ts" }); + expect(files).toEqual(["src/a.ts", "src/b.ts"]); + } finally { + cleanup(); + } + }); + + test("`**/*.ts` matches anywhere in tree", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "src/b.ts": "y", + "src/deep/c.ts": "z", + }); + try { + const { files } = await collectGlob({ cwd, patterns: "**/*.ts" }); + expect(files).toEqual(["a.ts", "src/b.ts", "src/deep/c.ts"]); + } finally { + cleanup(); + } + }); + + test("no matches returns empty array", async () => { + const { cwd, cleanup } = makeSandbox({ "a.txt": "x" }); + try { + const { files, truncated } = await collectGlob({ + cwd, + patterns: "*.nonexistent", + }); + expect(files).toEqual([]); + expect(truncated).toBe(false); + } finally { + cleanup(); + } + }); + + test("brace expansion works (picomatch grammar)", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "b.js": "y", + "c.md": "z", + }); + try { + const { files } = await collectGlob({ cwd, patterns: "*.{ts,js}" }); + expect(files).toEqual(["a.ts", "b.js"]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGlob — multiple patterns", () => { + test("array of patterns ORs them", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "b.js": "y", + "c.md": "z", + }); + try { + const { files } = await collectGlob({ + cwd, + patterns: ["*.ts", "*.md"], + }); + expect(files).toEqual(["a.ts", "c.md"]); + } finally { + cleanup(); + } + }); + + test("empty patterns array yields nothing", async () => { + const { cwd, cleanup } = makeSandbox({ "a.ts": "x" }); + try { + const { files } = await collectGlob({ cwd, patterns: [] }); + expect(files).toEqual([]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGlob — exclude", () => { + test("exclude suppresses matching files", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "b.test.ts": "y", + "c.ts": "z", + }); + try { + const { files } = await collectGlob({ + cwd, + patterns: "*.ts", + exclude: "*.test.ts", + }); + expect(files).toEqual(["a.ts", "c.ts"]); + } finally { + cleanup(); + } + }); + + test("exclude array also ORs", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "b.test.ts": "y", + "c.spec.ts": "z", + "d.ts": "w", + }); + try { + const { files } = await collectGlob({ + cwd, + patterns: "*.ts", + exclude: ["*.test.ts", "*.spec.ts"], + }); + expect(files).toEqual(["a.ts", "d.ts"]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGlob — truncation", () => { + test("maxResults caps emission and sets truncated", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "b.ts": "y", + "c.ts": "z", + "d.ts": "w", + "e.ts": "v", + }); + try { + const { files, truncated } = await collectGlob({ + cwd, + patterns: "*.ts", + maxResults: 3, + }); + expect(files.length).toBe(3); + expect(truncated).toBe(true); + } finally { + cleanup(); + } + }); +}); + +describe("collectGlob — path narrowing", () => { + test("path scopes walk and yields cwd-relative paths", async () => { + const { cwd, cleanup } = makeSandbox({ + "src/a.ts": "x", + "src/sub/b.ts": "y", + "other/c.ts": "z", + }); + try { + const { files } = await collectGlob({ + cwd, + patterns: "**/*.ts", + path: "src", + }); + // Files are reported relative to cwd, not `path`. + expect(files).toEqual(["src/a.ts", "src/sub/b.ts"]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGlob — .gitignore + hidden handling", () => { + test("respects root .gitignore by default", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "*.log\n", + "a.ts": "x", + "b.log": "y", + }); + try { + const { files } = await collectGlob({ cwd, patterns: "*" }); + // .gitignore is a dotfile; with default hidden: true it matches + // `*` under picomatch dot:true semantics. b.log is gitignored. + expect(files.includes("a.ts")).toBe(true); + expect(files.includes("b.log")).toBe(false); + } finally { + cleanup(); + } + }); + + test("hidden: false skips dotfiles", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + ".env": "y", + }); + try { + const { files } = await collectGlob({ + cwd, + patterns: "*", + hidden: false, + }); + expect(files.includes("a.ts")).toBe(true); + expect(files.includes(".env")).toBe(false); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/cli/test/lib/scan/grep.property.test.ts b/packages/cli/test/lib/scan/grep.property.test.ts new file mode 100644 index 000000000..8982a5d22 --- /dev/null +++ b/packages/cli/test/lib/scan/grep.property.test.ts @@ -0,0 +1,163 @@ +/** + * Property test: `grepFiles` agrees with a naive reference impl. + * + * The invariant we're pinning: for any pattern + corpus, + * `collectGrep` emits exactly the same matches as a naive + * `content.split("\n").forEach(line => regex.test(line))` pass over + * each file. Tests the engine's correctness without tying to any + * specific implementation detail. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + array, + asyncProperty, + constantFrom, + assert as fcAssert, +} from "fast-check"; +import { afterAll, describe, expect, test } from "vitest"; +import { collectGrep } from "../../../src/lib/scan/grep.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "scan-grep-prop-")); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +/** Alphabet safe for filenames and file content (no newlines, no meta). */ +const SAFE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789".split(""); + +/** Short filename without extension metas. */ +const filenameArb = array(constantFrom(...SAFE_CHARS), { + minLength: 2, + maxLength: 5, +}).map((chars) => `${chars.join("")}.txt`); + +/** + * File content: 1-8 lines, each 0-20 chars of SAFE_CHARS. + * + * We keep the character set small so generated patterns are + * statistically likely to match a non-trivial number of lines. + */ +const fileContentArb = array( + array(constantFrom(...SAFE_CHARS), { minLength: 0, maxLength: 20 }).map( + (chars) => chars.join("") + ), + { minLength: 1, maxLength: 8 } +).map((lines) => lines.join("\n")); + +/** Small tree: 1-5 filename/content pairs. */ +const treeArb = array( + constantFrom(null).chain(() => + filenameArb.chain((name) => + fileContentArb.map((content) => [name, content] as [string, string]) + ) + ), + { minLength: 1, maxLength: 5 } +); + +/** Pattern: 1-3 chars from the same alphabet — likely to match. */ +const patternArb = array(constantFrom(...SAFE_CHARS), { + minLength: 1, + maxLength: 3, +}).map((chars) => chars.join("")); + +/** + * Naive reference: read each file's content directly, split by `\n`, + * test each line against the regex, record matching lines with 1-based + * line numbers. Sort by [path, lineNum] to align with collectGrep's + * stable sort. + */ +type NaiveMatch = { path: string; lineNum: number; line: string }; +function naiveGrep( + layout: readonly [string, string][], + pattern: string +): NaiveMatch[] { + const out: NaiveMatch[] = []; + const regex = new RegExp(pattern); + for (const [path, content] of layout) { + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] as string; + if (regex.test(line)) { + out.push({ path, lineNum: i + 1, line }); + } + } + } + return out.sort((a, b) => { + if (a.path < b.path) return -1; + if (a.path > b.path) return 1; + return a.lineNum - b.lineNum; + }); +} + +describe("property: collectGrep matches naive reference", () => { + test("every regex + corpus produces identical matches", async () => { + await fcAssert( + asyncProperty(treeArb, patternArb, async (pairs, pattern) => { + // Dedupe paths — fc may generate duplicates, in which case + // later entries overwrite earlier ones on disk but the naive + // reference sees all. Drop duplicates from both sides. + const seen = new Set(); + const layout: [string, string][] = []; + for (const [path, content] of pairs) { + if (seen.has(path)) continue; + seen.add(path); + layout.push([path, content]); + } + const cwd = mkdtempSync(join(ROOT, "tree-")); + try { + for (const [path, content] of layout) { + mkdirSync(cwd, { recursive: true }); + writeFileSync(join(cwd, path), content, "utf8"); + } + const expected = naiveGrep(layout, pattern); + const { matches } = await collectGrep({ cwd, pattern }); + const got = matches.map((m) => ({ + path: m.path, + lineNum: m.lineNum, + line: m.line, + })); + expect(got).toEqual(expected); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }), + // Filesystem-heavy property; keep run count modest so CI stays + // fast. 20 runs still explores plenty of pattern/corpus shapes. + { numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } + ); + }); +}); + +describe("property: idempotence", () => { + test("running collectGrep twice returns identical matches", async () => { + await fcAssert( + asyncProperty(treeArb, patternArb, async (pairs, pattern) => { + const seen = new Set(); + const layout: [string, string][] = []; + for (const [path, content] of pairs) { + if (seen.has(path)) continue; + seen.add(path); + layout.push([path, content]); + } + const cwd = mkdtempSync(join(ROOT, "tree-")); + try { + for (const [path, content] of layout) { + mkdirSync(cwd, { recursive: true }); + writeFileSync(join(cwd, path), content, "utf8"); + } + const a = await collectGrep({ cwd, pattern }); + const b = await collectGrep({ cwd, pattern }); + expect(a.matches).toEqual(b.matches); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 15) } + ); + }); +}); diff --git a/packages/cli/test/lib/scan/grep.test.ts b/packages/cli/test/lib/scan/grep.test.ts new file mode 100644 index 000000000..f24308bd8 --- /dev/null +++ b/packages/cli/test/lib/scan/grep.test.ts @@ -0,0 +1,920 @@ +/** + * Unit tests for `src/lib/scan/grep.ts` (`grepFiles`, `collectGrep`). + * + * Each test builds a small sandbox under `tmpdir()`, runs grep with + * specific options, and asserts on the returned matches. + * + * We use `collectGrep` in most tests — it gives us a stable sorted + * order plus the stats bag to assert on. The iterable variant is + * tested separately for streaming and early-break semantics. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import { ValidationError } from "../../../src/lib/errors.js"; +import { collectGrep, grepFiles } from "../../../src/lib/scan/grep.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "scan-grep-test-")); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +/** Build a sandbox directory with the given relative-path → content map. */ +function makeSandbox(layout: Record): { + cwd: string; + cleanup: () => void; +} { + const cwd = mkdtempSync(join(ROOT, "box-")); + for (const [rel, content] of Object.entries(layout)) { + const abs = join(cwd, rel); + const parent = abs.slice(0, abs.lastIndexOf("/")); + mkdirSync(parent, { recursive: true }); + writeFileSync(abs, content, "utf8"); + } + return { cwd, cleanup: () => rmSync(cwd, { recursive: true, force: true }) }; +} + +describe("collectGrep — basic matching", () => { + test("finds a simple literal pattern across files", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "line one\nhello world\nline three", + "b.txt": "nothing here\nhello again", + "c.txt": "no match at all", + }); + try { + const { matches } = await collectGrep({ cwd, pattern: "hello" }); + expect(matches.map((m) => `${m.path}:${m.lineNum}`)).toEqual([ + "a.txt:2", + "b.txt:2", + ]); + expect(matches[0]?.line).toBe("hello world"); + } finally { + cleanup(); + } + }); + + test("no matches returns empty result with truncated: false", async () => { + const { cwd, cleanup } = makeSandbox({ "a.txt": "no match" }); + try { + const result = await collectGrep({ cwd, pattern: "xyz" }); + expect(result.matches).toEqual([]); + expect(result.stats.truncated).toBe(false); + expect(result.stats.filesRead).toBe(1); + } finally { + cleanup(); + } + }); + + test("regex metachars work as patterns", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "one\ntwo\n\nthree", + }); + try { + // `^t.*` matches any line starting with t. + const { matches } = await collectGrep({ cwd, pattern: "^t" }); + expect(matches.map((m) => m.line)).toEqual(["two", "three"]); + } finally { + cleanup(); + } + }); + + test("preserves non-ASCII / multi-byte UTF-8 in matched lines", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "héllo wörld TARGET here\npréfix TARGET 你好世界\n", + "b.txt": "emoji 🙂 TARGET 🎉 end\nmath ∑ ∞ TARGET ∂\n", + "c.txt": "astral 𝒜 TARGET 𝕏 𝔸\n", + }); + try { + const { matches } = await collectGrep({ cwd, pattern: "TARGET" }); + expect(matches.map((m) => m.line).sort()).toEqual([ + "astral 𝒜 TARGET 𝕏 𝔸", + "emoji 🙂 TARGET 🎉 end", + "héllo wörld TARGET here", + "math ∑ ∞ TARGET ∂", + "préfix TARGET 你好世界", + ]); + } finally { + cleanup(); + } + }); + + test("truncation at a surrogate-pair boundary doesn't leak U+FFFD", async () => { + // Regression: `maxLineLength` truncation used to slice on a + // code-unit boundary, which could split a pair and leave a lone + // high surrogate that `TextEncoder` replaces with U+FFFD. + const { cwd, cleanup } = makeSandbox({ + "a.txt": "TARGET🙂trailing content beyond the cutoff\n", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "TARGET", + maxLineLength: 8, + }); + expect(matches).toHaveLength(1); + const line = matches[0]?.line ?? ""; + expect(line.startsWith("TARGET")).toBe(true); + expect(line.endsWith("\u2026")).toBe(true); + expect(line.includes("\uFFFD")).toBe(false); + } finally { + cleanup(); + } + }); + + test("UTF-8 BOM at the start of a file preserves line offsets", async () => { + // Regression: the decoder defaults to `ignoreBOM: false`, which + // silently strips a leading U+FEFF. A BOM-prefixed source file + // would put U+FEFF at pool index 0 on the worker side; without + // `ignoreBOM: true` on the main-side decoder, the decoded pool is + // one code unit shorter than the worker's offsets assume, and + // every line in that batch shifts left by one character. + const { cwd, cleanup } = makeSandbox({}); + try { + const body = "TARGET first\nTARGET second\nTARGET third\n"; + const bytes = new Uint8Array(3 + body.length); + bytes[0] = 0xef; + bytes[1] = 0xbb; + bytes[2] = 0xbf; + bytes.set(new TextEncoder().encode(body), 3); + writeFileSync(join(cwd, "bom.txt"), bytes); + const { matches } = await collectGrep({ cwd, pattern: "TARGET" }); + // The first line keeps the BOM (that's what's in the source + // file); lines 2 and 3 are intact — no bleed-through. + expect(matches.map((m) => m.line)).toEqual([ + "\uFEFFTARGET first", + "TARGET second", + "TARGET third", + ]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — case sensitivity", () => { + test("default is case-sensitive", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "Hello\nhello\nHELLO", + }); + try { + const { matches } = await collectGrep({ cwd, pattern: "hello" }); + expect(matches.map((m) => m.line)).toEqual(["hello"]); + } finally { + cleanup(); + } + }); + + test("caseSensitive: false matches any case", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "Hello\nhello\nHELLO", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "hello", + caseSensitive: false, + }); + expect(matches.map((m) => m.line)).toEqual(["Hello", "hello", "HELLO"]); + } finally { + cleanup(); + } + }); + + test("leading (?i) in pattern also enables case-insensitive", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "Hello\nhello\nHELLO", + }); + try { + const { matches } = await collectGrep({ cwd, pattern: "(?i)hello" }); + expect(matches.length).toBe(3); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — multiline mode", () => { + test("default: ^ matches at line boundaries (grep-like)", async () => { + // Default `multiline: true` gives rg/grep semantics: `^foo` hits + // any line starting with `foo`, not just the first line of the + // file. Regression test for a PR 791 review finding that the + // `multiline` option was always forced to true internally; the + // fix ties behavior to the caller's explicit opt-in/out. + const { cwd, cleanup } = makeSandbox({ + "a.txt": "nope\nfoo line\nalso nope\nfoo again", + }); + try { + const { matches } = await collectGrep({ cwd, pattern: "^foo" }); + expect(matches.map((m) => m.line)).toEqual(["foo line", "foo again"]); + } finally { + cleanup(); + } + }); + + test("multiline: false applies strict buffer-boundary anchoring", async () => { + // With `multiline: false`, `^` anchors to the buffer start only. + // Only the first line can match `^foo`; a later `foo`-start line + // inside the same file does NOT. + const { cwd, cleanup } = makeSandbox({ + "a.txt": "nope\nfoo line\nalso nope", + "b.txt": "foo buffer-start", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "^foo", + multiline: false, + }); + expect(matches.map((m) => `${m.path}:${m.line}`)).toEqual([ + "b.txt:foo buffer-start", + ]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — include / exclude globs", () => { + test("include narrows to matching files", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "foo", + "b.js": "foo", + "c.md": "foo", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "foo", + include: "*.ts", + }); + expect(matches.map((m) => m.path)).toEqual(["a.ts"]); + } finally { + cleanup(); + } + }); + + test("exclude suppresses matching files", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "foo", + "b.test.ts": "foo", + "c.ts": "foo", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "foo", + include: "*.ts", + exclude: "*.test.ts", + }); + expect(matches.map((m) => m.path).sort()).toEqual(["a.ts", "c.ts"]); + } finally { + cleanup(); + } + }); + + test("include array with multiple patterns ORs them", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "foo", + "b.js": "foo", + "c.md": "foo", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "foo", + include: ["*.ts", "*.js"], + }); + expect(matches.map((m) => m.path).sort()).toEqual(["a.ts", "b.js"]); + } finally { + cleanup(); + } + }); + + test("path narrows the walk root", async () => { + const { cwd, cleanup } = makeSandbox({ + "src/a.ts": "foo", + "src/b.ts": "foo", + "other/c.ts": "foo", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "foo", + path: "src", + }); + expect(matches.map((m) => m.path).sort()).toEqual([ + "src/a.ts", + "src/b.ts", + ]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — truncation / limits", () => { + test("maxResults caps total matches and sets truncated", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "hit\nhit\nhit\nhit\nhit", + }); + try { + const { matches, stats } = await collectGrep({ + cwd, + pattern: "hit", + maxResults: 3, + }); + expect(matches.length).toBe(3); + expect(stats.truncated).toBe(true); + } finally { + cleanup(); + } + }); + + test("maxResults == exact match count does NOT set truncated", async () => { + // Regression for PR 791 review finding: `collectGrep` previously + // set `truncated = true` whenever `matchesEmitted >= maxResults`, + // so asking for `maxResults: 3` against a corpus with exactly 3 + // matches falsely reported truncation. The fix mirrors + // `collectGlob`'s `+1` overshoot probe. + const { cwd, cleanup } = makeSandbox({ + "a.txt": "hit\nhit\nhit", + }); + try { + const { matches, stats } = await collectGrep({ + cwd, + pattern: "hit", + maxResults: 3, + }); + expect(matches.length).toBe(3); + // Exactly 3 matches exist; we requested 3; no truncation. + expect(stats.truncated).toBe(false); + } finally { + cleanup(); + } + }); + + test("stopOnFirst returns on first match", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "hit\nhit\nhit", + }); + try { + const { matches, stats } = await collectGrep({ + cwd, + pattern: "hit", + stopOnFirst: true, + }); + expect(matches.length).toBe(1); + expect(stats.truncated).toBe(true); + } finally { + cleanup(); + } + }); + + test("maxMatchesPerFile caps per-file output", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "hit\nhit\nhit\nhit\nhit", + "b.txt": "hit\nhit\nhit\nhit\nhit", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "hit", + maxMatchesPerFile: 2, + }); + // 2 files × 2 matches/file = 4 total. + expect(matches.length).toBe(4); + const perFile = matches.reduce>((acc, m) => { + acc[m.path] = (acc[m.path] ?? 0) + 1; + return acc; + }, {}); + expect(perFile["a.txt"]).toBe(2); + expect(perFile["b.txt"]).toBe(2); + } finally { + cleanup(); + } + }); + + test("long lines truncated at maxLineLength with ellipsis", async () => { + const long = "x".repeat(3000); + const { cwd, cleanup } = makeSandbox({ "a.txt": `hit${long}` }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "hit", + maxLineLength: 50, + }); + expect(matches.length).toBe(1); + const line = matches[0]?.line ?? ""; + expect(line.length).toBe(50); + expect(line.endsWith("…")).toBe(true); + } finally { + cleanup(); + } + }); + + test("lines shorter than maxLineLength are emitted verbatim", async () => { + const { cwd, cleanup } = makeSandbox({ "a.txt": "hit: short" }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "hit", + }); + expect(matches[0]?.line).toBe("hit: short"); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — binary-file handling", () => { + test("default skips NUL-containing files", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + // 256-byte "binary" blob with a hit string in it and a NUL. + const bin = new Uint8Array(256); + const enc = new TextEncoder(); + const prefix = enc.encode("hitme\n"); + bin.set(prefix, 0); + bin[100] = 0; + writeFileSync(join(cwd, "blob.bin"), bin); + const { matches, stats } = await collectGrep({ + cwd, + pattern: "hitme", + }); + expect(matches.length).toBe(0); + expect(stats.filesSkippedBinary).toBeGreaterThanOrEqual(1); + } finally { + cleanup(); + } + }); + + test("includeBinary: true scans binary files", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + const bin = new Uint8Array(256); + const enc = new TextEncoder(); + const prefix = enc.encode("hitme\n"); + bin.set(prefix, 0); + bin[100] = 0; + writeFileSync(join(cwd, "blob.bin"), bin); + const { matches } = await collectGrep({ + cwd, + pattern: "hitme", + includeBinary: true, + }); + expect(matches.length).toBe(1); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — result ordering", () => { + test("matches sorted by [path, lineNum]", async () => { + const { cwd, cleanup } = makeSandbox({ + "z.txt": "hit\nhit", + "a.txt": "hit\nhit", + "m.txt": "hit", + }); + try { + const { matches } = await collectGrep({ cwd, pattern: "hit" }); + expect(matches.map((m) => `${m.path}:${m.lineNum}`)).toEqual([ + "a.txt:1", + "a.txt:2", + "m.txt:1", + "z.txt:1", + "z.txt:2", + ]); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — regex errors", () => { + test("bad regex throws ValidationError", async () => { + const { cwd, cleanup } = makeSandbox({ "a.txt": "foo" }); + try { + await expect(collectGrep({ cwd, pattern: "[unclosed" })).rejects.toThrow( + ValidationError + ); + } finally { + cleanup(); + } + }); + + test("pre-compiled RegExp used verbatim", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "Foo\nfoo\nFOO", + }); + try { + const { matches } = await collectGrep({ cwd, pattern: /foo/i }); + expect(matches.length).toBe(3); + } finally { + cleanup(); + } + }); +}); + +describe("grepFiles — iterable variant", () => { + test("yields matches lazily", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.txt": "hit\nhit\nhit\nhit\nhit", + }); + try { + const collected: number[] = []; + for await (const match of grepFiles({ cwd, pattern: "hit" })) { + collected.push(match.lineNum); + if (collected.length === 2) { + break; + } + } + expect(collected.length).toBe(2); + } finally { + cleanup(); + } + }); + + test("AbortSignal fires mid-iteration", async () => { + // Many files so the walker has work to do while the abort fires. + const layout: Record = {}; + for (let i = 0; i < 20; i += 1) { + layout[`dir${i}/file.txt`] = "hit\nhit\nhit"; + } + const { cwd, cleanup } = makeSandbox(layout); + try { + const controller = new AbortController(); + const iter = grepFiles({ + cwd, + pattern: "hit", + signal: controller.signal, + concurrency: 2, + }); + let yields = 0; + let threw: unknown = null; + try { + for await (const _ of iter) { + yields += 1; + if (yields === 2) { + controller.abort(); + } + } + } catch (error) { + threw = error; + } + expect(threw).toBeInstanceOf(DOMException); + expect((threw as DOMException).name).toBe("AbortError"); + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — pre-compiled RegExp isolation", () => { + test("concurrent workers each emit every match for a shared RegExp", async () => { + // Guards against a foot-gun identified in review: when a caller + // passes a pre-compiled `/gm` RegExp, `ensureGlobalMultilineFlags` + // returns the same object, which workers mutate via + // `regex.exec`'s `lastIndex`. Today the match loop is fully + // synchronous so JS's single-threaded microtask model hides the + // sharing — but if anyone introduces an `await` inside the loop + // the bug would manifest. The fix is a per-file `new RegExp(...)` + // clone. This test exercises the shared-regex shape so a + // regression would blow up here before landing. + const PATTERN_LINE = "hit-marker-unique-42"; + const NUM_FILES = 30; + const MATCHES_PER_FILE = 5; + const NOISE_LINES = 200; // large enough to force multi-line scan per file + + const layout: Record = {}; + for (let f = 0; f < NUM_FILES; f += 1) { + const lines: string[] = []; + for (let i = 0; i < NOISE_LINES; i += 1) { + lines.push(`noise noise noise line ${i}`); + if (i % Math.floor(NOISE_LINES / MATCHES_PER_FILE) === 0) { + lines.push(PATTERN_LINE); + } + } + layout[`file-${f}.txt`] = lines.join("\n"); + } + const { cwd, cleanup } = makeSandbox(layout); + try { + // Pre-compile with /gm — exactly the shape that would have + // returned-as-is through `ensureGlobalMultilineFlags`. + const sharedRegex = /hit-marker-unique-\d+/gm; + const { matches } = await collectGrep({ + cwd, + pattern: sharedRegex, + concurrency: 8, // plenty of room for interleaving + }); + // Each file emits one match per unique-matching line. Across all + // files, total = NUM_FILES * MATCHES_PER_FILE. If the race were + // present, some files would miss matches whose `index` is + // behind a concurrent worker's advanced `lastIndex`. + const perFile = new Map(); + for (const m of matches) { + perFile.set(m.path, (perFile.get(m.path) ?? 0) + 1); + } + expect(perFile.size).toBe(NUM_FILES); + for (const [file, count] of perFile) { + expect([file, count]).toEqual([file, MATCHES_PER_FILE]); + } + } finally { + cleanup(); + } + }); +}); + +describe("collectGrep — literal prefilter fast path", () => { + /** + * These tests exercise the `grepByLiteralPrefilter` path that kicks + * in when the pattern has an extractable literal but isn't itself a + * pure literal (e.g., `import.*from` → literal `import`). The tests + * verify both correctness (same results as whole-buffer) and that + * the multiline-false fallback path produces buffer-anchored + * matches. + */ + + test("regex with extractable literal produces same matches as whole-buffer", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": [ + "import { foo } from 'bar';", + "const x = 42;", + "import { baz } from 'qux';", + "// not an import statement", + "ximport_typeXYZ", // contains "import" but no "from" after + ].join("\n"), + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "import.*from", + }); + // Two true matches: lines 1 and 3. Line 5 has "import" but no + // "from" after — literal prefilter catches it as a candidate + // but regex verify rejects it. + expect(matches.map((m) => m.lineNum)).toEqual([1, 3]); + } finally { + cleanup(); + } + }); + + test("literal prefilter correctly handles escape sequences like Sentry\\.init", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": [ + "import * as Sentry from 'sentry';", + "Sentry.init({ dsn: '...' });", + "const x = Sentryxinit; // not a match", + ].join("\n"), + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "Sentry\\.init", + }); + expect(matches).toHaveLength(1); + expect(matches[0].lineNum).toBe(2); + } finally { + cleanup(); + } + }); + + test("case-insensitive literal prefilter finds all casings", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": ["IMPORT X FROM Y", "import y from z", "Import Z From W"].join( + "\n" + ), + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "import.*from", + caseSensitive: false, + }); + expect(matches).toHaveLength(3); + } finally { + cleanup(); + } + }); + + test("literal prefilter correctly handles file with zero literal hits", async () => { + // Pattern `import.*from` extracts literal "import". This file + // contains no "import" substring at all — prefilter short- + // circuits without running the regex. + const { cwd, cleanup } = makeSandbox({ + "a.ts": "const x = 42;\nconst y = x + 1;\n", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "import.*from", + }); + expect(matches).toEqual([]); + } finally { + cleanup(); + } + }); + + test("pure literal patterns still match correctly (via file-level gate + whole-buffer)", async () => { + // `SENTRY_DSN` is a pure literal. The extractor returns it, and + // the file-level gate uses it to cheaply reject files without + // the literal. Files that contain it fall through to the whole- + // buffer matcher, which finds all match positions via the regex + // engine (V8 handles pure-literal patterns efficiently). + const { cwd, cleanup } = makeSandbox({ + "a.ts": "const SENTRY_DSN = 'abc';\nconst other = 1;\nSENTRY_DSN again", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "SENTRY_DSN", + }); + expect(matches.map((m) => m.lineNum)).toEqual([1, 3]); + } finally { + cleanup(); + } + }); + + test("patterns with top-level alternation go through whole-buffer", async () => { + // `foo|bar` has no extractable literal (extractor bails on + // alternation). Must use whole-buffer path. + const { cwd, cleanup } = makeSandbox({ + "a.ts": "has foo\nhas bar\nhas qux", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "foo|bar", + }); + expect(matches.map((m) => m.lineNum)).toEqual([1, 2]); + } finally { + cleanup(); + } + }); + + test("character-class with parens + alternation finds all branches (Cursor bug #1)", async () => { + // Regression: `[(]foo|bar` previously extracted `foo` as a + // required literal, causing the prefilter to silently miss + // lines matching only the `bar` branch. The fix makes char + // classes opaque to alternation detection. + const { cwd, cleanup } = makeSandbox({ + "only-bar.txt": "bar line without the paren-branch\n", + "only-paren.txt": "(foo line with the paren-branch\n", + "both.txt": "bar then (foo on different lines\n(foo again\nbar again\n", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "[(]foo|bar", + }); + // All four lines should match: two in `both.txt`, one each + // in the single-branch files. + const lineDigest = matches.map((m) => `${m.path}:${m.lineNum}`).sort(); + expect(lineDigest).toEqual([ + "both.txt:1", + "both.txt:2", + "both.txt:3", + "only-bar.txt:1", + "only-paren.txt:1", + ]); + } finally { + cleanup(); + } + }); + + test("optional group containing class with paren matches all branches (Cursor #1 followup)", async () => { + // Regression: the group-depth tracker inside `extractInnerLiteral` + // didn't treat `[...]` as opaque, so `(ABC[)]DEF)?GHI` extracted + // `DEF` as a required literal. The prefilter then silently + // missed lines matching only the `GHI` branch (group optional). + // + // After the fix, the whole group is correctly skipped and `GHI` + // is identified as the post-group required literal, so both + // match shapes are found. + const { cwd, cleanup } = makeSandbox({ + "only-ghi.txt": "GHI line without the optional prefix\n", + "full-match.txt": "ABC)DEFGHI full match line\n", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "(ABC[)]DEF)?GHI", + }); + const lineDigest = matches.map((m) => `${m.path}:${m.lineNum}`).sort(); + expect(lineDigest).toEqual(["full-match.txt:1", "only-ghi.txt:1"]); + } finally { + cleanup(); + } + }); + + test("case-insensitive prefilter handles Unicode length-changing lowercase (Cursor #2)", async () => { + // Regression: `toLowerCase()` on content containing Turkish `İ` + // (U+0130) produces `i` + U+0307, making the lowercased haystack + // LONGER than the original. `haystack.indexOf(literal)` returned + // positions that didn't align with `content`, causing + // line-boundary math to point at the wrong line or miss later + // matches entirely (because `lineEnd + 1` advanced the search + // cursor past real match positions). + // + // Fix: when `toLowerCase().length !== content.length`, fall + // back to the whole-buffer regex path, which has no cross-string + // position dependency. + const { cwd, cleanup } = makeSandbox({ + "turkish.ts": [ + "İİİİİİİ line 1 with Sentry.init here", + "İİİİİİİ line 2 plain", + "İİİİİİİ line 3 with Sentry.init again", + "İİİİİİİ line 4 plain", + ].join("\n"), + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "Sentry\\.init", + caseSensitive: false, + }); + // Both Sentry.init matches on lines 1 and 3 should be found. + // Before the fix, only line 1 was found — the lineEnd from + // content got passed back to haystack's indexOf and skipped + // past line 3's match. + expect(matches.map((m) => m.lineNum)).toEqual([1, 3]); + } finally { + cleanup(); + } + }); + + test("cross-newline patterns (foo\\sbar) find matches spanning line boundaries (review followup)", async () => { + // Regression: the old prefilter did per-line verify via + // `verifyRegex.test(lineText)`, which failed to detect matches + // that span newlines (e.g., `\s` matches `\n`). After + // simplifying the prefilter to a file-level gate only, the + // regex engine handles cross-newline matches correctly. + const { cwd, cleanup } = makeSandbox({ + "same-line.txt": "foo bar on one line\n", + "split.txt": "foo\nbar\n", // \s (which includes \n) joins "foo" and "bar" + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "foo\\sbar", + }); + const paths = matches.map((m) => m.path).sort(); + expect(paths).toEqual(["same-line.txt", "split.txt"]); + } finally { + cleanup(); + } + }); + + test("multi-char escapes like \\x41 find matches for the decoded char (review followup)", async () => { + // Regression: the old extractor advanced `\x41` by 2 chars, + // leaving `41` as a "literal" that wrongly flagged the file + // gate. Pattern `\x41foo` matches `Afoo` (the compiled regex + // decodes `\x41` to `A`), so files containing `Afoo` should + // match and files containing literal `41foo` should NOT. + const { cwd, cleanup } = makeSandbox({ + "actual-match.txt": "Afoo here\n", // contains decoded `A` + foo + "literal-41foo.txt": "41foo here\n", // contains literal "41foo" text + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "\\x41foo", + }); + expect(matches.map((m) => m.path)).toEqual(["actual-match.txt"]); + } finally { + cleanup(); + } + }); + + test("scoped inline flags that introduce top-level alternation are safely handled (Cursor #3)", async () => { + // Regression: `compilePattern` rewrites `(?i:foo|bar)baz` into + // `foo|barbaz` + the global `i` flag. The rewritten source has + // top-level alternation the original lacked. If the literal + // extractor runs on the ORIGINAL source, `hasTopLevelAlternation` + // doesn't see the `|` and extracts `"baz"` as a required literal. + // But the compiled regex matches `foo` alone — lines with just + // `foo` get silently dropped by the prefilter. + // + // Fix: extract from `regex.source` (post-compile) so the + // extractor sees what the engine will run. `hasTopLevelAlternation` + // now sees the `foo|barbaz` and correctly returns null — the + // prefilter is skipped and both branches match. + const { cwd, cleanup } = makeSandbox({ + "foo-only.txt": "foo line without that other word\n", + "barbaz.txt": "contains barbaz here\n", + }); + try { + const { matches } = await collectGrep({ + cwd, + pattern: "(?i:foo|bar)baz", + }); + const paths = matches.map((m) => m.path).sort(); + expect(paths).toEqual(["barbaz.txt", "foo-only.txt"]); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/cli/test/lib/scan/ignore.property.test.ts b/packages/cli/test/lib/scan/ignore.property.test.ts new file mode 100644 index 000000000..70c5ea13d --- /dev/null +++ b/packages/cli/test/lib/scan/ignore.property.test.ts @@ -0,0 +1,125 @@ +/** + * Property test: a root-only IgnoreStack behaves identically to a + * plain `ignore` instance. + * + * This isn't a full cumulative-semantics test (the nested case is + * covered by the unit tests) — it's a round-trip anchor so refactors + * to the stack's root path can't silently diverge from the upstream + * package. + */ + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + array, + asyncProperty, + constantFrom, + assert as fcAssert, +} from "fast-check"; +import ignore from "ignore"; +import { afterAll, describe, expect, test } from "vitest"; +import { IgnoreStack } from "../../../src/lib/scan/ignore.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "scan-ignore-prop-")); + +/** Safe path-segment alphabet — no dots, slashes, or whitespace. */ +const SEGMENT_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_-".split(""); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +/** + * Arbitrary for well-formed relative POSIX path segments. + * + * Constrained to [a-z0-9_-] so we don't accidentally generate strings + * the `ignore` package treats specially (whitespace, leading `.`/`!`, + * glob metas, path separators). + */ +const segmentArb = array(constantFrom(...SEGMENT_CHARS), { + minLength: 1, + maxLength: 10, +}).map((chars) => chars.join("")); +const relPathArb = array(segmentArb, { minLength: 1, maxLength: 4 }).map((xs) => + xs.join("/") +); +/** + * Pattern alphabet kept small and safe — the `ignore` package treats + * many characters with special semantics (e.g. `\`, `[`, `{`) that we + * don't want to explore at the boundary here. + */ +const patternArb = constantFrom( + "*.log", + "*.tmp", + "build", + "dist/", + "node_modules", + "*.bak", + "src/**/*.generated.ts", + "coverage", + ".env" +); + +describe("property: IgnoreStack root-only == plain ignore instance", () => { + test("isIgnored matches `ignore` for root-only patterns", async () => { + await fcAssert( + asyncProperty( + array(patternArb, { minLength: 0, maxLength: 6 }), + relPathArb, + async (patterns, relPath) => { + const cwd = mkdtempSync(join(ROOT, "rt-")); + try { + if (patterns.length > 0) { + writeFileSync( + join(cwd, ".gitignore"), + `${patterns.join("\n")}\n`, + "utf8" + ); + } + const stack = await IgnoreStack.create({ + cwd, + alwaysSkipDirs: [], + }); + const plain = ignore().add(patterns); + expect(stack.isIgnored(relPath, false)).toBe( + plain.ignores(relPath) + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + } + ), + // Fewer runs: each run writes fs state. 25 is enough to catch + // regressions without blowing up CI time. + { numRuns: Math.min(DEFAULT_NUM_RUNS, 25) } + ); + }); +}); + +describe("property: alwaysSkipDirs always ignore their basename", () => { + test("skipped-dir basenames are always ignored", async () => { + await fcAssert( + asyncProperty( + constantFrom("node_modules", "dist", ".git", "venv"), + relPathArb, + async (skipDir, trailing) => { + const cwd = mkdtempSync(join(ROOT, "skip-")); + try { + // Ensure the skip dir appears as a segment in the query. + const rel = `${skipDir}/${trailing}`; + const stack = await IgnoreStack.create({ + cwd, + alwaysSkipDirs: [skipDir], + }); + expect(stack.isIgnored(rel, false)).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + } + ), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 25) } + ); + }); +}); diff --git a/packages/cli/test/lib/scan/ignore.test.ts b/packages/cli/test/lib/scan/ignore.test.ts new file mode 100644 index 000000000..140caaab6 --- /dev/null +++ b/packages/cli/test/lib/scan/ignore.test.ts @@ -0,0 +1,205 @@ +/** + * Unit tests for `src/lib/scan/ignore.ts` (IgnoreStack). + * + * Pins the semantics we care about most: + * + * 1. A single root `.gitignore` behaves like a plain `ignore` instance. + * 2. `alwaysSkipDirs` basenames are skipped even when no `.gitignore` + * mentions them (basename-anywhere semantics). + * 3. Nested `.gitignore` files apply ON TOP OF parent patterns — + * cumulative, root→leaf, with child negations overriding parents. + * 4. `.git/info/exclude` is treated as an additional root `.gitignore` + * when requested. + * 5. Malformed inputs (absolute paths, empty relPath) are handled + * gracefully. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import { IgnoreStack } from "../../../src/lib/scan/ignore.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "scan-ignore-test-")); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +/** Build a per-test sandbox directory with a fresh set of gitignore files. */ +function makeSandbox(layout: Record): { + cwd: string; + cleanup: () => void; +} { + const cwd = mkdtempSync(join(ROOT, "sandbox-")); + for (const [relPath, content] of Object.entries(layout)) { + const abs = join(cwd, relPath); + const parent = abs.slice(0, abs.lastIndexOf("/")); + mkdirSync(parent, { recursive: true }); + writeFileSync(abs, content, "utf8"); + } + return { cwd, cleanup: () => rmSync(cwd, { recursive: true, force: true }) }; +} + +describe("IgnoreStack — root .gitignore", () => { + test("respects simple patterns", async () => { + const { cwd, cleanup } = makeSandbox({ ".gitignore": "*.log\nbuild/\n" }); + try { + const stack = await IgnoreStack.create({ + cwd, + alwaysSkipDirs: [], + }); + expect(stack.isIgnored("foo.log", false)).toBe(true); + expect(stack.isIgnored("foo.txt", false)).toBe(false); + expect(stack.isIgnored("build", true)).toBe(true); + expect(stack.isIgnored("build", false)).toBe(false); // dir-only pattern + } finally { + cleanup(); + } + }); + + test("empty cwd .gitignore = no patterns", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + const stack = await IgnoreStack.create({ cwd, alwaysSkipDirs: [] }); + expect(stack.isIgnored("foo.log", false)).toBe(false); + } finally { + cleanup(); + } + }); + + test("alwaysSkipDirs matches basename anywhere", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + const stack = await IgnoreStack.create({ + cwd, + alwaysSkipDirs: ["node_modules", ".git"], + }); + expect(stack.isIgnored("node_modules", true)).toBe(true); + expect(stack.isIgnored("packages/foo/node_modules", true)).toBe(true); + expect(stack.isIgnored("src/code.ts", false)).toBe(false); + } finally { + cleanup(); + } + }); +}); + +describe("IgnoreStack — nested .gitignore", () => { + test("parent *.log + child !important.log un-ignores the child", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "*.log\n", + "src/.gitignore": "!important.log\n", + "src/foo.log": "", + "src/important.log": "", + }); + try { + const stack = await IgnoreStack.create({ cwd, alwaysSkipDirs: [] }); + await stack.loadFromDir(join(cwd, "src")); + expect(stack.isIgnored("src/foo.log", false)).toBe(true); + expect(stack.isIgnored("src/important.log", false)).toBe(false); + } finally { + cleanup(); + } + }); + + test("parent patterns still apply in deeper subdirs without their own .gitignore", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "*.log\n", + "src/deep/subdir/foo.log": "", + }); + try { + const stack = await IgnoreStack.create({ cwd, alwaysSkipDirs: [] }); + // No .gitignore in src/ or src/deep/ or src/deep/subdir/ — but the + // parent pattern should still match basename-anywhere. + expect(stack.isIgnored("src/deep/subdir/foo.log", false)).toBe(true); + } finally { + cleanup(); + } + }); + + test("loadFromDir is a no-op when no .gitignore present", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "*.log\n", + "src/foo.log": "", + }); + try { + const stack = await IgnoreStack.create({ cwd, alwaysSkipDirs: [] }); + await stack.loadFromDir(join(cwd, "src")); // no-op; src has no .gitignore + expect(stack.isIgnored("src/foo.log", false)).toBe(true); + } finally { + cleanup(); + } + }); + + test("loadFromDir called on cwd itself is idempotent (root already loaded)", async () => { + const { cwd, cleanup } = makeSandbox({ ".gitignore": "*.log\n" }); + try { + const stack = await IgnoreStack.create({ cwd, alwaysSkipDirs: [] }); + await stack.loadFromDir(cwd); // no-op per implementation + expect(stack.isIgnored("foo.log", false)).toBe(true); + } finally { + cleanup(); + } + }); +}); + +describe("IgnoreStack — .git/info/exclude", () => { + test("is read when includeGitInfoExclude: true", async () => { + const { cwd, cleanup } = makeSandbox({ + ".git/info/exclude": "secrets.txt\n", + "secrets.txt": "", + }); + try { + const stack = await IgnoreStack.create({ + cwd, + alwaysSkipDirs: [], + includeGitInfoExclude: true, + }); + expect(stack.isIgnored("secrets.txt", false)).toBe(true); + } finally { + cleanup(); + } + }); + + test("is ignored when includeGitInfoExclude: false", async () => { + const { cwd, cleanup } = makeSandbox({ + ".git/info/exclude": "secrets.txt\n", + "secrets.txt": "", + }); + try { + const stack = await IgnoreStack.create({ + cwd, + alwaysSkipDirs: [], + includeGitInfoExclude: false, + }); + expect(stack.isIgnored("secrets.txt", false)).toBe(false); + } finally { + cleanup(); + } + }); +}); + +describe("IgnoreStack — input validation", () => { + test("throws on absolute path (programming-error safeguard)", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + const stack = await IgnoreStack.create({ cwd, alwaysSkipDirs: [] }); + expect(() => stack.isIgnored("/etc/passwd", false)).toThrow( + /relative path/ + ); + } finally { + cleanup(); + } + }); + + test("empty or '.' relPath is never ignored", async () => { + const { cwd, cleanup } = makeSandbox({ ".gitignore": "*\n" }); + try { + const stack = await IgnoreStack.create({ cwd, alwaysSkipDirs: [] }); + expect(stack.isIgnored("", false)).toBe(false); + expect(stack.isIgnored(".", false)).toBe(false); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/cli/test/lib/scan/literal-extract.test.ts b/packages/cli/test/lib/scan/literal-extract.test.ts new file mode 100644 index 000000000..d432df4d1 --- /dev/null +++ b/packages/cli/test/lib/scan/literal-extract.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "vitest"; +import { extractInnerLiteral } from "../../../src/lib/scan/literal-extract.js"; + +describe("extractInnerLiteral — basic patterns", () => { + test.each([ + // [pattern, flags, expected literal] + ["import.*from", "", "import"], // quantifier breaks run + ["function\\s+\\w+", "", "function"], // \s, \w are not literals + ["http://", "", "http://"], // no metachars at all + ["SENTRY_DSN", "", "SENTRY_DSN"], // pure literal + ["hello world", "", "hello world"], // literal with space + ["^foo$", "", "foo"], // anchors break run, leaving foo + ["^foo", "", "foo"], // leading anchor only + ["foo$", "", "foo"], // trailing anchor only + ])("extracts %p with flags %p → %p", (pattern, flags, expected) => { + expect(extractInnerLiteral(pattern, flags)).toBe(expected); + }); +}); + +describe("extractInnerLiteral — escaped metacharacters", () => { + test.each([ + ["Sentry\\.init", "", "Sentry.init"], // \. is literal dot + ["foo\\.bar", "", "foo.bar"], + ["a\\/b", "", "a/b"], // escaped slash + ["\\\\foo", "", "\\foo"], // escaped backslash + ["a\\(b\\)", "", "a(b)"], // escaped parens + ["a\\[b\\]", "", "a[b]"], // escaped brackets + ])("recognizes escaped literal %p → %p", (pattern, flags, expected) => { + expect(extractInnerLiteral(pattern, flags)).toBe(expected); + }); +}); + +describe("extractInnerLiteral — escape sequences that break runs", () => { + test.each([ + ["\\bfoo\\b", "foo"], // \b anchor, not literal b + ["\\w+foo\\d+", "foo"], // \w, \d are classes + ["\\tfoo\\t", "foo"], // \t is tab escape + ["\\nfoo\\n", "foo"], // \n is newline escape + ["\\sfoo\\s", "foo"], // \s is whitespace class + ])("breaks run on non-literal escape %p → %p", (pattern, expected) => { + expect(extractInnerLiteral(pattern, "")).toBe(expected); + }); +}); + +describe("extractInnerLiteral — returns null", () => { + test.each([ + [".*", ""], // no literal content + [".", ""], // single metachar + ["foo|bar", ""], // top-level alternation + ["(foo|bar)", ""], // group with alternation + ["[abc]", ""], // character class only + ["a?", ""], // quantified single char (too short after drop) + ["ab", ""], // below MIN_LITERAL_LEN (3) + [" ", ""], // all whitespace + ["!!!", ""], // all punctuation + ])("returns null for %p", (pattern, flags) => { + expect(extractInnerLiteral(pattern, flags)).toBeNull(); + }); +}); + +describe("extractInnerLiteral — case-insensitive flag", () => { + test("lowercases the extracted literal when flags include i", () => { + expect(extractInnerLiteral("SENTRY_DSN", "i")).toBe("sentry_dsn"); + expect(extractInnerLiteral("Import.*From", "i")).toBe("import"); + }); + + test("leaves the literal case-sensitive without i flag", () => { + expect(extractInnerLiteral("SENTRY_DSN", "")).toBe("SENTRY_DSN"); + expect(extractInnerLiteral("SENTRY_DSN", "gm")).toBe("SENTRY_DSN"); + }); +}); + +describe("extractInnerLiteral — quantifier handling", () => { + test("drops the quantified character from the run", () => { + // `abc*def` — c is quantified (may be absent), so "ab" is one + // run (length 2, below threshold) and "def" (length 3) wins. + expect(extractInnerLiteral("abc*def", "")).toBe("def"); + }); + + test("handles ? quantifier", () => { + expect(extractInnerLiteral("a?bcdef", "")).toBe("bcdef"); + }); + + test("handles + quantifier", () => { + // `abc+def` — with +, c IS required (1+ of), but the extractor + // conservatively drops the preceding char. "ab" too short, + // "def" wins. + expect(extractInnerLiteral("abc+def", "")).toBe("def"); + }); + + test("handles {n,m} quantifier", () => { + expect(extractInnerLiteral("abc{2,3}def", "")).toBe("def"); + }); + + test("longest run wins when multiple exist", () => { + // `short.*longer_run.*short` — `longer_run` wins over `short`. + expect(extractInnerLiteral("short.*longer_run.*short", "")).toBe( + "longer_run" + ); + }); +}); + +describe("extractInnerLiteral — character-class opaqueness (Cursor bug #1)", () => { + /** + * Regression: `[(]foo|bar` previously extracted `"foo"` because + * the alternation detector treated `(` inside `[...]` as an + * opening paren, which corrupted `depthParen` and hid the + * top-level `|` that followed. The prefilter then silently + * skipped lines matching only the `bar` branch. + * + * The fix: character classes are opaque to the alternation + * detector. When we see `[`, skip past the first unescaped `]` + * in one step. Also `[...]` is NOT nestable — `[[]` is a class + * containing a literal `[`. + */ + test.each([ + ["[(]foo|bar", null], // ( inside class + top-level alternation + ["[)]foo|bar", null], + ["[[]foo|bar", null], // [ inside class (nested-looking but flat) + ["[|]foo|bar", null], // | inside class is literal; second | is top-level + ["[(|)]foo|bar", null], // class containing (, |, ) + ])("returns null for %p — alternation hidden by class corrupting paren depth", (pattern, expected) => { + expect(extractInnerLiteral(pattern, "")).toBe(expected); + }); + + test.each([ + ["foo[(]bar", "foo"], // ( inside class, no alternation → longest run "foo" + ["foo[abc]bar", "foo"], // normal class between literals + ["[\\]]foo", "foo"], // escaped ] inside class + ["[^abc]def", "def"], // negated class + ["[a-z]{3,5}MARKER", "MARKER"], // class + quantifier + literal + ])("extracts %p → %p (no alternation present)", (pattern, expected) => { + expect(extractInnerLiteral(pattern, "")).toBe(expected); + }); +}); + +describe("extractInnerLiteral — class-in-group opaqueness (Cursor bug #1 followup)", () => { + /** + * Sibling of the earlier char-class-opaqueness bug: the group- + * skipper `skipGroup` (formerly `skipToMatching`) didn't treat + * `[...]` as opaque, so a `)` inside a char class closed the + * enclosing group early. For `(ABC[)]DEF)?GHI`, the extractor + * thought `DEF` was outside the optional group (hence "required"), + * and used it as the prefilter — silently missing lines that + * matched only `GHI`. + * + * Fix: `skipGroup` now calls `skipCharacterClass` when it sees + * `[`, making classes opaque to group-depth tracking. + */ + test.each([ + // Optional group containing a literal paren inside a class — + // the content of the group is NOT required; only what follows is. + ["(ABC[)]DEF)?GHI", "GHI"], + ["(foo[)]bar)?baz", "baz"], + ["(foo[(]bar)?baz", "baz"], + ["(A[[]B)?CDE", "CDE"], + ["(A[\\)]B)?CDE", "CDE"], + ])("extracts the post-group literal when group contains a class with ( or )", (pattern, expected) => { + expect(extractInnerLiteral(pattern, "")).toBe(expected); + }); + + test.each([ + ["(foo)bar", "bar"], // regular group + literal + ["(foo)?bar", "bar"], // optional group + literal + ["(abc)?xyz", "xyz"], // sanity check + ["((foo))?bar", "bar"], // nested groups (skipGroup handles nesting) + ])("control: regular groups still skip correctly", (pattern, expected) => { + expect(extractInnerLiteral(pattern, "")).toBe(expected); + }); +}); + +describe("extractInnerLiteral — multi-char escape sequences (review followup)", () => { + /** + * Regression: `\x41` is a hex-escape encoding `A` — the literal + * is 4 source chars long (`\`, `x`, `4`, `1`). The old extractor + * always advanced escape sequences by 2, leaving `41` behind as + * "literal" text. For `\x41foo` the extractor returned `"41foo"` + * but the compiled regex matches `Afoo` — silent miss on the + * gate (file containing `Afoo` but not `41foo` got skipped). + * + * Fix: `escapeSequenceLength` correctly computes the full length + * of `\x..`, `\u....`, `\u{...}`, `\cX`, `\k`, and + * `\p{...}` / `\P{...}` sequences. The extractor now skips past + * the WHOLE sequence and breaks the run (we don't try to decode + * the escape into its character — conservative). + */ + test.each([ + // Multi-char escapes — tail must NOT be extracted as literal + ["\\x41foo", "foo"], // \x41 = A; skip + foo is the literal + ["\\u0041foo", "foo"], // \u0041 = A + ["\\u{1F600}foo", "foo"], // braced unicode + ["\\cAfoo", "foo"], // control-A + ["\\kfoo", "foo"], // named backref + ["\\p{L}foo", "foo"], // Unicode property + ["\\P{L}foo", "foo"], // negated Unicode property + // Pre-escape literals still work (first-tied-longest) + ["bar\\x41foo", "bar"], // both runs length 3; first wins + ["longer_bar\\x41foo", "longer_bar"], // longer_bar > foo, longer wins + ])("correctly skips multi-char escape %p → %p", (pattern, expected) => { + expect(extractInnerLiteral(pattern, "")).toBe(expected); + }); +}); diff --git a/packages/cli/test/lib/scan/regex.test.ts b/packages/cli/test/lib/scan/regex.test.ts new file mode 100644 index 000000000..a65f52f0b --- /dev/null +++ b/packages/cli/test/lib/scan/regex.test.ts @@ -0,0 +1,172 @@ +/** + * Unit tests for `src/lib/scan/regex.ts`. + * + * Tabular coverage of `extractInlineFlags` across the four cases that + * actually matter for init-wizard patterns: + * 1. No inline flag group — identity. + * 2. `(?i)foo` — strip flag group, flags = "i". + * 3. `(?i:foo)bar` — unwrap scoped group, flag widens to whole pattern. + * 4. `foo(?i)bar` — mid-pattern group left alone (identity). + * + * Plus `compilePattern` success / failure modes. + */ + +import { describe, expect, test } from "vitest"; +import { ValidationError } from "../../../src/lib/errors.js"; +import { + compilePattern, + ensureGlobalFlag, + ensureGlobalMultilineFlags, + ensureMultilineFlag, + extractInlineFlags, +} from "../../../src/lib/scan/regex.js"; + +describe("extractInlineFlags", () => { + test.each<[string, { cleaned: string; flags: string }]>([ + ["foo", { cleaned: "foo", flags: "" }], + ["", { cleaned: "", flags: "" }], + ["(?i)foo", { cleaned: "foo", flags: "i" }], + ["(?im)^foo$", { cleaned: "^foo$", flags: "im" }], + ["(?ims)anything", { cleaned: "anything", flags: "ims" }], + // rg's `U` → JS `s` (dotall). + ["(?U)foo.bar", { cleaned: "foo.bar", flags: "s" }], + // Scoped form widens to whole pattern. + ["(?i:foo)bar", { cleaned: "foobar", flags: "i" }], + ["(?i:hello)world", { cleaned: "helloworld", flags: "i" }], + // Mid-pattern flag group is untouched. + ["foo(?i)bar", { cleaned: "foo(?i)bar", flags: "" }], + // Escape before `(` — still recognized as inline flag + // (only leading position matters; the backslash after is part of + // the remaining pattern). + ["(?i)\\d+", { cleaned: "\\d+", flags: "i" }], + ])("extracts %p", (input, expected) => { + expect(extractInlineFlags(input)).toEqual(expected); + }); + + test("scoped form with nested groups: (?i:foo(bar))baz", () => { + expect(extractInlineFlags("(?i:foo(bar))baz")).toEqual({ + cleaned: "foo(bar)baz", + flags: "i", + }); + }); + + test("scoped form with char class containing ): (?i:[a-z)])", () => { + // Inside a `[...]` class, `)` doesn't close the group. The + // matching `)` is the outer one, outside the class. + expect(extractInlineFlags("(?i:[a-z)])")).toEqual({ + cleaned: "[a-z)]", + flags: "i", + }); + }); + + test("malformed scoped form (unclosed) passes through unchanged", () => { + // No matching close paren — translator falls back to "leave + // everything alone" so downstream compile throws a useful error. + expect(extractInlineFlags("(?i:foo")).toEqual({ + cleaned: "(?i:foo", + flags: "", + }); + }); +}); + +describe("compilePattern", () => { + test("string without flags → plain RegExp", () => { + expect(compilePattern("foo").toString()).toBe("/foo/"); + }); + + test("string with (?i) → /i flag", () => { + expect(compilePattern("(?i)foo").toString()).toBe("/foo/i"); + }); + + test("caseSensitive: false adds i flag", () => { + expect(compilePattern("foo", { caseSensitive: false }).toString()).toBe( + "/foo/i" + ); + }); + + test("inline + caseSensitive merge cleanly", () => { + expect( + compilePattern("(?m)^foo", { caseSensitive: false }).toString() + ).toBe("/^foo/im"); + }); + + test("multiline: true adds m flag", () => { + expect(compilePattern("foo", { multiline: true }).toString()).toBe( + "/foo/m" + ); + }); + + test("pre-compiled RegExp returned as-is", () => { + const re = /foo/gi; + expect(compilePattern(re)).toBe(re); + }); + + test("bad pattern throws ValidationError with a helpful message", () => { + expect(() => compilePattern("[unterminated")).toThrow(ValidationError); + try { + compilePattern("[unterminated"); + } catch (error) { + expect((error as ValidationError).field).toBe("pattern"); + expect((error as ValidationError).message).toMatch( + /Invalid grep pattern:/ + ); + } + }); + + test("no g flag — grep tests line-by-line", () => { + // Ensure we never leak the g flag even when requested via inline. + // (Our translator doesn't accept g, so the user can't provide it + // through extractInlineFlags; but a pre-compiled RegExp can bring + // its own.) + expect(compilePattern("(?i)foo").flags).toBe("i"); + }); +}); + +describe("ensureGlobalFlag", () => { + test("adds g to a regex without it", () => { + const re = ensureGlobalFlag(/foo/i); + expect(re.flags).toBe("gi"); + expect(re.source).toBe("foo"); + }); + + test("returns input unchanged when g is already present", () => { + const input = /foo/gi; + expect(ensureGlobalFlag(input)).toBe(input); + }); +}); + +describe("ensureMultilineFlag", () => { + test("adds m to a regex without it", () => { + const re = ensureMultilineFlag(/foo/i); + expect(re.flags).toBe("im"); + }); + + test("returns input unchanged when m is already present", () => { + const input = /foo/m; + expect(ensureMultilineFlag(input)).toBe(input); + }); + + test("^ matches at line boundaries with m flag", () => { + const re = ensureMultilineFlag(/^foo/); + expect(re.test("bar\nfoo")).toBe(true); + }); +}); + +describe("ensureGlobalMultilineFlags", () => { + test("adds both g and m in one clone", () => { + const re = ensureGlobalMultilineFlags(/foo/i); + expect(re.flags.includes("g")).toBe(true); + expect(re.flags.includes("m")).toBe(true); + expect(re.flags.includes("i")).toBe(true); + }); + + test("returns input unchanged when both flags already present", () => { + const input = /foo/gim; + expect(ensureGlobalMultilineFlags(input)).toBe(input); + }); + + test("preserves source verbatim", () => { + const re = ensureGlobalMultilineFlags(/[\w.]+@example\.com/); + expect(re.source).toBe("[\\w.]+@example\\.com"); + }); +}); diff --git a/packages/cli/test/lib/scan/walker.property.test.ts b/packages/cli/test/lib/scan/walker.property.test.ts new file mode 100644 index 000000000..66a03f4cb --- /dev/null +++ b/packages/cli/test/lib/scan/walker.property.test.ts @@ -0,0 +1,132 @@ +/** + * Property tests for `walkFiles`. + * + * Two invariants we want to hold for any randomly generated tree: + * + * 1. Descent cap: no file yielded beyond `maxDepth + 1`. The walker + * caps directory *descent* at `maxDepth` — files inside those + * last-entered dirs still yield (they sit at parent_depth + 1). + * 2. minDepth guarantee: for any tree, with `timeBudgetMs: 0` and a + * fixed `minDepth = N`, every file at depth ≤ N is yielded + * regardless of budget. + * + * Trees are built from a flat `(path, kind)` list. Generation is + * constrained to ASCII alphanumerics + `_` to avoid tripping over the + * `ignore` package's pattern escaping quirks. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + array, + asyncProperty, + constantFrom, + assert as fcAssert, + integer, +} from "fast-check"; +import { afterAll, describe, expect, test } from "vitest"; +import { walkFiles } from "../../../src/lib/scan/walker.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "scan-walker-prop-")); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +const SEGMENT_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_".split(""); +const segmentArb = array(constantFrom(...SEGMENT_CHARS), { + minLength: 1, + maxLength: 6, +}).map((chars) => chars.join("")); + +/** An array of 1–5 path segments — describes a file relative to cwd. */ +const filePathArb = array(segmentArb, { minLength: 1, maxLength: 5 }); + +function buildTree(cwd: string, paths: string[][]): void { + for (const segs of paths) { + const rel = `${segs.join("/")}.ts`; + const abs = join(cwd, rel); + const parent = abs.slice(0, abs.lastIndexOf("/")); + mkdirSync(parent, { recursive: true }); + writeFileSync(abs, "x", "utf8"); + } +} + +describe("property: walkFiles — invariants", () => { + test("maxDepth: no file yielded beyond maxDepth + 1", async () => { + // File depth = parent_dir_depth + 1. With maxDepth capping + // descent at N, the deepest dir entered is at N, so files inside + // it sit at N + 1. Files any deeper can't exist — their dir + // wouldn't have been entered. + await fcAssert( + asyncProperty( + array(filePathArb, { minLength: 0, maxLength: 20 }), + integer({ min: 0, max: 5 }), + async (paths, maxDepth) => { + const cwd = mkdtempSync(join(ROOT, "depth-")); + try { + buildTree(cwd, paths); + for await (const entry of walkFiles({ cwd, maxDepth })) { + expect(entry.depth).toBeLessThanOrEqual(maxDepth + 1); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + } + ), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } + ); + }); + + test( + "minDepth guarantee: with budget=0 and clock advancing, " + + "every file at depth <= minDepth still yields", + async () => { + await fcAssert( + asyncProperty( + array(filePathArb, { minLength: 0, maxLength: 20 }), + integer({ min: 0, max: 5 }), + async (paths, minDepth) => { + const cwd = mkdtempSync(join(ROOT, "min-")); + try { + buildTree(cwd, paths); + + // Which file paths have depth ≤ minDepth? (depth N means + // N-1 dir segments + filename, i.e., segments.length ≤ minDepth + // when the file sits at depth N = segments.length.) + const expected = new Set(); + for (const segs of paths) { + // File at depth === segs.length. + if (segs.length <= minDepth) { + expected.add(`${segs.join("/")}.ts`); + } + } + + let now = 0; + const yielded = new Set(); + for await (const entry of walkFiles({ + cwd, + minDepth, + timeBudgetMs: 0, + clock: () => { + now += 1000; + return now; + }, + })) { + yielded.add(entry.relativePath); + } + for (const expectedPath of expected) { + expect(yielded.has(expectedPath)).toBe(true); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + } + ), + { numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } + ); + } + ); +}); diff --git a/packages/cli/test/lib/scan/walker.test.ts b/packages/cli/test/lib/scan/walker.test.ts new file mode 100644 index 000000000..9b93f43ab --- /dev/null +++ b/packages/cli/test/lib/scan/walker.test.ts @@ -0,0 +1,878 @@ +/** + * Unit tests for `walkFiles` in `src/lib/scan/walker.ts`. + * + * Each test builds a small sandbox under `tmpdir()`, runs the walker + * with specific options, and asserts the yielded relative paths (order + * doesn't matter — we compare via Set). + * + * Time-budget tests inject a mock clock so we can verify the min-depth + * guarantee without flaky wall-clock dependencies. + */ + +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import type { WalkEntry } from "../../../src/lib/scan/types.js"; +import { walkFiles } from "../../../src/lib/scan/walker.js"; + +const ROOT = mkdtempSync(join(tmpdir(), "scan-walker-test-")); + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +/** Build a sandbox directory with the given relative-path → content map. */ +function makeSandbox(layout: Record): { + cwd: string; + cleanup: () => void; +} { + const cwd = mkdtempSync(join(ROOT, "box-")); + for (const [rel, content] of Object.entries(layout)) { + const abs = join(cwd, rel); + const parent = abs.slice(0, abs.lastIndexOf("/")); + mkdirSync(parent, { recursive: true }); + writeFileSync(abs, content, "utf8"); + } + return { cwd, cleanup: () => rmSync(cwd, { recursive: true, force: true }) }; +} + +/** Collect every relativePath a walk yields into a sorted array. */ +async function collect( + opts: Parameters[0] +): Promise { + const out: WalkEntry[] = []; + for await (const entry of walkFiles(opts)) { + out.push(entry); + } + return out.map((e) => e.relativePath).sort(); +} + +describe("walkFiles — basic traversal", () => { + test("yields every file at every depth under cwd", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "src/b.ts": "y", + "src/deep/c.ts": "z", + }); + try { + const files = await collect({ cwd }); + expect(files).toEqual(["a.ts", "src/b.ts", "src/deep/c.ts"]); + } finally { + cleanup(); + } + }); + + test("returns nothing when cwd doesn't exist", async () => { + const files = await collect({ cwd: join(ROOT, "does-not-exist") }); + expect(files).toEqual([]); + }); + + test("empty directory yields nothing", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + const files = await collect({ cwd }); + expect(files).toEqual([]); + } finally { + cleanup(); + } + }); + + test("throws on relative cwd (programming-error safeguard)", async () => { + await expect( + (async () => { + for await (const _ of walkFiles({ cwd: "./relative" })) { + break; + } + })() + ).rejects.toThrow(/absolute/); + }); +}); + +describe("walkFiles — filtering", () => { + test("extensions filter narrows yield to allowed suffixes", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "b.js": "y", + "c.md": "z", + }); + try { + const files = await collect({ + cwd, + extensions: new Set([".ts", ".js"]), + }); + expect(files).toEqual(["a.ts", "b.js"]); + expect(files.includes("c.md")).toBe(false); + } finally { + cleanup(); + } + }); + + test("alwaysSkipDirs skips matching directory subtrees", async () => { + const { cwd, cleanup } = makeSandbox({ + "src/a.ts": "x", + "node_modules/dep/index.js": "y", + "packages/p/node_modules/other/x.js": "z", + "packages/p/src/ok.ts": "w", + }); + try { + const files = await collect({ + cwd, + alwaysSkipDirs: ["node_modules"], + respectGitignore: false, + }); + expect(files).toEqual(["packages/p/src/ok.ts", "src/a.ts"]); + } finally { + cleanup(); + } + }); + + test("hidden: false skips dotfiles and dotdirs", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + ".env": "y", + ".hidden/z.ts": "z", + }); + try { + const files = await collect({ cwd, hidden: false }); + expect(files).toEqual(["a.ts"]); + } finally { + cleanup(); + } + }); + + test("hidden: true (default) yields dotfiles", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + ".env": "y", + }); + try { + const files = await collect({ cwd }); + expect(files.includes(".env")).toBe(true); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — size + depth limits", () => { + test("maxFileSize skips oversized files", async () => { + const { cwd, cleanup } = makeSandbox({ + "small.ts": "x", + "big.ts": "x".repeat(2000), + }); + try { + const files = await collect({ cwd, maxFileSize: 1000 }); + expect(files).toEqual(["small.ts"]); + } finally { + cleanup(); + } + }); + + test("maxDepth caps directory descent; files inside still yield", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "src/b.ts": "y", + "src/deep/c.ts": "z", + "src/deep/deeper/d.ts": "w", + }); + try { + // Dirs entered: cwd (0), src (1), deep (2). `deeper` (would be + // 3) is NOT entered. Files inside any entered dir yield: + // a.ts (inside cwd), src/b.ts (inside src), src/deep/c.ts + // (inside deep). `deeper/d.ts` skipped because `deeper` is + // never entered. + const files = await collect({ cwd, maxDepth: 2 }); + expect(files).toEqual(["a.ts", "src/b.ts", "src/deep/c.ts"]); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — .gitignore integration", () => { + test("respectGitignore: true honors root patterns", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "*.log\n", + "a.ts": "x", + "b.log": "y", + }); + try { + const files = await collect({ cwd }); + expect(files.sort()).toEqual([".gitignore", "a.ts"]); + } finally { + cleanup(); + } + }); + + test("respectGitignore: false skips even the root .gitignore", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "*.log\n", + "a.ts": "x", + "b.log": "y", + }); + try { + const files = await collect({ cwd, respectGitignore: false }); + expect(files).toEqual([".gitignore", "a.ts", "b.log"]); + } finally { + cleanup(); + } + }); + + test("nested .gitignore layers on top of parent (cumulative)", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "*.log\n", + "src/.gitignore": "!important.log\n", + "src/foo.log": "x", + "src/important.log": "y", + }); + try { + const files = await collect({ cwd }); + // Parent ignored *.log, child un-ignored important.log. + expect(files.includes("src/foo.log")).toBe(false); + expect(files.includes("src/important.log")).toBe(true); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — binary detection", () => { + test("known text extensions classify as text without opening", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "hello", + "b.json": "{}", + }); + try { + const entries: WalkEntry[] = []; + for await (const e of walkFiles({ cwd })) { + entries.push(e); + } + for (const e of entries) { + expect(e.isBinary).toBe(false); + } + } finally { + cleanup(); + } + }); + + test("binary files with unknown extensions classify as binary", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + // Write a NUL-containing blob with an unknown extension. + const bin = new Uint8Array(256); + bin[10] = 0; + writeFileSync(join(cwd, "blob.bin"), bin); + const entries: WalkEntry[] = []; + for await (const e of walkFiles({ cwd })) { + entries.push(e); + } + const blob = entries.find((e) => e.relativePath === "blob.bin"); + expect(blob?.isBinary).toBe(true); + } finally { + cleanup(); + } + }); + + test("classifyBinary: false yields files without sniffing (isBinary=false)", async () => { + const { cwd, cleanup } = makeSandbox({}); + try { + // A NUL-containing blob that the default sniff classifies as binary. + const bin = new Uint8Array(256); + bin[10] = 0; + writeFileSync(join(cwd, "blob.bin"), bin); + + const findBlob = async ( + opts: Parameters[0] + ): Promise => { + for await (const e of walkFiles(opts)) { + if (e.relativePath === "blob.bin") { + return e; + } + } + return; + }; + + // By default the blob sniffs as binary; with classification disabled the + // same file is still yielded but reported non-binary (the 8 KB head-read + // is skipped entirely). + expect((await findBlob({ cwd }))?.isBinary).toBe(true); + expect((await findBlob({ cwd, classifyBinary: false }))?.isBinary).toBe( + false + ); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — mtime recording", () => { + test("recordMtimes: false (default) leaves mtime = 0", async () => { + const { cwd, cleanup } = makeSandbox({ "a.ts": "x" }); + try { + const entries: WalkEntry[] = []; + for await (const e of walkFiles({ cwd })) { + entries.push(e); + } + expect(entries[0]?.mtime).toBe(0); + } finally { + cleanup(); + } + }); + + test("recordMtimes: true populates non-zero mtime", async () => { + const { cwd, cleanup } = makeSandbox({ "a.ts": "x" }); + try { + const entries: WalkEntry[] = []; + for await (const e of walkFiles({ cwd, recordMtimes: true })) { + entries.push(e); + } + expect(entries[0]?.mtime).toBeGreaterThan(0); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — time budget", () => { + test("minDepth guarantee: files at depth <= minDepth yield even with budget=0", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "src/b.ts": "y", + "src/deep/c.ts": "z", + }); + try { + let now = 0; + const files = await collect({ + cwd, + minDepth: 2, + timeBudgetMs: 0, + clock: () => { + const current = now; + now += 1; + return current; + }, + }); + // `a.ts` is depth 1, `src/b.ts` is depth 2 — both within minDepth + // so they yield regardless of budget. `src/deep/c.ts` at depth 3 + // is beyond minDepth and the 0ms budget kills the descent. + expect(files.includes("a.ts")).toBe(true); + expect(files.includes("src/b.ts")).toBe(true); + } finally { + cleanup(); + } + }); + + test("budget truncates beyond minDepth", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "src/b.ts": "y", + "src/deep/c.ts": "z", + "src/deep/deeper/d.ts": "w", + }); + try { + let now = 0; + const files = await collect({ + cwd, + minDepth: 1, + timeBudgetMs: 0, + // Advance the clock a lot after the first read so budget is + // definitely blown before going beyond depth 1. + clock: () => { + now += 100; + return now; + }, + }); + // Once minDepth==1 completes, the walker bails before descending + // into src/deep/ and src/deep/deeper/. `src/b.ts` is at depth 2 + // but comes from opening src/ (depth 1). `src/deep` itself is a + // directory at depth 2 — we won't descend past it. + expect(files.includes("a.ts")).toBe(true); + expect(files.includes("src/b.ts")).toBe(true); + expect(files.includes("src/deep/c.ts")).toBe(false); + expect(files.includes("src/deep/deeper/d.ts")).toBe(false); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — AbortSignal", () => { + test("aborting mid-walk throws AbortError on next advance", async () => { + // Use a deep tree so the iteration has somewhere to run. + const layout: Record = {}; + for (let i = 0; i < 10; i += 1) { + layout[`dir${i}/file.ts`] = "x"; + } + const { cwd, cleanup } = makeSandbox(layout); + try { + const controller = new AbortController(); + const iter = walkFiles({ cwd, signal: controller.signal }); + let yields = 0; + let threw: unknown = null; + try { + for await (const _ of iter) { + yields += 1; + if (yields === 2) { + controller.abort(); + } + } + } catch (error) { + threw = error; + } + expect(yields).toBeGreaterThanOrEqual(2); + expect(threw).toBeInstanceOf(DOMException); + expect((threw as DOMException).name).toBe("AbortError"); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — descentHook", () => { + test("default: linear depth counting", async () => { + const { cwd, cleanup } = makeSandbox({ + "packages/foo/src/deep/a.ts": "x", + }); + try { + const entries: WalkEntry[] = []; + for await (const e of walkFiles({ cwd })) { + entries.push(e); + } + const deep = entries.find( + (e) => e.relativePath === "packages/foo/src/deep/a.ts" + ); + // 5 path segments → file depth 5. + expect(deep?.depth).toBe(5); + } finally { + cleanup(); + } + }); + + test("custom descentHook resets depth at monorepo package dirs", async () => { + const { cwd, cleanup } = makeSandbox({ + "packages/foo/src/deep/a.ts": "x", + "packages/bar/src/deep/b.ts": "y", + "root.ts": "z", + }); + try { + // Reset to 0 on "packages/" boundaries; otherwise linear. + const descentHook = (relPath: string, currentDepth: number) => { + const segs = relPath.split("/"); + if (segs.length === 2 && segs[0] === "packages") { + return 0; + } + return currentDepth + 1; + }; + const entries: WalkEntry[] = []; + for await (const e of walkFiles({ cwd, descentHook })) { + entries.push(e); + } + // packages/foo resets to depth 0; src/ → depth 1 under it, + // deep/ → depth 2, a.ts (file) → depth 3. Without the reset + // a.ts would be depth 5 from the repo root. + const deep = entries.find( + (e) => e.relativePath === "packages/foo/src/deep/a.ts" + ); + expect(deep?.depth).toBe(3); + const root = entries.find((e) => e.relativePath === "root.ts"); + expect(root?.depth).toBe(1); + } finally { + cleanup(); + } + }); + + test("maxDepth applies to post-reset depth (descent cap)", async () => { + const { cwd, cleanup } = makeSandbox({ + "packages/foo/a.ts": "x", + "packages/foo/deep/b.ts": "y", + "packages/foo/deep/deeper/c.ts": "z", + "packages/foo/deep/deeper/more/d.ts": "w", + }); + try { + // After reset at packages/foo (depth 0), dirs entered at: + // packages/foo/ = 0 + // packages/foo/deep = 1 + // packages/foo/deep/deeper = 2 (entered) + // packages/foo/deep/deeper/more = 3 (NOT entered, > maxDepth=2) + // Files yielded: a.ts (inside foo), b.ts (inside deep), c.ts + // (inside deeper). d.ts skipped because `more` never entered. + const descentHook = (relPath: string, currentDepth: number) => { + const segs = relPath.split("/"); + if (segs.length === 2 && segs[0] === "packages") { + return 0; + } + return currentDepth + 1; + }; + const files = await collect({ cwd, descentHook, maxDepth: 2 }); + expect(files.includes("packages/foo/a.ts")).toBe(true); + expect(files.includes("packages/foo/deep/b.ts")).toBe(true); + expect(files.includes("packages/foo/deep/deeper/c.ts")).toBe(true); + expect(files.includes("packages/foo/deep/deeper/more/d.ts")).toBe(false); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — onDirectoryVisit hook", () => { + test("fires once per visited directory with a floored mtime", async () => { + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "src/b.ts": "y", + "src/deep/c.ts": "z", + }); + try { + const visits: Array<{ absDir: string; mtimeMs: number }> = []; + for await (const _ of walkFiles({ + cwd, + onDirectoryVisit: (absDir, mtimeMs) => { + visits.push({ absDir, mtimeMs }); + }, + })) { + // drain + } + // cwd + src + src/deep = 3 directory visits (node_modules / .git + // aren't created by the sandbox helper, so no spurious hits). + const dirs = visits.map((v) => v.absDir).sort(); + expect(dirs).toEqual([cwd, join(cwd, "src"), join(cwd, "src", "deep")]); + for (const v of visits) { + expect(Number.isInteger(v.mtimeMs)).toBe(true); + expect(v.mtimeMs).toBeGreaterThan(0); + } + } finally { + cleanup(); + } + }); + + test("not called when unset (zero cost for non-DSN callers)", async () => { + const { cwd, cleanup } = makeSandbox({ "a.ts": "x" }); + try { + let yields = 0; + // With no hook set, the walker must complete normally and yield + // the file. We can't directly observe "no extra stat" from JS + // — the contract is "no code path inside the walker should call + // the hook when opts.onDirectoryVisit is undefined." This test + // verifies the walk still works; correctness of the zero-cost + // path is covered by the above test (which DOES set the hook). + for await (const _ of walkFiles({ cwd })) { + yields += 1; + } + expect(yields).toBe(1); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — nested .gitignore loading", () => { + test("nested patterns apply to their subtree; siblings without .gitignore unaffected", async () => { + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "", + "a/file1.ts": "x", + "a/.gitignore": "ignored.ts\n", + "a/ignored.ts": "y", // skipped by a/.gitignore + "b/file2.ts": "z", // b has no .gitignore — yields normally + "c/file3.ts": "w", + "c/.gitignore": "*.log\n", + "c/foo.log": "v", // skipped by c/.gitignore + }); + try { + const files = await collect({ cwd }); + expect(files.includes("a/file1.ts")).toBe(true); + expect(files.includes("a/ignored.ts")).toBe(false); + expect(files.includes("b/file2.ts")).toBe(true); + expect(files.includes("c/file3.ts")).toBe(true); + expect(files.includes("c/foo.log")).toBe(false); + } finally { + cleanup(); + } + }); + + test("nested gitignore in a dir without one higher up still applies", async () => { + const { cwd, cleanup } = makeSandbox({ + "deep/sub/.gitignore": "secret.ts\n", + "deep/sub/secret.ts": "x", + "deep/sub/ok.ts": "y", + }); + try { + const files = await collect({ cwd }); + expect(files.includes("deep/sub/ok.ts")).toBe(true); + expect(files.includes("deep/sub/secret.ts")).toBe(false); + } finally { + cleanup(); + } + }); + + test("disabling nestedGitignore skips nested files", async () => { + const { cwd, cleanup } = makeSandbox({ + "a/.gitignore": "ignored.ts\n", + "a/ignored.ts": "x", + "a/ok.ts": "y", + }); + try { + const files = await collect({ cwd, nestedGitignore: false }); + // With nested disabled, the `a/.gitignore` is never read, so + // `a/ignored.ts` yields. + expect(files.includes("a/ignored.ts")).toBe(true); + expect(files.includes("a/ok.ts")).toBe(true); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — followSymlinks", () => { + test("skips symlinks by default (followSymlinks: false)", async () => { + const { cwd, cleanup } = makeSandbox({ + "real-dir/inside.ts": "x", + "real-file.ts": "y", + }); + try { + symlinkSync(join(cwd, "real-dir"), join(cwd, "link-dir")); + symlinkSync(join(cwd, "real-file.ts"), join(cwd, "link-file.ts")); + const files = await collect({ cwd }); + expect(files.sort()).toEqual(["real-dir/inside.ts", "real-file.ts"]); + } finally { + cleanup(); + } + }); + + test("followSymlinks: true follows symlinked files and dirs", async () => { + const { cwd, cleanup } = makeSandbox({ + "aaa-real/inside.ts": "x", + "real-file.ts": "y", + }); + try { + // Alphabetical order means `aaa-real` is visited first, so the + // real path claims the inode and subsequent symlinks to the + // same inode are skipped by cycle detection. + symlinkSync(join(cwd, "aaa-real"), join(cwd, "zzz-link")); + symlinkSync(join(cwd, "real-file.ts"), join(cwd, "link-file.ts")); + const files = await collect({ cwd, followSymlinks: true }); + // Real dir's files yield; symlinked file also yields (different + // inode as a file is a different dentry). + expect(files.includes("aaa-real/inside.ts")).toBe(true); + expect(files.includes("real-file.ts")).toBe(true); + expect(files.includes("link-file.ts")).toBe(true); + } finally { + cleanup(); + } + }); + + test("followSymlinks: true breaks circular symlinks via inode detection", async () => { + const { cwd, cleanup } = makeSandbox({ + "inner/x.ts": "hello", + }); + try { + // Create a cycle: inner/back -> cwd + symlinkSync(cwd, join(cwd, "inner", "back")); + const files = await collect({ cwd, followSymlinks: true }); + // If the cycle weren't broken, we'd loop infinitely. Bounded + // result proves the inodeKey guard fires. + expect(files.length).toBeLessThan(20); + expect(files.includes("inner/x.ts")).toBe(true); + } finally { + cleanup(); + } + }); + + test("broken symlink is silently skipped", async () => { + const { cwd, cleanup } = makeSandbox({ "real.ts": "hi" }); + try { + symlinkSync(join(cwd, "nonexistent"), join(cwd, "broken.ts")); + const files = await collect({ cwd, followSymlinks: true }); + expect(files).toEqual(["real.ts"]); + } finally { + cleanup(); + } + }); + + test("followSymlinks: true does not re-list the tree via a symlink to the scan root", async () => { + const { cwd, cleanup } = makeSandbox({ + "top.ts": "a", + "sub/nested.ts": "b", + }); + try { + // A symlink pointing back at the scan root. The root frame is pushed + // directly (not via maybeDescend), so unless its inode is seeded into + // the visited set the walker would re-list the whole tree under + // `sub/root-link/...`. Asserting the exact set guards that seeding. + symlinkSync(cwd, join(cwd, "sub", "root-link")); + const files = await collect({ cwd, followSymlinks: true }); + expect(files.sort()).toEqual(["sub/nested.ts", "top.ts"]); + } finally { + cleanup(); + } + }); +}); + +describe("walkFiles — parallel walker (concurrency > 1)", () => { + test("yields the same set of files as the serial walker", async () => { + // Build a non-trivial tree so parallelism actually exercises + // the worker pool. + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "apps/web/index.ts": "x", + "apps/web/src/a.ts": "x", + "apps/web/src/deep/b.ts": "x", + "libs/core/index.ts": "x", + "libs/core/src/c.ts": "x", + "libs/ui/button.ts": "x", + "services/api/server.ts": "x", + "services/api/routes/users.ts": "x", + }); + try { + const serial = await collect({ cwd, concurrency: 1 }); + const parallel = await collect({ cwd, concurrency: 4 }); + expect(parallel).toEqual(serial); + } finally { + cleanup(); + } + }); + + test("honors gitignore rules across concurrent descents", async () => { + // Root `.gitignore` excludes `ignored/` and `*.log` globally. + // Nested `libs/.gitignore` adds `extra-noise.txt` — a rule not + // reachable from the root — to prove nested gitignore loads + // under concurrent traversal. + const { cwd, cleanup } = makeSandbox({ + ".gitignore": "ignored/\n*.log\n", + "src/a.ts": "x", + "src/b.log": "noise", + "src/nested/c.ts": "x", + "ignored/d.ts": "skip", + "libs/e.ts": "x", + "libs/.gitignore": "extra-noise.txt\n", + "libs/extra-noise.txt": "skip", + "libs/f.ts": "x", + }); + try { + const parallel = await collect({ cwd, concurrency: 4, hidden: true }); + expect(parallel.filter((p) => !p.endsWith(".gitignore"))).toEqual([ + "libs/e.ts", + "libs/f.ts", + "src/a.ts", + "src/nested/c.ts", + ]); + } finally { + cleanup(); + } + }); + + test("honors descentHook under concurrent traversal", async () => { + // maxDepth: 1 + hook that resets depth at packages/*. Without + // the reset, packages/p would be depth 1 so its src/ would be + // skipped. With the reset, packages/p starts at depth 0, and + // its src reaches depth 1 (max) — inner.ts yields. + const { cwd, cleanup } = makeSandbox({ + "packages/p/src/inner.ts": "x", + "shallow.ts": "x", + "deep/deeper/too-deep.ts": "x", + }); + try { + const files = await collect({ + cwd, + concurrency: 4, + maxDepth: 1, + descentHook: (rel, depth) => + /^packages\/[^/]+$/u.test(rel) ? 0 : depth + 1, + }); + // `shallow.ts` (depth 1) yields; `deep/deeper/too-deep.ts` is + // at depth 3 so it's excluded; `packages/p/src/inner.ts` + // yields because the hook resets packages/p to depth 0. + expect(files.sort()).toEqual(["packages/p/src/inner.ts", "shallow.ts"]); + } finally { + cleanup(); + } + }); + + test("aborts mid-walk when signal fires", async () => { + const { cwd, cleanup } = makeSandbox({ + "a/1.ts": "x", + "b/1.ts": "x", + "c/1.ts": "x", + "d/1.ts": "x", + "e/1.ts": "x", + }); + const ctrl = new AbortController(); + try { + const drain = async (): Promise => { + const iter = walkFiles({ cwd, concurrency: 4, signal: ctrl.signal }); + let n = 0; + for await (const _ of iter) { + n += 1; + if (n === 1) { + ctrl.abort(); + } + } + return n; + }; + await expect(drain()).rejects.toThrow(/abort/i); + } finally { + cleanup(); + } + }); + + test("propagates a pre-fired abort through the parallel walker", async () => { + // Regression: a pre-fired signal must throw immediately instead + // of hanging. The bug was that `checkAborted` threw outside the + // worker's try/catch, so `producerError` was never set and the + // consumer parked on `consumerAwake` forever. + const { cwd, cleanup } = makeSandbox({ + "f.ts": "x", + "a/g.ts": "x", + "a/b/h.ts": "x", + }); + const ctrl = new AbortController(); + ctrl.abort(); + try { + const drain = async () => { + for await (const _ of walkFiles({ + cwd, + concurrency: 4, + signal: ctrl.signal, + })) { + /* drain */ + } + }; + // 2s ceiling — the bug manifested as an indefinite hang. With + // the fix, the throw propagates in ~10ms. + const watchdog = new Promise((_, rej) => + setTimeout(() => rej(new Error("timed out")), 2000) + ); + await expect(Promise.race([drain(), watchdog])).rejects.toThrow(/abort/i); + } finally { + cleanup(); + } + }); + + test("clamps pathological concurrency values instead of hanging", async () => { + // Regression: `concurrency: NaN` previously passed through + // `Math.max(1, NaN) = NaN`, so the dispatch routed to the + // parallel walker, which spawned zero workers (`i < NaN` is + // always false) but left the consumer parked on + // `consumerAwake` forever. `normalizeConcurrency` now clamps + // every non-finite / sub-1 value to the default. + const { cwd, cleanup } = makeSandbox({ + "a.ts": "x", + "b/c.ts": "y", + }); + try { + // All of these should route cleanly — either serial or the + // default parallel — and yield the same set of files. + for (const conc of [Number.NaN, Number.POSITIVE_INFINITY, -1, 0, 0.5]) { + const files = await collect({ cwd, concurrency: conc }); + expect(files).toEqual(["a.ts", "b/c.ts"]); + } + } finally { + cleanup(); + } + }); +}); diff --git a/packages/cli/test/lib/scanner-flags.integration.test.ts b/packages/cli/test/lib/scanner-flags.integration.test.ts new file mode 100644 index 000000000..5a4cc908f --- /dev/null +++ b/packages/cli/test/lib/scanner-flags.integration.test.ts @@ -0,0 +1,309 @@ +/** + * Integration tests for top-level flag handling by Stricli's patched route + * scanner and application runner. + * + * The `@stricli/core` patch (see + * `packages/cli/patches/@stricli%2Fcore@1.2.8.patch`) teaches `buildRouteScanner` + * to accept a fixed allow-list of Sentry global flags (`--verbose`, `--json`, + * `--org`, `--project`, `--log-level`, `--fields`, and the `-v` alias) at any + * route depth, forwarding them to the leaf command instead of failing route + * resolution. It also recognizes `--version` at any depth (printing the version + * from `runApplication`) and exposes a `documentation.renderHelp` hook that the + * app uses to render `--help --json` as structured JSON. Together these replace + * the old `argv-glue`/`argv-hoist` preprocessors. + * + * These tests exercise the real `app` end-to-end via `run()` so the patch — + * not a preprocessor — is what makes `sentry --verbose bash-hook`, + * `sentry cli --version`, and `sentry issue list --help --json` behave. + */ + +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "@stricli/core"; +import { describe, expect, test } from "vitest"; +import { app } from "../../src/app.js"; +import type { SentryContext } from "../../src/context.js"; +import { CLI_VERSION } from "../../src/lib/constants.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("scanner-flags-integration-"); + +// Empty working dir so any command that reaches target resolution finds no DSNs +// to auto-detect and fails fast instead of making real network calls. Routing +// has already happened by then, which is all these tests assert. +const emptyCwd = mkdtempSync(join(tmpdir(), "scanner-flags-cwd-")); + +/** Run the real app with a mock context, capturing stdout and stderr. */ +async function runApp( + args: string[] +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + let stdout = ""; + let stderr = ""; + const captureStderr = { + write(data: string | Uint8Array) { + stderr += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }; + const context: SentryContext = { + process: { + ...process, + // Route the underlying process streams into our buffers too — Stricli's + // argument-scanner errors write to context.process.stderr, not context.stderr. + stdout: { + write(data: string | Uint8Array) { + stdout += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }, + stderr: captureStderr, + exitCode: undefined, + } as unknown as typeof process, + env: { ...process.env }, + cwd: emptyCwd, + homeDir: "/tmp", + configDir: "/tmp", + stdout: { + write(data: string | Uint8Array) { + stdout += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }, + stderr: captureStderr, + stdin: process.stdin, + }; + + const exitCode = await run(app, args, context); + return { stdout, stderr, exitCode: exitCode ?? 0 }; +} + +/** + * Stricli's error message when the route scanner treats a token as an unknown + * subcommand — the failure mode the patch fixes for global flags at depth. + */ +const NO_COMMAND_REGISTERED = "No command registered"; + +describe("top-level flags on a leaf command (bash-hook, no auth)", () => { + // bash-hook runs without auth and emits its script to stdout, so a successful + // route + execution is observable regardless of where the global flag sits. + + test("--verbose before the command still runs it", async () => { + const { stdout, stderr } = await runApp(["--verbose", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("-v (verbose alias) before the command runs it, not `--version`", async () => { + // The patch drops Stricli's built-in `-v`=version alias, so `-v` stays the + // Sentry CLI's --verbose alias and reaches the leaf command via the scanner. + const { stdout, stderr } = await runApp(["-v", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + // Not the bare version string. + expect(stdout).not.toMatch(/^\d+\.\d+\.\d+/); + }); + + test("--log-level with a value before the command still runs it", async () => { + const { stdout, stderr } = await runApp([ + "--log-level", + "debug", + "bash-hook", + ]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("a value flag's value is not mistaken for the command", async () => { + // `--org acme` must consume `acme` as the flag value, leaving `bash-hook` + // as the route. A naive scanner would treat `acme` as the subcommand. + const { stdout, stderr } = await runApp(["--org", "acme", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("--org=acme inline form before the command still runs it", async () => { + const { stdout, stderr } = await runApp(["--org=acme", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("the command's own flags still parse alongside a global flag", async () => { + const { stdout, stderr } = await runApp([ + "--verbose", + "bash-hook", + "--release", + "1.0.0", + ]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("--release '1.0.0'"); + }); +}); + +describe("top-level flags on a nested group command", () => { + // `cli defaults` is a no-auth, no-network group subcommand, so routing through + // the `cli` route map to the `defaults` leaf is observable without side effects. + // The patch is what lets a global flag between (or before) the group and + // subcommand resolve; without it the scanner rejects it as an unknown route. + + test("--verbose between group and subcommand resolves the route", async () => { + const { stderr } = await runApp(["cli", "--verbose", "defaults"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + }); + + test("--verbose before the group resolves the route", async () => { + const { stderr } = await runApp(["--verbose", "cli", "defaults"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + }); + + test("a value flag between group and subcommand does not break routing", async () => { + // `cli --org acme defaults`: `acme` is the --org value, `defaults` the + // subcommand — not a route segment. + const { stderr } = await runApp(["cli", "--org", "acme", "defaults"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + }); +}); + +describe("a value flag does not swallow --help", () => { + // A value-taking global flag given without its value (`--org --help`) must not + // consume the following `--help` as its value — help should still fire, matching + // Stricli's leaf-level behavior where a following flag isn't taken as a value. + test("--org --help renders help instead of eating --help", async () => { + const { stdout, stderr } = await runApp(["--org", "--help"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("USAGE"); + }); + + test("--org --help at a group renders help", async () => { + const { stdout, stderr } = await runApp(["cli", "--org", "--help"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("USAGE"); + }); + + test.each([ + "--json", + "-v", + ])("%s after a missing value flag still reaches the leaf command", async (flag) => { + const { stderr } = await runApp(["--org", flag, "issue", "list"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + }); +}); + +describe("escape sequence is still respected", () => { + test("a global flag after -- is not treated as a top-level flag", async () => { + // After `--`, tokens are positional/pass-through. `bash-hook` takes no + // positionals, so Stricli reports too-many-arguments — proving `--verbose` + // was NOT consumed as a global flag by the scanner (which would have made + // the command run cleanly). + const { stdout, stderr } = await runApp(["bash-hook", "--", "--verbose"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stderr.toLowerCase()).toContain("too many arguments"); + expect(stdout).not.toContain("_sentry_err_trap"); + }); +}); + +describe("--version at any route depth", () => { + // Stricli only prints `--version` when it is the very first token. The patch + // adds a `versionRequested` scanner state so `--version` at any depth (before + // a `--` escape) prints the version from runApplication. + const versionLine = `${CLI_VERSION}\n`; + + test("top-level --version prints the version", async () => { + const { stdout } = await runApp(["--version"]); + expect(stdout).toBe(versionLine); + }); + + test("--version after a route group prints the version", async () => { + const { stdout, stderr } = await runApp(["cli", "--version"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toBe(versionLine); + }); + + test("--version after a nested subcommand prints the version", async () => { + const { stdout, stderr } = await runApp(["issue", "list", "--version"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toBe(versionLine); + }); + + test("a value flag without its value does not swallow --version", async () => { + // `--org --version`: `--version` must print the version, not be consumed as + // the value of `--org` (mirrors the `--org --help` behavior). + const { stdout } = await runApp(["--org", "--version"]); + expect(stdout).toBe(versionLine); + }); + + test("--version after a -- escape is left for the wrapped command", async () => { + // bash-hook takes no positionals, so a `--version` past `--` triggers a + // too-many-arguments error instead of printing the CLI version. + const { stdout, stderr } = await runApp(["bash-hook", "--", "--version"]); + expect(stdout).not.toBe(versionLine); + expect(stderr.toLowerCase()).toContain("too many arguments"); + }); +}); + +describe("--help --json renders structured help via renderHelp hook", () => { + // The patch exposes a `documentation.renderHelp` hook; app.ts uses it to emit + // structured JSON (identical to `sentry help --json`) for `--help --json`, + // instead of Stricli's text usage. + + test("top-level --help --json emits the full command tree as JSON", async () => { + const { stdout } = await runApp(["--help", "--json"]); + const parsed = JSON.parse(stdout); + expect(parsed).toHaveProperty("routes"); + expect(parsed).toHaveProperty("flags"); + expect(Array.isArray(parsed.routes)).toBe(true); + }); + + test("command --help --json emits that command's metadata as JSON", async () => { + const { stdout } = await runApp(["issue", "list", "--help", "--json"]); + const parsed = JSON.parse(stdout); + expect(parsed).toHaveProperty("path"); + expect(parsed.path).toContain("issue list"); + }); + + test("--json --help order-insensitive still emits JSON", async () => { + const { stdout } = await runApp(["--json", "issue", "list", "--help"]); + const parsed = JSON.parse(stdout); + expect(parsed).toHaveProperty("path"); + expect(parsed.path).toContain("issue list"); + }); + + test("--fields narrows the JSON help output", async () => { + const { stdout } = await runApp([ + "issue", + "list", + "--help", + "--json", + "--fields", + "path", + ]); + const parsed = JSON.parse(stdout); + expect(Object.keys(parsed)).toEqual(["path"]); + }); + + test("bare --help (no --json) still renders text usage", async () => { + const { stdout } = await runApp(["issue", "list", "--help"]); + expect(stdout).toContain("USAGE"); + expect(() => JSON.parse(stdout)).toThrow(); + }); + + test("an unknown subcommand under a group emits a JSON error, not the group", async () => { + // `issue` has a default command, so the scanner forwards `nope` to it and + // reaches renderHelp. The unknown token must surface as a JSON `error` + // rather than silently rendering the `issue` group. + const { stdout } = await runApp(["issue", "nope", "--help", "--json"]); + const parsed = JSON.parse(stdout); + expect(parsed).toHaveProperty("error"); + expect(parsed.error).toContain("nope"); + }); + + test("a top-level unknown command emits a JSON error", async () => { + const { stdout } = await runApp(["nope", "--help", "--json"]); + const parsed = JSON.parse(stdout); + expect(parsed).toHaveProperty("error"); + expect(parsed.error).toContain("nope"); + }); +}); diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts new file mode 100644 index 000000000..11ff67e1e --- /dev/null +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, test, vi } from "vitest"; +import { ApiError, AuthError } from "../../src/lib/errors.js"; +import { OAUTH_SCOPES } from "../../src/lib/oauth.js"; +import { + captureOAuthScopeRecoveryGate, + ensureCurrentOAuthScopes, + runWithScopeRecovery, + type ScopeRecoveryRuntime, +} from "../../src/lib/scope-recovery.js"; + +function runtime( + overrides: Partial = {} +): ScopeRecoveryRuntime { + return { + assertTrustedHost: vi.fn(), + getAuthScopes: vi.fn().mockResolvedValue(OAUTH_SCOPES), + getAuthSource: () => "oauth", + inputIsTty: () => true, + promptsAllowed: () => true, + write: vi.fn(), + ...overrides, + }; +} + +describe("runWithScopeRecovery", () => { + test("checks the token after a 403, re-authorizes a stale grant, and retries", async () => { + const error = new ApiError("Forbidden", 403); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + const testRuntime = runtime({ + getAuthScopes: vi + .fn() + .mockResolvedValue( + OAUTH_SCOPES.filter((scope) => scope !== "team:admin") + ), + }); + + await runWithScopeRecovery( + proceed, + ["project", "create"], + login, + testRuntime + ); + + expect(proceed).toHaveBeenCalledTimes(2); + expect(testRuntime.getAuthScopes).toHaveBeenCalledOnce(); + expect(testRuntime.assertTrustedHost).toHaveBeenCalledOnce(); + expect(login).toHaveBeenCalledWith(); + expect(testRuntime.write).toHaveBeenCalledWith( + expect.stringContaining("team:admin") + ); + }); + + test("does not re-authorize a role or policy 403 when the token has every scope", async () => { + const error = new ApiError("Forbidden", 403); + const proceed = vi.fn().mockRejectedValue(error); + const login = vi.fn(); + + await expect( + runWithScopeRecovery(proceed, [], login, runtime()) + ).rejects.toBe(error); + + expect(login).not.toHaveBeenCalled(); + }); + + test("re-authorizes after a 401 when the API reports no active token", async () => { + const error = new ApiError("Unauthorized", 401); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + + await runWithScopeRecovery( + proceed, + [], + login, + runtime({ getAuthScopes: vi.fn().mockResolvedValue(null) }) + ); + + expect(login).toHaveBeenCalledOnce(); + expect(proceed).toHaveBeenCalledTimes(2); + }); + + test("re-authorizes when scope inspection rejects an invalid token", async () => { + const error = new ApiError("Unauthorized", 401); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + + await runWithScopeRecovery( + proceed, + [], + login, + runtime({ + getAuthScopes: vi + .fn() + .mockRejectedValue(new ApiError("Invalid token", 401)), + }) + ); + + expect(login).toHaveBeenCalledOnce(); + expect(proceed).toHaveBeenCalledTimes(2); + }); + + test("re-authorizes when a failed refresh clears the stored OAuth row", async () => { + const error = new ApiError("Unauthorized", 401); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(); + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + const getAuthSource = vi + .fn<() => "oauth" | undefined>() + .mockReturnValueOnce("oauth") + .mockReturnValueOnce(undefined); + const getAuthScopes = vi.fn(); + + await runWithScopeRecovery( + proceed, + [], + login, + runtime({ getAuthSource, getAuthScopes }) + ); + + expect(login).toHaveBeenCalledOnce(); + expect(getAuthScopes).not.toHaveBeenCalled(); + }); + + test("does not launch OAuth for environment tokens", async () => { + const error = new ApiError("Forbidden", 403); + const login = vi.fn(); + const getAuthScopes = vi.fn().mockResolvedValue([]); + + await expect( + runWithScopeRecovery( + vi.fn().mockRejectedValue(error), + [], + login, + runtime({ + getAuthSource: () => "env:SENTRY_AUTH_TOKEN", + getAuthScopes, + }) + ) + ).rejects.toBe(error); + + expect(login).not.toHaveBeenCalled(); + expect(getAuthScopes).not.toHaveBeenCalled(); + }); + + test("checks scopes but does not launch OAuth without an interactive TTY", async () => { + const error = new ApiError("Forbidden", 403); + const getAuthScopes = vi.fn().mockResolvedValue([]); + const login = vi.fn(); + + await expect( + runWithScopeRecovery( + vi.fn().mockRejectedValue(error), + [], + login, + runtime({ getAuthScopes, inputIsTty: () => false }) + ) + ).rejects.toBe(error); + + expect(getAuthScopes).toHaveBeenCalledOnce(); + expect(login).not.toHaveBeenCalled(); + }); + + test("does not launch OAuth when JSON output disables prompts", async () => { + const error = new ApiError("Forbidden", 403); + const getAuthScopes = vi.fn().mockResolvedValue([]); + const login = vi.fn(); + + await expect( + runWithScopeRecovery( + vi.fn().mockRejectedValue(error), + ["--json"], + login, + runtime({ getAuthScopes, promptsAllowed: () => false }) + ) + ).rejects.toBe(error); + + expect(getAuthScopes).toHaveBeenCalledOnce(); + expect(login).not.toHaveBeenCalled(); + }); + + test("preserves the original error when scope inspection fails", async () => { + const error = new ApiError("Forbidden", 403); + const login = vi.fn(); + + await expect( + runWithScopeRecovery( + vi.fn().mockRejectedValue(error), + [], + login, + runtime({ + getAuthScopes: vi.fn().mockRejectedValue(new Error("offline")), + }) + ) + ).rejects.toBe(error); + expect(login).not.toHaveBeenCalled(); + }); + + test("does not attempt a second recovery when the retry fails", async () => { + const first = new ApiError("Forbidden", 403); + const second = new ApiError("Still forbidden", 403); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(first) + .mockRejectedValueOnce(second); + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + + await expect( + runWithScopeRecovery( + proceed, + [], + login, + runtime({ getAuthScopes: vi.fn().mockResolvedValue([]) }) + ) + ).rejects.toBe(second); + expect(login).toHaveBeenCalledOnce(); + expect(proceed).toHaveBeenCalledTimes(2); + }); +}); + +describe("scope recovery availability", () => { + test("only gives init errors to recovery when authorization can run", async () => { + const error = new ApiError("Forbidden", 403); + const availableRuntime = runtime({ + getAuthScopes: vi.fn().mockResolvedValue([]), + }); + expect( + await captureOAuthScopeRecoveryGate(availableRuntime).shouldDelegate( + error, + { unattended: false } + ) + ).toBe(true); + + const blockedCases = [ + { + unattended: true, + runtime: runtime(), + }, + { + unattended: false, + runtime: runtime({ inputIsTty: () => false }), + }, + { + unattended: false, + runtime: runtime({ promptsAllowed: () => false }), + }, + ]; + + for (const blocked of blockedCases) { + const getAuthScopes = vi.mocked(blocked.runtime.getAuthScopes); + expect( + await captureOAuthScopeRecoveryGate(blocked.runtime).shouldDelegate( + error, + { unattended: blocked.unattended } + ) + ).toBe(false); + expect(getAuthScopes).not.toHaveBeenCalled(); + } + }); + + test("retains OAuth provenance when a failed refresh clears stored auth", async () => { + const getAuthSource = vi + .fn<() => "oauth" | undefined>() + .mockReturnValueOnce("oauth") + .mockReturnValueOnce(undefined); + const getAuthScopes = vi.fn(); + const testRuntime = runtime({ getAuthSource, getAuthScopes }); + const scopeRecovery = captureOAuthScopeRecoveryGate(testRuntime); + + expect( + await scopeRecovery.shouldDelegate(new ApiError("Unauthorized", 401), { + unattended: false, + }) + ).toBe(true); + expect(getAuthScopes).not.toHaveBeenCalled(); + }); +}); + +describe("upgrade scope check", () => { + test("re-authorizes a stale OAuth grant", async () => { + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + const refreshed = await ensureCurrentOAuthScopes( + login, + runtime({ getAuthScopes: vi.fn().mockResolvedValue(["org:read"]) }) + ); + + expect(refreshed).toBe(true); + expect(login).toHaveBeenCalledOnce(); + }); + + test("re-authorizes when the stored token is rejected during upgrade", async () => { + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + const refreshed = await ensureCurrentOAuthScopes( + login, + runtime({ + getAuthScopes: vi + .fn() + .mockRejectedValue(new ApiError("Invalid token", 401)), + }) + ); + + expect(refreshed).toBe(true); + expect(login).toHaveBeenCalledOnce(); + }); + + test("re-authorizes when upgrade scope inspection reports expired auth", async () => { + const login = vi.fn().mockResolvedValue({ method: "oauth" }); + const refreshed = await ensureCurrentOAuthScopes( + login, + runtime({ + getAuthScopes: vi.fn().mockRejectedValue(new AuthError("expired")), + }) + ); + + expect(refreshed).toBe(true); + expect(login).toHaveBeenCalledOnce(); + }); + + test("does nothing when the stored grant is current", async () => { + const login = vi.fn(); + expect(await ensureCurrentOAuthScopes(login, runtime())).toBe(false); + expect(login).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/sdk-flag-types.test.ts b/packages/cli/test/lib/sdk-flag-types.test.ts new file mode 100644 index 000000000..9986ad9ef --- /dev/null +++ b/packages/cli/test/lib/sdk-flag-types.test.ts @@ -0,0 +1,33 @@ +/** + * Unit Tests for Generated SDK Flag Types + * + * Stricli passes variadic flags to a command as arrays, and the SDK forwards flags to the + * handler unchanged. The generator used to declare them with their element type, so + * `sdk.auth.login({ scope: "org:read" })` type-checked and then failed inside the handler + * (`flags.scope.flatMap is not a function`). + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +const declarations = readFileSync( + fileURLToPath(new URL("../../src/sdk.generated.d.cts", import.meta.url)), + "utf-8" +); + +function paramsType(name: string): string { + const start = declarations.indexOf(`export type ${name} = {`); + if (start === -1) { + throw new Error(`${name} is missing from the generated SDK declarations`); + } + return declarations.slice(start, declarations.indexOf("\n};", start)); +} + +describe("generated SDK flag types", () => { + test("declares variadic flags as arrays and keeps scalar flags scalar", () => { + const login = paramsType("AuthLoginParams"); + expect(login).toContain("scope?: Array;"); + expect(login).toContain("token?: string;"); + }); +}); diff --git a/packages/cli/test/lib/sdk-invoke.test.ts b/packages/cli/test/lib/sdk-invoke.test.ts new file mode 100644 index 000000000..7cfc32897 --- /dev/null +++ b/packages/cli/test/lib/sdk-invoke.test.ts @@ -0,0 +1,224 @@ +/** + * Unit tests for the SDK invoke layer. + * + * Focuses on `applyFlagDefaults` which replicates Stricli's default + * application for the SDK direct-invoke path (bypasses Stricli parsing). + */ + +import { describe, expect, test } from "vitest"; +import { + applyFlagDefaults, + type FlagDef, + parseOutput, +} from "../../src/lib/sdk-invoke.js"; + +// --------------------------------------------------------------------------- +// applyFlagDefaults — parsed flags with defaults +// --------------------------------------------------------------------------- + +describe("applyFlagDefaults: parsed flags with defaults", () => { + test("calls parse on string default when flag is missing", () => { + const flagDefs: Record = { + period: { + kind: "parsed", + default: "7d", + parse: (v: string) => ({ type: "relative", period: v }), + }, + }; + const result = applyFlagDefaults({}, flagDefs); + expect(result.period).toEqual({ type: "relative", period: "7d" }); + }); + + test("calls parse on string default when flag value is undefined", () => { + const flagDefs: Record = { + period: { + kind: "parsed", + default: "90d", + parse: (v: string) => ({ type: "relative", period: v }), + }, + }; + const result = applyFlagDefaults({ period: undefined }, flagDefs); + expect(result.period).toEqual({ type: "relative", period: "90d" }); + }); + + test("preserves caller-provided value over default", () => { + const flagDefs: Record = { + period: { + kind: "parsed", + default: "7d", + parse: (v: string) => ({ type: "relative", period: v }), + }, + }; + const callerValue = { type: "relative", period: "24h" }; + const result = applyFlagDefaults({ period: callerValue }, flagDefs); + expect(result.period).toBe(callerValue); + }); + + test("handles parse function that returns a number", () => { + const flagDefs: Record = { + limit: { + kind: "parsed", + default: "25", + parse: (v: string) => Number(v), + }, + }; + const result = applyFlagDefaults({}, flagDefs); + expect(result.limit).toBe(25); + }); + + test("re-throws if parse function rejects its own default", () => { + const flagDefs: Record = { + period: { + kind: "parsed", + default: "invalid", + parse: () => { + throw new Error("parse error"); + }, + }, + }; + expect(() => applyFlagDefaults({}, flagDefs)).toThrow("parse error"); + }); +}); + +// --------------------------------------------------------------------------- +// applyFlagDefaults — boolean flags +// --------------------------------------------------------------------------- + +describe("applyFlagDefaults: boolean flags", () => { + test("applies raw boolean default", () => { + const flagDefs: Record = { + json: { kind: "boolean", default: false }, + fresh: { kind: "boolean", default: false }, + }; + const result = applyFlagDefaults({}, flagDefs); + expect(result.json).toBe(false); + expect(result.fresh).toBe(false); + }); + + test("preserves caller-provided boolean over default", () => { + const flagDefs: Record = { + fresh: { kind: "boolean", default: false }, + }; + const result = applyFlagDefaults({ fresh: true }, flagDefs); + expect(result.fresh).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// applyFlagDefaults — enum flags +// --------------------------------------------------------------------------- + +describe("applyFlagDefaults: enum flags", () => { + test("applies raw enum default", () => { + const flagDefs: Record = { + sort: { kind: "enum", default: "date" }, + }; + const result = applyFlagDefaults({}, flagDefs); + expect(result.sort).toBe("date"); + }); +}); + +// --------------------------------------------------------------------------- +// applyFlagDefaults — optional flags without defaults +// --------------------------------------------------------------------------- + +describe("applyFlagDefaults: optional flags without defaults", () => { + test("does not inject value for optional flag with no default", () => { + const flagDefs: Record = { + query: { kind: "parsed", optional: true }, + }; + const result = applyFlagDefaults({}, flagDefs); + expect("query" in result).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// applyFlagDefaults — strips undefined, preserves other falsy values +// --------------------------------------------------------------------------- + +describe("applyFlagDefaults: undefined stripping", () => { + test("strips undefined values from input flags", () => { + const flagDefs: Record = {}; + const result = applyFlagDefaults( + { a: undefined, b: null, c: 0, d: "", e: false }, + flagDefs + ); + expect("a" in result).toBe(false); + expect(result.b).toBeNull(); + expect(result.c).toBe(0); + expect(result.d).toBe(""); + expect(result.e).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// applyFlagDefaults — multiple flags combined +// --------------------------------------------------------------------------- + +describe("applyFlagDefaults: combined scenario", () => { + test("applies defaults for missing flags while preserving provided ones", () => { + const flagDefs: Record = { + period: { + kind: "parsed", + default: "7d", + parse: (v: string) => ({ type: "relative", period: v }), + }, + limit: { + kind: "parsed", + default: "25", + parse: (v: string) => Number(v), + }, + sort: { kind: "enum", default: "date" }, + fresh: { kind: "boolean", default: false }, + query: { kind: "parsed", optional: true }, + }; + const result = applyFlagDefaults({ limit: 50, query: undefined }, flagDefs); + // period: default applied via parse + expect(result.period).toEqual({ type: "relative", period: "7d" }); + // limit: caller value preserved + expect(result.limit).toBe(50); + // sort: default applied (raw) + expect(result.sort).toBe("date"); + // fresh: default applied (raw) + expect(result.fresh).toBe(false); + // query: undefined stripped, no default → absent + expect("query" in result).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseOutput — binary vs textual capture +// --------------------------------------------------------------------------- + +describe("parseOutput: binary output", () => { + test("returns raw bytes when a chunk is a Uint8Array (no comma-decimal coercion)", () => { + // PNG signature — a byte-for-byte round-trip is required so SDK/run() + // callers downloading attachments get faithful data, not "137,80,78,71". + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const result = parseOutput(undefined, [png]); + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result)).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + }); + + test("concatenates mixed string and binary chunks as bytes", () => { + const result = parseOutput(undefined, [ + "AB", + new Uint8Array([0x00, 0xff]), + ]); + expect(Array.from(result)).toEqual([0x41, 0x42, 0x00, 0xff]); + }); + + test("still JSON-parses textual chunks", () => { + const result = parseOutput<{ ok: boolean }>(undefined, ['{"ok":true}']); + expect(result).toEqual({ ok: true }); + }); + + test("prefers captured object over stdout chunks", () => { + const obj = { id: 1 }; + expect(parseOutput(obj, ["ignored"])).toBe(obj); + }); +}); diff --git a/packages/cli/test/lib/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts new file mode 100644 index 000000000..4b48c4767 --- /dev/null +++ b/packages/cli/test/lib/sdk-positionals.test.ts @@ -0,0 +1,91 @@ +/** + * Unit Tests for Generated SDK Positional Arguments + * + * Commands whose positionals are declared as a tuple of several parameters must forward one argv + * token per parameter. The generator used to join their placeholders into a single param, so the + * whole string arrived as argv[0] and every later argument was missing - `release deploy` and + * `snapshots diff` failed outright because their second positional is required. + * + * These tests drive the generated method tree with a recording invoker, so they fail if the + * generator regresses without anyone having to read the generated output. + */ + +import { describe, expect, test } from "vitest"; +import { createSDKMethods } from "../../src/sdk.generated.js"; + +type RecordedCall = { path: string[]; positional: string[] }; + +function createRecordingSDK(): { + calls: RecordedCall[]; + sdk: ReturnType; +} { + const calls: RecordedCall[] = []; + const invoke = ((path: string[], _flags: unknown, positional: string[]) => { + calls.push({ path, positional }); + return Promise.resolve(undefined); + }) as Parameters[0]; + + return { calls, sdk: createSDKMethods(invoke) }; +} + +describe("generated SDK positional arguments", () => { + test("release deploy passes version, environment and name as separate tokens", async () => { + const { calls, sdk } = createRecordingSDK(); + + await sdk.release.deploy({ + orgVersion: "1.0.0", + environment: "production", + name: "Deploy #42", + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.path).toEqual(["release", "deploy"]); + expect(calls[0]?.positional).toEqual(["1.0.0", "production", "Deploy #42"]); + }); + + test("release deploy omits a trailing optional positional", async () => { + const { calls, sdk } = createRecordingSDK(); + + await sdk.release.deploy({ + orgVersion: "1.0.0", + environment: "production", + }); + + expect(calls[0]?.positional).toEqual(["1.0.0", "production"]); + }); + + test("a missing middle positional keeps later arguments in their own slots", async () => { + const { calls, sdk } = createRecordingSDK(); + + // Dropping the hole instead would make "Deploy #42" the environment, quietly recording the + // deploy against an environment the caller never named. + await sdk.release.deploy({ orgVersion: "1.0.0", name: "Deploy #42" }); + + expect(calls[0]?.positional).toEqual(["1.0.0", "", "Deploy #42"]); + }); + + test("snapshots diff passes both directories", async () => { + const { calls, sdk } = createRecordingSDK(); + + await sdk.snapshots.diff({ baseDir: "./base", headDir: "./head" }); + + expect(calls[0]?.positional).toEqual(["./base", "./head"]); + }); + + test("commands with a single compound positional still pass one token", async () => { + const { calls, sdk } = createRecordingSDK(); + + // "org/version" is one placeholder that the command splits itself, not two positionals. + await sdk.release.finalize({ orgVersion: "my-org/1.0.0" }); + + expect(calls[0]?.positional).toEqual(["my-org/1.0.0"]); + }); + + test("a command with no positionals passes none", async () => { + const { calls, sdk } = createRecordingSDK(); + + await sdk.release.deploy({}); + + expect(calls[0]?.positional).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/sea-assets.test.ts b/packages/cli/test/lib/sea-assets.test.ts new file mode 100644 index 000000000..b718ea286 --- /dev/null +++ b/packages/cli/test/lib/sea-assets.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test, vi } from "vitest"; + +const seaModule = vi.hoisted(() => ({ + load: () => { + throw new Error("node:sea unavailable"); + }, +})); + +vi.mock("node:module", () => ({ + createRequire: + () => + (...args: unknown[]) => + seaModule.load(...args), +})); + +const { getSeaRawAsset } = await import("../../src/lib/sea-assets.js"); + +describe("getSeaRawAsset", () => { + test("propagates an embedded asset read failure", () => { + seaModule.load = () => ({ + isSea: () => true, + getRawAsset: () => { + throw new Error("embedded asset is missing"); + }, + }); + + expect(() => getSeaRawAsset("dist-build/missing.bin")).toThrow( + "embedded asset is missing" + ); + }); +}); diff --git a/packages/cli/test/lib/search-query.property.test.ts b/packages/cli/test/lib/search-query.property.test.ts new file mode 100644 index 000000000..9b9c87632 --- /dev/null +++ b/packages/cli/test/lib/search-query.property.test.ts @@ -0,0 +1,178 @@ +/** + * Property-based tests for search query sanitization. + * + * Covers invariants of `sanitizeQuery` from `src/lib/search-query.ts`: + * - Same-key qualifier OR always rewrites to valid in-list + * - Free-text OR always throws + * - Different-key OR always throws + * - AND stripping preserves all non-AND tokens + * - Safe queries pass through unchanged + */ + +import { constantFrom, assert as fcAssert, property, tuple } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { ValidationError } from "../../src/lib/errors.js"; +import { sanitizeQuery } from "../../src/lib/search-query.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** Keys that are valid for in-list syntax. */ +const validInListKeyArb = constantFrom( + "level", + "browser", + "assigned", + "release", + "message", + "platform", + "os.name", + "transaction" +); + +/** Keys that are NOT valid for in-list syntax. */ +const invalidInListKeyArb = constantFrom("is", "has"); + +/** Simple values without wildcards, quotes, or brackets. */ +const simpleValueArb = constantFrom( + "error", + "warning", + "fatal", + "info", + "debug", + "me", + "none", + "Chrome", + "Firefox", + "unresolved", + "resolved" +); + +/** Free-text terms (no colon → not a qualifier). */ +const freeTextArb = constantFrom( + "timeout", + "crash", + "sandbox", + "order", + "android", + "poolexhaustion" +); + +/** Safe terms that contain neither OR nor AND as standalone tokens. */ +const safeTermArb = constantFrom( + "is:unresolved", + "level:error", + "timeout", + "crash", + "http.status:500", + "assigned:me", + "sandbox", + "order", + "android" +); + +describe("property: sanitizeQuery", () => { + test("same-key qualifier OR rewrites to valid in-list", () => { + fcAssert( + property( + validInListKeyArb, + simpleValueArb, + simpleValueArb, + (key, val1, val2) => { + const query = `${key}:${val1} OR ${key}:${val2}`; + const result = sanitizeQuery(query); + expect(result).toBe(`${key}:[${val1},${val2}]`); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("three-way same-key OR rewrites correctly", () => { + fcAssert( + property( + validInListKeyArb, + simpleValueArb, + simpleValueArb, + simpleValueArb, + (key, v1, v2, v3) => { + const query = `${key}:${v1} OR ${key}:${v2} OR ${key}:${v3}`; + const result = sanitizeQuery(query); + expect(result).toBe(`${key}:[${v1},${v2},${v3}]`); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("free-text OR always throws ValidationError", () => { + fcAssert( + property( + tuple(freeTextArb, freeTextArb).map(([a, b]) => `${a} OR ${b}`), + (query) => { + expect(() => sanitizeQuery(query)).toThrow(ValidationError); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("different-key OR always throws ValidationError", () => { + fcAssert( + property( + validInListKeyArb, + validInListKeyArb, + simpleValueArb, + simpleValueArb, + (key1, key2, val1, val2) => { + // Only test when keys actually differ + if (key1 === key2) { + return; + } + const query = `${key1}:${val1} OR ${key2}:${val2}`; + expect(() => sanitizeQuery(query)).toThrow(ValidationError); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("invalid-key OR (is/has) always throws ValidationError", () => { + fcAssert( + property( + invalidInListKeyArb, + simpleValueArb, + simpleValueArb, + (key, val1, val2) => { + const query = `${key}:${val1} OR ${key}:${val2}`; + expect(() => sanitizeQuery(query)).toThrow(ValidationError); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("AND removal preserves all non-AND tokens", () => { + fcAssert( + property( + tuple(safeTermArb, safeTermArb).map(([a, b]) => `${a} AND ${b}`), + (query) => { + const result = sanitizeQuery(query); + const parts = query.split(/\s+AND\s+/); + for (const part of parts) { + expect(result).toContain(part.trim()); + } + // AND must not be present as standalone token + expect(result).not.toMatch(/\bAND\b/); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("safe queries pass through unchanged", () => { + fcAssert( + property(safeTermArb, (term) => { + expect(sanitizeQuery(term)).toBe(term); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/search-query.test.ts b/packages/cli/test/lib/search-query.test.ts new file mode 100644 index 000000000..6bf9d7abf --- /dev/null +++ b/packages/cli/test/lib/search-query.test.ts @@ -0,0 +1,544 @@ +/** + * Search Query Sanitization Tests + * + * Tests for `sanitizeQuery` and its OR→in-list rewriting logic + * in `src/lib/search-query.ts`. + * + * Core invariants (round-trips, random inputs) are tested via + * property-based tests in `search-query.property.test.ts`. + * These tests focus on specific rewrite cases, edge cases, + * and error messages. + */ + +import { describe, expect, test } from "vitest"; +import { ValidationError } from "../../src/lib/errors.js"; +import { __testing, sanitizeQuery } from "../../src/lib/search-query.js"; + +const { normalizeQuery, transformUnquoted } = __testing; + +// --------------------------------------------------------------------------- +// Passthrough (no operators) +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: passthrough", () => { + test("passes through a simple qualifier query unchanged", () => { + expect(sanitizeQuery("is:unresolved level:error")).toBe( + "is:unresolved level:error" + ); + }); + + test("passes through a plain text query unchanged", () => { + expect(sanitizeQuery("timeout crash")).toBe("timeout crash"); + }); + + test("does not match 'and'/'or' as substrings of normal words", () => { + expect(sanitizeQuery("sandbox handler")).toBe("sandbox handler"); + expect(sanitizeQuery("order error")).toBe("order error"); + }); + + test("does not match OR inside qualifier values (tag:OR)", () => { + expect(sanitizeQuery("tag:OR")).toBe("tag:OR"); + }); + + test("does not match AND inside qualifier values (tag:AND)", () => { + expect(sanitizeQuery("tag:AND")).toBe("tag:AND"); + }); + + test("does not match OR inside quoted strings", () => { + expect(sanitizeQuery('message:"error OR timeout"')).toBe( + 'message:"error OR timeout"' + ); + }); + + test("does not match AND inside quoted strings", () => { + expect(sanitizeQuery('title:"error AND timeout"')).toBe( + 'title:"error AND timeout"' + ); + }); + + test("does not match OR in qualifier values with more context", () => { + expect(sanitizeQuery("is:unresolved tag:OR_something")).toBe( + "is:unresolved tag:OR_something" + ); + }); +}); + +// --------------------------------------------------------------------------- +// AND stripping +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: AND", () => { + test("strips AND and returns cleaned query", () => { + expect(sanitizeQuery("error AND timeout")).toBe("error timeout"); + }); + + test("strips multiple AND operators", () => { + expect(sanitizeQuery("error AND timeout AND crash")).toBe( + "error timeout crash" + ); + }); + + test("handles case-insensitive AND", () => { + expect(sanitizeQuery("error And timeout")).toBe("error timeout"); + expect(sanitizeQuery("error and timeout")).toBe("error timeout"); + }); + + test("strips AND with qualifiers", () => { + expect(sanitizeQuery("is:unresolved AND level:error")).toBe( + "is:unresolved level:error" + ); + }); + + test("handles leading AND", () => { + expect(sanitizeQuery("AND error timeout")).toBe("error timeout"); + }); + + test("handles trailing AND", () => { + expect(sanitizeQuery("error timeout AND")).toBe("error timeout"); + }); +}); + +// --------------------------------------------------------------------------- +// project: → project_id +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: numeric project:", () => { + test("rewrites a numeric project: filter to project_id", () => { + expect( + sanitizeQuery("project:4511730126487632 environment:vercel-production") + ).toBe("project_id:4511730126487632 environment:vercel-production"); + }); + + test("rewrites a numeric project: in-list", () => { + expect( + sanitizeQuery("is:unresolved project:[4505521413357568,6442225]") + ).toBe("is:unresolved project_id:[4505521413357568,6442225]"); + }); + + test("rewrites a negated numeric project: filter", () => { + expect(sanitizeQuery("!project:1423462 lastSeen:-1h")).toBe( + "!project_id:1423462 lastSeen:-1h" + ); + }); + + test("leaves project slugs alone", () => { + expect(sanitizeQuery("project:frontend is:unresolved")).toBe( + "project:frontend is:unresolved" + ); + }); + + test("leaves project_id numeric filters alone", () => { + expect(sanitizeQuery("project_id:4511730126487632")).toBe( + "project_id:4511730126487632" + ); + }); + + test("leaves namespaced project keys alone", () => { + expect(sanitizeQuery("bolt.project_id:70054175")).toBe( + "bolt.project_id:70054175" + ); + expect(sanitizeQuery("bolt.project:70054175")).toBe( + "bolt.project:70054175" + ); + }); + + test("does not rewrite a numeric id inside a quoted value", () => { + expect(sanitizeQuery('message:"project:4511730126487632"')).toBe( + 'message:"project:4511730126487632"' + ); + }); + + test("does not rewrite mixed slug/numeric in-lists", () => { + expect(sanitizeQuery("project:[frontend,6442225]")).toBe( + "project:[frontend,6442225]" + ); + }); + + test("rewrites numeric project: then OR in one step", () => { + expect(sanitizeQuery("project:123 OR project:456")).toBe( + "project_id:[123,456]" + ); + }); +}); + +// --------------------------------------------------------------------------- +// OR → in-list rewrites (successful) +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: OR → in-list (success)", () => { + test("rewrites same-key qualifier OR to in-list", () => { + expect(sanitizeQuery("level:error OR level:warning")).toBe( + "level:[error,warning]" + ); + }); + + test("rewrites OR chain of 3+ same-key qualifiers", () => { + expect(sanitizeQuery("level:error OR level:warning OR level:fatal")).toBe( + "level:[error,warning,fatal]" + ); + }); + + test("preserves surrounding tokens", () => { + expect(sanitizeQuery("is:unresolved level:error OR level:warning")).toBe( + "is:unresolved level:[error,warning]" + ); + }); + + test("preserves surrounding tokens on both sides", () => { + expect( + sanitizeQuery("is:unresolved level:error OR level:warning firstSeen:-24h") + ).toBe("is:unresolved level:[error,warning] firstSeen:-24h"); + }); + + test("rewrites quoted values", () => { + expect( + sanitizeQuery('message:"pool exhaustion" OR message:"connection timeout"') + ).toBe('message:["pool exhaustion","connection timeout"]'); + }); + + test("merges existing in-list value with plain value", () => { + expect(sanitizeQuery("level:[error,warning] OR level:fatal")).toBe( + "level:[error,warning,fatal]" + ); + }); + + test("merges two in-list values", () => { + expect(sanitizeQuery("level:[error] OR level:[warning,fatal]")).toBe( + "level:[error,warning,fatal]" + ); + }); + + test("rewrites multiple independent OR groups", () => { + expect( + sanitizeQuery( + "level:error OR level:warning browser:Chrome OR browser:Firefox" + ) + ).toBe("level:[error,warning] browser:[Chrome,Firefox]"); + }); + + test("handles case-insensitive OR", () => { + expect(sanitizeQuery("level:error or level:warning")).toBe( + "level:[error,warning]" + ); + expect(sanitizeQuery("level:error Or level:warning")).toBe( + "level:[error,warning]" + ); + }); + + test("handles mixed AND and OR", () => { + expect( + sanitizeQuery("is:unresolved AND level:error OR level:warning") + ).toBe("is:unresolved level:[error,warning]"); + }); + + test("preserves key casing from first token", () => { + expect(sanitizeQuery("Level:error OR level:warning")).toBe( + "Level:[error,warning]" + ); + }); + + test("handles leading OR (stray)", () => { + expect(sanitizeQuery("OR level:error OR level:warning")).toBe( + "level:[error,warning]" + ); + }); + + test("handles trailing OR (stray)", () => { + expect(sanitizeQuery("level:error OR level:warning OR")).toBe( + "level:[error,warning]" + ); + }); +}); + +// --------------------------------------------------------------------------- +// OR → throws (cannot rewrite) +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: OR → throws", () => { + test("throws for free-text OR", () => { + expect(() => sanitizeQuery("error OR timeout")).toThrow(ValidationError); + }); + + test("throws for different keys across OR", () => { + expect(() => sanitizeQuery("level:error OR assigned:me")).toThrow( + ValidationError + ); + }); + + test("throws for is: qualifier (not supported with in-list)", () => { + expect(() => sanitizeQuery("is:unresolved OR is:resolved")).toThrow( + ValidationError + ); + }); + + test("throws for has: qualifier (not supported with in-list)", () => { + expect(() => sanitizeQuery("has:user OR has:email")).toThrow( + ValidationError + ); + }); + + test("throws for negated qualifiers", () => { + expect(() => sanitizeQuery("!level:error OR !level:warning")).toThrow( + ValidationError + ); + }); + + test("throws for wildcards in values", () => { + expect(() => sanitizeQuery("message:*error* OR message:*timeout*")).toThrow( + ValidationError + ); + }); + + test("throws for comparison operator values (not valid in in-list)", () => { + expect(() => sanitizeQuery("age:>24h OR age:>7d")).toThrow(ValidationError); + expect(() => sanitizeQuery("times_seen:>100 OR times_seen:>200")).toThrow( + ValidationError + ); + expect(() => + sanitizeQuery("span.duration:>=1s OR span.duration:>=500ms") + ).toThrow(ValidationError); + expect(() => + sanitizeQuery("firstSeen:<=2024-01-01 OR firstSeen:<=2024-06-01") + ).toThrow(ValidationError); + }); + + test("throws for mixed free-text and qualifier OR", () => { + expect(() => sanitizeQuery("is:unresolved error OR timeout")).toThrow( + ValidationError + ); + }); + + test("error includes field and rewritable example", () => { + try { + sanitizeQuery("error OR timeout"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const ve = error as ValidationError; + expect(ve.field).toBe("query"); + expect(ve.message).toContain("OR"); + expect(ve.message).toContain("level:error OR level:warning"); + expect(ve.message).toContain("key:[val1,val2]"); + } + }); + + test("throws for OR with qualifiers mixed in (real-world query 1)", () => { + // From CLI-16J: AI agent tried free-text OR + expect(() => + sanitizeQuery( + "is:unresolved pool exhaustion OR connection timeout OR connection terminated" + ) + ).toThrow(ValidationError); + }); + + test("throws for parenthesized groups across OR boundary", () => { + expect(() => + sanitizeQuery("(level:error assigned:me) OR (level:warning assigned:bob)") + ).toThrow(ValidationError); + }); +}); + +// --------------------------------------------------------------------------- +// Paren group handling +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: paren groups", () => { + test("throws for OR inside paren groups", () => { + expect(() => sanitizeQuery("(level:error OR level:warning)")).toThrow( + ValidationError + ); + }); + + test("throws for OR inside paren groups with surrounding filters", () => { + expect(() => + sanitizeQuery("(level:error OR level:warning) assigned:me") + ).toThrow(ValidationError); + }); + + test("passes through paren groups without boolean operators", () => { + expect(sanitizeQuery("(level:error) assigned:me")).toBe( + "(level:error) assigned:me" + ); + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: edge cases", () => { + test("throws for all-OR input", () => { + expect(() => sanitizeQuery("OR OR OR")).toThrow(ValidationError); + }); + + test("passes through malformed queries (unmatched parens)", () => { + // Unmatched parens cause PEG parse failure — pass through to API + expect(sanitizeQuery("(level:error")).toBe("(level:error"); + expect(sanitizeQuery("level:error)")).toBe("level:error)"); + }); + + test("throws for OR in paren group even with rewritable top-level OR", () => { + expect(() => + sanitizeQuery("level:error OR level:warning (a:1 OR a:2)") + ).toThrow(ValidationError); + }); + + test("passes through AND inside paren groups unchanged", () => { + // AND inside parens can't be stripped from opaque raw text — pass through + expect(sanitizeQuery("(error AND timeout)")).toBe("(error AND timeout)"); + }); + + test("rejects wildcard values in existing in-list during merge", () => { + expect(() => sanitizeQuery("key:[*err*] OR key:val")).toThrow( + ValidationError + ); + }); +}); + +describe("normalizeQuery: pre-parse text normalization", () => { + describe("mismatched brackets", () => { + test("fixes wrong closing delimiter ) → ]", () => { + expect(normalizeQuery("status_code:[401,403,429,500,)")).toBe( + "status_code:[401,403,429,500]" + ); + }); + + test("fixes trailing comma + wrong delimiter combined", () => { + expect(normalizeQuery("error.http.status_code:[401,403,429,500,)")).toBe( + "error.http.status_code:[401,403,429,500]" + ); + }); + + test("repairs within a longer query", () => { + expect( + normalizeQuery( + "is:unresolved error.http.status_code:[401,403,429,500,)" + ) + ).toBe("is:unresolved error.http.status_code:[401,403,429,500]"); + }); + }); + + describe("trailing list commas", () => { + test("strips trailing comma in in-list filter", () => { + expect(normalizeQuery("level:[error,warning,]")).toBe( + "level:[error,warning]" + ); + }); + + test("strips trailing comma with spaces", () => { + expect(normalizeQuery("level:[error, warning, ]")).toBe( + "level:[error, warning]" + ); + }); + }); + + describe("passthrough", () => { + test("leaves valid queries unchanged", () => { + expect(normalizeQuery("level:[error,warning]")).toBe( + "level:[error,warning]" + ); + }); + + test("leaves non-list queries unchanged", () => { + expect(normalizeQuery("is:unresolved level:error")).toBe( + "is:unresolved level:error" + ); + }); + + test("leaves empty query unchanged", () => { + expect(normalizeQuery("")).toBe(""); + }); + + test("does not cross filter boundaries", () => { + // Two filters — each should be repaired independently + expect(normalizeQuery("a:[1,) b:[2,)")).toBe("a:[1] b:[2]"); + }); + }); + + describe("quote awareness", () => { + test("does not modify bracket content inside double quotes", () => { + expect(normalizeQuery('message:"error [500,] found"')).toBe( + 'message:"error [500,] found"' + ); + }); + + test("does not modify mismatched brackets inside quotes", () => { + expect(normalizeQuery('message:"codes [401,403,)"')).toBe( + 'message:"codes [401,403,)"' + ); + }); + + test("repairs unquoted filter but preserves quoted content", () => { + expect( + normalizeQuery('level:[error,warning,) message:"[trailing,]"') + ).toBe('level:[error,warning] message:"[trailing,]"'); + }); + + test("handles multiple quoted regions", () => { + expect(normalizeQuery('a:"[1,]" level:[x,) b:"[2,]"')).toBe( + 'a:"[1,]" level:[x] b:"[2,]"' + ); + }); + + test("handles escaped quotes inside quoted strings", () => { + // The input has backslash-escaped quotes inside the quoted value: + // message:"say \"hello [500,]\"" level:[a,] + const input = 'message:"say \\"hello [500,]\\"" level:[a,]'; + const expected = 'message:"say \\"hello [500,]\\"" level:[a]'; + expect(normalizeQuery(input)).toBe(expected); + }); + }); +}); + +describe("transformUnquoted", () => { + const upper = (s: string) => s.toUpperCase(); + + test("transforms entire string when no quotes present", () => { + expect(transformUnquoted("hello world", upper)).toBe("HELLO WORLD"); + }); + + test("preserves quoted segments", () => { + expect(transformUnquoted('hello "world" foo', upper)).toBe( + 'HELLO "world" FOO' + ); + }); + + test("handles string that is entirely quoted", () => { + expect(transformUnquoted('"hello world"', upper)).toBe('"hello world"'); + }); + + test("handles adjacent quoted segments", () => { + expect(transformUnquoted('"a""b"', upper)).toBe('"a""b"'); + }); + + test("handles empty string", () => { + expect(transformUnquoted("", upper)).toBe(""); + }); +}); + +describe("sanitizeQuery: normalization integration", () => { + test("normalizes trailing comma before ] (pre-parse)", () => { + // Trailing comma is stripped before PEG parsing + const result = sanitizeQuery("level:[error,warning,]"); + expect(result).toBe("level:[error,warning]"); + }); + + test("normalizes wrong closing delimiter ) (pre-parse)", () => { + const result = sanitizeQuery("level:[error,warning,)"); + expect(result).toBe("level:[error,warning]"); + }); + + test("normalizes complex filter in longer query", () => { + const result = sanitizeQuery( + "is:unresolved error.http.status_code:[401,403,429,500,)" + ); + expect(result).toBe( + "is:unresolved error.http.status_code:[401,403,429,500]" + ); + }); + + test("unfixable malformed query passes through to API", () => { + const result = sanitizeQuery("((( broken"); + expect(result).toBe("((( broken"); + }); +}); diff --git a/packages/cli/test/lib/search-query.warn.test.ts b/packages/cli/test/lib/search-query.warn.test.ts new file mode 100644 index 000000000..85d879bfc --- /dev/null +++ b/packages/cli/test/lib/search-query.warn.test.ts @@ -0,0 +1,106 @@ +/** + * Warning copy for stacked search-query rewrites. + * + * `sanitizeQuery` must emit one warn: reasons, then a newline, then + * `Running query:` quoting the string that is actually sent. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { fakeLog } = vi.hoisted(() => { + const log = { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + withTag() { + return log; + }, + }; + return { fakeLog: log }; +}); + +vi.mock("../../src/lib/logger.js", () => ({ + logger: fakeLog, +})); + +const { sanitizeQuery } = await import("../../src/lib/search-query.js"); + +function runningQueries(): string[] { + return fakeLog.warn.mock.calls + .map((call) => String(call[0])) + .filter((msg) => msg.includes("Running query:")); +} + +describe("sanitizeQuery: rewrite warnings", () => { + beforeEach(() => { + fakeLog.warn.mockClear(); + }); + + test("numeric project: plus OR warns once with the final in-list", () => { + expect(sanitizeQuery("project:123 OR project:456")).toBe( + "project_id:[123,456]" + ); + const warns = runningQueries(); + expect(warns).toHaveLength(1); + expect(warns[0].split("\n")).toEqual([ + "`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Rewrote OR using in-list syntax: key:[val1,val2].", + 'Running query: "project_id:[123,456]"', + ]); + expect(warns[0]).not.toContain("project_id:123 OR project_id:456"); + }); + + test("numeric project: plus AND warns once with the stripped query", () => { + expect(sanitizeQuery("project:123 AND is:unresolved")).toBe( + "project_id:123 is:unresolved" + ); + const warns = runningQueries(); + expect(warns).toHaveLength(1); + expect(warns[0].split("\n")).toEqual([ + "`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Sentry search implicitly ANDs terms — removed explicit AND operator.", + 'Running query: "project_id:123 is:unresolved"', + ]); + }); + + test("OR-only still warns once with the in-list", () => { + expect(sanitizeQuery("level:error OR level:warning")).toBe( + "level:[error,warning]" + ); + const warns = runningQueries(); + expect(warns).toHaveLength(1); + expect(warns[0].split("\n")).toEqual([ + "Rewrote OR using in-list syntax: key:[val1,val2].", + 'Running query: "level:[error,warning]"', + ]); + }); + + test("does not warn Running query: when OR rewrite fails", () => { + expect(() => sanitizeQuery("level:error OR assigned:me")).toThrow(); + expect(runningQueries()).toHaveLength(0); + }); + + test("does not warn Running query: when numeric rewrite is followed by a failed OR", () => { + expect(() => sanitizeQuery("project:123 OR assigned:me")).toThrow(); + expect(runningQueries()).toHaveLength(0); + }); + + test("still warns about the numeric rewrite when the query does not parse", () => { + // Unmatched paren → PEG parse fails → passthrough. The rewrite already + // happened, and the API 400 will quote project_id, so the user must + // be told. + expect(sanitizeQuery("project:123 ((( broken")).toBe( + "project_id:123 ((( broken" + ); + const warns = runningQueries(); + expect(warns).toHaveLength(1); + expect(warns[0].split("\n")).toEqual([ + "`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters.", + 'Running query: "project_id:123 ((( broken"', + ]); + }); + + test("does not warn on unparseable passthrough with no rewrites", () => { + expect(sanitizeQuery("((( broken")).toBe("((( broken"); + expect(runningQueries()).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/lib/security/auto-login-host-guard.test.ts b/packages/cli/test/lib/security/auto-login-host-guard.test.ts new file mode 100644 index 000000000..a26ae9f51 --- /dev/null +++ b/packages/cli/test/lib/security/auto-login-host-guard.test.ts @@ -0,0 +1,174 @@ +/** + * Regression: auto-login (the error-recovery middleware that runs the OAuth + * device flow when a command hits an auth error in an interactive TTY) must + * honor the same host-trust gate as `sentry auth login`. + * + * Bug (getsentry/cli#1121): `sentry auth login` refuses a self-hosted host + * unless `--url` confirms it, but `sentry auth whoami` (and any other command) + * triggered an unconfirmed auto-login against `env.SENTRY_HOST`/`SENTRY_URL`, + * bypassing that gate. Since a `.sentryclirc` shim can inject `env.SENTRY_URL`, + * the bypass also reopened the OAuth-phishing vector that the `auth login` + * gate was added to close (see login-token-rc-poison.test.ts). + * + * These tests pin the trust predicates that the middleware now calls before + * starting the device flow. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { clearAuth, setAuthToken } from "../../../src/lib/db/auth.js"; +import { setDefaultUrl } from "../../../src/lib/db/defaults.js"; +import { + buildHostRefusalMessage, + isAutoLoginHostTrusted, + isLoginHostTrusted, + resolveEffectiveLoginHost, +} from "../../../src/lib/login-host-guard.js"; +import { registerLoginTrustAnchor } from "../../../src/lib/token-host.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../../helpers.js"; + +const ENV_KEYS = ["SENTRY_HOST", "SENTRY_URL"] as const; + +describe("auto-login host guard", () => { + useTestConfigDir("auto-login-guard-"); + useEnvSandbox(ENV_KEYS); + + beforeEach(async () => { + await resetHostScopingState(); + }); + + afterEach(async () => { + await resetHostScopingState(); + }); + + describe("resolveEffectiveLoginHost", () => { + test("falls back to SaaS when no host env is set", () => { + expect(resolveEffectiveLoginHost()).toBe("https://sentry.io"); + }); + + test("reads SENTRY_HOST", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect(resolveEffectiveLoginHost()).toBe("https://sentry.example.com"); + }); + + test("reads SENTRY_URL when SENTRY_HOST is absent", () => { + process.env.SENTRY_URL = "https://sentry.example.com/"; + expect(resolveEffectiveLoginHost()).toBe("https://sentry.example.com"); + }); + }); + + describe("isLoginHostTrusted (explicit `auth login` gate)", () => { + test("SaaS is always trusted", () => { + expect(isLoginHostTrusted("https://sentry.io")).toBe(true); + }); + + test("self-hosted without a trust anchor is refused", () => { + expect(isLoginHostTrusted("https://sentry.example.com")).toBe(false); + }); + + test("self-hosted with a matching trust anchor is trusted", () => { + registerLoginTrustAnchor("https://sentry.example.com"); + expect(isLoginHostTrusted("https://sentry.example.com")).toBe(true); + }); + + test("a persisted default URL does NOT relax the explicit gate", () => { + // Explicit `auth login` is intentionally stricter than auto-login: + // confirming a self-hosted host still requires `--url` even when that + // host is the persisted default. + setDefaultUrl("https://sentry.example.com"); + expect(isLoginHostTrusted("https://sentry.example.com")).toBe(false); + }); + }); + + describe("isAutoLoginHostTrusted (the #1121 fix)", () => { + test("SaaS auto-login still proceeds", () => { + expect(isAutoLoginHostTrusted("https://sentry.io")).toBe(true); + }); + + test("self-hosted with no confirmed host is REFUSED", () => { + // The reported bug: `whoami` against SENTRY_HOST with no creds used to + // auto-login here. Must now be refused, matching `auth login`. + expect(isAutoLoginHostTrusted("https://sentry.example.com")).toBe(false); + }); + + test("self-hosted re-auth against the persisted default URL is allowed", () => { + // Expired-session re-auth: the host was confirmed via `auth login --url` + // (which persists defaults.url), so we don't force `--url` again on + // every token expiry. + setDefaultUrl("https://sentry.example.com"); + expect(isAutoLoginHostTrusted("https://sentry.example.com")).toBe(true); + }); + + test("the anchor survives clearAuth (the expired-session path)", async () => { + // Both expired paths call clearAuth() before the AuthError reaches the + // middleware, so the stored token row is gone by the time the guard + // runs. defaults.url survives clearAuth, so re-auth still proceeds — + // this is the regression Cursor flagged. + setAuthToken("tok", undefined, undefined, { + host: "https://sentry.example.com", + }); + setDefaultUrl("https://sentry.example.com"); + await clearAuth(); + expect(isAutoLoginHostTrusted("https://sentry.example.com")).toBe(true); + }); + + test("scope-recovery re-auth against the stored token host is allowed (no default URL)", () => { + // 403 scope recovery: the OAuth token row is still present (clearAuth + // has NOT run). The token host is authoritative even when default-URL + // persistence failed or was cleared, so re-auth proceeds without --url. + setAuthToken("tok", undefined, undefined, { + host: "https://sentry.example.com", + }); + expect(isAutoLoginHostTrusted("https://sentry.example.com")).toBe(true); + }); + + test("injected host that differs from the confirmed default is REFUSED", () => { + // Default is the real instance; a poisoned env.SENTRY_URL points + // elsewhere. The confirmed host must not act as a free pass for a + // different (attacker) host. + setDefaultUrl("https://sentry.example.com"); + expect(isAutoLoginHostTrusted("https://evil.com")).toBe(false); + }); + + test("injected host that differs from the stored token host is REFUSED", () => { + // Token exists for the real instance; isHostTrusted requires an exact + // match, so an injected env.SENTRY_URL pointing elsewhere is refused. + setAuthToken("tok", undefined, undefined, { + host: "https://sentry.example.com", + }); + expect(isAutoLoginHostTrusted("https://evil.com")).toBe(false); + }); + + test("self-hosted with a matching trust anchor is allowed", () => { + registerLoginTrustAnchor("https://sentry.example.com"); + expect(isAutoLoginHostTrusted("https://sentry.example.com")).toBe(true); + }); + }); + + describe("buildHostRefusalMessage", () => { + test("auto-login (generic) message names the host and the --url fix", () => { + const msg = buildHostRefusalMessage("https://sentry.example.com"); + expect(msg).toBe( + "Refusing to log in against https://sentry.example.com — --url was not provided.\n\n" + + "To authenticate against this self-hosted instance, confirm the host explicitly:\n" + + " sentry auth login --url https://sentry.example.com" + ); + }); + + test("rcSource and tokenFlag are reflected (explicit-login wording)", () => { + const msg = buildHostRefusalMessage("https://sentry.example.com", { + tokenFlag: true, + rcSource: "/repo/.sentryclirc", + }); + expect(msg).toContain( + "this URL was read from .sentryclirc (/repo/.sentryclirc) but hasn't been confirmed as trusted yet" + ); + expect(msg).toContain( + " sentry auth login --url https://sentry.example.com --token " + ); + }); + }); +}); diff --git a/packages/cli/test/lib/security/custom-headers-leak.test.ts b/packages/cli/test/lib/security/custom-headers-leak.test.ts new file mode 100644 index 000000000..8fc146594 --- /dev/null +++ b/packages/cli/test/lib/security/custom-headers-leak.test.ts @@ -0,0 +1,129 @@ +/** + * CVE regression: custom-headers leak via share URL and via the + * `auth login` rc-URL bypass. + * + * Tests `applyCustomHeaders` trust scoping. Two attack shapes: + * 1. Share URL: `getSharedIssue(https://evil.com, ...)` — headers must + * not attach to URLs that don't match the active token. + * 2. auth login bypass: when `env.SENTRY_URL` is rc-poisoned and no + * token is active yet, headers must fail closed. + * + * See also `fetch-layer-guard.test.ts` for the Bearer-token path. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getSharedIssue } from "../../../src/lib/api/issues.js"; +import { + _resetCustomHeadersCache, + applyCustomHeaders, +} from "../../../src/lib/custom-headers.js"; +import { + captureEnvTokenHost, + resetEnvTokenHostForTesting, +} from "../../../src/lib/env-token-host.js"; +import { useEnvSandbox } from "../../helpers.js"; + +const ENV_KEYS = [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CUSTOM_HEADERS", +] as const; + +describe("CVE: custom-headers leak (share URL + auth-login bypass)", () => { + useEnvSandbox(ENV_KEYS); + + beforeEach(() => { + resetEnvTokenHostForTesting(); + _resetCustomHeadersCache(); + }); + + afterEach(() => { + resetEnvTokenHostForTesting(); + _resetCustomHeadersCache(); + }); + + test("getSharedIssue with attacker baseUrl does not leak custom headers (direct-call regression)", async () => { + // Simulates someone bypassing applySentryUrlContext and calling + // getSharedIssue directly with an attacker-controlled baseUrl. + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: secret"; + process.env.SENTRY_HOST = "https://sentry.acme.com"; + captureEnvTokenHost(); + + // Intercept fetch to capture outbound headers + const originalFetch = globalThis.fetch; + let capturedHeaders: Headers | undefined; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit + ) => { + capturedHeaders = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined) + ); + return new Response("{}", { status: 404 }); + }) as typeof fetch; + + try { + await getSharedIssue( + "https://evil.com", + "test-org", + "deadbeef12345678" + ).catch(() => { + /* we only care about headers, not the response */ + }); + expect(capturedHeaders?.get("X-IAP-Token")).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("IAP onboarding: 'auth login --url' registers a trust anchor so custom headers attach during OAuth device flow", async () => { + // Scenario: first-time self-hosted login with IAP protection. + // User runs `sentry auth login --url https://sentry.acme.com` before + // having any token. The device-code request MUST carry the IAP + // token or the IAP proxy will block it. + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: legit"; + + // `applyLoginUrl` (from login command) registers the trust anchor + const { applyLoginUrl } = await import( + "../../../src/commands/auth/login.js" + ); + applyLoginUrl("https://sentry.acme.com"); + + const headers = new Headers(); + applyCustomHeaders(headers, "https://sentry.acme.com/oauth/device/code/"); + // Legitimate IAP token attaches — this is the onboarding case + expect(headers.get("X-IAP-Token")).toBe("legit"); + + // Cleanup the anchor so subsequent tests aren't affected + const { resetLoginTrustAnchorForTesting } = await import( + "../../../src/lib/token-host.js" + ); + resetLoginTrustAnchorForTesting(); + }); + + test("Attacker .sentryclirc does NOT register a login trust anchor (rc bypass still fails closed)", async () => { + // This is the critical distinguishing test: the .sentryclirc shim + // writes env.SENTRY_URL (trust check is deferred to buildCommand), but it + // does NOT call registerLoginTrustAnchor. So no-token + // applyCustomHeaders must still fail closed even though SENTRY_URL + // is set. + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: secret"; + process.env.SENTRY_URL = "https://evil.com"; // simulating rc shim write + // Intentionally NOT calling applyLoginUrl — attacker flow doesn't + + const { resetLoginTrustAnchorForTesting } = await import( + "../../../src/lib/token-host.js" + ); + resetLoginTrustAnchorForTesting(); + + const headers = new Headers(); + applyCustomHeaders(headers, "https://evil.com/oauth/device/code/"); + expect(headers.get("X-IAP-Token")).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/security/fetch-layer-guard.test.ts b/packages/cli/test/lib/security/fetch-layer-guard.test.ts new file mode 100644 index 000000000..887860231 --- /dev/null +++ b/packages/cli/test/lib/security/fetch-layer-guard.test.ts @@ -0,0 +1,100 @@ +/** + * Defense-in-depth regression tests for the fetch layer. + * + * Even if a future code path bypasses the URL-arg / .sentryclirc entry-point + * guards and writes `SENTRY_HOST`/`SENTRY_URL` directly, the fetch layer + * must still refuse to attach credentials to a request whose origin + * doesn't match the active token's scope. + * + * This file simulates the bypass by directly calling the lower-level + * primitives (`apiRequest`, `applyCustomHeaders`, `refreshAccessToken`) + * with mismatched hosts. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + _resetCustomHeadersCache, + applyCustomHeaders, +} from "../../../src/lib/custom-headers.js"; +import { resetEnvTokenHostForTesting } from "../../../src/lib/env-token-host.js"; +import { useEnvSandbox } from "../../helpers.js"; + +const ENV_KEYS = [ + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CUSTOM_HEADERS", +] as const; + +describe("CVE defense-in-depth: fetch layer refuses mismatched hosts", () => { + useEnvSandbox(ENV_KEYS); + + beforeEach(() => { + resetEnvTokenHostForTesting(); + _resetCustomHeadersCache(); + }); + + afterEach(() => { + resetEnvTokenHostForTesting(); + _resetCustomHeadersCache(); + }); + + test("applyCustomHeaders: IAP token does NOT leak to untrusted URL (CVE #3)", () => { + // Reproduce the share-URL attack directly at the header layer. + // Even if something bypasses applySentryUrlContext and arrives at + // applyCustomHeaders with a mismatched URL, the header attach must + // refuse. + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: sensitive-iap-value"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const headers = new Headers(); + applyCustomHeaders( + headers, + "https://evil.com/api/0/shared/issues/deadbeef/" + ); + + expect(headers.get("X-IAP-Token")).toBeNull(); + // Verify no other custom headers leaked either + expect([...headers.keys()]).toHaveLength(0); + }); + + test("applyCustomHeaders: IAP token attaches to trusted URL", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: sensitive-iap-value"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const headers = new Headers(); + applyCustomHeaders( + headers, + "https://sentry.example.com/api/0/organizations/" + ); + + expect(headers.get("X-IAP-Token")).toBe("sensitive-iap-value"); + }); + + test("applyCustomHeaders: does NOT attach to a look-alike SaaS host (prefix/suffix attack)", () => { + // Critical: sentry.io.evil.com is NOT a sentry.io subdomain, so even + // a SaaS-scoped token shouldn't grant trust to it. + // SENTRY_CUSTOM_HEADERS is an IAP/proxy feature that only applies to + // self-hosted instances (getCustomHeaders short-circuits on SaaS), + // so we set SENTRY_HOST to enable it for this test. + process.env.SENTRY_CUSTOM_HEADERS = "X-Custom: value"; + process.env.SENTRY_HOST = "https://sentry.acme.com"; + + const headers = new Headers(); + applyCustomHeaders(headers, "https://sentry.acme.com.evil.com/api/0/"); + + expect(headers.get("X-Custom")).toBeNull(); + }); + + test("applyCustomHeaders: subdomain-attack on self-hosted is refused", () => { + // Token scoped to sentry.acme.com must not authorize sub.sentry.acme.com + // when it's actually an attacker-controlled subdomain takeover. + // (Non-SaaS trust class requires EXACT origin match.) + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: secret"; + process.env.SENTRY_HOST = "https://sentry.acme.com"; + + const headers = new Headers(); + applyCustomHeaders(headers, "https://attacker.sentry.acme.com/api/0/"); + + expect(headers.get("X-IAP-Token")).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/security/login-token-rc-poison.test.ts b/packages/cli/test/lib/security/login-token-rc-poison.test.ts new file mode 100644 index 000000000..0168bfab4 --- /dev/null +++ b/packages/cli/test/lib/security/login-token-rc-poison.test.ts @@ -0,0 +1,247 @@ +/** + * CVE regression: `sentry auth login` in a `.sentryclirc`-poisoned repo. + * + * Two attack shapes are blocked by `refuseLoginToUntrustedHost`: + * + * **Token leak** (`auth login --token X`): + * 1. User has a SaaS API token from `https://sentry.io/settings/auth-tokens/`. + * 2. User cd's into an attacker repo with `.sentryclirc` setting + * `url = https://evil.com`. + * 3. User runs `sentry auth login --token $SENTRY_API_TOKEN` (no --url). + * 4. The rc shim writes `env.SENTRY_URL = evil.com` (the URL trust check + * is deferred to buildCommand, and auth login has skipRcUrlCheck: true). Without the refusal, login validation would + * POST the user's token to evil.com. + * + * **Phishing** (`auth login` OAuth device flow, no --token): + * 1. Same poisoned-rc setup. + * 2. User runs `sentry auth login` to set up Sentry per the repo's README. + * 3. CLI prints the device-code URL pointing at evil.com. With a + * homograph or look-alike domain (e.g. `sentry-io.example-attacker.com`), + * the user opens it without scrutiny and authenticates with their + * SSO credentials at the attacker's cloned login page. SSO leak is + * worse than a single token leak — it compromises every service the + * SSO covers. + * + * Fix: `refuseLoginToUntrustedHost` rejects login (both --token and + * OAuth paths) when the effective host wasn't registered as a login + * trust anchor (didn't come from explicit `--url` or boot-time env). + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { loginCommand } from "../../../src/commands/auth/login.js"; +import { captureEnvTokenHost } from "../../../src/lib/env-token-host.js"; +import { HostScopeError } from "../../../src/lib/errors.js"; +import { + extractFetchUrl, + resetHostScopingState, + useEnvSandbox, +} from "../../helpers.js"; + +const ENV_KEYS = [ + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_CLIENT_ID", + "SENTRY_CUSTOM_HEADERS", +] as const; + +type LoginFlags = { + readonly token?: string; + readonly timeout: number; + readonly force: boolean; + readonly url?: string; +}; + +type LoginFunc = (this: unknown, flags: LoginFlags) => Promise; + +const noop = () => { + // write sinks in test context +}; + +function createContext() { + return { + stdout: { write: noop }, + stderr: { write: noop }, + cwd: "/tmp", + }; +} + +/** + * Check if a URL's hostname exactly matches one of the given hostnames. + * Use in preference to `.includes()` substring matching — a crafted + * URL like `https://evil.com.attacker.com/` would pass a substring + * check on `"evil.com"` and produce a false security assurance. + */ +function urlHostnameIn(url: string, hostnames: string[]): boolean { + try { + return hostnames.includes(new URL(url).hostname); + } catch { + return false; + } +} + +describe("CVE: auth login --token with rc-poisoned env.SENTRY_URL", () => { + useEnvSandbox(ENV_KEYS); + + let fetchCalls: { url: string; authorization: string | null }[]; + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + await resetHostScopingState(); + + // Intercept fetch to assert no outbound requests on the attacker path. + fetchCalls = []; + originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit + ) => { + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined) + ); + fetchCalls.push({ + url: extractFetchUrl(input), + authorization: headers.get("Authorization"), + }); + throw new Error("test: unexpected fetch"); + }) as typeof fetch; + }); + + afterEach(async () => { + await resetHostScopingState(); + globalThis.fetch = originalFetch; + }); + + test("auth login --token without --url in rc-poisoned env throws before network I/O", async () => { + // Simulate the attack state: no SENTRY_HOST at boot (env-token-host + // captures SaaS default), then rc shim writes env.SENTRY_URL to the + // attacker's host. + captureEnvTokenHost(); // captures SaaS default (no env set) + process.env.SENTRY_URL = "https://evil.com"; // simulate rc shim write + + const func = (await loginCommand.loader()) as unknown as LoginFunc; + const context = createContext(); + + await expect( + func.call(context, { + token: "user-saas-api-token-secret", + force: false, + timeout: 900, + }) + ).rejects.toBeInstanceOf(HostScopeError); + + // Critical: the user's token NEVER hit the wire. + const leaked = fetchCalls.filter((c) => + c.authorization?.includes("user-saas-api-token-secret") + ); + expect(leaked).toEqual([]); + // And no requests to the attacker at all + const toEvil = fetchCalls.filter((c) => urlHostnameIn(c.url, ["evil.com"])); + expect(toEvil).toEqual([]); + }); + + test("auth login OAuth flow (no token, no --url) in rc-poisoned env: refused before browser open", async () => { + // Phishing defense: the OAuth device flow would otherwise direct + // the user's browser to /oauth/authorize/... A homograph + // domain plus a Sentry-cloned login page can capture SSO + // credentials. Refusing here keeps the user from opening the + // attacker URL in the first place — much stronger than relying on + // them to spot the malicious URL in terminal output. + captureEnvTokenHost(); + process.env.SENTRY_URL = "https://evil.com"; + + const func = (await loginCommand.loader()) as unknown as LoginFunc; + const context = createContext(); + + await expect( + func.call(context, { force: false, timeout: 900 }) + ).rejects.toBeInstanceOf(HostScopeError); + + const toEvil = fetchCalls.filter((c) => urlHostnameIn(c.url, ["evil.com"])); + expect(toEvil).toEqual([]); + }); + + test("auth login --url explicitly acknowledges the host (legitimate onboarding)", async () => { + // The explicit --url path is the documented way to acknowledge a + // new host. rc-sourced env.SENTRY_URL is overwritten by applyLoginUrl. + captureEnvTokenHost(); + process.env.SENTRY_URL = "https://evil.com"; // poisoned by rc + + const func = (await loginCommand.loader()) as unknown as LoginFunc; + const context = createContext(); + + // User explicitly says `--url https://sentry.example.com` — this + // wins over the rc-poisoned env. Host is registered as anchor, so + // no HostScopeError. The actual login fires and hits our mock fetch + // (which throws), but importantly: it targets the user's intended + // host, not the attacker's. + await expect( + func.call(context, { + token: "legit-token", + url: "https://sentry.example.com", + force: false, + timeout: 900, + }) + ).rejects.toThrow(); // our fetch mock throws + + const toEvil = fetchCalls.filter((c) => urlHostnameIn(c.url, ["evil.com"])); + expect(toEvil).toEqual([]); + const toIntended = fetchCalls.filter((c) => + urlHostnameIn(c.url, ["sentry.example.com"]) + ); + expect(toIntended.length).toBeGreaterThan(0); + }); + + test("auth login --token with SENTRY_HOST but no --url: requires explicit --url", async () => { + // Even when SENTRY_HOST is legitimately shell-exported, --token login + // requires --url to confirm the host. This simplifies the trust model + // (only --url registers a trust anchor) and avoids the boot-snapshot + // comparison complexity. Self-hosted users add --url on first login. + process.env.SENTRY_HOST = "https://sentry.acme.com"; + captureEnvTokenHost(); + + const func = (await loginCommand.loader()) as unknown as LoginFunc; + const context = createContext(); + + await expect( + func.call(context, { + token: "legit-token", + force: false, + timeout: 900, + }) + ).rejects.toBeInstanceOf(HostScopeError); + + // No network I/O attempted. + expect(fetchCalls).toEqual([]); + }); + + test("stale login anchor for hostA does NOT admit login --token in rc-poisoned hostB", async () => { + // Library-mode regression: a previous applyLoginUrl(--url=hostA) in + // the same process registers a login anchor for hostA. A subsequent + // `auth login --token X` in a poisoned-rc hostB env must NOT use + // hostA's anchor as a free pass — the refusal guard checks anchor↔host + // match, not anchor existence. + captureEnvTokenHost(); + const { registerLoginTrustAnchor } = await import( + "../../../src/lib/token-host.js" + ); + registerLoginTrustAnchor("https://sentry.hosta.com"); + + process.env.SENTRY_URL = "https://evil.com"; + + const func = (await loginCommand.loader()) as unknown as LoginFunc; + const context = createContext(); + + await expect( + func.call(context, { + token: "user-saas-api-token-secret", + force: false, + timeout: 900, + }) + ).rejects.toBeInstanceOf(HostScopeError); + + const toEvil = fetchCalls.filter((c) => urlHostnameIn(c.url, ["evil.com"])); + expect(toEvil).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/security/refresh-token-poison.test.ts b/packages/cli/test/lib/security/refresh-token-poison.test.ts new file mode 100644 index 000000000..f3f6608ec --- /dev/null +++ b/packages/cli/test/lib/security/refresh-token-poison.test.ts @@ -0,0 +1,95 @@ +/** + * CVE defense-in-depth: OAuth refresh-token credential exfiltration. + * + * Attack: if something bypasses the entry-point guards and poisons + * `env.SENTRY_URL` before the next OAuth refresh fires, the refresh token + * would previously be POSTed to the attacker's `/oauth/token/` endpoint. + * + * Fix: `refreshAccessToken` calls `assertRefreshHostTrusted()` before + * building the request body, which throws `CliError` on mismatch. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + captureEnvTokenHost, + resetEnvTokenHostForTesting, +} from "../../../src/lib/env-token-host.js"; +import { refreshAccessToken } from "../../../src/lib/oauth.js"; +import { extractFetchUrl, useEnvSandbox } from "../../helpers.js"; + +const ENV_KEYS = ["SENTRY_HOST", "SENTRY_URL", "SENTRY_CLIENT_ID"] as const; + +describe("CVE defense-in-depth: refresh token", () => { + useEnvSandbox(ENV_KEYS); + + let fetchCalls: string[]; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + // A client ID is required for refreshAccessToken to proceed past the + // config check. We want it to reach (and fail at) the host assertion. + process.env.SENTRY_CLIENT_ID = "test-client-id"; + resetEnvTokenHostForTesting(); + // Intercept fetch to detect any outbound request attempt. + fetchCalls = []; + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(extractFetchUrl(input)); + throw new Error("test: unexpected fetch"); + }) as typeof fetch; + }); + + afterEach(() => { + resetEnvTokenHostForTesting(); + globalThis.fetch = originalFetch; + }); + + test("refreshAccessToken throws before fetch when env.SENTRY_URL is poisoned after boot", async () => { + // Step 1: simulate boot — capture env-token-host with no SENTRY_URL set + // (defaults to SaaS, matching a user who got SENTRY_AUTH_TOKEN from their + // shell without configuring SENTRY_HOST). + resetEnvTokenHostForTesting(); + captureEnvTokenHost(); // snapshots → SaaS default + + // Step 2: simulate the bypass — something writes env.SENTRY_URL AFTER + // the snapshot. This is the attack shape: env got poisoned by a + // code path that skipped the URL-arg / rc-shim guards. + process.env.SENTRY_URL = "https://evil.com"; + + // `refreshAccessToken` throws synchronously from its host-scope guard + // (before returning the promise from withHttpSpan). Handle both shapes. + let thrown: unknown; + try { + await refreshAccessToken("fake-refresh-token"); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toMatch( + /does not match|sentry auth login --url/ + ); + + // Critical: zero outbound requests to evil.com (or anywhere). + expect(fetchCalls).toEqual([]); + }); + + test("refreshAccessToken proceeds when URL matches token scope", async () => { + // Pin env-token to the self-hosted instance BEFORE the url is used. + process.env.SENTRY_HOST = "https://sentry.example.com"; + resetEnvTokenHostForTesting(); + captureEnvTokenHost(); + // Also set SENTRY_URL so getSentryUrl() returns the same host + process.env.SENTRY_URL = "https://sentry.example.com"; + + // Should NOT throw at the host-assertion; the actual fetch will fail + // with the mock "test: unexpected fetch" error, which is fine — the + // important thing is that the pre-fetch assertion let us through. + await expect(refreshAccessToken("fake-refresh-token")).rejects.toThrow( + /unexpected fetch|Cannot connect|fetch failed/ + ); + + // A request was attempted, and it went to the correct host + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]).toBe("https://sentry.example.com/oauth/token/"); + }); +}); diff --git a/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts b/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts new file mode 100644 index 000000000..0a6e61e8b --- /dev/null +++ b/packages/cli/test/lib/security/sentryclirc-url-poison.test.ts @@ -0,0 +1,169 @@ +/** + * CVE regression: .sentryclirc URL injection attack. + * + * Attack: a committed `.sentryclirc` file in a cloned repo sets + * `[defaults] url = https://evil.com`. The env-shim previously wrote + * `env.SENTRY_URL=https://evil.com` when neither `SENTRY_HOST` nor + * `SENTRY_URL` was set, independent of whether `SENTRY_AUTH_TOKEN` was + * present — so a developer's real token got sent to the attacker on every + * CLI command. + * + * Fix (this PR): the shim consults `getActiveTokenHost()` (from env-only + * snapshot, not .sentryclirc itself) and throws `CliError` when the rc + * url doesn't match the active token's scope. SaaS URLs bypass the check + * (no credentials can leak to SaaS). + */ + +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { closeDatabase } from "../../../src/lib/db/index.js"; +import { + captureEnvTokenHost, + resetEnvTokenHostForTesting, +} from "../../../src/lib/env-token-host.js"; +import { + applySentryCliRcEnvShim, + assertRcUrlTrusted, + CONFIG_FILENAME, + clearSentryCliRcCache, +} from "../../../src/lib/sentryclirc.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; + +// Don't use useEnvSandbox here: these tests depend on preload's +// SENTRY_AUTH_TOKEN being present (so getActiveTokenHost() returns a host) +// while still controlling SENTRY_HOST/URL/CONFIG_DIR per-test. +const ENV_KEYS = ["SENTRY_HOST", "SENTRY_URL", "SENTRY_CONFIG_DIR"] as const; + +function writeRc(dir: string, content: string): void { + writeFileSync(join(dir, CONFIG_FILENAME), content, "utf-8"); +} + +describe("CVE: .sentryclirc URL credential exfiltration", () => { + let testDir: string; + let saved: Record; + + beforeEach(async () => { + clearSentryCliRcCache(); + closeDatabase(); + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + testDir = await createTestConfigDir("sentryclirc-cve-", { + isolateProjectRoot: true, + }); + process.env.SENTRY_CONFIG_DIR = testDir; + resetEnvTokenHostForTesting(); + }); + + afterEach(async () => { + clearSentryCliRcCache(); + closeDatabase(); + for (const k of ENV_KEYS) { + const v = saved[k]; + if (v !== undefined) { + process.env[k] = v; + } else { + delete process.env[k]; + } + } + resetEnvTokenHostForTesting(); + await cleanupTestDir(testDir); + }); + + test("repo-local .sentryclirc with attacker URL throws (SENTRY_AUTH_TOKEN default SaaS scope)", async () => { + // Attack setup: user has SENTRY_AUTH_TOKEN (scoped to SaaS by default + // because no SENTRY_HOST is set). Attacker repo ships .sentryclirc + // pointing at evil.com. + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + // SENTRY_AUTH_TOKEN is already set by test/preload.ts + // Capture env-token host BEFORE the shim mutates env (mirrors boot + // ordering in cli.ts::preloadProjectContext). + captureEnvTokenHost(); + writeRc(testDir, "[defaults]\nurl = https://evil.com\n"); + + // Shim applies the URL unconditionally — the trust check is deferred. + await applySentryCliRcEnvShim(testDir); + expect(process.env.SENTRY_URL).toBe("https://evil.com"); + + // Critical: assertRcUrlTrusted must throw, blocking any credentialed + // request via buildCommand's wrapper. + await expect(assertRcUrlTrusted(testDir)).rejects.toThrow( + /does not match|sentry auth login --url/ + ); + }); + + test("global-style .sentryclirc with attacker URL is rejected too (no 'global is trusted' bypass)", async () => { + // The plan explicitly rejects 'global rc is trusted' because CI runners + // can write ~/.sentryclirc. Writing to the config dir (treated as + // global) must NOT be a trust-establishment pathway. + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + captureEnvTokenHost(); + // Write the rc in the config dir (SENTRY_CONFIG_DIR is set above). + // That's treated as a "global" path by the shim's scope tagging. + writeRc(testDir, "[defaults]\nurl = https://evil.com\n"); + + await applySentryCliRcEnvShim(testDir); + await expect(assertRcUrlTrusted(testDir)).rejects.toThrow( + /does not match|sentry auth login --url/ + ); + }); + + test("SaaS URL in .sentryclirc proceeds (no credential leak possible to SaaS)", async () => { + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + writeRc(testDir, "[defaults]\nurl = https://sentry.io\n"); + + await applySentryCliRcEnvShim(testDir); + expect(process.env.SENTRY_URL).toBe("https://sentry.io"); + }); + + test("matching self-hosted URL proceeds when env-token is scoped to that host", async () => { + // Simulate boot ordering: user sets SENTRY_HOST via env (shell export), + // captureEnvTokenHost() pins the token's scope to that host, THEN the + // env gets cleared (simulating e.g. a test isolation or a shell that + // only exported SENTRY_HOST for a particular subcommand). The rc file + // then proposes the same host the token was scoped to — match. + process.env.SENTRY_HOST = "https://sentry.example.com"; + resetEnvTokenHostForTesting(); + captureEnvTokenHost(); // pin to sentry.example.com + // Now simulate the shim running when both env vars are unset so the + // shim's "only write if both unset" guard admits the rc-sourced URL. + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + writeRc(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); + }); + + test("existing SENTRY_HOST is never overridden by rc (shim has its own guard)", async () => { + // Pre-existing SENTRY_HOST from user env — rc must not override it + // regardless of what the rc says. + process.env.SENTRY_HOST = "https://sentry.example.com"; + resetEnvTokenHostForTesting(); + writeRc(testDir, "[defaults]\nurl = https://evil.com\n"); + + // Shim's own "only write if both unset" guard kicks in first → silent no-op + await applySentryCliRcEnvShim(testDir); + expect(process.env.SENTRY_HOST).toBe("https://sentry.example.com"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("shim applies rc URL unconditionally; assertRcUrlTrusted is the gate", async () => { + // The shim always writes the rc URL to env (for routing). The trust + // check is deferred to assertRcUrlTrusted, which buildCommand calls + // after Stricli identifies the command. Commands that opt out + // (auth login/logout via skipRcUrlCheck: true) never call it. + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + writeRc(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + // URL applied to env (no trust check at this stage). + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); + + // The trust check is the deferred gate — would throw for non-matching + // hosts. Already covered by the other tests in this file. + }); +}); diff --git a/packages/cli/test/lib/security/sntrys-claim-mismatch.test.ts b/packages/cli/test/lib/security/sntrys-claim-mismatch.test.ts new file mode 100644 index 000000000..c602aaa30 --- /dev/null +++ b/packages/cli/test/lib/security/sntrys-claim-mismatch.test.ts @@ -0,0 +1,275 @@ +/** + * Defense-in-depth: `sntrys_` token claim vs request-origin mismatch. + * + * The fetch-layer guard refuses to attach a `sntrys_` token when its + * embedded `url` claim disagrees with the request origin. Defends users + * with access to multiple Sentry instances against routing one + * instance's token to another. Claim is unsigned (see token-claims.ts), + * so this catches honest misconfigurations more than malicious attacks. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + extractFetchUrl, + mintSntrysToken, + resetHostScopingState, + useEnvSandbox, +} from "../../helpers.js"; + +const ENV_KEYS = [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +] as const; + +describe("CVE defense-in-depth: sntrys_ claim vs request mismatch", () => { + useEnvSandbox(ENV_KEYS); + + let fetchCalls: { url: string; auth: string | null }[]; + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + await resetHostScopingState(); + const { + resetAuthTokenCache, + resetAuthRowCache, + resetIdentityFingerprintCache, + } = await import("../../../src/lib/db/auth.js"); + // Clear the GET response cache between tests so a cached 200 from + // a prior test's identical URL doesn't short-circuit the fetch + // wrapper (which would leave `fetchCalls` empty). + const { clearResponseCache } = await import( + "../../../src/lib/response-cache.js" + ); + resetAuthTokenCache(); + resetAuthRowCache(); + resetIdentityFingerprintCache(); + await clearResponseCache(); + + fetchCalls = []; + originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit + ) => { + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined) + ); + fetchCalls.push({ + url: extractFetchUrl(input), + auth: headers.get("Authorization"), + }); + return new Response("{}", { status: 200 }); + }) as typeof fetch; + }); + + afterEach(async () => { + await resetHostScopingState(); + globalThis.fetch = originalFetch; + }); + + test("token whose claim says A is refused when env routing says B", async () => { + // Token issued by sentry.firsthost.com (claim). + // Env says SENTRY_HOST = sentry.secondhost.com (user's shell — trusted). + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.firsthost.com", + org: "x", + }); + process.env.SENTRY_HOST = "https://sentry.secondhost.com"; + const { captureEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + // Direct request to sentry.secondhost.com (matches env scope but NOT the claim). + const { apiRequestToRegion } = await import( + "../../../src/lib/api/infrastructure.js" + ); + await expect( + apiRequestToRegion("https://sentry.secondhost.com", "/organizations/", { + method: "GET", + }) + ).rejects.toThrow(/embedded claim|sentry\.firsthost\.com/i); + + // Token never hit the wire. + const leaked = fetchCalls.filter((c) => + c.auth?.includes("secret-tail-for-test") + ); + expect(leaked).toEqual([]); + }); + + test("token whose claim matches the request proceeds normally", async () => { + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.acme.com", + org: "x", + }); + process.env.SENTRY_HOST = "https://sentry.acme.com"; + const { captureEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + // Request to the host the claim agrees with → bearer attaches. + const { apiRequestToRegion } = await import( + "../../../src/lib/api/infrastructure.js" + ); + await apiRequestToRegion("https://sentry.acme.com", "/organizations/", { + method: "GET", + }); + + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.auth).toContain("Bearer "); + }); + + test("self-hosted multi-region: claim check honors region URL extension", async () => { + // Regression: previously the claim check used raw `isHostTrusted`, + // which only honors exact-origin + SaaS equivalence. For self-hosted + // multi-region setups, the claim's url points at the control silo + // but fan-out goes to regional silos discovered via + // `/users/me/regions/`. The fix uses `isHostTrustedForClaim` which + // also consults the region-URL extension. + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.acme.com", + org: "x", + }); + process.env.SENTRY_HOST = "https://sentry.acme.com"; + const { captureEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + // Simulate: control silo's /users/me/regions/ told us about a + // regional silo at https://us.sentry.acme.com. + const { registerTrustedRegionUrls } = await import( + "../../../src/lib/db/regions.js" + ); + registerTrustedRegionUrls(["https://us.sentry.acme.com"]); + + // Request to the regional silo (NOT the claim's url) must succeed + // because the region URL is part of the same trust class. + const { apiRequestToRegion } = await import( + "../../../src/lib/api/infrastructure.js" + ); + await apiRequestToRegion("https://us.sentry.acme.com", "/organizations/", { + method: "GET", + }); + + // Request fired with the bearer token attached. Cleanup of the + // in-process region allow-list happens in afterEach. + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.auth).toContain("Bearer "); + }); + + test("opaque (non-sntrys_) tokens are not affected by claim check", async () => { + // A `sntryu_` user-auth token has no claim — the claim check is a + // no-op. The existing host-scoping check is the only enforcement. + process.env.SENTRY_AUTH_TOKEN = "sntryu_opaqueusertoken1234567890abcdef"; + process.env.SENTRY_HOST = "https://sentry.acme.com"; + const { captureEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + const { apiRequestToRegion } = await import( + "../../../src/lib/api/infrastructure.js" + ); + await apiRequestToRegion("https://sentry.acme.com", "/organizations/", { + method: "GET", + }); + + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.auth).toContain("Bearer "); + }); +}); + +describe("UX path: env-token-host falls back to sntrys_ claim url", () => { + // These tests check the captureEnvTokenHost snapshot — no fetch + // mocking needed. + useEnvSandbox(ENV_KEYS); + + beforeEach(resetHostScopingState); + afterEach(resetHostScopingState); + + test("self-hosted user with sntrys_ token but no SENTRY_HOST → snapshot uses claim", async () => { + // User pasted a sntrys_ token from their self-hosted UI but didn't + // also export SENTRY_HOST. Without the claim fallback, the snapshot + // would default to SaaS and every command would trip the host + // guard. With the claim fallback, the snapshot picks up the + // self-hosted url from the token itself. + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.selfhosted.example.com", + org: "x", + }); + // No SENTRY_HOST, no SENTRY_URL set. + + const { captureEnvTokenHost, getEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + expect(getEnvTokenHost()).toBe("https://sentry.selfhosted.example.com"); + }); + + test("sntrys_ claim wins over SENTRY_HOST (immune to env injection)", async () => { + // The claim is authoritative for sntrys_ tokens. Even when + // SENTRY_HOST is set (legitimately or via CI env injection), the + // snapshot uses the claim — it's the only value the token's + // issuing server can vouch for. + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.firsthost.com", + org: "x", + }); + process.env.SENTRY_HOST = "https://sentry.secondhost.com"; + + const { captureEnvTokenHost, getEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + expect(getEnvTokenHost()).toBe("https://sentry.firsthost.com"); + }); + + test("non-sntrys_ token + no SENTRY_HOST → snapshot falls back to SaaS default", async () => { + process.env.SENTRY_AUTH_TOKEN = "sntryu_opaque-user-token"; + // No SENTRY_HOST. + + const { captureEnvTokenHost, getEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + expect(getEnvTokenHost()).toBe("https://sentry.io"); + }); + + test("forged claim url is captured (claim is NOT a security primitive)", async () => { + // Documents the trust contract: the snapshot picks up whatever the + // claim says, even if forged. This is acceptable because: + // - For a legitimate token, the url is authoritative. + // - For a forged token (user pasted attacker's token), the user + // has already authorized the attacker server — out of threat + // model. + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://evil.com", + org: "victim", + }); + + const { captureEnvTokenHost, getEnvTokenHost } = await import( + "../../../src/lib/env-token-host.js" + ); + captureEnvTokenHost(); + + // Yes, the snapshot is evil.com — because that's what the user's + // token says. If the user trusts the token they pasted, they're + // trusting that source. The CLI's job is to prevent OTHER inputs + // (rc files, URL args) from redirecting credentials AWAY from the + // token's host, not to second-guess the token itself. + expect(getEnvTokenHost()).toBe("https://evil.com"); + }); +}); diff --git a/packages/cli/test/lib/security/url-arg-poison.test.ts b/packages/cli/test/lib/security/url-arg-poison.test.ts new file mode 100644 index 000000000..c8f5d5046 --- /dev/null +++ b/packages/cli/test/lib/security/url-arg-poison.test.ts @@ -0,0 +1,98 @@ +/** + * CVE regression: URL argument attack. + * + * Attack: `sentry issue view https://evil.com/organizations/x/issues/1/` + * previously wrote `env.SENTRY_HOST=https://evil.com` with no validation, + * causing every subsequent authenticated fetch + OAuth refresh to send + * credentials to the attacker. + * + * Fix (this PR): `applySentryUrlContext` rejects non-SaaS URLs that don't + * match the active token's scoped host. Only `sentry auth login --url ` + * can establish trust for a new host. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { parsePositionalArgs } from "../../../src/commands/event/view.js"; +import { + parseIssueArg, + parseOrgProjectArg, +} from "../../../src/lib/arg-parsing.js"; +import { resetEnvTokenHostForTesting } from "../../../src/lib/env-token-host.js"; +import { useEnvSandbox } from "../../helpers.js"; + +const ENV_KEYS = ["SENTRY_HOST", "SENTRY_URL"] as const; + +describe("CVE: URL argument credential exfiltration", () => { + useEnvSandbox(ENV_KEYS); + + beforeEach(resetEnvTokenHostForTesting); + afterEach(resetEnvTokenHostForTesting); + + test("parseIssueArg with attacker URL throws before env is poisoned (SaaS-scoped token)", () => { + // preload sets SENTRY_AUTH_TOKEN; env-token defaults to SaaS. + expect(() => + parseIssueArg("https://evil.com/organizations/target-org/issues/12345/") + ).toThrow(/does not match|sentry auth login --url/); + + // Env untouched — no routing poison. + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("parseOrgProjectArg with attacker URL throws before env is poisoned", () => { + expect(() => + parseOrgProjectArg( + "https://evil.com/organizations/target-org/issues/12345/" + ) + ).toThrow(/does not match|sentry auth login --url/); + + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("event view with attacker URL throws before env is poisoned", () => { + expect(() => + parsePositionalArgs([ + "https://evil.com/organizations/acme/issues/999/events/deadbeef/", + ]) + ).toThrow(/does not match|sentry auth login --url/); + + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("share URL from attacker host throws (CVE #3: custom-headers leak)", () => { + // The share-URL variant of the CVE. parseIssueArg still runs + // applySentryUrlContext, which throws before getSharedIssue is invoked. + expect(() => + parseIssueArg( + "https://evil.com/share/issue/deadbeef12345678deadbeef12345678/" + ) + ).toThrow(/does not match|sentry auth login --url/); + + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("SaaS URL arg proceeds even when SENTRY_HOST is currently set to self-hosted", () => { + // SaaS URLs always proceed — they're part of the SaaS trust class + // when the active token is SaaS-scoped, and they clear env to route + // correctly. + process.env.SENTRY_HOST = "https://old-self-hosted.example.com"; + process.env.SENTRY_URL = "https://old-self-hosted.example.com"; + parseIssueArg("https://sentry.io/organizations/acme/issues/1234/"); + // env cleared so default SaaS routing takes over + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("matching self-hosted URL arg is honored when token scoped to that host", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + resetEnvTokenHostForTesting(); + parseOrgProjectArg( + "https://sentry.example.com/organizations/acme/issues/1/" + ); + expect(process.env.SENTRY_HOST).toBe("https://sentry.example.com"); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); + }); +}); diff --git a/packages/cli/test/lib/seer-trial.test.ts b/packages/cli/test/lib/seer-trial.test.ts new file mode 100644 index 000000000..90f898769 --- /dev/null +++ b/packages/cli/test/lib/seer-trial.test.ts @@ -0,0 +1,302 @@ +/** + * Seer Trial Prompt Tests + * + * Tests for the interactive trial prompt flow. + * Note: isTrialEligible tests that depend on isatty(0) mocking live in + * test/isolated/ to avoid mock.module pollution. Tests here focus on + * promptAndStartTrial which doesn't call isatty directly. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../src/lib/api-client.js"; +import { SeerError } from "../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as loggerModule from "../../src/lib/logger.js"; +import { + isTrialEligible, + promptAndStartTrial, +} from "../../src/lib/seer-trial.js"; + +describe("isTrialEligible", () => { + // Note: These tests run in a non-interactive terminal (bun test), + // so isatty(0) returns false. We can only test the false cases here. + // The positive case (isatty=true) would need mock.module in an isolated test. + + test("returns false for ai_disabled reason", () => { + const err = new SeerError("ai_disabled", "test-org"); + expect(isTrialEligible(err)).toBe(false); + }); + + test("returns false when orgSlug is undefined", () => { + const err = new SeerError("no_budget"); + expect(isTrialEligible(err)).toBe(false); + }); + + test("returns false when orgSlug is undefined for not_enabled", () => { + const err = new SeerError("not_enabled"); + expect(isTrialEligible(err)).toBe(false); + }); + + test("returns false for non-SeerError", () => { + expect(isTrialEligible(new Error("random error"))).toBe(false); + }); + + test("returns false for null", () => { + expect(isTrialEligible(null)).toBe(false); + }); + + test("returns false for string", () => { + expect(isTrialEligible("some error string")).toBe(false); + }); +}); + +describe("promptAndStartTrial", () => { + let getProductTrialsSpy: ReturnType; + let startProductTrialSpy: ReturnType; + let loggerPromptSpy: ReturnType; + let loggerWithTagSpy: ReturnType; + let logDebugCalls: string[]; + let logInfoCalls: string[]; + let logWarnCalls: string[]; + let logSuccessCalls: string[]; + + const MOCK_SEER_TRIAL = { + category: "seerUsers", + startDate: null, + endDate: null, + reasonCode: 0, + isStarted: false, + lengthDays: 14, + }; + + beforeEach(() => { + logDebugCalls = []; + logInfoCalls = []; + logWarnCalls = []; + logSuccessCalls = []; + + getProductTrialsSpy = vi + .spyOn(apiClient, "getProductTrials") + .mockResolvedValue([]); + startProductTrialSpy = vi + .spyOn(apiClient, "startProductTrial") + .mockResolvedValue(undefined); + + // Mock the logger's withTag to return an object with all needed methods + loggerPromptSpy = vi.spyOn({ prompt: async () => false }, "prompt"); + const mockLogInstance = { + prompt: loggerPromptSpy, + debug: (...args: unknown[]) => { + logDebugCalls.push(args.map(String).join(" ")); + }, + info: (...args: unknown[]) => { + logInfoCalls.push(args.map(String).join(" ")); + }, + warn: (...args: unknown[]) => { + logWarnCalls.push(args.map(String).join(" ")); + }, + success: (...args: unknown[]) => { + logSuccessCalls.push(args.map(String).join(" ")); + }, + }; + loggerWithTagSpy = vi + .spyOn(loggerModule.logger, "withTag") + .mockReturnValue( + mockLogInstance as ReturnType + ); + }); + + afterEach(() => { + getProductTrialsSpy.mockRestore(); + startProductTrialSpy.mockRestore(); + loggerWithTagSpy.mockRestore(); + }); + + test("returns false when no trial is available and shows expired message", async () => { + getProductTrialsSpy.mockResolvedValue([]); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(false); + expect(getProductTrialsSpy).toHaveBeenCalledWith("test-org"); + // Should not prompt if no trial available + expect(loggerPromptSpy).not.toHaveBeenCalled(); + // Should show expired/upgrade message + expect( + logInfoCalls.some((m) => m.includes("No Seer trial available")) + ).toBe(true); + expect(logInfoCalls.some((m) => m.includes("upgrading your plan"))).toBe( + true + ); + }); + + test("returns false when only non-seer trials exist", async () => { + getProductTrialsSpy.mockResolvedValue([ + { ...MOCK_SEER_TRIAL, category: "replays" }, + ]); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(false); + expect(loggerPromptSpy).not.toHaveBeenCalled(); + }); + + test("returns false when seer trial is already started", async () => { + getProductTrialsSpy.mockResolvedValue([ + { ...MOCK_SEER_TRIAL, isStarted: true }, + ]); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(false); + expect(loggerPromptSpy).not.toHaveBeenCalled(); + }); + + test("returns false when trial check throws (graceful degradation)", async () => { + getProductTrialsSpy.mockRejectedValue(new Error("Network error")); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(false); + expect(loggerPromptSpy).not.toHaveBeenCalled(); + }); + + test("returns false when user declines the prompt", async () => { + getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); + loggerPromptSpy.mockResolvedValue(false); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(false); + expect(logInfoCalls.some((m) => m.includes("run out of Seer quota"))).toBe( + true + ); + expect(startProductTrialSpy).not.toHaveBeenCalled(); + }); + + test("returns false when user cancels with Ctrl+C (Symbol)", async () => { + getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); + // consola returns Symbol(clack:cancel) on Ctrl+C + loggerPromptSpy.mockResolvedValue(Symbol("clack:cancel")); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(false); + expect(startProductTrialSpy).not.toHaveBeenCalled(); + }); + + test("starts trial and returns true on confirmation", async () => { + getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); + loggerPromptSpy.mockResolvedValue(true); + startProductTrialSpy.mockResolvedValue(undefined); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(true); + expect(startProductTrialSpy).toHaveBeenCalledWith("test-org", "seerUsers"); + expect(logInfoCalls.some((m) => m.includes("Starting Seer trial"))).toBe( + true + ); + expect( + logSuccessCalls.some((m) => m.includes("Seer trial activated")) + ).toBe(true); + }); + + test("prefers seerUsers over seerAutofix", async () => { + getProductTrialsSpy.mockResolvedValue([ + { ...MOCK_SEER_TRIAL, category: "seerAutofix" }, + MOCK_SEER_TRIAL, + ]); + loggerPromptSpy.mockResolvedValue(true); + startProductTrialSpy.mockResolvedValue(undefined); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(true); + expect(startProductTrialSpy).toHaveBeenCalledWith("test-org", "seerUsers"); + }); + + test("falls back to seerAutofix when seerUsers is not available", async () => { + getProductTrialsSpy.mockResolvedValue([ + { ...MOCK_SEER_TRIAL, category: "seerAutofix" }, + ]); + loggerPromptSpy.mockResolvedValue(true); + startProductTrialSpy.mockResolvedValue(undefined); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(true); + expect(startProductTrialSpy).toHaveBeenCalledWith( + "test-org", + "seerAutofix" + ); + }); + + test("returns false when trial start fails and shows settings link", async () => { + getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); + loggerPromptSpy.mockResolvedValue(true); + startProductTrialSpy.mockRejectedValue(new Error("API error")); + + const result = await promptAndStartTrial("test-org", "no_budget"); + + expect(result).toBe(false); + expect(logWarnCalls.some((m) => m.includes("Failed to start trial"))).toBe( + true + ); + // Should include a link to billing/settings + expect( + logWarnCalls.some((m) => m.includes("settings/billing/overview")) + ).toBe(true); + // Should mention support contact + expect(logWarnCalls.some((m) => m.includes("support@sentry"))).toBe(true); + }); + + test("shows correct context message for not_enabled reason", async () => { + getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); + loggerPromptSpy.mockResolvedValue(false); + + await promptAndStartTrial("test-org", "not_enabled"); + + expect( + logInfoCalls.some((m) => m.includes("not enabled for your organization")) + ).toBe(true); + }); + + test("shows correct context message for no_budget reason", async () => { + getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); + loggerPromptSpy.mockResolvedValue(false); + + await promptAndStartTrial("test-org", "no_budget"); + + expect(logInfoCalls.some((m) => m.includes("run out of Seer quota"))).toBe( + true + ); + }); + + test("includes trial length in prompt message", async () => { + getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); + loggerPromptSpy.mockResolvedValue(false); + + await promptAndStartTrial("test-org", "no_budget"); + + expect(loggerPromptSpy).toHaveBeenCalled(); + const promptMessage = loggerPromptSpy.mock.calls[0]?.[0] as string; + expect(promptMessage).toContain("14-day"); + }); + + test("omits trial length when null", async () => { + getProductTrialsSpy.mockResolvedValue([ + { ...MOCK_SEER_TRIAL, lengthDays: null }, + ]); + loggerPromptSpy.mockResolvedValue(false); + + await promptAndStartTrial("test-org", "no_budget"); + + expect(loggerPromptSpy).toHaveBeenCalled(); + const promptMessage = loggerPromptSpy.mock.calls[0]?.[0] as string; + expect(promptMessage).not.toContain("day"); + expect(promptMessage).toContain("free Seer trial"); + }); +}); diff --git a/packages/cli/test/lib/sentry-client.auth.test.ts b/packages/cli/test/lib/sentry-client.auth.test.ts new file mode 100644 index 000000000..ad50c18e0 --- /dev/null +++ b/packages/cli/test/lib/sentry-client.auth.test.ts @@ -0,0 +1,443 @@ +/** Regression coverage for malformed bearer credentials. */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { shouldAutoAuth } from "../../src/lib/auto-auth.js"; +import { getAuthConfig, setAuthToken } from "../../src/lib/db/auth.js"; +import { getDatabase } from "../../src/lib/db/index.js"; +import { setEnv } from "../../src/lib/env.js"; +import { + AuthError, + EXIT, + HostScopeError, + MalformedAuthTokenError, + withAuthGuard, +} from "../../src/lib/errors.js"; +import { + getCachedResponse, + storeCachedResponse, +} from "../../src/lib/response-cache.js"; +import { + getSdkConfig, + resetAuthenticatedFetch, +} from "../../src/lib/sentry-client.js"; +import { + extractFetchUrl, + mintSntrysToken, + mockFetch, + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +const REGION_URL = "https://us.sentry.io"; +const RESOURCE_URL = `${REGION_URL}/api/0/organizations/synthetic-org/chunk-upload/`; +const ENV_TOKEN_KEYS = ["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN"] as const; +const ORG_TOKEN = mintSntrysToken({ + iat: 1, + url: "https://sentry.io", + org: "synthetic-org", +}); +const MALFORMED_TOKEN = ORG_TOKEN.replace( + "test-secret-tail", + "te\nst-secret-tail" +); +const EDGE_CONTROLS = `${Array.from({ length: 32 }, (_, code) => + String.fromCharCode(code) +).join("")}\x7f`; + +describe("authenticated fetch bearer validation", () => { + useTestConfigDir("sentry-client-auth-"); + useEnvSandbox([ + ...ENV_TOKEN_KEYS, + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CLIENT_ID", + ]); + + let originalFetch: typeof globalThis.fetch; + let requests: { url: string; authorization: string | null }[]; + + /** Mock responses while recording the actual request URL and Authorization. */ + function mockResponses( + respond: (url: string, authorization: string | null) => Response + ): typeof fetch { + return mockFetch((input, init) => { + const url = extractFetchUrl(input); + const authorization = new Headers(init?.headers).get("Authorization"); + requests.push({ url, authorization }); + return Promise.resolve(respond(url, authorization)); + }); + } + + beforeEach(async () => { + await resetHostScopingState(); + resetAuthenticatedFetch(); + originalFetch = globalThis.fetch; + requests = []; + globalThis.fetch = mockResponses(() => new Response("{}", { status: 200 })); + }); + + afterEach(async () => { + setEnv(process.env); + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); + await resetHostScopingState(); + }); + + function request(): Promise { + return getSdkConfig(REGION_URL).fetch(RESOURCE_URL); + } + + /** Simulate credentials persisted before setAuthToken validated its input. */ + function storeLegacyToken(token: string): void { + setAuthToken("legacy-token"); + getDatabase().query("UPDATE auth SET token = ? WHERE id = 1").run(token); + } + + test.each([ + ...ENV_TOKEN_KEYS, + "stored", + ])("rejects an internal LF from %s before any request or auth fallback", async (source) => { + if (source === "stored") { + storeLegacyToken(MALFORMED_TOKEN); + } else { + process.env[source] = MALFORMED_TOKEN; + } + + const error = await withAuthGuard(request).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(AuthError); + expect(error).toBeInstanceOf(MalformedAuthTokenError); + const authError = error as AuthError; + expect(authError.reason).toBe("invalid"); + expect(authError.exitCode).toBe(EXIT.AUTH_INVALID); + expect(authError.message).toContain("single line"); + expect(authError.cause).toBeUndefined(); + expect(`${authError.message}\n${authError.stack}`).not.toContain( + MALFORMED_TOKEN + ); + expect(shouldAutoAuth(authError, () => true)).toBe(false); + expect(requests).toEqual([]); + }); + + test.each([ + ["CR", "\r"], + ["NUL", "\0"], + ["tab", "\t"], + ["space", " "], + ["control character", "\x01"], + ["DEL", "\x7f"], + ["non-ASCII byte", "\x80"], + ["non-ByteString character", "\u0100"], + ["surrogate pair", "💥"], + ])("rejects %s in SDK credentials without exposing it", async (_, char) => { + const token = `synthetic-prefix${char}synthetic-secret`; + // SDK options use an in-memory env copy, which preserves even NULs. + // process.env and the SQLite binding truncate NUL-containing strings. + setEnv({ ...process.env, SENTRY_AUTH_TOKEN: token }); + const error = await request().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(AuthError); + expect(error).toMatchObject({ + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(String(error)).not.toContain(token); + expect(requests).toEqual([]); + }); + + test.each([ + ["org", ORG_TOKEN], + ["user", `sntryu_${"a".repeat(64)}`], + ["opaque legacy/OAuth", "opaque.legacy_token+with/punctuation=~-"], + ])("preserves a printable %s token", async (_, token) => { + setAuthToken(token); + await request(); + expect(requests).toEqual([ + { url: RESOURCE_URL, authorization: `Bearer ${token}` }, + ]); + }); + + test.each(ENV_TOKEN_KEYS)("preserves outer trimming for %s", async (key) => { + process.env[key] = "\t\n synthetic-token \r\n"; + await request(); + expect(requests[0]?.authorization).toBe("Bearer synthetic-token"); + }); + + test.each( + ENV_TOKEN_KEYS + )("trims surrounding C0, DEL and whitespace from SDK %s", async (key) => { + // An isolated SDK environment preserves NULs that process.env cannot. + setEnv({ + ...process.env, + [key]: `${EDGE_CONTROLS}\u00a0synthetic-token\ufeff${EDGE_CONTROLS}`, + }); + + await request(); + + expect(requests).toEqual([ + { url: RESOURCE_URL, authorization: "Bearer synthetic-token" }, + ]); + }); + + test.each([ + " stored-token ", + "\t\n\u00a0stored-token\r\n", + "\x1f\u00a0stored-token\ufeff\x7f", + ])("trims surrounding controls and whitespace from legacy credentials %#", async (token) => { + storeLegacyToken(token); + await request(); + expect(requests).toEqual([ + { url: RESOURCE_URL, authorization: "Bearer stored-token" }, + ]); + }); + + test("rejects a whitespace-only stored credential", async () => { + storeLegacyToken(" \t\r\n "); + await expect(request()).rejects.toMatchObject({ + name: "MalformedAuthTokenError", + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(requests).toEqual([]); + }); + + test("looks up Vary: Authorization using the normalized legacy credential", async () => { + storeLegacyToken("\x1fsynthetic-token\x7f"); + await storeCachedResponse( + "GET", + RESOURCE_URL, + { authorization: "Bearer synthetic-token" }, + Response.json( + { source: "cache" }, + { + headers: { + "Cache-Control": "private, max-age=300", + Vary: "Authorization", + }, + } + ) + ); + + expect(await (await request()).json()).toEqual({ source: "cache" }); + expect(requests).toEqual([]); + }); + + test("stores Vary: Authorization with the credential actually sent", async () => { + storeLegacyToken("\x1fsynthetic-token\x7f"); + globalThis.fetch = mockResponses(() => + Response.json( + { source: "network" }, + { + headers: { + "Cache-Control": "private, max-age=300", + Vary: "Authorization", + }, + } + ) + ); + + await request(); + + // Cache writes are fire-and-forget; wait for the entry rather than sleeping. + await expect + .poll(async () => { + const cached = await getCachedResponse("GET", RESOURCE_URL, { + authorization: "Bearer synthetic-token", + }); + return cached?.json(); + }) + .toEqual({ source: "network" }); + expect(requests).toEqual([ + { url: RESOURCE_URL, authorization: "Bearer synthetic-token" }, + ]); + }); + + test("checks token host claims after removing surrounding whitespace", async () => { + const token = mintSntrysToken({ + iat: 1, + url: "https://other-sentry.example.com", + org: "synthetic-org", + }); + storeLegacyToken(` \n${token}\t `); + await expect(request()).rejects.toBeInstanceOf(HostScopeError); + expect(requests).toEqual([]); + }); + + test.each( + ENV_TOKEN_KEYS + )("ignores a malformed %s when stored OAuth takes precedence", async (key) => { + process.env[key] = MALFORMED_TOKEN; + setAuthToken("stored-token"); + await request(); + expect(requests[0]?.authorization).toBe("Bearer stored-token"); + }); + + test("rejects forced malformed env credentials instead of using stored OAuth", async () => { + process.env.SENTRY_AUTH_TOKEN = MALFORMED_TOKEN; + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + setAuthToken("stored-token"); + await expect(request()).rejects.toMatchObject({ + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(requests).toEqual([]); + }); + + test.each([ + { key: "SENTRY_AUTH_TOKEN", forceEnv: false }, + { key: "SENTRY_AUTH_TOKEN", forceEnv: true }, + { key: "SENTRY_TOKEN", forceEnv: true }, + ])("rejects control-only $key without fallback (force-env=$forceEnv)", async ({ + key, + forceEnv, + }) => { + if (forceEnv) { + setAuthToken("stored-token"); + } + setEnv({ + ...process.env, + SENTRY_AUTH_TOKEN: undefined, + SENTRY_TOKEN: "alias-token", + SENTRY_FORCE_ENV_TOKEN: forceEnv ? "1" : undefined, + [key]: "\0\x01\x7f", + }); + + await expect(request()).rejects.toMatchObject({ + name: "MalformedAuthTokenError", + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(requests).toEqual([]); + }); + + test.each([ + "refreshed-token", + " \nrefreshed-token\r\t", + "\x1f\nrefreshed-token\r\x7f", + ])("retries with a normalized valid refreshed bearer %#", async (token) => { + process.env.SENTRY_CLIENT_ID = "synthetic-client-id"; + setAuthToken("stored-token", 3600, "synthetic-refresh-token"); + globalThis.fetch = mockResponses((url, authorization) => { + if (url.endsWith("/oauth/token/")) { + return Response.json({ + access_token: token, + token_type: "bearer", + expires_in: 3600, + }); + } + return new Response("{}", { + status: authorization === "Bearer stored-token" ? 401 : 200, + }); + }); + + expect((await request()).status).toBe(200); + expect(requests).toEqual([ + { url: RESOURCE_URL, authorization: "Bearer stored-token" }, + { url: "https://sentry.io/oauth/token/", authorization: null }, + { url: RESOURCE_URL, authorization: "Bearer refreshed-token" }, + ]); + expect(getAuthConfig()?.token).toBe("refreshed-token"); + }); + + test.each([ + MALFORMED_TOKEN, + "", + " \t\n ", + "opaque-\0-token", + "opaque-\u0100-token", + ])("rejects malformed refreshed credentials without retrying the request %#", async (token) => { + process.env.SENTRY_CLIENT_ID = "synthetic-client-id"; + setAuthToken("stored-token", 3600, "synthetic-refresh-token"); + globalThis.fetch = mockResponses((url) => { + if (url.endsWith("/oauth/token/")) { + return Response.json({ + access_token: token, + token_type: "bearer", + expires_in: 3600, + refresh_token: "synthetic-refresh-token", + }); + } + return new Response("{}", { status: 401 }); + }); + + for (let attempt = 0; attempt < 2; attempt++) { + const error = await request().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(MalformedAuthTokenError); + expect(error).toMatchObject({ + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect((error as Error).cause).toBeUndefined(); + if (token) { + expect(String(error)).not.toContain(token); + } + expect(getAuthConfig()).toMatchObject({ + token: "stored-token", + refreshToken: "synthetic-refresh-token", + }); + } + const refreshAttempt = [ + { url: RESOURCE_URL, authorization: "Bearer stored-token" }, + { url: "https://sentry.io/oauth/token/", authorization: null }, + ]; + expect(requests).toEqual([...refreshAttempt, ...refreshAttempt]); + }); + + test.each([ + "none", + ...ENV_TOKEN_KEYS, + ])("normalizes a proactive refresh with malformed env source %s", async (source) => { + process.env.SENTRY_CLIENT_ID = "synthetic-client-id"; + if (source !== "none") { + process.env[source] = MALFORMED_TOKEN; + } + setAuthToken("expired-token", -1, "synthetic-refresh-token"); + globalThis.fetch = mockResponses((url) => + url.endsWith("/oauth/token/") + ? Response.json({ + access_token: "\x1f \nrefreshed-token\r\t\x7f", + token_type: "bearer", + expires_in: 3600, + refresh_token: "replacement-refresh-token", + }) + : Response.json({}) + ); + + expect((await request()).status).toBe(200); + expect(getAuthConfig()).toMatchObject({ + token: "refreshed-token", + refreshToken: "replacement-refresh-token", + }); + expect(requests).toEqual([ + { url: "https://sentry.io/oauth/token/", authorization: null }, + { url: RESOURCE_URL, authorization: "Bearer refreshed-token" }, + ]); + }); + + test("rejects malformed proactive refresh before storing or using the token", async () => { + process.env.SENTRY_CLIENT_ID = "synthetic-client-id"; + setAuthToken("expired-token", -1, "synthetic-refresh-token"); + globalThis.fetch = mockResponses(() => + Response.json({ + access_token: "opaque-\0-secret-tail", + token_type: "bearer", + expires_in: 3600, + refresh_token: "replacement-refresh-token", + }) + ); + + await expect(request()).rejects.toMatchObject({ + reason: "invalid", + exitCode: EXIT.AUTH_INVALID, + }); + expect(getAuthConfig()).toMatchObject({ + token: "expired-token", + refreshToken: "synthetic-refresh-token", + }); + expect(requests).toEqual([ + { url: "https://sentry.io/oauth/token/", authorization: null }, + ]); + }); +}); diff --git a/packages/cli/test/lib/sentry-client.invalidation.test.ts b/packages/cli/test/lib/sentry-client.invalidation.test.ts new file mode 100644 index 000000000..342bbad69 --- /dev/null +++ b/packages/cli/test/lib/sentry-client.invalidation.test.ts @@ -0,0 +1,162 @@ +/** + * Integration tests for the HTTP-layer auto-invalidation hook. + * + * The hook lives in `sentry-client.ts` (`invalidateAfterMutation`) + * and fires after every successful non-GET at the + * `authenticatedFetch` seam. Prefix computation is delegated to + * `computeInvalidationPrefixes`; this file verifies the end-to-end + * behavior through the real response cache. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + getCachedResponse, + storeCachedResponse, +} from "../../src/lib/response-cache.js"; +import { + getSdkConfig, + resetAuthenticatedFetch, +} from "../../src/lib/sentry-client.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("invalidation-"); + +function makeResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +let originalFetch: typeof globalThis.fetch; +type FetchHandler = ( + input: Request | string | URL, + init?: RequestInit +) => Promise; + +beforeEach(() => { + originalFetch = globalThis.fetch; + resetAuthenticatedFetch(); + setAuthToken("test-token", 3600, "test-refresh"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); +}); + +/** Swap `globalThis.fetch` with a deterministic handler for one test. */ +function installMockFetch(handler: FetchHandler): void { + globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => + handler(input, init)) as typeof fetch; +} + +/** + * Run the authenticated fetch. Works because `sentry-client.ts` calls + * `fetch(...)` as a bare global reference (see `fetchWithTimeout`), so + * swapping `globalThis.fetch` per-test is observable. + */ +function runAuthenticatedFetch(url: string, method = "GET"): Promise { + return getSdkConfig("https://us.sentry.io").fetch(url, { method }); +} + +const BASE = "https://us.sentry.io/api/0/"; +const DETAIL_URL = `${BASE}organizations/acme/issues/12345/`; +const LIST_URL = `${BASE}organizations/acme/issues/`; + +describe("HTTP-layer auto-invalidation", () => { + test("successful non-GET clears cached detail + list entries", async () => { + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ id: "12345" }) + ); + await storeCachedResponse( + "GET", + `${LIST_URL}?cursor=abc`, + {}, + makeResponse({ data: [] }) + ); + + installMockFetch(async (input, init) => { + expect(init?.method).toBe("PUT"); + expect(String(input)).toBe(DETAIL_URL); + return makeResponse({ id: "12345", status: "resolved" }); + }); + const response = await runAuthenticatedFetch(DETAIL_URL, "PUT"); + expect(response.ok).toBe(true); + + // Invalidation is awaited inside the hook, so the cache is + // already cleared when the caller sees the response. + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeUndefined(); + expect( + await getCachedResponse("GET", `${LIST_URL}?cursor=abc`, {}) + ).toBeUndefined(); + }); + + test("failed non-GET does NOT invalidate the cache", async () => { + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ id: "12345" }) + ); + + installMockFetch(async () => makeResponse({ error: "denied" }, 403)); + const response = await runAuthenticatedFetch(DETAIL_URL, "PUT"); + expect(response.status).toBe(403); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + }); + + test("GET does NOT invalidate the cache", async () => { + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ id: "12345" }) + ); + + installMockFetch(async () => makeResponse({ id: "99999" })); + await runAuthenticatedFetch( + `${BASE}organizations/acme/issues/99999/`, + "GET" + ); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + }); + + test("cross-endpoint rule fires for project delete", async () => { + const orgListUrl = `${BASE}organizations/acme/projects/`; + await storeCachedResponse( + "GET", + `${orgListUrl}?cursor=xyz`, + {}, + makeResponse({ data: [] }) + ); + + installMockFetch(async () => new Response(null, { status: 204 })); + await runAuthenticatedFetch(`${BASE}projects/acme/frontend/`, "DELETE"); + + expect( + await getCachedResponse("GET", `${orgListUrl}?cursor=xyz`, {}) + ).toBeUndefined(); + }); + + test("another identity's cache survives a mutation", async () => { + setAuthToken("identity-a", 3600, "refresh-a"); + await storeCachedResponse( + "GET", + DETAIL_URL, + {}, + makeResponse({ owner: "a" }) + ); + + setAuthToken("identity-b", 3600, "refresh-b"); + installMockFetch(async () => makeResponse({}, 200)); + await runAuthenticatedFetch(DETAIL_URL, "PUT"); + + setAuthToken("identity-a", 3600, "refresh-a"); + expect(await getCachedResponse("GET", DETAIL_URL, {})).toBeDefined(); + }); +}); diff --git a/packages/cli/test/lib/sentry-client.test.ts b/packages/cli/test/lib/sentry-client.test.ts new file mode 100644 index 000000000..2caaf9e33 --- /dev/null +++ b/packages/cli/test/lib/sentry-client.test.ts @@ -0,0 +1,355 @@ +/** + * Tests for the authenticated fetch retry + timeout behavior — CLI-1D6 + * regression coverage. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { TimeoutError } from "../../src/lib/errors.js"; +import { + __injectTimeoutOverrideForTests, + __resolveRequestTimeoutMsForTests, + getSdkConfig, + resetAuthenticatedFetch, +} from "../../src/lib/sentry-client.js"; +import { mockFetch, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("sentry-client-"); + +let originalFetch: typeof globalThis.fetch; +const REGION_URL = "https://us.sentry.io"; + +beforeEach(async () => { + originalFetch = globalThis.fetch; + // Non-expiring token — refreshToken() becomes a no-op. + await setAuthToken("test-token"); + resetAuthenticatedFetch(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); +}); + +function getAuthenticatedFetch(): typeof fetch { + return getSdkConfig(REGION_URL).fetch as typeof fetch; +} + +describe("fetchWithRetry / buildAttemptFactory", () => { + test("retries a POST with a string body without re-consuming the body", async () => { + const marker = "__test_string_body__"; + const seen: string[] = []; + let callCount = 0; + + globalThis.fetch = mockFetch(async (_input, init) => { + callCount += 1; + const body = init?.body; + if (typeof body === "string") { + seen.push(body); + } else if (body) { + seen.push(await new Response(body as BodyInit).text()); + } else { + seen.push(""); + } + if (callCount === 1) { + return new Response("busy", { status: 503 }); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const authFetch = getAuthenticatedFetch(); + const res = await authFetch( + `${REGION_URL}/api/0/${marker}/organizations/`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug: "acme" }), + } + ); + + expect(res.status).toBe(200); + expect(callCount).toBe(2); + expect(seen).toEqual([ + JSON.stringify({ slug: "acme" }), + JSON.stringify({ slug: "acme" }), + ]); + }); + + test("retries a POST built from a Request object without consuming its body", async () => { + // SDK path: @sentry/api hands us a Request as the sole argument. + // Pre-fix: attempt 2 saw a consumed body stream. + const marker = "__test_request_body__"; + let callCount = 0; + const seen: string[] = []; + + globalThis.fetch = mockFetch(async (input) => { + callCount += 1; + if (input instanceof Request) { + seen.push(await input.clone().text()); + } else { + seen.push(""); + } + if (callCount === 1) { + return new Response("gateway", { status: 502 }); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const payload = JSON.stringify({ stopping_point: "root_cause" }); + const request = new Request(`${REGION_URL}/api/0/${marker}/autofix/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + }); + + const authFetch = getAuthenticatedFetch(); + const res = await authFetch(request); + + expect(res.status).toBe(200); + expect(callCount).toBe(2); + expect(seen).toEqual([payload, payload]); + }); + + test("retries with a ReadableStream body by materializing once", async () => { + // Streams are consumed on first read; without up-front materialization + // attempt 2 would see an undefined body. + const marker = "__test_stream_body__"; + let callCount = 0; + const seen: string[] = []; + + globalThis.fetch = mockFetch(async (_input, init) => { + callCount += 1; + const body = init?.body; + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + seen.push(new TextDecoder().decode(body as ArrayBuffer)); + } else if (typeof body === "string") { + seen.push(body); + } else if (body) { + seen.push(await new Response(body as BodyInit).text()); + } else { + seen.push(""); + } + if (callCount === 1) { + return new Response("", { status: 500 }); + } + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("streamed-body")); + controller.close(); + }, + }); + + const authFetch = getAuthenticatedFetch(); + // Node's fetch requires duplex: "half" for streamed bodies; Bun accepts + // it as a no-op. + const res = await authFetch(`${REGION_URL}/api/0/${marker}/streamed/`, { + method: "POST", + body: stream, + duplex: "half", + } as RequestInit & { duplex?: string }); + + expect(res.status).toBe(200); + expect(callCount).toBe(2); + expect(seen).toEqual(["streamed-body", "streamed-body"]); + }); + + test("retries a FormData body without losing the multipart boundary", async () => { + // Regression for a Cursor Bugbot finding: materializing FormData to + // an ArrayBuffer drops the auto-negotiated + // `Content-Type: multipart/form-data; boundary=...` header that + // `fetch` derives from the FormData body. Sourcemap chunk upload + // (src/lib/api/sourcemaps.ts) sends FormData through this path; + // without correct handling even the first upload attempt fails. + const marker = "__test_formdata_body__"; + let callCount = 0; + const contentTypes: (string | null)[] = []; + const bodies: string[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + callCount += 1; + const req = new Request(input as string, init); + contentTypes.push(req.headers.get("content-type")); + bodies.push(await req.text()); + if (callCount === 1) { + return new Response("retry me", { status: 503 }); + } + return new Response("", { status: 200 }); + }); + + const form = new FormData(); + form.append( + "file", + new Blob([new Uint8Array([1, 2, 3, 4])], { + type: "application/octet-stream", + }), + "chunk.bin" + ); + + const authFetch = getAuthenticatedFetch(); + const res = await authFetch(`${REGION_URL}/api/0/${marker}/chunks/`, { + method: "POST", + body: form, + }); + + expect(res.status).toBe(200); + expect(callCount).toBe(2); + // Both attempts must carry a well-formed multipart Content-Type. + // Bun picks a fresh boundary per serialization, so the two + // headers differ — the invariant is that each attempt's + // header+body is internally consistent, and that + // Content-Type is never lost to a raw `application/octet-stream` + // or missing header (the pre-fix failure mode). + for (const ct of contentTypes) { + expect(ct).toMatch(/^multipart\/form-data; boundary=.+/u); + } + // And both attempts carried the same FormData contents. + for (const body of bodies) { + expect(body).toContain('name="file"'); + expect(body).toContain("chunk.bin"); + } + }); +}); + +describe("fetchWithTimeout internal timeout classification", () => { + test("surfaces TimeoutError on the last attempt when our own timeout fires", async () => { + // Inject a tiny timeout so we don't wait 30 s for the default to fire. + const marker = "___timeout-test___"; + const restore = __injectTimeoutOverrideForTests({ + pattern: new RegExp(`/${marker}/`), + timeoutMs: 50, + }); + + try { + let calls = 0; + globalThis.fetch = mockFetch(async (_input, init) => { + calls += 1; + return await new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error("no signal provided — test bug")); + return; + } + if (signal.aborted) { + reject(new DOMException("aborted", "AbortError")); + return; + } + signal.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true } + ); + }); + }); + + const authFetch = getAuthenticatedFetch(); + let thrown: unknown; + try { + await authFetch(`${REGION_URL}/${marker}/`); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(TimeoutError); + expect((thrown as TimeoutError).message).toContain( + "Request timed out after" + ); + // MAX_RETRIES = 2 → attempts 0, 1, 2. + expect(calls).toBe(3); + } finally { + restore(); + } + }); +}); + +describe("user abort passthrough", () => { + test("external AbortSignal.abort() propagates the original AbortError", async () => { + const marker = "__test_user_abort__"; + let fetchCalled = false; + globalThis.fetch = mockFetch(async (_input, init) => { + fetchCalled = true; + return await new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(new DOMException("aborted", "AbortError")); + return; + } + signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true } + ); + }); + }); + + const controller = new AbortController(); + const authFetch = getAuthenticatedFetch(); + const promise = authFetch(`${REGION_URL}/api/0/${marker}/organizations/`, { + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 10); + + let thrown: unknown; + try { + await promise; + } catch (err) { + thrown = err; + } + + expect(fetchCalled).toBe(true); + // User aborts must propagate unchanged — neither a TimeoutError nor an + // ApiError wrapper. The central error handler in app.ts depends on it. + expect(thrown).toBeInstanceOf(DOMException); + expect((thrown as DOMException).name).toBe("AbortError"); + }); +}); + +describe("resolveTimeoutMs", () => { + test("returns 120 000 ms for Seer /autofix/ POST paths", () => { + expect( + __resolveRequestTimeoutMsForTests( + "https://us.sentry.io/api/0/organizations/acme/issues/1/autofix/" + ) + ).toBe(120_000); + expect( + __resolveRequestTimeoutMsForTests( + "https://us.sentry.io/api/0/organizations/acme/issues/1/autofix/?run_id=42" + ) + ).toBe(120_000); + expect( + __resolveRequestTimeoutMsForTests( + "https://us.sentry.io/api/0/organizations/acme/issues/1/autofix" + ) + ).toBe(120_000); + }); + + test("returns the 30 000 ms default for non-overridden paths", () => { + expect( + __resolveRequestTimeoutMsForTests( + "https://us.sentry.io/api/0/organizations/" + ) + ).toBe(30_000); + expect( + __resolveRequestTimeoutMsForTests( + "https://us.sentry.io/api/0/issues/1/events/" + ) + ).toBe(30_000); + // Substring-only matches must not inherit the override. + expect( + __resolveRequestTimeoutMsForTests( + "https://us.sentry.io/api/0/autofixation-report/" + ) + ).toBe(30_000); + }); +}); diff --git a/packages/cli/test/lib/sentry-url-parser.property.test.ts b/packages/cli/test/lib/sentry-url-parser.property.test.ts new file mode 100644 index 000000000..3f1198917 --- /dev/null +++ b/packages/cli/test/lib/sentry-url-parser.property.test.ts @@ -0,0 +1,170 @@ +/** + * Property-Based Tests for Sentry URL Parser + * + * Round-trip tests: verifies that URLs built by sentry-urls.ts builders + * can be correctly parsed back by parseSentryUrl(). + */ + +import { + assert as fcAssert, + property, + stringMatching, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { parseSentryUrl } from "../../src/lib/sentry-url-parser.js"; +import { + buildOrgUrl, + buildProjectUrl, + buildReplayUrl, + buildTraceUrl, +} from "../../src/lib/sentry-urls.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** + * Generates valid org slugs (lowercase, alphanumeric with hyphens). + * + * Excludes `xn--` prefixes (punycode-encoded IDN labels) because the URL + * constructor silently decodes them, collapsing `xn--XX.sentry.io` into + * `sentry.io` and dropping the org subdomain. + */ +const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/).filter( + (s) => !s.startsWith("xn--") +); + +/** Generates valid project slugs (lowercase, alphanumeric with hyphens) */ +const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/).filter( + (s) => !s.startsWith("xn--") +); + +/** Generates valid 32-character hex trace IDs */ +const traceIdArb = stringMatching(/^[0-9a-f]{32}$/); + +/** Generates valid 32-character hex replay IDs */ +const replayIdArb = stringMatching(/^[0-9a-f]{32}$/); + +/** Generates numeric issue IDs */ +const numericIdArb = stringMatching(/^[1-9][0-9]{0,10}$/); + +/** Generates hex-like event IDs (32 chars) */ +const eventIdArb = stringMatching(/^[0-9a-f]{32}$/); + +describe("parseSentryUrl round-trip properties", () => { + test("buildOrgUrl → parseSentryUrl extracts org", async () => { + await fcAssert( + property(orgSlugArb, (org) => { + const url = buildOrgUrl(org); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBeUndefined(); + expect(parsed?.project).toBeUndefined(); + expect(parsed?.traceId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buildProjectUrl → parseSentryUrl extracts org and project", async () => { + await fcAssert( + property(tuple(orgSlugArb, projectSlugArb), ([org, project]) => { + const url = buildProjectUrl(org, project); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.project).toBe(project); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buildTraceUrl → parseSentryUrl extracts org and traceId", async () => { + await fcAssert( + property(tuple(orgSlugArb, traceIdArb), ([org, traceId]) => { + const url = buildTraceUrl(org, traceId); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.traceId).toBe(traceId); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buildReplayUrl → parseSentryUrl extracts org and replayId", async () => { + await fcAssert( + property(tuple(orgSlugArb, replayIdArb), ([org, replayId]) => { + const url = buildReplayUrl(org, replayId); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.replayId).toBe(replayId); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("issue URL round-trip: org and numeric issueId extracted", async () => { + await fcAssert( + property(tuple(orgSlugArb, numericIdArb), ([org, issueId]) => { + // Construct the URL pattern that Sentry uses for issues + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBe(issueId); + expect(parsed?.eventId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("event URL round-trip: org, issueId, and eventId extracted", async () => { + await fcAssert( + property( + tuple(orgSlugArb, numericIdArb, eventIdArb), + ([org, issueId, eventId]) => { + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/events/${eventId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBe(issueId); + expect(parsed?.eventId).toBe(eventId); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-URL strings always return null", async () => { + // Org slugs alone should never parse as URLs + await fcAssert( + property(orgSlugArb, (org) => { + expect(parseSentryUrl(org)).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("baseUrl always contains scheme and host", async () => { + await fcAssert( + property(tuple(orgSlugArb, numericIdArb), ([org, issueId]) => { + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.baseUrl).toMatch(/^https?:\/\/.+/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/sentry-url-parser.test.ts b/packages/cli/test/lib/sentry-url-parser.test.ts new file mode 100644 index 000000000..299582cfd --- /dev/null +++ b/packages/cli/test/lib/sentry-url-parser.test.ts @@ -0,0 +1,753 @@ +/** + * Sentry URL Parser Tests + * + * Unit tests for parseSentryUrl() and applySentryUrlContext(). + * Uses fictional domains (sentry.example.com, sentry.acme.internal) + * — never real customer data. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../src/lib/sentry-url-parser.js"; + +describe("parseSentryUrl", () => { + describe("non-URL inputs return null", () => { + test("plain text", () => { + expect(parseSentryUrl("sentry/cli-G")).toBeNull(); + }); + + test("numeric ID", () => { + expect(parseSentryUrl("12345")).toBeNull(); + }); + + test("short ID", () => { + expect(parseSentryUrl("CLI-4Y")).toBeNull(); + }); + + test("org/project format", () => { + expect(parseSentryUrl("my-org/my-project")).toBeNull(); + }); + + test("empty string", () => { + expect(parseSentryUrl("")).toBeNull(); + }); + + test("malformed URL", () => { + expect(parseSentryUrl("http://")).toBeNull(); + }); + }); + + describe("organization URLs", () => { + test("SaaS /organizations/{org}/", () => { + const result = parseSentryUrl("https://sentry.io/organizations/my-org/"); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + + test("self-hosted /organizations/{org}/", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + }); + }); + + test("self-hosted Feedback permalink supports legacy mixed-case project slugs", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/feedback/?feedbackSlug=Legacy_Project%3A5146636313" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + project: "Legacy_Project", + issueId: "5146636313", + }); + }); + + test("strips trailing path segments after org", () => { + // /organizations/{org}/ with no further recognized segments → org only + const result = parseSentryUrl("https://sentry.io/organizations/my-org/"); + expect(result?.org).toBe("my-org"); + expect(result?.issueId).toBeUndefined(); + }); + }); + + describe("issue URLs", () => { + test("/organizations/{org}/issues/{numericId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/32886/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "32886", + }); + }); + + test("/organizations/{org}/issues/{shortId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/CLI-G/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "CLI-G", + }); + }); + + test("self-hosted issue URL with query params", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/issues/32886/?project=2" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + issueId: "32886", + }); + }); + + test("self-hosted issue URL with port", () => { + const result = parseSentryUrl( + "https://sentry.acme.internal:9000/organizations/devops/issues/100/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.acme.internal:9000", + org: "devops", + issueId: "100", + }); + }); + + test("HTTP (non-HTTPS) self-hosted URL", () => { + const result = parseSentryUrl( + "http://sentry.local:8080/organizations/dev/issues/42/" + ); + expect(result).toEqual({ + baseUrl: "http://sentry.local:8080", + org: "dev", + issueId: "42", + }); + }); + }); + + describe("event URLs", () => { + test("/organizations/{org}/issues/{id}/events/{eventId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/32886/events/abc123def456/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "32886", + eventId: "abc123def456", + }); + }); + + test("self-hosted event URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme/issues/999/events/deadbeef/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme", + issueId: "999", + eventId: "deadbeef", + }); + }); + + test("event URL without trailing slash", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/1/events/evt001" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "1", + eventId: "evt001", + }); + }); + }); + + describe("trace URLs", () => { + test("/organizations/{org}/explore/traces/trace/{traceId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/traces/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + + test("org-domain /explore/traces/trace/{traceId}/", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/traces/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + + test("/organizations/{org}/traces/{traceId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/traces/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + + test("self-hosted trace URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/devops/traces/00112233445566778899aabbccddeeff/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "devops", + traceId: "00112233445566778899aabbccddeeff", + }); + }); + + test("explore/traces without the trace segment is not a detail", () => { + // `explore/traces/{slug}/` (no `trace/` segment) is the list route or an + // unrelated sub-route — the slug must not be captured as a trace ID. It + // resolves to the org (like the replay list), not a trace detail. + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/traces/some-subroute/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); + + test("subdomain trace list resolves to the org", () => { + const result = parseSentryUrl("https://my-org.sentry.io/explore/traces/"); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); + + test("organizations explore/traces without trace segment is org-only", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/traces/some-subroute/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + }); + + describe("replay URLs", () => { + test("/organizations/{org}/explore/replays/{replayId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("legacy /organizations/{org}/replays/{replayId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("self-hosted replay URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("normalizes uppercase replay IDs in replay URLs", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/346789A703F6454384F1DE473B8B9FCC/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("falls back to org for replay listing URL", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + + test("falls back to org for legacy replay listing URL", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/replays/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + + test("rejects non-hex replay ID on explore path", () => { + expect( + parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/some-random-page/" + ) + ).toBeNull(); + }); + + test("rejects non-hex replay ID on legacy path", () => { + expect( + parseSentryUrl( + "https://sentry.io/organizations/my-org/replays/some-random-page/" + ) + ).toBeNull(); + }); + + test("rejects non-hex replay ID on subdomain explore path", () => { + expect( + parseSentryUrl( + "https://my-org.sentry.io/explore/replays/some-random-page/" + ) + ).toBeNull(); + }); + + test("falls back to org for subdomain replay listing URL", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/replays/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); + }); + + describe("dashboard URLs", () => { + test("/organizations/{org}/dashboard/{id}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/dashboard/4326879/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + dashboardId: "4326879", + }); + }); + + test("self-hosted dashboard URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/devops/dashboard/12345/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "devops", + dashboardId: "12345", + }); + }); + + test("dashboard URL without trailing slash", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/dashboard/999" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + dashboardId: "999", + }); + }); + }); + + describe("project settings URLs", () => { + test("/settings/{org}/projects/{project}/", () => { + const result = parseSentryUrl( + "https://sentry.io/settings/my-org/projects/backend/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + project: "backend", + }); + }); + + test("self-hosted project settings URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/settings/acme/projects/web-frontend/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme", + project: "web-frontend", + }); + }); + }); + + describe("SaaS subdomain-style URLs (org in hostname)", () => { + test("issue URL extracts org from subdomain", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/issues/99124558/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + issueId: "99124558", + }); + }); + + test("issue URL with event ID", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/issues/99124558/events/abc123/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + issueId: "99124558", + eventId: "abc123", + }); + }); + + test("trace URL extracts org from subdomain", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/traces/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + + test("replay URL extracts org from subdomain", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("legacy replay URL extracts org from subdomain", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("dashboard URL extracts org from subdomain", () => { + const result = parseSentryUrl( + "https://sentry-sdks.sentry.io/dashboard/4326879/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry-sdks.sentry.io", + org: "sentry-sdks", + dashboardId: "4326879", + }); + }); + + test("bare org subdomain returns org only", () => { + const result = parseSentryUrl("https://my-org.sentry.io/"); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); + + test("modern Feedback permalink returns its org", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/feedback/?feedbackSlug=my-project%3A5146636313" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + project: "my-project", + issueId: "5146636313", + }); + }); + + test.each([ + "my-project%3Aa%3A5146636313", + "my-project%3Aabc%2Fdef", + "my-project%250A%3A5146636313", + ])("does not extract malformed Feedback slug %s", (feedbackSlug) => { + const result = parseSentryUrl( + `https://my-org.sentry.io/feedback/?feedbackSlug=${feedbackSlug}` + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); + + test("hyphenated org slug", () => { + const result = parseSentryUrl( + "https://acme-corp.sentry.io/issues/12345/" + ); + expect(result).toEqual({ + baseUrl: "https://acme-corp.sentry.io", + org: "acme-corp", + issueId: "12345", + }); + }); + + test("region subdomains are ignored (us.sentry.io)", () => { + // Region hosts don't have org in subdomain — return null for unknown paths + expect(parseSentryUrl("https://us.sentry.io/issues/123/")).toBeNull(); + }); + + test("region subdomains are ignored (de.sentry.io)", () => { + expect(parseSentryUrl("https://de.sentry.io/issues/123/")).toBeNull(); + }); + + test("unknown subdomain path returns null", () => { + expect(parseSentryUrl("https://my-org.sentry.io/auth/login/")).toBeNull(); + }); + + test("self-hosted URLs are not matched as subdomain orgs", () => { + // Self-hosted hostname doesn't end with .sentry.io — subdomain extraction must not apply. + // The path /issues/123/ has no /organizations/ prefix, so no matcher handles it. + expect( + parseSentryUrl("https://sentry.example.com/issues/123/") + ).toBeNull(); + expect( + parseSentryUrl("https://sentry.acme.internal:9000/issues/456/") + ).toBeNull(); + }); + }); + + describe("share URLs", () => { + test("SaaS subdomain share URL extracts org and shareId", () => { + const result = parseSentryUrl( + "https://gibush-kq.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ); + expect(result).toEqual({ + baseUrl: "https://gibush-kq.sentry.io", + org: "gibush-kq", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("bare sentry.io share URL has no org", () => { + const result = parseSentryUrl( + "https://sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("self-hosted share URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/share/issue/aabbccdd11223344aabbccdd11223344/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + shareId: "aabbccdd11223344aabbccdd11223344", + }); + }); + + test("self-hosted share URL with port", () => { + const result = parseSentryUrl( + "https://sentry.acme.internal:9000/share/issue/deadbeefdeadbeefdeadbeefdeadbeef/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.acme.internal:9000", + shareId: "deadbeefdeadbeefdeadbeefdeadbeef", + }); + }); + + test("region subdomain share URL has no org", () => { + // us.sentry.io is a region, not an org — share URL falls through to matchSharePath + const result = parseSentryUrl( + "https://us.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ); + expect(result).toEqual({ + baseUrl: "https://us.sentry.io", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("share URL without trailing slash", () => { + const result = parseSentryUrl( + "https://sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("/share/ without issue segment returns null", () => { + expect(parseSentryUrl("https://sentry.io/share/")).toBeNull(); + }); + + test("/share/issue/ without shareId returns null", () => { + expect(parseSentryUrl("https://sentry.io/share/issue/")).toBeNull(); + }); + }); + + describe("unrecognized paths return null", () => { + test("root URL", () => { + expect(parseSentryUrl("https://sentry.io/")).toBeNull(); + }); + + test("unknown path", () => { + expect(parseSentryUrl("https://sentry.io/auth/login/")).toBeNull(); + }); + + test("/settings without project segment", () => { + expect(parseSentryUrl("https://sentry.io/settings/my-org/")).toBeNull(); + }); + + test("/settings/{org}/projects/ without project slug", () => { + expect( + parseSentryUrl("https://sentry.io/settings/my-org/projects/") + ).toBeNull(); + }); + }); +}); + +describe("applySentryUrlContext", () => { + // Host-scoping: applySentryUrlContext honors non-SaaS URLs ONLY when the + // destination matches the active token's scoped host (with SaaS + // equivalence). Mismatches throw CliError so credentials can't leak to an + // attacker-chosen host. + // + // The test preload (test/preload.ts) sets `SENTRY_AUTH_TOKEN` scoped to + // SaaS by default. To simulate a self-hosted-authenticated user, set + // `SENTRY_HOST` BEFORE the module loads (which pins the env-token's scope) + // and use `resetEnvTokenHostForTesting()` between cases. + let originalSentryUrl: string | undefined; + let originalSentryHost: string | undefined; + + beforeEach(async () => { + originalSentryUrl = process.env.SENTRY_URL; + originalSentryHost = process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + delete process.env.SENTRY_HOST; + // Reset env-token-host capture so each test can re-pin based on the + // SENTRY_HOST they set (or leave unset → SaaS default). + const { resetEnvTokenHostForTesting } = await import( + "../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + }); + + afterEach(async () => { + if (originalSentryUrl !== undefined) { + process.env.SENTRY_URL = originalSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + if (originalSentryHost !== undefined) { + process.env.SENTRY_HOST = originalSentryHost; + } else { + delete process.env.SENTRY_HOST; + } + const { resetEnvTokenHostForTesting } = await import( + "../../src/lib/env-token-host.js" + ); + resetEnvTokenHostForTesting(); + }); + + test("writes env when non-SaaS URL matches token-scoped host", () => { + // Pin env-token to the self-hosted instance via SENTRY_HOST before + // calling captureEnvTokenHost() (implicit on first getEnvTokenHost call). + process.env.SENTRY_HOST = "https://sentry.example.com"; + applySentryUrlContext("https://sentry.example.com"); + expect(process.env.SENTRY_HOST).toBe("https://sentry.example.com"); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); + }); + + test("throws CliError for non-SaaS URL that does not match token host", () => { + // Env-token defaults to SaaS (no SENTRY_HOST set), so a self-hosted URL + // is a host-scope mismatch → CliError, env untouched. + expect(() => applySentryUrlContext("https://sentry.example.com")).toThrow( + /does not match|sentry auth login --url/ + ); + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("does not set SENTRY_HOST or SENTRY_URL for SaaS (sentry.io)", () => { + applySentryUrlContext("https://sentry.io"); + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("does not set SENTRY_HOST or SENTRY_URL for SaaS subdomain (us.sentry.io)", () => { + applySentryUrlContext("https://us.sentry.io"); + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("throws on mismatch even when SENTRY_HOST is pre-set to a different host", () => { + // Token scoped to existing.example.com; URL-arg points at sentry.other.com. + // Primary guard refuses to re-scope by writing the new host — only + // `sentry auth login --url` may change scope. + process.env.SENTRY_HOST = "https://existing.example.com"; + process.env.SENTRY_URL = "https://existing.example.com"; + expect(() => applySentryUrlContext("https://sentry.other.com")).toThrow( + /does not match|sentry auth login --url/ + ); + // Existing env left intact — throw happens before any write. + expect(process.env.SENTRY_HOST).toBe("https://existing.example.com"); + expect(process.env.SENTRY_URL).toBe("https://existing.example.com"); + }); + + test("writes env for self-hosted URL with port when it matches token host", () => { + process.env.SENTRY_HOST = "https://sentry.acme.internal:9000"; + applySentryUrlContext("https://sentry.acme.internal:9000"); + expect(process.env.SENTRY_HOST).toBe("https://sentry.acme.internal:9000"); + expect(process.env.SENTRY_URL).toBe("https://sentry.acme.internal:9000"); + }); + + test("clears both env vars when SaaS URL is detected", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + process.env.SENTRY_URL = "https://sentry.example.com"; + applySentryUrlContext("https://sentry.io"); + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("clears both env vars when SaaS subdomain is detected", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + process.env.SENTRY_URL = "https://sentry.example.com"; + applySentryUrlContext("https://us.sentry.io"); + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/sentry-urls.property.test.ts b/packages/cli/test/lib/sentry-urls.property.test.ts new file mode 100644 index 000000000..ec7bb8d81 --- /dev/null +++ b/packages/cli/test/lib/sentry-urls.property.test.ts @@ -0,0 +1,792 @@ +/** + * Property-Based Tests for Sentry URL Utilities + * + * Uses fast-check to verify invariants of URL validation and building + * functions that are difficult to exhaustively test with example-based tests. + */ + +import { + constantFrom, + assert as fcAssert, + oneof, + property, + stringMatching, + tuple, +} from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + buildBillingUrl, + buildDashboardsListUrl, + buildDashboardUrl, + buildEventSearchUrl, + buildLogsUrl, + buildOrgSettingsUrl, + buildOrgUrl, + buildProjectIssuesUrl, + buildProjectUrl, + buildSeerSettingsUrl, + buildTraceUrl, + getOrgBaseUrl, + isSentrySaasUrl, + parseOrgProjectFromSettingsUrl, +} from "../../src/lib/sentry-urls.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Save original env +let originalSentryUrl: string | undefined; +let originalSentryHost: string | undefined; + +beforeEach(() => { + originalSentryUrl = process.env.SENTRY_URL; + originalSentryHost = process.env.SENTRY_HOST; + // Clear SENTRY_HOST/SENTRY_URL for consistent base URL in tests + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; +}); + +afterEach(() => { + if (originalSentryUrl !== undefined) { + process.env.SENTRY_URL = originalSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + if (originalSentryHost !== undefined) { + process.env.SENTRY_HOST = originalSentryHost; + } else { + delete process.env.SENTRY_HOST; + } +}); + +// Arbitraries + +/** Valid subdomain parts (lowercase alphanumeric with hyphens, not starting/ending with hyphen) */ +const subdomainPartArb = stringMatching( + /^[a-z][a-z0-9-]{0,10}[a-z0-9]$/ +).filter((s) => !s.includes("--") && s.length >= 2); + +/** Valid org/project slugs (exclude xn-- punycode prefix — invalid IDN breaks URL parser) */ +const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/).filter( + (s) => !s.startsWith("xn--") +); + +/** Valid event IDs (32-char hex) */ +const eventIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** Valid log IDs (32-char hex, same format as event IDs) */ +const logIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** Valid trace IDs (32-char hex) */ +const traceIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** Valid dashboard IDs (numeric strings) */ +const dashboardIdArb = stringMatching(/^[1-9][0-9]{0,8}$/); + +/** Valid project IDs (numeric strings, same shape as other Sentry IDs) */ +const projectIdArb = stringMatching(/^[1-9][0-9]{0,8}$/); + +/** Common Sentry regions */ +const sentryRegionArb = constantFrom("us", "de", "eu", "staging"); + +/** Arbitrary domain that is NOT sentry.io */ +const nonSentryDomainArb = oneof( + constantFrom( + "example.com", + "localhost", + "evil.com", + "sentry-fake.com", + "notsentry.io", + "sentry.io.evil.com", + "example.sentry.io.evil.com" + ), + // Generate random domains + tuple(subdomainPartArb, constantFrom(".com", ".org", ".net", ".io")).map( + ([sub, tld]) => `${sub}${tld}` + ) +).filter((domain) => { + // Ensure it's not actually sentry.io or a subdomain + return domain !== "sentry.io" && !domain.endsWith(".sentry.io"); +}); + +/** Hash fragments for URLs */ +const hashArb = stringMatching(/^[a-zA-Z][a-zA-Z0-9-]{0,20}$/); + +/** Product names for billing URLs */ +const productArb = constantFrom("seer", "errors", "performance", "replays"); + +describe("isSentrySaasUrl properties", () => { + test("sentry.io always returns true", () => { + expect(isSentrySaasUrl("https://sentry.io")).toBe(true); + expect(isSentrySaasUrl("http://sentry.io")).toBe(true); + expect(isSentrySaasUrl("https://sentry.io/")).toBe(true); + expect(isSentrySaasUrl("https://sentry.io/path")).toBe(true); + }); + + test("*.sentry.io subdomains always return true", async () => { + await fcAssert( + property(sentryRegionArb, (region) => { + const url = `https://${region}.sentry.io`; + expect(isSentrySaasUrl(url)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("arbitrary subdomains of sentry.io return true", async () => { + await fcAssert( + property(subdomainPartArb, (subdomain) => { + const url = `https://${subdomain}.sentry.io`; + expect(isSentrySaasUrl(url)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-sentry.io domains always return false", async () => { + await fcAssert( + property(nonSentryDomainArb, (domain) => { + const url = `https://${domain}`; + expect(isSentrySaasUrl(url)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("invalid URLs return false", () => { + const invalidUrls = [ + "not-a-url", + "", + "://sentry.io", + "sentry.io", // no protocol + "http://", // no host + ]; + + for (const url of invalidUrls) { + expect(isSentrySaasUrl(url)).toBe(false); + } + }); + + test("non-HTTP protocols still work for sentry.io domain", () => { + // The function checks hostname, not protocol + // ftp://sentry.io is still a sentry.io domain + expect(isSentrySaasUrl("ftp://sentry.io")).toBe(true); + }); + + test("security: lookalike domains return false", () => { + // These should all return false - they're attempts to spoof sentry.io + const lookalikes = [ + "https://sentry.io.evil.com", + "https://sentry.io-fake.com", + "https://evil.com/sentry.io", + "https://notsentry.io", + "https://sentry.io.example.com", + ]; + + for (const url of lookalikes) { + expect(isSentrySaasUrl(url)).toBe(false); + } + }); + + test("deterministic: same input always produces same output", async () => { + await fcAssert( + property( + oneof( + constantFrom("https://sentry.io", "https://us.sentry.io"), + nonSentryDomainArb.map((d) => `https://${d}`) + ), + (url) => { + const result1 = isSentrySaasUrl(url); + const result2 = isSentrySaasUrl(url); + expect(result1).toBe(result2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildOrgUrl properties", () => { + test("output always starts with org base URL", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildOrgUrl(orgSlug); + expect(result.startsWith(getOrgBaseUrl(orgSlug))).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output always contains the org slug", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildOrgUrl(orgSlug); + expect(result).toContain(orgSlug); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output follows expected pattern", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildOrgUrl(orgSlug); + expect(result).toBe(`${getOrgBaseUrl(orgSlug)}/`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildOrgUrl(orgSlug); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildProjectUrl properties", () => { + test("output contains both org and project slugs", async () => { + await fcAssert( + property(tuple(slugArb, slugArb), ([orgSlug, projectSlug]) => { + const result = buildProjectUrl(orgSlug, projectSlug); + expect(result).toContain(orgSlug); + expect(result).toContain(projectSlug); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output follows expected pattern", async () => { + await fcAssert( + property(tuple(slugArb, slugArb), ([orgSlug, projectSlug]) => { + const result = buildProjectUrl(orgSlug, projectSlug); + expect(result).toBe( + `${getOrgBaseUrl(orgSlug)}/settings/projects/${projectSlug}/` + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, slugArb), ([orgSlug, projectSlug]) => { + const result = buildProjectUrl(orgSlug, projectSlug); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildEventSearchUrl properties", () => { + test("output contains event ID in query string", async () => { + await fcAssert( + property(tuple(slugArb, eventIdArb), ([orgSlug, eventId]) => { + const result = buildEventSearchUrl(orgSlug, eventId); + expect(result).toContain(`event.id:${eventId}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL with query parameter", async () => { + await fcAssert( + property(tuple(slugArb, eventIdArb), ([orgSlug, eventId]) => { + const result = buildEventSearchUrl(orgSlug, eventId); + const url = new URL(result); + expect(url.searchParams.get("query")).toContain(eventId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildProjectIssuesUrl properties", () => { + test("with a project ID, output filters the Issues stream by project", async () => { + await fcAssert( + property(tuple(slugArb, projectIdArb), ([orgSlug, projectId]) => { + const result = buildProjectIssuesUrl(orgSlug, projectId); + expect(result).toBe( + `${getOrgBaseUrl(orgSlug)}/issues/?project=${projectId}` + ); + expect(new URL(result).searchParams.get("project")).toBe(projectId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("without a project ID, output is the org-wide Issues stream", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildProjectIssuesUrl(orgSlug); + expect(result).toBe(`${getOrgBaseUrl(orgSlug)}/issues/`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL that points at the Issues stream", async () => { + await fcAssert( + property(tuple(slugArb, projectIdArb), ([orgSlug, projectId]) => { + const result = buildProjectIssuesUrl(orgSlug, projectId); + expect(() => new URL(result)).not.toThrow(); + expect(new URL(result).pathname).toBe("/issues/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("parseOrgProjectFromSettingsUrl properties", () => { + test("round-trips org and project through a settings URL (SaaS)", async () => { + await fcAssert( + property(tuple(slugArb, slugArb), ([orgSlug, projectSlug]) => { + const url = buildProjectUrl(orgSlug, projectSlug); + expect(parseOrgProjectFromSettingsUrl(url)).toEqual({ + orgSlug, + projectSlug, + }); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("round-trips org and project through a settings URL (self-hosted)", async () => { + process.env.SENTRY_URL = "https://sentry.example.com"; + await fcAssert( + property(tuple(slugArb, slugArb), ([orgSlug, projectSlug]) => { + const url = buildProjectUrl(orgSlug, projectSlug); + expect(parseOrgProjectFromSettingsUrl(url)).toEqual({ + orgSlug, + projectSlug, + }); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty for unparseable input", () => { + expect(parseOrgProjectFromSettingsUrl("not a url")).toEqual({}); + }); +}); + +describe("buildOrgSettingsUrl properties", () => { + test("without hash, output ends with trailing slash", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildOrgSettingsUrl(orgSlug); + expect(result.endsWith("/")).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("with hash, output contains hash fragment", async () => { + await fcAssert( + property(tuple(slugArb, hashArb), ([orgSlug, hash]) => { + const result = buildOrgSettingsUrl(orgSlug, hash); + expect(result).toContain(`#${hash}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("hash is appended at the end", async () => { + await fcAssert( + property(tuple(slugArb, hashArb), ([orgSlug, hash]) => { + const result = buildOrgSettingsUrl(orgSlug, hash); + expect(result.endsWith(`#${hash}`)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildSeerSettingsUrl properties", () => { + test("output contains /seer/ path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildSeerSettingsUrl(orgSlug); + expect(result).toContain("/seer/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is under settings path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildSeerSettingsUrl(orgSlug); + expect(result).toContain("/settings/seer/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildBillingUrl properties", () => { + test("without product, output has no query string", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildBillingUrl(orgSlug); + expect(result.includes("?")).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("with product, output has product query parameter", async () => { + await fcAssert( + property(tuple(slugArb, productArb), ([orgSlug, product]) => { + const result = buildBillingUrl(orgSlug, product); + expect(result).toContain(`?product=${product}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains /billing/overview/ path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildBillingUrl(orgSlug); + expect(result).toContain("/billing/overview/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildLogsUrl properties", () => { + test("without logId, output has no query string", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildLogsUrl(orgSlug); + expect(result.includes("?")).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("with logId, output contains query parameter with log ID", async () => { + await fcAssert( + property(tuple(slugArb, logIdArb), ([orgSlug, logId]) => { + const result = buildLogsUrl(orgSlug, logId); + expect(result).toContain(`?query=sentry.item_id:${logId}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains /explore/logs/ path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildLogsUrl(orgSlug); + expect(result).toContain("/explore/logs/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, logIdArb), ([orgSlug, logId]) => { + expect(() => new URL(buildLogsUrl(orgSlug))).not.toThrow(); + expect(() => new URL(buildLogsUrl(orgSlug, logId))).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildTraceUrl properties", () => { + test("output contains /explore/traces/trace/ path with trace ID", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(result).toContain(`/explore/traces/trace/${traceId}/`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains the org slug", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(result).toContain(orgSlug); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output follows expected pattern", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(result).toBe( + `${getOrgBaseUrl(orgSlug)}/explore/traces/trace/${traceId}/` + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildDashboardsListUrl properties", () => { + test("output contains /dashboards/ path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(result).toContain("/dashboards/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains the org slug", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(result).toContain(orgSlug); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildDashboardUrl properties", () => { + test("output contains /dashboard/{id}/ path", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(result).toContain(`/dashboard/${dashboardId}/`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains org slug and dashboard ID", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(result).toContain(orgSlug); + expect(result).toContain(dashboardId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("SENTRY_HOST precedence", () => { + test("SENTRY_HOST takes precedence over SENTRY_URL for URL builders", () => { + process.env.SENTRY_HOST = "https://host.company.com"; + process.env.SENTRY_URL = "https://url.company.com"; + expect(buildOrgUrl("my-org")).toContain("host.company.com"); + expect(buildOrgUrl("my-org")).not.toContain("url.company.com"); + }); + + test("SENTRY_HOST alone configures self-hosted URL builders", () => { + process.env.SENTRY_HOST = "https://sentry.company.com"; + expect(getOrgBaseUrl("my-org")).toBe("https://sentry.company.com"); + expect(buildOrgUrl("my-org")).toBe( + "https://sentry.company.com/organizations/my-org/" + ); + }); +}); + +describe("self-hosted URLs", () => { + const SELF_HOSTED_URL = "https://sentry.company.com"; + + beforeEach(() => { + process.env.SENTRY_URL = SELF_HOSTED_URL; + }); + + test("getOrgBaseUrl returns base URL without subdomain", () => { + expect(getOrgBaseUrl("my-org")).toBe(SELF_HOSTED_URL); + }); + + test("buildOrgUrl uses path-based pattern", () => { + expect(buildOrgUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/` + ); + }); + + test("buildEventSearchUrl uses path-based pattern", () => { + expect( + buildEventSearchUrl("my-org", "abc123def456abc123def456abc123de") + ).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/issues/?query=event.id:abc123def456abc123def456abc123de` + ); + }); + + test("buildLogsUrl uses path-based pattern", () => { + expect(buildLogsUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/explore/logs/` + ); + }); + + test("buildTraceUrl uses path-based pattern", () => { + expect(buildTraceUrl("my-org", "abc123def456abc123def456abc123de")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/explore/traces/trace/abc123def456abc123def456abc123de/` + ); + }); + + test("buildDashboardsListUrl uses path-based pattern", () => { + expect(buildDashboardsListUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/dashboards/` + ); + }); + + test("buildDashboardUrl uses path-based pattern", () => { + expect(buildDashboardUrl("my-org", "42")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/dashboard/42/` + ); + }); + + test("buildProjectUrl uses path-based pattern", () => { + expect(buildProjectUrl("my-org", "my-project")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/projects/my-project/` + ); + }); + + test("buildOrgSettingsUrl uses path-based pattern", () => { + expect(buildOrgSettingsUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/` + ); + }); + + test("buildSeerSettingsUrl uses path-based pattern", () => { + expect(buildSeerSettingsUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/seer/` + ); + }); + + test("buildBillingUrl uses path-based pattern", () => { + expect(buildBillingUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/settings/my-org/billing/overview/` + ); + }); + + test("no URL builder prepends org as subdomain", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, eventIdArb), + ([orgSlug, projectSlug, eventId]) => { + const urls = [ + buildOrgUrl(orgSlug), + buildProjectUrl(orgSlug, projectSlug), + buildEventSearchUrl(orgSlug, eventId), + buildOrgSettingsUrl(orgSlug), + buildSeerSettingsUrl(orgSlug), + buildBillingUrl(orgSlug), + buildLogsUrl(orgSlug), + buildTraceUrl(orgSlug, eventId), + buildDashboardsListUrl(orgSlug), + buildDashboardUrl(orgSlug, "1"), + ]; + + for (const url of urls) { + const parsed = new URL(url); + expect(parsed.hostname).toBe("sentry.company.com"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("URL building cross-function properties", () => { + test("all URL builders produce valid URLs", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, eventIdArb, hashArb, productArb), + ([orgSlug, projectSlug, eventId, hash, product]) => { + const urls = [ + buildOrgUrl(orgSlug), + buildProjectUrl(orgSlug, projectSlug), + buildEventSearchUrl(orgSlug, eventId), + buildOrgSettingsUrl(orgSlug), + buildOrgSettingsUrl(orgSlug, hash), + buildSeerSettingsUrl(orgSlug), + buildBillingUrl(orgSlug), + buildBillingUrl(orgSlug, product), + buildDashboardsListUrl(orgSlug), + buildDashboardUrl(orgSlug, "42"), + ]; + + for (const url of urls) { + expect(() => new URL(url)).not.toThrow(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("all URL builders are deterministic", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, eventIdArb, hashArb, productArb), + ([orgSlug, projectSlug, eventId, hash, product]) => { + expect(buildOrgUrl(orgSlug)).toBe(buildOrgUrl(orgSlug)); + expect(buildProjectUrl(orgSlug, projectSlug)).toBe( + buildProjectUrl(orgSlug, projectSlug) + ); + expect(buildEventSearchUrl(orgSlug, eventId)).toBe( + buildEventSearchUrl(orgSlug, eventId) + ); + expect(buildOrgSettingsUrl(orgSlug, hash)).toBe( + buildOrgSettingsUrl(orgSlug, hash) + ); + expect(buildSeerSettingsUrl(orgSlug)).toBe( + buildSeerSettingsUrl(orgSlug) + ); + expect(buildBillingUrl(orgSlug, product)).toBe( + buildBillingUrl(orgSlug, product) + ); + expect(buildDashboardsListUrl(orgSlug)).toBe( + buildDashboardsListUrl(orgSlug) + ); + expect(buildDashboardUrl(orgSlug, "42")).toBe( + buildDashboardUrl(orgSlug, "42") + ); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/sentryclirc-import.property.test.ts b/packages/cli/test/lib/sentryclirc-import.property.test.ts new file mode 100644 index 000000000..215ade271 --- /dev/null +++ b/packages/cli/test/lib/sentryclirc-import.property.test.ts @@ -0,0 +1,216 @@ +/** + * Property-based tests for the `.sentryclirc` import engine. + * + * Tests core invariants that must hold for any valid input: + * - Same-file rule: co-present token+URL in one file → always trusted + * - Cross-file rule: token and URL from different files (non-SaaS) → never trusted + * - Merge order: closest-wins for project-local, then config-dir, then homedir + * - Hash determinism: same content → same hash + * - maskToken: output always contains last 4 chars + */ + +import { + array, + constantFrom, + assert as fcAssert, + property, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import type { + DiscoveredRcFile, + ImportPlan, +} from "../../src/lib/sentryclirc-import.js"; +import { + buildImportPlan, + isSameFileOrigin, + maskToken, +} from "../../src/lib/sentryclirc-import.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +const slugArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789-".split("")), + { minLength: 1, maxLength: 12 } +).map((chars) => chars.join("")); + +const tokenArb = string({ minLength: 8, maxLength: 40 }); + +const nonSaasUrlArb = slugArb.map((slug) => `https://${slug}.example.com`); + +const filePathArb = slugArb.map((slug) => `/${slug}/.sentryclirc`); + +// --------------------------------------------------------------------------- +// Same-File Rule +// --------------------------------------------------------------------------- + +describe("property: isSameFileOrigin", () => { + test("single file with both token and non-SaaS URL → always trusted", () => { + fcAssert( + property(filePathArb, tokenArb, nonSaasUrlArb, (path, token, url) => { + const plan: ImportPlan = { + sources: [], + effective: { token, url }, + effectiveSources: { token: path, url: path }, + newFields: [], + hasExistingAuth: false, + isSaas: false, + trusted: true, + warnings: [], + }; + expect(isSameFileOrigin(plan)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("token and URL from different files (non-SaaS) → never trusted", () => { + fcAssert( + property( + filePathArb, + filePathArb, + tokenArb, + nonSaasUrlArb, + (pathA, pathB, token, url) => { + // Ensure paths are actually different + if (pathA === pathB) { + return; + } + const plan: ImportPlan = { + sources: [], + effective: { token, url }, + effectiveSources: { token: pathA, url: pathB }, + newFields: [], + hasExistingAuth: false, + isSaas: false, + trusted: true, + warnings: [], + }; + expect(isSameFileOrigin(plan)).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("token only (no URL) → always trusted", () => { + fcAssert( + property(filePathArb, tokenArb, (path, token) => { + const plan: ImportPlan = { + sources: [], + effective: { token }, + effectiveSources: { token: path }, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + }; + expect(isSameFileOrigin(plan)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// maskToken +// --------------------------------------------------------------------------- + +describe("property: maskToken", () => { + test("output always contains at least one asterisk", () => { + fcAssert( + property(string({ minLength: 1, maxLength: 100 }), (token) => { + const masked = maskToken(token); + expect(masked).toContain("*"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output never equals the original input", () => { + // Tokens that are entirely asterisks are already fully masked, so + // maskToken cannot meaningfully change them — exclude that edge case. + fcAssert( + property( + string({ minLength: 1, maxLength: 100 }).filter( + (t) => !/^\*+$/.test(t) + ), + (token) => { + const masked = maskToken(token); + expect(masked).not.toBe(token); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("short tokens (<=12) are fully masked", () => { + fcAssert( + property(string({ minLength: 1, maxLength: 12 }), (token) => { + const masked = maskToken(token); + // Every character should be an asterisk + expect(masked).toBe("*".repeat(token.length)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// buildImportPlan merge order +// --------------------------------------------------------------------------- + +describe("property: buildImportPlan merge order", () => { + test("first file's values win over later files", () => { + fcAssert( + property(slugArb, slugArb, (orgA, orgB) => { + // Ensure different values to test precedence + if (orgA === orgB) { + return; + } + const fileA: DiscoveredRcFile = { + path: "/a/.sentryclirc", + location: "project-local", + contentHash: "aaa", + org: orgA, + }; + const fileB: DiscoveredRcFile = { + path: "/b/.sentryclirc", + location: "homedir", + contentHash: "bbb", + org: orgB, + }; + const plan = buildImportPlan([fileA, fileB]); + expect(plan.effective.org).toBe(orgA); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("later file fills gaps from earlier file", () => { + fcAssert( + property(slugArb, tokenArb, (org, token) => { + const fileA: DiscoveredRcFile = { + path: "/a/.sentryclirc", + location: "project-local", + contentHash: "aaa", + org, + }; + const fileB: DiscoveredRcFile = { + path: "/b/.sentryclirc", + location: "homedir", + contentHash: "bbb", + token, + }; + const plan = buildImportPlan([fileA, fileB]); + expect(plan.effective.org).toBe(org); + expect(plan.effective.token).toBe(token); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/sentryclirc-import.test.ts b/packages/cli/test/lib/sentryclirc-import.test.ts new file mode 100644 index 000000000..455eea057 --- /dev/null +++ b/packages/cli/test/lib/sentryclirc-import.test.ts @@ -0,0 +1,843 @@ +/** + * Unit tests for the `.sentryclirc` import engine. + * + * Core invariants (same-file rule, hash verification, merge order) are tested + * via property-based tests in sentryclirc-import.property.test.ts. These tests + * focus on specific scenarios, edge cases, and SQLite integration. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + getAuthToken, + resetAuthTokenCache, + setAuthToken, +} from "../../src/lib/db/auth.js"; +import { + getDefaultOrganization, + getDefaultProject, + getDefaultUrl, + setDefaultOrganization, + setDefaultProject, + setDefaultUrl, +} from "../../src/lib/db/defaults.js"; +import { clearSentryCliRcCache } from "../../src/lib/sentryclirc.js"; +import type { + DiscoveredRcFile, + ImportPlan, +} from "../../src/lib/sentryclirc-import.js"; +import { + buildImportPlan, + classifyRcFileLocation, + clearImportState, + discoverRcFiles, + executeImport, + isImportNeededAsync, + isSameFileOrigin, + markImportCompleted, + markImportDeclined, + maskToken, +} from "../../src/lib/sentryclirc-import.js"; +import { useTestConfigDir } from "../helpers.js"; + +const getConfigDir = useTestConfigDir("import-test-"); + +beforeEach(() => { + clearSentryCliRcCache(); +}); + +afterEach(() => { + clearSentryCliRcCache(); +}); + +// --------------------------------------------------------------------------- +// classifyRcFileLocation +// --------------------------------------------------------------------------- + +describe("classifyRcFileLocation", () => { + test("homedir path returns 'homedir'", () => { + const path = join(homedir(), ".sentryclirc"); + expect(classifyRcFileLocation(path)).toBe("homedir"); + }); + + test("config-dir path returns 'config-dir'", () => { + const path = join(getConfigDir(), ".sentryclirc"); + expect(classifyRcFileLocation(path)).toBe("config-dir"); + }); + + test("arbitrary path returns 'project-local'", () => { + expect(classifyRcFileLocation("/tmp/some/project/.sentryclirc")).toBe( + "project-local" + ); + }); +}); + +// --------------------------------------------------------------------------- +// isSameFileOrigin +// --------------------------------------------------------------------------- + +describe("isSameFileOrigin", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [], + effective: {}, + effectiveSources: {}, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("no URL → trusted", () => { + const plan = makePlan({ effective: { token: "tok" } }); + expect(isSameFileOrigin(plan)).toBe(true); + }); + + test("token and URL from same file → trusted", () => { + const plan = makePlan({ + effective: { token: "tok", url: "https://sentry.example.com" }, + effectiveSources: { token: "/a/.sentryclirc", url: "/a/.sentryclirc" }, + isSaas: false, + }); + expect(isSameFileOrigin(plan)).toBe(true); + }); + + test("token and URL from different files → not trusted", () => { + const plan = makePlan({ + effective: { token: "tok", url: "https://sentry.example.com" }, + effectiveSources: { token: "/a/.sentryclirc", url: "/b/.sentryclirc" }, + isSaas: false, + }); + expect(isSameFileOrigin(plan)).toBe(false); + }); + + test("SaaS URL without explicit source → trusted", () => { + const plan = makePlan({ + effective: { token: "tok", url: "https://sentry.io" }, + effectiveSources: { token: "/a/.sentryclirc" }, + }); + expect(isSameFileOrigin(plan)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// buildImportPlan +// --------------------------------------------------------------------------- + +describe("buildImportPlan", () => { + function makeFile( + overrides: Partial = {} + ): DiscoveredRcFile { + return { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc123", + ...overrides, + }; + } + + test("single file with token+URL is trusted", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.trusted).toBe(true); + expect(plan.effective.token).toBe("test-token"); + expect(plan.warnings).toHaveLength(0); + }); + + test("token only, no URL → trusted (SaaS default)", () => { + const file = makeFile({ token: "test-token" }); + const plan = buildImportPlan([file]); + expect(plan.trusted).toBe(true); + expect(plan.isSaas).toBe(true); + }); + + test("cross-file token+URL with non-SaaS → not trusted + warning", () => { + const fileA = makeFile({ + path: "/a/.sentryclirc", + token: "test-token", + }); + const fileB = makeFile({ + path: "/b/.sentryclirc", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([fileA, fileB]); + expect(plan.trusted).toBe(false); + expect(plan.warnings.length).toBeGreaterThan(0); + expect(plan.warnings[0]).toContain("different files"); + }); + + test("closest-wins merge order", () => { + const close = makeFile({ + path: "/a/b/.sentryclirc", + org: "close-org", + project: "close-proj", + }); + const far = makeFile({ + path: "/a/.sentryclirc", + org: "far-org", + token: "far-token", + }); + const plan = buildImportPlan([close, far]); + expect(plan.effective.org).toBe("close-org"); + expect(plan.effective.project).toBe("close-proj"); + expect(plan.effective.token).toBe("far-token"); + }); + + test("newFields detects what would be new", () => { + const file = makeFile({ + token: "test-token", + org: "my-org", + project: "my-proj", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).toContain("token"); + expect(plan.newFields).toContain("org"); + expect(plan.newFields).toContain("project"); + }); + + test("newFields excludes already-set defaults", () => { + setDefaultOrganization("existing-org"); + const file = makeFile({ + token: "test-token", + org: "my-org", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).toContain("token"); + expect(plan.newFields).not.toContain("org"); + }); + + test("hasExistingAuth true when stored credentials exist", () => { + setAuthToken("existing-token", undefined, undefined, { + host: "https://sentry.io", + }); + const file = makeFile({ token: "new-token" }); + const plan = buildImportPlan([file]); + expect(plan.hasExistingAuth).toBe(true); + // Token already stored — should NOT be in newFields + expect(plan.newFields).not.toContain("token"); + }); +}); + +// --------------------------------------------------------------------------- +// executeImport +// --------------------------------------------------------------------------- + +describe("executeImport", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + token: "test-token", + }, + ], + effective: { token: "test-token" }, + effectiveSources: { token: "/test/.sentryclirc" }, + newFields: ["token"], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("stores token with SaaS host by default", async () => { + const plan = makePlan(); + const result = await executeImport(plan, { validateToken: false }); + expect(result.imported).toBe(true); + expect(result.stored.token).toBe(true); + }); + + test("stores token with custom host", async () => { + const plan = makePlan({ + effective: { + token: "test-token", + url: "https://sentry.example.com", + }, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.imported).toBe(true); + expect(result.stored.token).toBe(true); + }); + + test("stores org/project defaults when not already set", async () => { + const plan = makePlan({ + effective: { + token: "test-token", + org: "my-org", + project: "my-proj", + }, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.org).toBe(true); + expect(result.stored.project).toBe(true); + expect(getDefaultOrganization()).toBe("my-org"); + expect(getDefaultProject()).toBe("my-proj"); + }); + + test("does not overwrite existing defaults", async () => { + setDefaultOrganization("existing-org"); + const plan = makePlan({ + effective: { token: "test-token", org: "new-org" }, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.org).toBe(false); + expect(getDefaultOrganization()).toBe("existing-org"); + }); + + test("stores non-SaaS URL default", async () => { + const plan = makePlan({ + effective: { + token: "test-token", + url: "https://sentry.example.com", + }, + isSaas: false, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.url).toBe(true); + expect(getDefaultUrl()).toBe("https://sentry.example.com"); + }); + + test("does not store SaaS URL as default", async () => { + const plan = makePlan({ + effective: { token: "test-token", url: "https://sentry.io" }, + isSaas: true, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.url).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Import state tracking +// --------------------------------------------------------------------------- + +describe("import state tracking", () => { + test("isImportNeededAsync returns true initially", async () => { + expect(await isImportNeededAsync()).toBe(true); + }); + + test("isImportNeededAsync returns false after completion", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Simulate a real import: store auth + mark completed + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + + clearSentryCliRcCache(); + expect(await isImportNeededAsync()).toBe(false); + }); + + test("isImportNeededAsync returns false after decline", async () => { + markImportDeclined(); + expect(await isImportNeededAsync()).toBe(false); + }); + + test("isImportNeededAsync returns true after file mutation", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Simulate a real import + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + + // Mutate the file + writeFileSync(rcPath, "[auth]\ntoken = different-token\n"); + clearSentryCliRcCache(); + + expect(await isImportNeededAsync()).toBe(true); + }); + + test("markImportCompleted clears previous decline", async () => { + markImportDeclined(); + expect(await isImportNeededAsync()).toBe(false); + + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + clearSentryCliRcCache(); + + // After marking completed, the decline is cleared + clearImportState(); + expect(await isImportNeededAsync()).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// discoverRcFiles +// --------------------------------------------------------------------------- + +describe("discoverRcFiles", () => { + test("finds .sentryclirc in config dir", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync( + rcPath, + "[auth]\ntoken = test-token\n\n[defaults]\norg = my-org\n" + ); + + const files = await discoverRcFiles(configDir); + expect(files.length).toBeGreaterThanOrEqual(1); + const file = files.find((f) => f.path === rcPath); + expect(file).toBeDefined(); + expect(file?.token).toBe("test-token"); + expect(file?.org).toBe("my-org"); + expect(file?.location).toBe("config-dir"); + expect(file?.contentHash).toHaveLength(64); // SHA-256 hex + }); + + test("finds project-local .sentryclirc via walk-up", async () => { + const configDir = getConfigDir(); + const projectDir = join(configDir, "project", "sub"); + mkdirSync(projectDir, { recursive: true }); + const rcPath = join(configDir, "project", ".sentryclirc"); + writeFileSync(rcPath, "[defaults]\nproject = my-proj\n"); + + const files = await discoverRcFiles(projectDir); + const file = files.find((f) => f.path === rcPath); + expect(file).toBeDefined(); + expect(file?.project).toBe("my-proj"); + expect(file?.location).toBe("project-local"); + }); + + test("returns empty array when no files exist", async () => { + const configDir = getConfigDir(); + const emptyDir = join(configDir, "empty"); + mkdirSync(emptyDir, { recursive: true }); + // Create a .git marker to stop walk-up early + mkdirSync(join(emptyDir, ".git")); + + const files = await discoverRcFiles(emptyDir); + // May find global files — filter to just this dir + const local = files.filter((f) => f.path.startsWith(emptyDir)); + expect(local).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// maskToken +// --------------------------------------------------------------------------- + +describe("maskToken", () => { + test("short token is fully masked", () => { + expect(maskToken("abcdef")).toBe("******"); + }); + + test("12-char token is fully masked", () => { + expect(maskToken("abcdefghijkl")).toBe("************"); + }); + + test("13-char token shows first 4 + last 4", () => { + // 13 chars: 13-8=5 stars → "abcd*****ijklm" + const result = maskToken("abcdefghijklm"); + expect(result.startsWith("abcd")).toBe(true); + expect(result.endsWith("jklm")).toBe(true); + expect(result.length).toBe(13); + }); + + test("long token shows first 4 + last 4", () => { + expect(maskToken("abcdefghijklmnop")).toBe("abcd********mnop"); + }); + + test("very short token is fully masked", () => { + expect(maskToken("ab")).toBe("**"); + }); + + test("masked output never reveals full token", () => { + // Tokens <= 12 chars are fully masked + expect(maskToken("secret")).not.toContain("secret"); + expect(maskToken("123456789ab")).not.toContain("123456789ab"); + // Longer tokens show partial but not full + expect(maskToken("my-secret-token-value")).not.toBe( + "my-secret-token-value" + ); + }); +}); + +// --------------------------------------------------------------------------- +// executeImport — token guarding (C1 fix) +// --------------------------------------------------------------------------- + +describe("executeImport — token guard", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + token: "test-token", + }, + ], + effective: { token: "test-token" }, + effectiveSources: { token: "/test/.sentryclirc" }, + newFields: ["token"], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("does not overwrite existing stored auth when token not in newFields", async () => { + // Store existing auth + setAuthToken("existing-oauth-token", 3600, "refresh-tok", { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + // Import plan has token but NOT in newFields (existing auth detected) + const plan = makePlan({ + newFields: [], // token excluded because auth already exists + hasExistingAuth: true, + }); + const result = await executeImport(plan, { validateToken: false }); + + // Token should NOT have been overwritten + expect(result.stored.token).toBe(false); + resetAuthTokenCache(); + expect(getAuthToken()).toBe("existing-oauth-token"); + }); + + test("stores token when it IS in newFields", async () => { + const plan = makePlan({ newFields: ["token"] }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.token).toBe(true); + resetAuthTokenCache(); + expect(getAuthToken()).toBe("test-token"); + }); + + test("does not store anything when effective has no token", async () => { + const plan = makePlan({ + effective: { org: "my-org" }, + newFields: ["org"], + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.token).toBe(false); + expect(result.stored.org).toBe(true); + expect(result.imported).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// buildImportPlan — URL normalization and sntrys_ warnings +// --------------------------------------------------------------------------- + +describe("buildImportPlan — URL handling", () => { + function makeFile( + overrides: Partial = {} + ): DiscoveredRcFile { + return { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc123", + ...overrides, + }; + } + + test("normalizes URL to origin", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com/path/to/something", + }); + const plan = buildImportPlan([file]); + expect(plan.effective.url).toBe("https://sentry.example.com"); + }); + + test("handles bare hostname URL", () => { + const file = makeFile({ + token: "test-token", + url: "sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.effective.url).toBe("https://sentry.example.com"); + }); + + test("non-SaaS URL adds url to newFields", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).toContain("url"); + expect(plan.isSaas).toBe(false); + }); + + test("SaaS URL does NOT add url to newFields", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.io", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).not.toContain("url"); + expect(plan.isSaas).toBe(true); + }); + + test("url not in newFields when default already set", () => { + setDefaultUrl("https://existing.example.com"); + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).not.toContain("url"); + }); + + test("empty effective fields produce empty newFields", () => { + const file = makeFile({}); // no token, url, org, project + const plan = buildImportPlan([file]); + expect(plan.newFields).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// executeImport — defaults edge cases +// --------------------------------------------------------------------------- + +describe("executeImport — defaults edge cases", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + }, + ], + effective: {}, + effectiveSources: {}, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("does not overwrite existing URL default", async () => { + setDefaultUrl("https://existing.example.com"); + const plan = makePlan({ + effective: { url: "https://new.example.com" }, + isSaas: false, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.url).toBe(false); + expect(getDefaultUrl()).toBe("https://existing.example.com"); + }); + + test("does not overwrite existing project default", async () => { + setDefaultProject("existing-proj"); + const plan = makePlan({ + effective: { project: "new-proj" }, + newFields: [], + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.project).toBe(false); + expect(getDefaultProject()).toBe("existing-proj"); + }); + + test("marks import completed even with defaults only", async () => { + // Store auth so isImportNeededAsync doesn't short-circuit + setAuthToken("existing-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const plan = makePlan({ + effective: { org: "my-org", project: "my-proj" }, + newFields: ["org", "project"], + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.imported).toBe(true); + expect(result.stored.org).toBe(true); + expect(result.stored.project).toBe(true); + expect(await isImportNeededAsync()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Import state tracking — edge cases +// --------------------------------------------------------------------------- + +describe("import state tracking — edge cases", () => { + test("isImportNeededAsync returns true when source file deleted", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Store auth so hasStoredAuth() returns true (simulating a real import) + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + clearSentryCliRcCache(); + + // Verify it's initially not needed (auth + hashes match) + expect(await isImportNeededAsync()).toBe(false); + + // Delete the file + const { unlinkSync } = await import("node:fs"); + unlinkSync(rcPath); + + // Now the hash check fails → import needed again + expect(await isImportNeededAsync()).toBe(true); + }); + + test("clearImportState resets everything", async () => { + markImportDeclined(); + expect(await isImportNeededAsync()).toBe(false); + clearImportState(); + expect(await isImportNeededAsync()).toBe(true); + }); + + test("isImportNeededAsync returns true after logout (auth cleared)", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Simulate a real import + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + clearSentryCliRcCache(); + + // Initially not needed + expect(await isImportNeededAsync()).toBe(false); + + // Simulate logout — clear auth without clearing import record + const { clearAuth } = await import("../../src/lib/db/auth.js"); + await clearAuth(); + resetAuthTokenCache(); + + // Now import is needed again (auth gone, .sentryclirc still has token) + expect(await isImportNeededAsync()).toBe(true); + }); + + test("import with no contributing sources stores empty sources array", () => { + const plan: ImportPlan = { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + // No token, url, org, or project + }, + ], + effective: {}, + effectiveSources: {}, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + }; + // Should not throw + markImportCompleted(plan); + }); +}); + +// --------------------------------------------------------------------------- +// discoverRcFiles — additional scenarios +// --------------------------------------------------------------------------- + +describe("discoverRcFiles — additional", () => { + test("discovers files with all four fields", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync( + rcPath, + "[auth]\ntoken = my-token\n\n[defaults]\nurl = https://sentry.example.com\norg = my-org\nproject = my-proj\n" + ); + + const files = await discoverRcFiles(configDir); + const file = files.find((f) => f.path === rcPath); + expect(file).toBeDefined(); + expect(file?.token).toBe("my-token"); + expect(file?.url).toBe("https://sentry.example.com"); + expect(file?.org).toBe("my-org"); + expect(file?.project).toBe("my-proj"); + }); + + test("skips files with only comments/empty sections", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "# just a comment\n[defaults]\n; nothing here\n"); + + const files = await discoverRcFiles(configDir); + const file = files.find((f) => f.path === rcPath); + // File is found but has no fields + if (file) { + expect(file.token).toBeUndefined(); + expect(file.url).toBeUndefined(); + expect(file.org).toBeUndefined(); + expect(file.project).toBeUndefined(); + } + }); + + test("walk-up merges project-local and global files", async () => { + const configDir = getConfigDir(); + const projectDir = join(configDir, "myproject"); + mkdirSync(projectDir, { recursive: true }); + + // Project-local file has project + writeFileSync( + join(projectDir, ".sentryclirc"), + "[defaults]\nproject = local-proj\n" + ); + // Global file has token + org + writeFileSync( + join(configDir, ".sentryclirc"), + "[auth]\ntoken = global-token\n\n[defaults]\norg = global-org\n" + ); + + const files = await discoverRcFiles(projectDir); + expect(files.length).toBeGreaterThanOrEqual(2); + + // Build a plan to verify merge + const plan = buildImportPlan(files); + expect(plan.effective.project).toBe("local-proj"); + expect(plan.effective.token).toBe("global-token"); + expect(plan.effective.org).toBe("global-org"); + }); +}); diff --git a/packages/cli/test/lib/sentryclirc.property.test.ts b/packages/cli/test/lib/sentryclirc.property.test.ts new file mode 100644 index 000000000..f436eff5d --- /dev/null +++ b/packages/cli/test/lib/sentryclirc.property.test.ts @@ -0,0 +1,233 @@ +/** + * Property-Based Tests for .sentryclirc Config Reader + * + * Verifies merge properties that should hold for any valid config: + * - Monotonicity: adding files never removes resolved fields + * - Closest-wins: closest file always takes priority per-field + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + array, + asyncProperty, + constantFrom, + assert as fcAssert, + record, +} from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + CONFIG_FILENAME, + clearSentryCliRcCache, + loadSentryCliRc, +} from "../../src/lib/sentryclirc.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +let testDir: string; +let savedConfigDir: string | undefined; + +beforeEach(async () => { + clearSentryCliRcCache(); + savedConfigDir = process.env.SENTRY_CONFIG_DIR; + testDir = await createTestConfigDir("sentryclirc-prop-", { + isolateProjectRoot: true, + }); + process.env.SENTRY_CONFIG_DIR = testDir; +}); + +afterEach(async () => { + clearSentryCliRcCache(); + if (savedConfigDir !== undefined) { + process.env.SENTRY_CONFIG_DIR = savedConfigDir; + } + await cleanupTestDir(testDir); +}); + +// Arbitraries + +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; +const slugArb = array(constantFrom(...slugChars.split("")), { + minLength: 1, + maxLength: 15, +}).map((chars) => chars.join("")); + +const tokenArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), + { + minLength: 5, + maxLength: 20, + } +).map((chars) => `sntrys_${chars.join("")}`); + +/** Generate a partial config (each field may or may not be present) */ +const partialConfigArb = record( + { + org: slugArb, + project: slugArb, + token: tokenArb, + }, + { requiredKeys: [] } +); + +type PartialConfig = { org?: string; project?: string; token?: string }; + +/** Serialize a partial config to .sentryclirc INI format */ +function serializeConfig(config: PartialConfig): string { + const lines: string[] = []; + if (config.org || config.project) { + lines.push("[defaults]"); + if (config.org) { + lines.push(`org = ${config.org}`); + } + if (config.project) { + lines.push(`project = ${config.project}`); + } + } + if (config.token) { + lines.push("[auth]"); + lines.push(`token = ${config.token}`); + } + return lines.join("\n"); +} + +/** Counter for creating unique subdirectories per property iteration */ +let iterCounter = 0; + +/** Create an isolated dir tree for one property test iteration */ +function createIterDirs(): { parentDir: string; childDir: string } { + iterCounter += 1; + const parentDir = join(testDir, `iter-${iterCounter}`); + const childDir = join(parentDir, "child"); + mkdirSync(childDir, { recursive: true }); + return { parentDir, childDir }; +} + +describe("property: loadSentryCliRc", () => { + test("monotonicity: adding a parent config never removes resolved fields", async () => { + await fcAssert( + asyncProperty( + partialConfigArb, + partialConfigArb, + async (childConfig, parentConfig) => { + clearSentryCliRcCache(); + const { parentDir, childDir } = createIterDirs(); + + // Create child with its config + writeFileSync( + join(childDir, CONFIG_FILENAME), + serializeConfig(childConfig), + "utf-8" + ); + + // Load with child only + const resultChildOnly = await loadSentryCliRc(childDir); + const fieldsChildOnly: string[] = []; + if (resultChildOnly.org) fieldsChildOnly.push("org"); + if (resultChildOnly.project) fieldsChildOnly.push("project"); + if (resultChildOnly.token) fieldsChildOnly.push("token"); + + clearSentryCliRcCache(); + + // Now add parent config + writeFileSync( + join(parentDir, CONFIG_FILENAME), + serializeConfig(parentConfig), + "utf-8" + ); + + // Load again with parent also present + const resultBoth = await loadSentryCliRc(childDir); + + // All fields that were set with child-only should still be set + for (const field of fieldsChildOnly) { + expect(resultBoth[field as keyof typeof resultBoth]).toBeDefined(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("closest-wins: child config values always take priority over parent", async () => { + await fcAssert( + asyncProperty( + partialConfigArb, + partialConfigArb, + async (childConfig, parentConfig) => { + clearSentryCliRcCache(); + const { parentDir, childDir } = createIterDirs(); + + // Write parent config + writeFileSync( + join(parentDir, CONFIG_FILENAME), + serializeConfig(parentConfig), + "utf-8" + ); + + // Write child config + writeFileSync( + join(childDir, CONFIG_FILENAME), + serializeConfig(childConfig), + "utf-8" + ); + + const result = await loadSentryCliRc(childDir); + + // For every field set in child config, the result should match the child's value + if (childConfig.org) { + expect(result.org).toBe(childConfig.org); + } + if (childConfig.project) { + expect(result.project).toBe(childConfig.project); + } + if (childConfig.token) { + expect(result.token).toBe(childConfig.token); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("parent fills gaps: fields not in child come from parent", async () => { + await fcAssert( + asyncProperty( + partialConfigArb, + partialConfigArb, + async (childConfig, parentConfig) => { + clearSentryCliRcCache(); + const { parentDir, childDir } = createIterDirs(); + + // Write parent config + writeFileSync( + join(parentDir, CONFIG_FILENAME), + serializeConfig(parentConfig), + "utf-8" + ); + + // Write child config + writeFileSync( + join(childDir, CONFIG_FILENAME), + serializeConfig(childConfig), + "utf-8" + ); + + const result = await loadSentryCliRc(childDir); + + // For fields NOT in child config, they should come from parent + if (!childConfig.org && parentConfig.org) { + expect(result.org).toBe(parentConfig.org); + } + if (!childConfig.project && parentConfig.project) { + expect(result.project).toBe(parentConfig.project); + } + if (!childConfig.token && parentConfig.token) { + expect(result.token).toBe(parentConfig.token); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/sentryclirc.test.ts b/packages/cli/test/lib/sentryclirc.test.ts new file mode 100644 index 000000000..220c3ea0d --- /dev/null +++ b/packages/cli/test/lib/sentryclirc.test.ts @@ -0,0 +1,415 @@ +/** + * Unit Tests for .sentryclirc Config File Reader + * + * Tests the walk-up discovery, merging, env shim, and caching behavior. + * Uses real temp directories with actual .sentryclirc files. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { closeDatabase } from "../../src/lib/db/index.js"; +import { + captureEnvTokenHost, + resetEnvTokenHostForTesting, +} from "../../src/lib/env-token-host.js"; +import { + applySentryCliRcEnvShim, + assertRcUrlTrusted, + CONFIG_FILENAME, + clearSentryCliRcCache, + getRcInjectedTokenSource, + loadSentryCliRc, +} from "../../src/lib/sentryclirc.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; + +const ENV_KEYS = [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CONFIG_DIR", +] as const; + +let testDir: string; +let savedEnv: Record; + +beforeEach(async () => { + clearSentryCliRcCache(); + closeDatabase(); + // Save env vars we'll modify + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + testDir = await createTestConfigDir("sentryclirc-test-", { + isolateProjectRoot: true, + }); + // Point config dir at the test dir so getConfigDir() returns a predictable path + process.env.SENTRY_CONFIG_DIR = testDir; + resetEnvTokenHostForTesting(); +}); + +afterEach(async () => { + clearSentryCliRcCache(); + closeDatabase(); + // Restore all saved env vars to their exact pre-test state. + // Vars that were undefined before must be deleted — otherwise values + // set during tests leak into subsequent test files in the suite. + for (const key of ENV_KEYS) { + const saved = savedEnv[key]; + if (saved !== undefined) { + process.env[key] = saved; + } else { + delete process.env[key]; + } + } + resetEnvTokenHostForTesting(); + await cleanupTestDir(testDir); +}); + +/** Helper: write a .sentryclirc file */ +function writeRcFile(dir: string, content: string): void { + writeFileSync(join(dir, CONFIG_FILENAME), content, "utf-8"); +} + +/** Read an env var without TypeScript narrowing issues after `delete` */ +function readEnv(key: string): string | undefined { + return process.env[key]; +} + +describe("loadSentryCliRc", () => { + test("returns empty config when no .sentryclirc files exist", async () => { + const result = await loadSentryCliRc(testDir); + expect(result.org).toBeUndefined(); + expect(result.project).toBeUndefined(); + expect(result.url).toBeUndefined(); + expect(result.token).toBeUndefined(); + expect(result.sources).toEqual({}); + }); + + test("reads org and project from local .sentryclirc", async () => { + writeRcFile(testDir, "[defaults]\norg = my-org\nproject = my-project\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBe("my-org"); + expect(result.project).toBe("my-project"); + expect(result.sources.org).toBe(join(testDir, CONFIG_FILENAME)); + expect(result.sources.project).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("reads auth token from local .sentryclirc", async () => { + writeRcFile(testDir, "[auth]\ntoken = sntrys_abc123\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.token).toBe("sntrys_abc123"); + expect(result.sources.token).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("reads url from local .sentryclirc", async () => { + writeRcFile(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.url).toBe("https://sentry.example.com"); + }); + + test("reads all fields from a complete .sentryclirc", async () => { + writeRcFile( + testDir, + `[defaults] +org = my-org +project = my-project +url = https://sentry.io/ + +[auth] +token = sntrys_test +` + ); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBe("my-org"); + expect(result.project).toBe("my-project"); + expect(result.url).toBe("https://sentry.io/"); + expect(result.token).toBe("sntrys_test"); + }); + + test("walks up from subdirectory to find .sentryclirc", async () => { + writeRcFile(testDir, "[defaults]\norg = parent-org\nproject = parent-proj"); + + const subDir = join(testDir, "packages", "frontend", "src"); + mkdirSync(subDir, { recursive: true }); + + const result = await loadSentryCliRc(subDir); + expect(result.org).toBe("parent-org"); + expect(result.project).toBe("parent-proj"); + expect(result.sources.org).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("closest file wins for overlapping fields", async () => { + // Parent has org + project + writeRcFile(testDir, "[defaults]\norg = parent-org\nproject = parent-proj"); + + // Child overrides project only + const childDir = join(testDir, "packages", "frontend"); + mkdirSync(childDir, { recursive: true }); + writeRcFile(childDir, "[defaults]\nproject = child-proj"); + + const result = await loadSentryCliRc(childDir); + expect(result.org).toBe("parent-org"); + expect(result.project).toBe("child-proj"); + expect(result.sources.org).toBe(join(testDir, CONFIG_FILENAME)); + expect(result.sources.project).toBe(join(childDir, CONFIG_FILENAME)); + }); + + test("closest file wins: token from child, org from parent", async () => { + writeRcFile( + testDir, + "[defaults]\norg = parent-org\n[auth]\ntoken = parent-token" + ); + + const childDir = join(testDir, "sub"); + mkdirSync(childDir, { recursive: true }); + writeRcFile(childDir, "[auth]\ntoken = child-token"); + + const result = await loadSentryCliRc(childDir); + expect(result.org).toBe("parent-org"); + expect(result.token).toBe("child-token"); + }); + + test("partial config is valid (only org, no project)", async () => { + writeRcFile(testDir, "[defaults]\norg = only-org\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBe("only-org"); + expect(result.project).toBeUndefined(); + }); + + test("empty values are treated as unset", async () => { + writeRcFile(testDir, "[defaults]\norg =\nproject = real-proj\n"); + + const result = await loadSentryCliRc(testDir); + // Empty string after trim is falsy, so org should not be set + expect(result.org).toBeUndefined(); + expect(result.project).toBe("real-proj"); + }); + + test("cache returns same object on repeated calls", async () => { + writeRcFile(testDir, "[defaults]\norg = cached-org\n"); + + const promise1 = loadSentryCliRc(testDir); + const promise2 = loadSentryCliRc(testDir); + // Same promise reference (concurrent callers share the load) + expect(promise1).toBe(promise2); + }); + + test("clearSentryCliRcCache invalidates the cache", async () => { + writeRcFile(testDir, "[defaults]\norg = first\n"); + const result1 = await loadSentryCliRc(testDir); + + clearSentryCliRcCache(); + + // Modify the file + writeRcFile(testDir, "[defaults]\norg = second\n"); + const result2 = await loadSentryCliRc(testDir); + + expect(result1.org).toBe("first"); + expect(result2.org).toBe("second"); + expect(result1).not.toBe(result2); + }); + + test("invalid INI content is gracefully handled", async () => { + writeRcFile(testDir, "this is not ini format at all\n===\nmore garbage"); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBeUndefined(); + expect(result.project).toBeUndefined(); + }); +}); + +describe("applySentryCliRcEnvShim", () => { + test("sets SENTRY_AUTH_TOKEN when not already set", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe("rc-token"); + }); + + test("records the rc file as the injected token source", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(getRcInjectedTokenSource()).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("does not record an injected source when a real env token is set", async () => { + process.env.SENTRY_AUTH_TOKEN = "existing-token"; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(getRcInjectedTokenSource()).toBeUndefined(); + }); + + test("does not override existing SENTRY_AUTH_TOKEN", async () => { + process.env.SENTRY_AUTH_TOKEN = "existing-token"; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe("existing-token"); + }); + + test("does not override non-empty whitespace SENTRY_AUTH_TOKEN", async () => { + process.env.SENTRY_AUTH_TOKEN = " real-token "; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe(" real-token "); + }); + + test("overrides empty/whitespace-only SENTRY_AUTH_TOKEN", async () => { + process.env.SENTRY_AUTH_TOKEN = " "; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe("rc-token"); + }); + + test("does not override existing SENTRY_TOKEN (fallback env var)", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + process.env.SENTRY_TOKEN = "env-fallback-token"; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + // SENTRY_AUTH_TOKEN should NOT be set — SENTRY_TOKEN already provides auth + expect(readEnv("SENTRY_AUTH_TOKEN")).toBeUndefined(); + expect(readEnv("SENTRY_TOKEN")).toBe("env-fallback-token"); + }); + + test("sets SENTRY_URL for SaaS rc url (no credential risk, no scoping check)", async () => { + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + writeRcFile(testDir, "[defaults]\nurl = https://sentry.io\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBe("https://sentry.io"); + }); + + test("normalizes bare-hostname rc url before SaaS / scope checks", async () => { + // Regression: `url = sentry.io` (no scheme) used to throw inside + // `new URL(...)` deep in the SaaS check. The shim now normalizes + // the rc url through `normalizeUrl` first so bare hostnames are + // accepted as the equivalent `https://sentry.io`. + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + writeRcFile(testDir, "[defaults]\nurl = sentry.io\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBe("https://sentry.io"); + }); + + test("assertRcUrlTrusted throws when non-SaaS rc url does not match active token's scoped host", async () => { + // Env-token defaults to SaaS (no SENTRY_HOST set at capture time). + // Any non-SaaS rc url is therefore a mismatch → CliError. This closes + // the CVE where a committed .sentryclirc could redirect requests + + // token to an attacker host. The shim itself applies the URL + // unconditionally; the trust check lives in assertRcUrlTrusted, called + // by buildCommand's wrapper. + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + // Capture env-token host BEFORE shim mutates env (mirrors boot order). + captureEnvTokenHost(); + writeRcFile(testDir, "[defaults]\nurl = https://evil.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBe("https://evil.example.com"); + + await expect(assertRcUrlTrusted(testDir)).rejects.toThrow( + /does not match|sentry auth login --url/ + ); + }); + + test("does not set SENTRY_URL when SENTRY_HOST is set", async () => { + process.env.SENTRY_HOST = "sentry.other.com"; + delete process.env.SENTRY_URL; + writeRcFile(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBeUndefined(); + }); + + test("does not set SENTRY_URL when SENTRY_URL is already set", async () => { + delete process.env.SENTRY_HOST; + process.env.SENTRY_URL = "https://existing.sentry.io"; + writeRcFile(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBe("https://existing.sentry.io"); + }); + + test("does nothing when no .sentryclirc exists", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBeUndefined(); + expect(readEnv("SENTRY_URL")).toBeUndefined(); + }); + + test("does not set org/project as env vars (only token and url)", async () => { + writeRcFile(testDir, "[defaults]\norg = my-org\nproject = my-proj\n"); + + const orgBefore = readEnv("SENTRY_ORG"); + const projBefore = readEnv("SENTRY_PROJECT"); + + await applySentryCliRcEnvShim(testDir); + + // Org and project should NOT be set as env vars + // (they're handled in the resolution chain, not via env shim) + expect(readEnv("SENTRY_ORG")).toBe(orgBefore); + expect(readEnv("SENTRY_PROJECT")).toBe(projBefore); + }); +}); + +describe("monorepo scenario", () => { + test("root has org, each package has project", async () => { + // Root: org only + writeRcFile(testDir, "[defaults]\norg = acme-corp\n"); + + // Frontend package: project override + const frontendDir = join(testDir, "packages", "frontend"); + mkdirSync(frontendDir, { recursive: true }); + writeRcFile(frontendDir, "[defaults]\nproject = frontend-web\n"); + + // Backend package: project override + const backendDir = join(testDir, "packages", "backend"); + mkdirSync(backendDir, { recursive: true }); + writeRcFile(backendDir, "[defaults]\nproject = backend-api\n"); + + const frontendResult = await loadSentryCliRc(frontendDir); + expect(frontendResult.org).toBe("acme-corp"); + expect(frontendResult.project).toBe("frontend-web"); + + clearSentryCliRcCache(); + + const backendResult = await loadSentryCliRc(backendDir); + expect(backendResult.org).toBe("acme-corp"); + expect(backendResult.project).toBe("backend-api"); + }); + + test("deep nesting: child of child inherits from multiple ancestors", async () => { + writeRcFile(testDir, "[auth]\ntoken = root-token\n"); + + const pkgDir = join(testDir, "packages", "web"); + mkdirSync(pkgDir, { recursive: true }); + writeRcFile(pkgDir, "[defaults]\norg = web-org\nproject = web-proj\n"); + + const srcDir = join(pkgDir, "src", "components"); + mkdirSync(srcDir, { recursive: true }); + + const result = await loadSentryCliRc(srcDir); + expect(result.org).toBe("web-org"); + expect(result.project).toBe("web-proj"); + expect(result.token).toBe("root-token"); + }); +}); diff --git a/packages/cli/test/lib/shell.property.test.ts b/packages/cli/test/lib/shell.property.test.ts new file mode 100644 index 000000000..c7f3d07b2 --- /dev/null +++ b/packages/cli/test/lib/shell.property.test.ts @@ -0,0 +1,266 @@ +/** + * Property-Based Tests for Shell Utilities + * + * Uses fast-check to verify invariants that should hold for any valid input, + * catching edge cases that hand-picked unit tests would miss. + */ + +import { mkdirSync, rmSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + asyncProperty, + constantFrom, + assert as fcAssert, + property, + uniqueArray, +} from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + addToPath, + detectShellType, + getConfigCandidates, + getPathCommand, + isInPath, + type ShellType, +} from "../../src/lib/shell.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// -- Arbitraries -- + +/** All valid ShellType values */ +const allShellTypes: ShellType[] = [ + "bash", + "zsh", + "fish", + "sh", + "ash", + "unknown", +]; + +/** Known shells that map to a named type (excludes "unknown") */ +const knownShells = ["bash", "zsh", "fish", "sh", "ash"] as const; + +const shellTypeArb = constantFrom(...allShellTypes); +const knownShellArb = constantFrom(...knownShells); + +/** Generate directory-like path prefixes */ +const pathPrefixArb = constantFrom( + "/bin", + "/usr/bin", + "/usr/local/bin", + "/home/user/.local/bin", + "/opt/homebrew/bin", + "/nix/store/abc123-bash-5.2/bin", + "/snap/bin" +); + +/** Generate absolute directory paths */ +const directoryArb = constantFrom( + "/home/user/.sentry/bin", + "/home/user/.local/bin", + "/usr/local/bin", + "/opt/sentry/bin", + "/home/user/bin", + "/tmp/test/bin" +); + +/** Generate home directory paths */ +const homeDirArb = constantFrom( + "/home/user", + "/home/alice", + "/Users/bob", + "/root" +); + +/** Generate PATH strings from a set of directories */ +const pathStringArb = uniqueArray(directoryArb, { + minLength: 1, + maxLength: 6, +}).map((dirs) => dirs.join(":")); + +// -- Tests -- + +describe("property: detectShellType", () => { + test("known shells are detected regardless of path prefix", () => { + fcAssert( + property(pathPrefixArb, knownShellArb, (prefix, shell) => { + const result = detectShellType(`${prefix}/${shell}`); + expect(result).toBe(shell); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result depends only on the basename of the path", () => { + fcAssert( + property( + pathPrefixArb, + pathPrefixArb, + knownShellArb, + (prefix1, prefix2, shell) => { + expect(detectShellType(`${prefix1}/${shell}`)).toBe( + detectShellType(`${prefix2}/${shell}`) + ); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always returns a valid ShellType", () => { + fcAssert( + property(pathPrefixArb, (prefix) => { + // Even for unrecognized shells, should return a valid type + const result = detectShellType(`${prefix}/xonsh`); + expect(allShellTypes).toContain(result); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("undefined always returns 'unknown'", () => { + expect(detectShellType(undefined)).toBe("unknown"); + }); +}); + +describe("property: getConfigCandidates", () => { + test("always returns a non-empty array", () => { + fcAssert( + property(shellTypeArb, homeDirArb, (shellType, homeDir) => { + const candidates = getConfigCandidates(shellType, homeDir); + expect(candidates.length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("every path starts with the home directory", () => { + fcAssert( + property(shellTypeArb, homeDirArb, (shellType, homeDir) => { + const candidates = getConfigCandidates(shellType, homeDir); + for (const path of candidates) { + expect(path.startsWith(homeDir)).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: getPathCommand", () => { + test("always contains the directory in the output", () => { + fcAssert( + property(shellTypeArb, directoryArb, (shellType, dir) => { + const cmd = getPathCommand(shellType, dir); + expect(cmd).toContain(dir); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("directory is always quoted", () => { + fcAssert( + property(shellTypeArb, directoryArb, (shellType, dir) => { + const cmd = getPathCommand(shellType, dir); + expect(cmd).toContain(`"${dir}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("fish returns fish_add_path, others return export PATH=", () => { + fcAssert( + property(directoryArb, (dir) => { + expect(getPathCommand("fish", dir)).toContain("fish_add_path"); + for (const shell of ["bash", "zsh", "sh", "ash", "unknown"] as const) { + expect(getPathCommand(shell, dir)).toContain("export PATH="); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: isInPath", () => { + test("a directory in a PATH string is always found", () => { + fcAssert( + property(directoryArb, pathStringArb, (dir, pathStr) => { + // Construct a PATH that definitely contains dir + const fullPath = `${pathStr}:${dir}`; + expect(isInPath(dir, fullPath)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("a directory not in PATH is never found", () => { + // Use a directory that's guaranteed not to be in our set + const absentDir = "/this/path/is/never/in/the/set"; + fcAssert( + property(pathStringArb, (pathStr) => { + expect(isInPath(absentDir, pathStr)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("undefined PATH always returns false", () => { + fcAssert( + property(directoryArb, (dir) => { + expect(isInPath(dir, undefined)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: addToPath", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-prop-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("idempotent: second call returns modified=false", async () => { + let fileCounter = 0; + const shellArb = constantFrom(...(["bash", "zsh", "fish"] as const)); + await fcAssert( + asyncProperty(shellArb, directoryArb, async (shellType, dir) => { + fileCounter += 1; + const configFile = join(testDir, `.rc-${fileCounter}`); + const first = await addToPath(configFile, dir, shellType); + expect(first.modified).toBe(true); + + const second = await addToPath(configFile, dir, shellType); + expect(second.modified).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("round-trip: file contains the path command after addToPath", async () => { + let fileCounter = 0; + const shellArb = constantFrom(...(["bash", "zsh", "fish"] as const)); + await fcAssert( + asyncProperty(shellArb, directoryArb, async (shellType, dir) => { + fileCounter += 1; + const configFile = join(testDir, `.rc-rt-${fileCounter}`); + await addToPath(configFile, dir, shellType); + + const content = await readFile(configFile, "utf-8"); + const expectedCmd = getPathCommand(shellType, dir); + expect(content).toContain(expectedCmd); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/shell.test.ts b/packages/cli/test/lib/shell.test.ts new file mode 100644 index 000000000..a5e0571db --- /dev/null +++ b/packages/cli/test/lib/shell.test.ts @@ -0,0 +1,409 @@ +/** + * Shell Utilities Tests + * + * Unit tests for I/O-dependent shell operations (file creation, writing, + * GitHub Actions). Pure function tests are in shell.property.test.ts. + */ + +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + addToFpath, + addToGitHubPath, + addToPath, + detectShell, + findExistingConfigFile, + getConfigCandidates, + isBashAvailable, + isInPath, +} from "../../src/lib/shell.js"; +import { whichSync } from "../../src/lib/which.js"; + +describe("shell utilities", () => { + describe("getConfigCandidates", () => { + test("returns fallback candidates for unknown shell", () => { + const candidates = getConfigCandidates( + "unknown", + "/home/user", + "/home/user/.config" + ); + expect(candidates).toContain("/home/user/.bashrc"); + expect(candidates).toContain("/home/user/.bash_profile"); + expect(candidates).toContain("/home/user/.profile"); + }); + }); + + describe("findExistingConfigFile", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns first existing file", () => { + const file1 = join(testDir, ".bashrc"); + const file2 = join(testDir, ".bash_profile"); + writeFileSync(file2, "# bash profile"); + + const result = findExistingConfigFile([file1, file2]); + expect(result).toBe(file2); + }); + + test("returns null when no files exist", () => { + const result = findExistingConfigFile([ + join(testDir, ".nonexistent1"), + join(testDir, ".nonexistent2"), + ]); + expect(result).toBeNull(); + }); + }); + + describe("detectShell", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("detects shell type and finds config file", () => { + const zshrc = join(testDir, ".zshrc"); + writeFileSync(zshrc, "# zshrc"); + + const result = detectShell("/bin/zsh", testDir); + expect(result.type).toBe("zsh"); + expect(result.configFile).toBe(zshrc); + }); + + test("returns null configFile when none exist", () => { + const result = detectShell("/bin/zsh", testDir); + expect(result.type).toBe("zsh"); + expect(result.configFile).toBeNull(); + }); + }); + + describe("addToPath", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("creates config file if it doesn't exist", async () => { + const configFile = join(testDir, ".bashrc"); + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(true); + expect(result.configFile).toBe(configFile); + + const content = await readFile(configFile, "utf-8"); + expect(content).toContain('export PATH="/home/user/.sentry/bin:$PATH"'); + }); + + test("appends to existing config file", async () => { + const configFile = join(testDir, ".bashrc"); + writeFileSync(configFile, "# existing content\n"); + + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(true); + + const content = await readFile(configFile, "utf-8"); + expect(content).toContain("# existing content"); + expect(content).toContain("# sentry"); + expect(content).toContain('export PATH="/home/user/.sentry/bin:$PATH"'); + }); + + test("skips if already configured", async () => { + const configFile = join(testDir, ".bashrc"); + writeFileSync( + configFile, + '# sentry\nexport PATH="/home/user/.sentry/bin:$PATH"\n' + ); + + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(false); + expect(result.message).toContain("already configured"); + }); + + test("appends newline separator when file doesn't end with newline", async () => { + const configFile = join(testDir, ".bashrc"); + writeFileSync(configFile, "# existing content without newline"); + + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(true); + + const content = await readFile(configFile, "utf-8"); + expect(content).toContain( + "# existing content without newline\n\n# sentry\n" + ); + }); + + test("returns manualCommand when config file cannot be created", async () => { + const configFile = "/dev/null/impossible/path/.bashrc"; + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(false); + expect(result.manualCommand).toBe( + 'export PATH="/home/user/.sentry/bin:$PATH"' + ); + }); + }); + + describe("addToFpath", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("creates config file if it doesn't exist", async () => { + const configFile = join(testDir, ".zshrc"); + const result = await addToFpath( + configFile, + "/home/user/.local/share/zsh/site-functions" + ); + + expect(result.modified).toBe(true); + expect(result.configFile).toBe(configFile); + + const content = await readFile(configFile, "utf-8"); + expect(content).toContain( + 'fpath=("/home/user/.local/share/zsh/site-functions" $fpath)' + ); + }); + + test("appends to existing config file", async () => { + const configFile = join(testDir, ".zshrc"); + writeFileSync(configFile, "# existing content\n"); + + const result = await addToFpath( + configFile, + "/home/user/.local/share/zsh/site-functions" + ); + + expect(result.modified).toBe(true); + + const content = await readFile(configFile, "utf-8"); + expect(content).toContain("# existing content"); + expect(content).toContain("# sentry"); + expect(content).toContain( + 'fpath=("/home/user/.local/share/zsh/site-functions" $fpath)' + ); + }); + + test("skips if already configured", async () => { + const configFile = join(testDir, ".zshrc"); + writeFileSync( + configFile, + '# sentry\nfpath=("/home/user/.local/share/zsh/site-functions" $fpath)\n' + ); + + const result = await addToFpath( + configFile, + "/home/user/.local/share/zsh/site-functions" + ); + + expect(result.modified).toBe(false); + expect(result.message).toContain("already configured"); + }); + + test("appends newline separator when file doesn't end with newline", async () => { + const configFile = join(testDir, ".zshrc"); + writeFileSync(configFile, "# existing content without newline"); + + const result = await addToFpath( + configFile, + "/home/user/.local/share/zsh/site-functions" + ); + + expect(result.modified).toBe(true); + + const content = await readFile(configFile, "utf-8"); + expect(content).toContain( + "# existing content without newline\n\n# sentry\n" + ); + }); + + test("returns manualCommand when config file cannot be created", async () => { + const configFile = "/dev/null/impossible/path/.zshrc"; + const result = await addToFpath( + configFile, + "/home/user/.local/share/zsh/site-functions" + ); + + expect(result.modified).toBe(false); + expect(result.manualCommand).toBe( + 'fpath=("/home/user/.local/share/zsh/site-functions" $fpath)' + ); + }); + }); + + describe("addToGitHubPath", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns false when not in GitHub Actions", async () => { + const result = await addToGitHubPath("/usr/local/bin", {}); + expect(result).toBe(false); + }); + + test("returns false when GITHUB_PATH is not set", async () => { + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + }); + expect(result).toBe(false); + }); + + test("writes directory to GITHUB_PATH file", async () => { + const pathFile = join(testDir, "github_path"); + writeFileSync(pathFile, ""); + + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + GITHUB_PATH: pathFile, + }); + + expect(result).toBe(true); + const content = await readFile(pathFile, "utf-8"); + expect(content).toContain("/usr/local/bin"); + }); + + test("does not duplicate existing directory", async () => { + const pathFile = join(testDir, "github_path"); + writeFileSync(pathFile, "/usr/local/bin\n"); + + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + GITHUB_PATH: pathFile, + }); + + expect(result).toBe(true); + const content = await readFile(pathFile, "utf-8"); + expect(content).toBe("/usr/local/bin\n"); + }); + + test("returns false when GITHUB_PATH file is not writable", async () => { + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + GITHUB_PATH: "/dev/null/impossible", + }); + + expect(result).toBe(false); + }); + }); +}); + +describe("isInPath", () => { + const sep = process.platform === "win32" ? ";" : ":"; + + test("returns true for an exact match", () => { + expect(isInPath("/usr/local/bin", `/usr/bin${sep}/usr/local/bin`)).toBe( + true + ); + }); + + test("returns false when the directory is absent", () => { + expect(isInPath("/opt/bin", `/usr/bin${sep}/usr/local/bin`)).toBe(false); + }); + + test("returns false for undefined or empty PATH", () => { + expect(isInPath("/usr/bin", undefined)).toBe(false); + expect(isInPath("/usr/bin", "")).toBe(false); + }); + + test("case sensitivity follows the platform", () => { + const result = isInPath("/Users/User/.local/bin", "/users/user/.local/bin"); + if (process.platform === "win32" || process.platform === "darwin") { + expect(result).toBe(true); + } else { + expect(result).toBe(false); + } + }); +}); + +describe("isBashAvailable", () => { + test("returns true when bash is in PATH", () => { + // Point PATH at the directory containing bash + const bashPath = whichSync("bash"); + if (!bashPath) { + // Skip if bash truly isn't on this system + return; + } + expect(isBashAvailable(dirname(bashPath))).toBe(true); + }); + + test("returns false when PATH has no bash", () => { + expect(isBashAvailable("/nonexistent")).toBe(false); + }); + + test("returns false when PATH is empty", () => { + expect(isBashAvailable("")).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/sixel-image.test.ts b/packages/cli/test/lib/sixel-image.test.ts new file mode 100644 index 000000000..6b3e7b8f4 --- /dev/null +++ b/packages/cli/test/lib/sixel-image.test.ts @@ -0,0 +1,296 @@ +/** + * Runtime image → sixel encoder tests. + * + * Exercises format detection (magic bytes + Content-Type fallback), PNG/JPEG + * decoding, median-cut palette construction, nearest-neighbor downscaling, and + * the shape of the emitted sixel escape sequence. Real terminal I/O is not + * involved — everything here is pure and deterministic. + */ + +import { encode as encodeJpeg } from "jpeg-js"; +import { PNG } from "pngjs"; +import { describe, expect, test } from "vitest"; +import { + buildPalette, + type DecodedImage, + decodeImage, + detectImageFormat, + downscale, + encodeImageToSixel, + imageBytesToSixel, + readImageDimensions, +} from "../../src/lib/sixel-image.js"; + +const ESC = "\x1b"; + +/** Build a solid-color RGBA image of the given size. */ +function solidImage( + width: number, + height: number, + rgba: [number, number, number, number] +): DecodedImage { + const data = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + data[i * 4] = rgba[0]; + data[i * 4 + 1] = rgba[1]; + data[i * 4 + 2] = rgba[2]; + data[i * 4 + 3] = rgba[3]; + } + return { width, height, data }; +} + +/** Encode an RGBA DecodedImage as PNG bytes. */ +function toPngBytes(img: DecodedImage): Uint8Array { + const png = new PNG({ width: img.width, height: img.height }); + png.data = Buffer.from(img.data); + return new Uint8Array(PNG.sync.write(png)); +} + +/** Encode an RGB(A) DecodedImage as JPEG bytes. */ +function toJpegBytes(img: DecodedImage): Uint8Array { + const { data } = encodeJpeg( + { data: Buffer.from(img.data), width: img.width, height: img.height }, + 90 + ); + return new Uint8Array(data); +} + +describe("detectImageFormat", () => { + test("detects PNG from magic bytes", () => { + const png = toPngBytes(solidImage(2, 2, [255, 0, 0, 255])); + expect(detectImageFormat(png)).toBe("png"); + }); + + test("detects JPEG from magic bytes", () => { + const jpeg = toJpegBytes(solidImage(4, 4, [0, 128, 0, 255])); + expect(detectImageFormat(jpeg)).toBe("jpeg"); + }); + + test("magic bytes win over a mislabeled Content-Type", () => { + const png = toPngBytes(solidImage(2, 2, [0, 0, 255, 255])); + expect(detectImageFormat(png, "application/octet-stream")).toBe("png"); + }); + + test("falls back to Content-Type when bytes are inconclusive", () => { + const notAnImage = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + expect(detectImageFormat(notAnImage, "image/png")).toBe("png"); + expect(detectImageFormat(notAnImage, "image/jpeg")).toBe("jpeg"); + }); + + test("returns undefined for unsupported input", () => { + const notAnImage = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + expect(detectImageFormat(notAnImage)).toBeUndefined(); + expect(detectImageFormat(notAnImage, "image/gif")).toBeUndefined(); + }); +}); + +describe("decodeImage", () => { + test("round-trips a PNG to RGBA pixels", () => { + const src = solidImage(3, 2, [10, 20, 30, 255]); + const decoded = decodeImage(toPngBytes(src), "png"); + expect(decoded).toBeDefined(); + expect(decoded?.width).toBe(3); + expect(decoded?.height).toBe(2); + expect(decoded?.data[0]).toBe(10); + expect(decoded?.data[1]).toBe(20); + expect(decoded?.data[2]).toBe(30); + }); + + test("returns undefined for corrupt bytes", () => { + const garbage = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]); + expect(decodeImage(garbage, "png")).toBeUndefined(); + }); + + test("decodes a JPEG to a 4-byte RGBA layout", () => { + // JPEG is lossy, so assert the stride/alpha rather than exact colors. + // pixelAt assumes a 4-byte RGBA stride, so decodeImage must always hand + // back RGBA (we pass formatAsRGBA explicitly); a 3-byte RGB buffer would + // be misread. Guards against a jpeg-js default change under semver bumps. + const src = solidImage(8, 8, [200, 40, 60, 255]); + const decoded = decodeImage(toJpegBytes(src), "jpeg"); + expect(decoded).toBeDefined(); + expect(decoded?.width).toBe(8); + expect(decoded?.height).toBe(8); + expect(decoded?.data.length).toBe(8 * 8 * 4); + // Alpha channel is fully opaque for a JPEG (no transparency). + expect(decoded?.data[3]).toBe(255); + // First pixel's red should be close to the source (lossy but not shifted). + expect(Math.abs((decoded?.data[0] ?? 0) - 200)).toBeLessThan(40); + }); + + test("refuses to decode a PNG declaring huge dimensions (OOM guard)", () => { + // Real 1×1 PNG, then overwrite the IHDR width/height with 100000×100000 + // so the pre-decode dimension guard fires before pngjs allocates. + const png = toPngBytes(solidImage(1, 1, [0, 0, 0, 255])); + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + view.setUint32(16, 100_000); + view.setUint32(20, 100_000); + expect(decodeImage(png, "png")).toBeUndefined(); + }); +}); + +describe("readImageDimensions", () => { + test("reads PNG dimensions from the IHDR header", () => { + const png = toPngBytes(solidImage(7, 3, [1, 2, 3, 255])); + expect(readImageDimensions(png, "png")).toEqual({ width: 7, height: 3 }); + }); + + test("reads JPEG dimensions from the SOF marker", () => { + const jpeg = toJpegBytes(solidImage(12, 8, [4, 5, 6, 255])); + expect(readImageDimensions(jpeg, "jpeg")).toEqual({ width: 12, height: 8 }); + }); + + test("reads JPEG dimensions past fill bytes and standalone markers", () => { + // Minimal JPEG-ish stream: SOI, a 0xFF fill byte, an RSTn standalone + // marker, then a SOF0 declaring 3×5. Exercises the marker walker's + // fill-byte and standalone-marker handling so the SOF isn't skipped. + const jpeg = new Uint8Array([ + 0xff, + 0xd8, // SOI + 0xff, // stray fill byte + 0xff, + 0xd0, // RST0 (standalone, no length) + 0xff, + 0xc0, // SOF0 + 0x00, + 0x11, // segment length (17) + 0x08, // precision + 0x00, + 0x05, // height = 5 + 0x00, + 0x03, // width = 3 + 0x03, + 0x01, + 0x22, + 0x00, // (partial component data, unread) + ]); + expect(readImageDimensions(jpeg, "jpeg")).toEqual({ width: 3, height: 5 }); + }); + + test("returns undefined for a truncated header", () => { + expect( + readImageDimensions(new Uint8Array([0x89, 0x50]), "png") + ).toBeUndefined(); + }); +}); + +describe("downscale", () => { + test("returns the source unchanged when already within bounds", () => { + const img = solidImage(10, 5, [1, 2, 3, 255]); + expect(downscale(img, 20, 20)).toBe(img); + }); + + test("scales width down and height proportionally", () => { + const img = solidImage(100, 50, [1, 2, 3, 255]); + const out = downscale(img, 20, 2000); + expect(out.width).toBe(20); + expect(out.height).toBe(10); + }); + + test("scales a tall image down by its height cap", () => { + // Narrow but very tall: width is within bounds, height is 10x over. + const img = solidImage(40, 4000, [1, 2, 3, 255]); + const out = downscale(img, 800, 400); + expect(out.height).toBe(400); + expect(out.width).toBe(4); + }); +}); + +describe("buildPalette", () => { + test("skips fully transparent pixels", () => { + const img = solidImage(4, 4, [255, 0, 0, 0]); + expect(buildPalette(img, 16)).toEqual([]); + }); + + test("produces a single entry for a solid opaque image", () => { + const img = solidImage(4, 4, [128, 64, 32, 255]); + const palette = buildPalette(img, 16); + expect(palette.length).toBe(1); + expect(palette[0]).toEqual([128, 64, 32]); + }); + + test("never exceeds the requested size", () => { + // A gradient with many distinct colors. + const width = 32; + const height = 1; + const data = new Uint8Array(width * height * 4); + for (let x = 0; x < width; x++) { + data[x * 4] = x * 8; + data[x * 4 + 1] = 255 - x * 8; + data[x * 4 + 2] = 100; + data[x * 4 + 3] = 255; + } + const palette = buildPalette({ width, height, data }, 8); + expect(palette.length).toBeLessThanOrEqual(8); + expect(palette.length).toBeGreaterThan(0); + }); +}); + +describe("encodeImageToSixel", () => { + test("emits a well-formed DCS sixel payload", () => { + const img = solidImage(6, 6, [200, 100, 50, 255]); + const sixel = encodeImageToSixel(img); + expect(sixel).toBeDefined(); + expect(sixel?.startsWith(`${ESC}P0;1;0q`)).toBe(true); + expect(sixel?.endsWith(`${ESC}\\`)).toBe(true); + // Raster attributes reserve the pixel box. + expect(sixel).toContain('"1;1;6;6'); + // At least one palette definition and one color-plane selector. + expect(sixel).toContain("#0;2;"); + }); + + test("returns undefined when the image is fully transparent", () => { + const img = solidImage(4, 4, [255, 255, 255, 0]); + expect(encodeImageToSixel(img)).toBeUndefined(); + }); + + test("downscales to a caller-supplied maxWidth narrower than the image", () => { + // 200px-wide image, terminal budget of 40px → raster attributes report 40. + const img = solidImage(200, 20, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 40); + expect(sixel).toContain('"1;1;40;'); + }); + + test("clamps maxWidth to the default ceiling for very wide budgets", () => { + // A 2000px image with a 5000px budget must still cap at DEFAULT_MAX_WIDTH (800). + const img = solidImage(2000, 10, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 5000); + expect(sixel).toContain('"1;1;800;'); + }); + + test("preserves an explicitly bounded wide canvas", () => { + const img = solidImage(1024, 10, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 1024, true); + expect(sixel).toContain('"1;1;1024;'); + }); + + test("preserves an explicitly bounded tall canvas", () => { + const img = solidImage(10, 3000, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img, 10, true); + expect(sixel).toContain('"1;1;10;3000'); + }); + + test("bounds the height of a narrow but very tall image", () => { + // 40px wide (within budget) but 5000px tall — scaled uniformly to the + // DEFAULT_MAX_HEIGHT (2000) so the escape sequence stays bounded. Width + // scales proportionally: 40 * (2000/5000) = 16. + const img = solidImage(40, 5000, [10, 20, 30, 255]); + const sixel = encodeImageToSixel(img); + expect(sixel).toContain('"1;1;16;2000'); + }); +}); + +describe("imageBytesToSixel", () => { + test("decodes and encodes a PNG in one step", () => { + const png = toPngBytes(solidImage(8, 8, [0, 150, 255, 255])); + const sixel = imageBytesToSixel(png, "image/png"); + expect(sixel).toBeDefined(); + expect(sixel?.startsWith(`${ESC}P`)).toBe(true); + expect(sixel?.endsWith(`${ESC}\\`)).toBe(true); + }); + + test("returns undefined for unsupported bytes", () => { + const notAnImage = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + expect(imageBytesToSixel(notAnImage, "text/plain")).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/sixel.test.ts b/packages/cli/test/lib/sixel.test.ts new file mode 100644 index 000000000..c9686f5f2 --- /dev/null +++ b/packages/cli/test/lib/sixel.test.ts @@ -0,0 +1,383 @@ +/** + * Sixel Banner Tests + * + * Covers the pure, terminal-independent pieces of sixel support: DA1/cell-size + * reply parsing, fit calculation, the safe non-TTY default, and the shape of the + * baked banner module. The terminal round-trip in `probe()` is intentionally not + * exercised (it requires a real tty); everything it feeds is unit-tested here. + */ + +import { + closeSync, + mkdtempSync, + openSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assert as fcAssert, integer, property, uniqueArray } from "fast-check"; +import { afterEach, describe, expect, test } from "vitest"; +import { BANNER_SIXEL } from "../../src/generated/banner-sixel.js"; +import { + __resetSixelCache, + detectSixelCaps, + graphicsCellSize, + optedOut, + parseSixelCaps, + readReply, + selectGraphicsFormat, + selectGraphicsFormatFromAvailability, + sixelBanner, + sixelFits, + terminalPixelWidth, +} from "../../src/lib/sixel.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const ESC = "\x1b"; + +describe("parseSixelCaps", () => { + test("detects sixel from DA1 attribute 4", () => { + const caps = parseSixelCaps(`${ESC}[?62;4;6c`); + expect(caps.supported).toBe(true); + }); + + test("treats missing attribute 4 as unsupported", () => { + expect(parseSixelCaps(`${ESC}[?62;1;6c`).supported).toBe(false); + }); + + test("does not match 4 as a substring of another attribute", () => { + // 14, 40, 64 all contain '4' but are not the sixel attribute. + expect(parseSixelCaps(`${ESC}[?14;40;64c`).supported).toBe(false); + }); + + test("parses cell size from CSI 16 t report", () => { + const caps = parseSixelCaps(`${ESC}[?62;4c${ESC}[6;20;10t`); + expect(caps).toMatchObject({ + supported: true, + cellHeight: 20, + cellWidth: 10, + }); + }); + + test("supported but no cell size when 16t is absent", () => { + const caps = parseSixelCaps(`${ESC}[?62;4c`); + expect(caps.supported).toBe(true); + expect(caps.cellWidth).toBeUndefined(); + }); + + test("garbage / empty replies are unsupported", () => { + expect(parseSixelCaps("").supported).toBe(false); + expect(parseSixelCaps("not a terminal reply").supported).toBe(false); + }); + + test("property: supported iff the attribute list contains exactly '4'", () => { + fcAssert( + property( + uniqueArray(integer({ min: 0, max: 99 }), { maxLength: 8 }), + (attrs) => { + const reply = `${ESC}[?${attrs.join(";")}c`; + expect(parseSixelCaps(reply).supported).toBe(attrs.includes(4)); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("detects kitty support from an OK graphics reply", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;OK${ESC}\\${ESC}[?62c`); + expect(caps.kitty).toBe(true); + }); + + test("kitty support does not imply sixel support", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;OK${ESC}\\${ESC}[?62c`); + expect(caps.supported).toBe(false); + }); + + test("reports both when the terminal advertises sixel and kitty", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;OK${ESC}\\${ESC}[?62;4c`); + expect(caps).toMatchObject({ supported: true, kitty: true }); + }); + + test("a kitty error reply is not treated as support", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;ENOENT:bad${ESC}\\${ESC}[?62c`); + expect(caps.kitty).toBeUndefined(); + }); +}); + +describe("sixelFits", () => { + const caps = { supported: true, cellWidth: 10, cellHeight: 20 }; + + test("fits when banner width <= columns * cellWidth", () => { + expect(sixelFits(caps, 80, 640)).toBe(true); // 800px available >= 640 + expect(sixelFits(caps, 64, 640)).toBe(true); // exactly 640px + }); + + test("does not fit when the image is wider than the terminal", () => { + expect(sixelFits(caps, 60, 640)).toBe(false); // 600px < 640 + }); + + test("declines without a known cell width", () => { + expect(sixelFits({ supported: true }, 200, 640)).toBe(false); + expect(sixelFits({ supported: true, cellWidth: 0 }, 200, 640)).toBe(false); + }); + + test("declines when unsupported", () => { + expect(sixelFits({ supported: false, cellWidth: 10 }, 200, 640)).toBe( + false + ); + }); +}); + +describe("terminalPixelWidth", () => { + afterEach(() => { + __resetSixelCache(); + }); + + test("returns undefined when the terminal has no sixel caps (non-TTY test env)", () => { + // Under the test runner stdin/stdout are not TTYs, so the probe reports + // unsupported and there's no cell width to derive a pixel budget from. + __resetSixelCache(); + expect(terminalPixelWidth(80)).toBeUndefined(); + }); +}); + +describe("selectGraphicsFormat", () => { + afterEach(() => { + __resetSixelCache(); + }); + + test("returns undefined in a non-interactive (test) environment", () => { + // Under vitest stdin/stdout are not TTYs, so neither kitty nor sixel is + // available and the caller falls back to ASCII. + __resetSixelCache(); + expect(selectGraphicsFormat()).toBeUndefined(); + }); + + test("falls back to the automatic renderer when the requested one is unavailable", () => { + expect( + selectGraphicsFormatFromAvailability("sixel", { + kitty: true, + sixel: false, + }) + ).toBe("kitty"); + }); +}); + +describe("graphicsCellSize", () => { + afterEach(() => { + __resetSixelCache(); + }); + + test("returns undefined when no graphics format is available", () => { + // No TTY in the test env means no graphics format, so no cell size either. + __resetSixelCache(); + expect(graphicsCellSize()).toBeUndefined(); + }); +}); + +describe("sixelBanner", () => { + test("returns undefined in a non-interactive (test) environment", () => { + // Under vitest stdin/stdout are not TTYs, so the probe opts out and no + // sixel is emitted — the caller falls back to block art. + expect(sixelBanner(200)).toBeUndefined(); + }); + + test("suppresses the image when opted out even with a TTY", () => { + const saved = { + stdout: process.stdout.isTTY, + stdin: process.stdin.isTTY, + noSixel: process.env.SENTRY_NO_SIXEL, + }; + try { + // TTY on both ends, but SENTRY_NO_SIXEL forces opt-out: the emit-path + // guard must return undefined without emitting (and without probing). + process.stdout.isTTY = true; + process.stdin.isTTY = true; + process.env.SENTRY_NO_SIXEL = "1"; + __resetSixelCache(); + expect(sixelBanner(200)).toBeUndefined(); + } finally { + process.stdout.isTTY = saved.stdout; + process.stdin.isTTY = saved.stdin; + if (saved.noSixel === undefined) { + delete process.env.SENTRY_NO_SIXEL; + } else { + process.env.SENTRY_NO_SIXEL = saved.noSixel; + } + __resetSixelCache(); + } + }); +}); + +describe("BANNER_SIXEL (generated)", () => { + test("is a well-formed sixel payload with positive dimensions", () => { + expect(BANNER_SIXEL.width).toBeGreaterThan(0); + expect(BANNER_SIXEL.height).toBeGreaterThan(0); + // DCS sixel introducer ... String Terminator. + expect(BANNER_SIXEL.data.startsWith(`${ESC}P`)).toBe(true); + expect(BANNER_SIXEL.data.endsWith(`${ESC}\\`)).toBe(true); + // Transparent background: P2 = 1 in the DCS parameters. + expect(BANNER_SIXEL.data.startsWith(`${ESC}P0;1;0q`)).toBe(true); + }); +}); + +describe("optedOut", () => { + const saved = { + stdout: process.stdout.isTTY, + stdin: process.stdin.isTTY, + TERM: process.env.TERM, + SENTRY_NO_SIXEL: process.env.SENTRY_NO_SIXEL, + SENTRY_NO_GRAPHICS: process.env.SENTRY_NO_GRAPHICS, + NO_COLOR: process.env.NO_COLOR, + SENTRY_PLAIN_OUTPUT: process.env.SENTRY_PLAIN_OUTPUT, + FORCE_COLOR: process.env.FORCE_COLOR, + }; + + const setEnv = (key: string, value: string | undefined): void => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + + afterEach(() => { + process.stdout.isTTY = saved.stdout; + process.stdin.isTTY = saved.stdin; + setEnv("TERM", saved.TERM); + setEnv("SENTRY_NO_SIXEL", saved.SENTRY_NO_SIXEL); + setEnv("SENTRY_NO_GRAPHICS", saved.SENTRY_NO_GRAPHICS); + setEnv("NO_COLOR", saved.NO_COLOR); + setEnv("SENTRY_PLAIN_OUTPUT", saved.SENTRY_PLAIN_OUTPUT); + setEnv("FORCE_COLOR", saved.FORCE_COLOR); + }); + + /** Put the process in a state where sixel probing WOULD be allowed. */ + function makeInteractive(): void { + process.stdout.isTTY = true; + process.stdin.isTTY = true; + setEnv("TERM", "xterm-256color"); + setEnv("SENTRY_NO_SIXEL", undefined); + setEnv("NO_COLOR", undefined); + setEnv("SENTRY_PLAIN_OUTPUT", undefined); + setEnv("FORCE_COLOR", undefined); + } + + test("does not opt out on an interactive color terminal (non-win32)", () => { + makeInteractive(); + if (process.platform !== "win32") { + expect(optedOut()).toBe(false); + } + }); + + test("opts out when stdout or stdin is not a TTY", () => { + makeInteractive(); + process.stdout.isTTY = false; + expect(optedOut()).toBe(true); + makeInteractive(); + process.stdin.isTTY = false; + expect(optedOut()).toBe(true); + }); + + test("opts out when TERM is unset or dumb", () => { + makeInteractive(); + setEnv("TERM", undefined); + expect(optedOut()).toBe(true); + makeInteractive(); + setEnv("TERM", "dumb"); + expect(optedOut()).toBe(true); + }); + + test("opts out under plain-output signals (NO_COLOR)", () => { + makeInteractive(); + setEnv("NO_COLOR", "1"); + expect(optedOut()).toBe(true); + }); + + test("SENTRY_NO_SIXEL honors truthiness (0/false do not opt out)", () => { + makeInteractive(); + setEnv("SENTRY_NO_SIXEL", "1"); + expect(optedOut()).toBe(true); + makeInteractive(); + setEnv("SENTRY_NO_SIXEL", "0"); + if (process.platform !== "win32") { + expect(optedOut()).toBe(false); + } + makeInteractive(); + setEnv("SENTRY_NO_SIXEL", "false"); + if (process.platform !== "win32") { + expect(optedOut()).toBe(false); + } + }); + + test("SENTRY_NO_GRAPHICS opts out", () => { + setEnv("SENTRY_NO_GRAPHICS", "1"); + expect(optedOut()).toBe(true); + }); +}); + +describe("readReply", () => { + function withReplyFile(bytes: string, fn: (fd: number) => void): void { + const dir = mkdtempSync(join(tmpdir(), "sixel-reply-")); + const file = join(dir, "reply"); + writeFileSync(file, bytes, "latin1"); + const fd = openSync(file, "r"); + try { + fn(fd); + } finally { + closeSync(fd); + rmSync(dir, { recursive: true, force: true }); + } + } + + test("drains the full reply up to the Primary DA sentinel", () => { + // Cell-size report first, Primary DA (sentinel, ends in `c`) last. + withReplyFile(`${ESC}[6;20;10t${ESC}[?62;4;6c`, (fd) => { + const reply = readReply(fd); + expect(reply).toContain("c"); + expect(parseSixelCaps(reply)).toMatchObject({ + supported: true, + cellHeight: 20, + cellWidth: 10, + }); + }); + }); + + test("returns an empty string at EOF with no reply", () => { + withReplyFile("", (fd) => { + expect(readReply(fd)).toBe(""); + }); + }); + + test("does not stop on a stray 'c' before the Primary DA reply", () => { + // A stray `c` (e.g. a keypress) followed by the cell-size report but NO DA + // reply: readReply must keep draining rather than stop at the stray `c`. + withReplyFile(`c${ESC}[6;20;10t`, (fd) => { + expect(readReply(fd)).toBe(`c${ESC}[6;20;10t`); + }); + }); + + test("captures the full reply even when preceded by a stray 'c'", () => { + withReplyFile(`c${ESC}[6;20;10t${ESC}[?62;4;6c`, (fd) => { + expect(parseSixelCaps(readReply(fd))).toMatchObject({ + supported: true, + cellWidth: 10, + }); + }); + }); +}); + +describe("detectSixelCaps", () => { + afterEach(() => { + __resetSixelCache(); + }); + + test("caches the probe result (unsupported in a non-TTY test env)", () => { + __resetSixelCache(); + const first = detectSixelCaps(); + const second = detectSixelCaps(); + expect(first).toBe(second); // same cached reference — probed at most once + expect(first.supported).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/snapshots/archive.test.ts b/packages/cli/test/lib/snapshots/archive.test.ts new file mode 100644 index 000000000..500abc3cf --- /dev/null +++ b/packages/cli/test/lib/snapshots/archive.test.ts @@ -0,0 +1,147 @@ +/** + * Tests for streaming snapshot archive extraction, including Zip-Slip protection. + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { strToU8, zipSync } from "fflate"; +import { afterEach, describe, expect, test } from "vitest"; +import { extractZipStream } from "../../../src/lib/snapshots/archive.js"; + +const dirs: string[] = []; +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "snap-extract-")); + dirs.push(dir); + return dir; +} + +/** Yield a buffer as an async stream, split into fixed-size chunks. */ +async function* streamOf( + buffer: Uint8Array, + chunkSize = 8 +): AsyncIterable { + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + yield buffer.subarray(offset, offset + chunkSize); + } +} + +afterEach(() => { + while (dirs.length > 0) { + const dir = dirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("extractZipStream", () => { + test("extracts files (incl. nested) and skips directory entries", async () => { + const zip = zipSync({ + "a.png": strToU8("A"), + "sub/b.png": strToU8("BB"), + "emptydir/": new Uint8Array(), + }); + const out = tempDir(); + + const count = await extractZipStream(streamOf(zip), out); + + expect(count).toBe(2); + expect(readFileSync(join(out, "a.png"), "utf8")).toBe("A"); + expect(readFileSync(join(out, "sub", "b.png"), "utf8")).toBe("BB"); + }); + + test("skips traversal, absolute, and empty entries but keeps safe names", async () => { + const zip = zipSync({ + "../evil.png": strToU8("X"), + "a/../../evil2.png": strToU8("X"), + "/etc/evil3.png": strToU8("X"), + "": strToU8("X"), + // Legitimate name that merely starts with ".." — must NOT be skipped. + "..config.png": strToU8("C"), + "ok.png": strToU8("Y"), + }); + const out = tempDir(); + + const count = await extractZipStream(streamOf(zip), out); + + expect(count).toBe(2); // "..config.png" + "ok.png" + expect(existsSync(join(out, "ok.png"))).toBe(true); + expect(readFileSync(join(out, "..config.png"), "utf8")).toBe("C"); + // No traversal entry escaped the output dir. + expect(existsSync(join(out, "..", "evil.png"))).toBe(false); + expect(existsSync(join(out, "..", "..", "evil2.png"))).toBe(false); + }); + + test("handles a larger deflated entry split across many chunks", async () => { + const big = "pixel-data-".repeat(5000); + const zip = zipSync({ "big.png": strToU8(big) }); + const out = tempDir(); + + const count = await extractZipStream(streamOf(zip, 64), out); + + expect(count).toBe(1); + expect(readFileSync(join(out, "big.png"), "utf8")).toBe(big); + }); + + test("extracts stored (uncompressed, method 0) entries", async () => { + const body = "A".repeat(4000); + // level: 0 → STORE, exercising the UnzipPassThrough decoder. + const zip = zipSync({ "img.png": [strToU8(body), { level: 0 }] }); + const out = tempDir(); + + const count = await extractZipStream(streamOf(zip, 128), out); + + expect(count).toBe(1); + expect(readFileSync(join(out, "img.png"), "utf8")).toBe(body); + }); + + test("surfaces a write error without emitting unhandled rejections", async () => { + const out = tempDir(); + // Pre-create the entry's destination as a directory → write fails (EISDIR). + mkdirSync(join(out, "a.png")); + const zip = zipSync({ "a.png": strToU8("A"), "b.png": strToU8("B") }); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + await expect(extractZipStream(streamOf(zip), out)).rejects.toThrow(); + // Let any stray microtasks/timers surface a rejection if one leaked. + await new Promise((r) => setTimeout(r, 20)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + test("propagates a mid-stream source error and cleans up", async () => { + const out = tempDir(); + const zip = zipSync({ "a.png": strToU8("A".repeat(2000)) }); + // Emit a partial chunk (opening a write stream) then fail, exercising the + // early-bail cleanup path (skips Promise.all). + async function* faulty(): AsyncIterable { + yield zip.subarray(0, 40); + throw new Error("network boom"); + } + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + await expect(extractZipStream(faulty(), out)).rejects.toThrow( + "network boom" + ); + await new Promise((r) => setTimeout(r, 20)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); diff --git a/packages/cli/test/lib/snapshots/diff.test.ts b/packages/cli/test/lib/snapshots/diff.test.ts new file mode 100644 index 000000000..143f51430 --- /dev/null +++ b/packages/cli/test/lib/snapshots/diff.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for local snapshot image diffing (pixelmatch + pngjs). + * + * Fixtures are generated in-memory with pngjs — no committed binaries. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { encode as encodeJpeg } from "jpeg-js"; +import { PNG } from "pngjs"; +import { afterEach, describe, expect, test } from "vitest"; +import { + categorizeImages, + collectImageFiles, + compareImages, +} from "../../../src/lib/snapshots/diff.js"; + +/** Build a solid-color PNG of the given size. */ +function png( + width: number, + height: number, + rgb: [number, number, number] +): Buffer { + const image = new PNG({ width, height }); + for (let i = 0; i < width * height * 4; i += 4) { + image.data[i] = rgb[0]; + image.data[i + 1] = rgb[1]; + image.data[i + 2] = rgb[2]; + image.data[i + 3] = 255; + } + return PNG.sync.write(image); +} + +/** Build a solid-color JPEG (RGBA in, JPEG out). */ +function jpeg( + width: number, + height: number, + rgb: [number, number, number] +): Buffer { + const data = Buffer.alloc(width * height * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = rgb[0]; + data[i + 1] = rgb[1]; + data[i + 2] = rgb[2]; + data[i + 3] = 255; + } + return Buffer.from(encodeJpeg({ data, width, height }, 100).data); +} + +const OPTS = { threshold: 0.1, antialiasing: true }; + +describe("compareImages", () => { + test("identical pixels → match", () => { + const result = compareImages( + png(4, 4, [10, 20, 30]), + png(4, 4, [10, 20, 30]), + "a.png", + OPTS + ); + expect(result.kind).toBe("match"); + }); + + test("differing pixels → changed with a mask", () => { + const result = compareImages( + png(4, 4, [255, 0, 0]), + png(4, 4, [0, 255, 0]), + "a.png", + OPTS + ); + expect(result.kind).toBe("changed"); + if (result.kind === "changed") { + expect(result.diffCount).toBe(16); + // Percentage (0–100), matching legacy odiff output — all 16 px differ. + expect(result.diffPercentage).toBe(100); + expect(result.mask.length).toBeGreaterThan(0); + } + }); + + test("mismatched dimensions → layout", () => { + const result = compareImages( + png(4, 4, [0, 0, 0]), + png(8, 8, [0, 0, 0]), + "a.png", + OPTS + ); + expect(result.kind).toBe("layout"); + }); + + test("decodes JPEG inputs (identical → match)", () => { + const result = compareImages( + jpeg(4, 4, [20, 40, 60]), + jpeg(4, 4, [20, 40, 60]), + "a.jpg", + OPTS + ); + expect(result.kind).toBe("match"); + }); + + test("throws on undecodable image data", () => { + expect(() => + compareImages( + Buffer.from("not a real png"), + png(4, 4, [0, 0, 0]), + "a.png", + OPTS + ) + ).toThrow(); + }); +}); + +describe("categorizeImages", () => { + test("partitions matched/added/removed", () => { + expect(categorizeImages(["a", "b"], ["b", "c"], false)).toEqual({ + matched: ["b"], + added: ["c"], + removed: ["a"], + skipped: [], + }); + }); + + test("selective treats base-only images as skipped", () => { + expect(categorizeImages(["a", "b"], ["b", "c"], true)).toEqual({ + matched: ["b"], + added: ["c"], + removed: [], + skipped: ["a"], + }); + }); +}); + +describe("collectImageFiles", () => { + const dirs: string[] = []; + afterEach(() => { + while (dirs.length > 0) { + const dir = dirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + test("collects images recursively and skips non-images", async () => { + const dir = mkdtempSync(join(tmpdir(), "snap-collect-")); + dirs.push(dir); + writeFileSync(join(dir, "a.png"), png(2, 2, [0, 0, 0])); + mkdirSync(join(dir, "sub")); + writeFileSync(join(dir, "sub", "b.png"), png(2, 2, [0, 0, 0])); + writeFileSync(join(dir, "notes.txt"), "not an image"); + + await expect(collectImageFiles(dir)).resolves.toEqual([ + "a.png", + "sub/b.png", + ]); + }); +}); diff --git a/packages/cli/test/lib/snapshots/images.test.ts b/packages/cli/test/lib/snapshots/images.test.ts new file mode 100644 index 000000000..058704b08 --- /dev/null +++ b/packages/cli/test/lib/snapshots/images.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for snapshot image collection + validation. + * + * Real PNG fixtures are generated in-memory with pngjs (no committed binaries) + * so the header parser reads genuine headers. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PNG } from "pngjs"; +import { afterEach, describe, expect, test } from "vitest"; +import { ValidationError } from "../../../src/lib/errors.js"; +import { + type CollectedImage, + collectImages, + MAX_PIXELS_PER_IMAGE, + normalizeImageNames, + splitAndTrim, + validateImageSizes, +} from "../../../src/lib/snapshots/images.js"; + +/** Encode a solid PNG of the given dimensions. */ +function pngBytes(width: number, height: number): Buffer { + const png = new PNG({ width, height }); + png.data.fill(0xff); + return PNG.sync.write(png); +} + +const dirs: string[] = []; +function tempTree(build: (root: string) => void): string { + const root = mkdtempSync(join(tmpdir(), "snap-img-")); + dirs.push(root); + build(root); + return root; +} +afterEach(() => { + while (dirs.length > 0) { + const d = dirs.pop(); + if (d) { + rmSync(d, { recursive: true, force: true }); + } + } +}); + +describe("collectImages", () => { + test("collects PNGs with dimensions, hash, and sidecar; skips hidden + non-images", async () => { + const root = tempTree((r) => { + writeFileSync(join(r, "a.png"), pngBytes(4, 3)); + writeFileSync(join(r, "a.json"), JSON.stringify({ custom: "value" })); + mkdirSync(join(r, "sub")); + writeFileSync(join(r, "sub", "b.png"), pngBytes(2, 2)); + writeFileSync(join(r, "notes.txt"), "not an image"); + writeFileSync(join(r, ".hidden.png"), pngBytes(1, 1)); + }); + + const images = await collectImages(root); + const byKey = new Map(images.map((i) => [i.relativePath, i])); + + expect([...byKey.keys()].sort()).toEqual(["a.png", "sub/b.png"]); + const a = byKey.get("a.png"); + expect(a?.width).toBe(4); + expect(a?.height).toBe(3); + expect(a?.hash).toMatch(/^[0-9a-f]{64}$/); + expect(a?.sidecar).toEqual({ custom: "value" }); + // No sidecar for sub/b.png → empty object. + expect(byKey.get("sub/b.png")?.sidecar).toEqual({}); + }); + + test("returns an empty list for a directory with no images", async () => { + const root = tempTree((r) => { + writeFileSync(join(r, "readme.md"), "hi"); + }); + expect(await collectImages(root)).toEqual([]); + }); + + test("skips malformed and unsupported image content without decoding it", async () => { + const root = tempTree((r) => { + writeFileSync(join(r, "malformed.png"), Buffer.from("not a PNG")); + writeFileSync(join(r, "unsupported.jpg"), Buffer.from("not a JPEG")); + writeFileSync(join(r, "jxl.png"), Buffer.from([0xff, 0x0a, 0x00, 0x00])); + }); + + await expect(collectImages(root)).resolves.toEqual([]); + }); +}); + +describe("validateImageSizes", () => { + function img(width: number, height: number): CollectedImage { + return { + path: "/x.png", + relativePath: "x.png", + width, + height, + hash: "h", + sidecar: {}, + }; + } + + test("passes at the pixel limit", () => { + expect(() => validateImageSizes([img(8000, 5000)])).not.toThrow(); // 40,000,000 + }); + + test("throws listing images over the pixel limit", () => { + expect(() => validateImageSizes([img(8001, 5000)])).toThrow( + ValidationError + ); + expect(() => validateImageSizes([img(8001, 5000)])).toThrow( + String(MAX_PIXELS_PER_IMAGE) + ); + }); +}); + +describe("splitAndTrim", () => { + test("splits on a comma, trimming and dropping empties", () => { + expect(splitAndTrim("a.png, b.png , , c.png", ",")).toEqual([ + "a.png", + "b.png", + "c.png", + ]); + }); + + test("splits on newlines", () => { + expect(splitAndTrim("a.png\nb.png\n\nc.png\n", "\n")).toEqual([ + "a.png", + "b.png", + "c.png", + ]); + }); +}); + +describe("normalizeImageNames", () => { + test("strips a leading ./ and backslashes → forward slashes", () => { + expect(normalizeImageNames(["./img/a.png", "img\\b.png", "c.png"])).toEqual( + ["img/a.png", "img/b.png", "c.png"] + ); + }); +}); diff --git a/packages/cli/test/lib/sourcemap/directive.test.ts b/packages/cli/test/lib/sourcemap/directive.test.ts new file mode 100644 index 000000000..a14360cdb --- /dev/null +++ b/packages/cli/test/lib/sourcemap/directive.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for the byte-level `sourceMappingURL` directive parser. + * + * The reader (`readLastLine`) is internal and exercised end-to-end via + * discovery tests (see inject.test.ts, including the >2MB inline-blob case). + * Here we test the directive classification directly. + */ + +import { describe, expect, test } from "vitest"; +import { parseSourceMappingDirective } from "../../../src/lib/sourcemap/inject.js"; + +/** Parse a directive from a UTF-8 string line. */ +function parse(line: string) { + return parseSourceMappingDirective(Buffer.from(line, "utf-8")); +} + +describe("parseSourceMappingDirective", () => { + test("classifies an external relative path", () => { + expect(parse("//# sourceMappingURL=app.js.map")).toEqual({ + kind: "external", + value: "app.js.map", + }); + }); + + test("classifies an inline data URL", () => { + const result = parse( + "//# sourceMappingURL=data:application/json;base64,e30=" + ); + expect(result?.kind).toBe("inline"); + expect(result?.value).toBe("data:application/json;base64,e30="); + }); + + test("classifies a remote URL", () => { + expect( + parse("//# sourceMappingURL=https://cdn.example.com/app.js.map")?.kind + ).toBe("remote"); + }); + + test("accepts the //@ marker variant", () => { + expect(parse("//@ sourceMappingURL=app.js.map")?.value).toBe("app.js.map"); + }); + + test("tolerates a trailing CR (CRLF line)", () => { + expect(parse("//# sourceMappingURL=app.js.map\r")?.value).toBe( + "app.js.map" + ); + }); + + test("returns undefined for a non-directive line", () => { + expect(parse("console.log(1)")).toBeUndefined(); + expect(parse("// just a comment")).toBeUndefined(); + expect(parse("")).toBeUndefined(); + }); + + test("returns undefined when the value is missing", () => { + expect(parse("//# sourceMappingURL=")).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/sourcemap/inject.test.ts b/packages/cli/test/lib/sourcemap/inject.test.ts new file mode 100644 index 000000000..be05615a6 --- /dev/null +++ b/packages/cli/test/lib/sourcemap/inject.test.ts @@ -0,0 +1,756 @@ +/** + * Tests for directory-level debug-ID injection. Covers the discovery + * walk (used to be hand-rolled, now delegates to `walkFiles`) — + * specifically the skip policy for `node_modules` / dotfiles, the + * `.gitignore` bypass for build-output dirs, and the extension + * filter. + */ + +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getDebugIdSnippet } from "../../../src/lib/sourcemap/debug-id.js"; +import { injectDirectory } from "../../../src/lib/sourcemap/inject.js"; +import { tryDecodeInlineSourcemap } from "../../../src/lib/sourcemap/inline-sourcemap.js"; + +describe("injectDirectory — discovery", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** Write a .js + .js.map pair at `rel` inside `dir`. */ + function writePair(rel: string): void { + const full = join(dir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, `// ${rel}\n`); + writeFileSync(`${full}.map`, "{}\n"); + } + + test("discovers .js pairs in nested dirs", async () => { + writePair("app.js"); + writePair("a/nested.js"); + writePair("a/b/deep.js"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)).sort(); + expect(paths).toEqual(["a/b/deep.js", "a/nested.js", "app.js"]); + }); + + test("skips .js files without a companion .map", async () => { + writePair("withmap.js"); + writeFileSync(join(dir, "orphan.js"), "// orphan\n"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)); + expect(paths).toEqual(["withmap.js"]); + }); + + test("discovers .cjs and .mjs files by default", async () => { + writePair("a.js"); + writePair("b.cjs"); + writePair("c.mjs"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)).sort(); + expect(paths).toEqual(["a.js", "b.cjs", "c.mjs"]); + }); + + test("respects custom extensions", async () => { + writePair("a.js"); + writePair("b.ts"); + + const results = await injectDirectory(dir, { + dryRun: true, + extensions: ["ts"], + }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)); + expect(paths).toEqual(["b.ts"]); + }); + + test("skips node_modules", async () => { + writePair("app.js"); + writePair("node_modules/foo/lib.js"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)); + expect(paths).toEqual(["app.js"]); + }); + + test("skips hidden (dot-prefixed) directories", async () => { + writePair("app.js"); + writePair(".cache/cached.js"); + writePair(".git/hooks/script.js"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)); + expect(paths).toEqual(["app.js"]); + }); + + test("ignores .gitignore — build-output dirs are always scanned", async () => { + // Typical build setup: `dist/` is gitignored but contains the + // files we want to inject into. + writeFileSync(join(dir, ".gitignore"), "dist/\nbuild/\n"); + writePair("src/a.js"); + writePair("dist/bundle.js"); + writePair("build/out.js"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)).sort(); + expect(paths).toEqual(["build/out.js", "dist/bundle.js", "src/a.js"]); + }); + + test("scans a directory that's itself named like a gitignore target", async () => { + // User passes `dist/` directly as the scan root. The default + // skip list in `scan/` includes "dist" — we explicitly narrow + // it to `["node_modules"]` for this use case. + writePair("bundle.js"); + writePair("chunks/one.js"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)).sort(); + expect(paths).toEqual(["bundle.js", "chunks/one.js"]); + }); + + test("does not follow symlinks", async () => { + // Default: symlinks are ignored (matches pre-refactor behavior). + writePair("real.js"); + const realDir = join(dir, "src"); + const linkDir = join(dir, "link"); + mkdirSync(realDir, { recursive: true }); + writePair("src/x.js"); + try { + symlinkSync(realDir, linkDir, "dir"); + } catch { + // Some filesystems (e.g. Windows without dev mode) can't + // create symlinks — skip this assertion in that case. + return; + } + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)).sort(); + // `real.js` + `src/x.js` should be discovered; `link/x.js` must NOT. + expect(paths).toEqual(["real.js", "src/x.js"]); + }); + + test("returns empty for missing directory", async () => { + const results = await injectDirectory(join(dir, "does-not-exist"), { + dryRun: true, + }); + expect(results).toEqual([]); + }); + + test("accepts relative paths (not just absolute)", async () => { + // Regression: `walkFiles` enforces absolute cwd and throws on + // relative input. CLI callers (`sourcemap inject ./dist`) pass + // the user-supplied arg straight through, so the adapter must + // resolve it to absolute itself. + writePair("app.js"); + const originalCwd = process.cwd(); + process.chdir(dir); + try { + for (const relDir of ["./", ".", "./."]) { + const results = await injectDirectory(relDir, { dryRun: true }); + expect(results).toHaveLength(1); + // The jsPath must still be absolute — consumers expect + // absolute paths for downstream file ops. + expect(results[0]?.jsPath).toMatch(/^\//); + } + } finally { + process.chdir(originalCwd); + } + }); + + test("discovers large JS bundles (> walker's default 256 KB)", async () => { + // Regression: `walkFiles` defaults to `maxFileSize: 256 KB`, + // which silently skipped any `.js` file larger than that — + // i.e. every real-world webpack/rollup/Next.js bundle. The + // adapter must opt out of the size cap. + const bundlePath = join(dir, "bundle.js"); + // 512 KB of filler — exceeds the walker's default 256 KB cap. + writeFileSync(bundlePath, "x".repeat(512 * 1024)); + writeFileSync(`${bundlePath}.map`, "{}\n"); + + const results = await injectDirectory(dir, { dryRun: true }); + const paths = results.map((r) => r.jsPath.slice(dir.length + 1)); + expect(paths).toEqual(["bundle.js"]); + }); +}); + +describe("injectDirectory — inline sourcemaps", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-inline-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** Build a `data:` URL for a sourcemap object. */ + function toDataUrl(map: unknown): string { + const b64 = Buffer.from(JSON.stringify(map)).toString("base64"); + return `data:application/json;base64,${b64}`; + } + + /** Write a JS file with an inline sourcemap and no companion .map. */ + function writeInline(rel: string, map: unknown, body = "console.log(1)\n") { + const full = join(dir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, `${body}//# sourceMappingURL=${toDataUrl(map)}\n`); + return full; + } + + const SAMPLE_MAP = { + version: 3, + sources: ["a.ts"], + mappings: "AAAA", + names: [], + }; + + test("discovers an inline-map JS file (no companion .map)", async () => { + writeInline("inline.js", SAMPLE_MAP); + const results = await injectDirectory(dir, { dryRun: true }); + expect(results).toHaveLength(1); + expect(results[0]?.map.kind).toBe("inline"); + expect(results[0]?.mapPath).toBeUndefined(); + }); + + test("injects a debug ID and rewrites the inline directive in place", async () => { + const jsPath = writeInline("inline.js", SAMPLE_MAP); + const results = await injectDirectory(dir); + expect(results).toHaveLength(1); + const { debugId, injected, injectedMapContent } = results[0] ?? {}; + expect(injected).toBe(true); + expect(debugId).toMatch(/^[0-9a-f-]{36}$/); + + const js = readFileSync(jsPath, "utf-8"); + // IIFE snippet + debugId comment present. + expect(js).toContain(`sentry-dbid-${debugId}`); + expect(js).toContain(`//# debugId=${debugId}`); + + // The rewritten inline directive carries the injected map. + const m = js.match( + /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/ + ); + expect(m).not.toBeNull(); + const rewritten = JSON.parse( + Buffer.from(m?.[1] ?? "", "base64").toString("utf-8") + ); + expect(rewritten.debug_id).toBe(debugId); + expect(rewritten.debugId).toBe(debugId); + expect(rewritten.mappings).toBe(`;${SAMPLE_MAP.mappings}`); + + // injectedMapContent matches the rewritten inline map. + expect( + JSON.parse((injectedMapContent ?? Buffer.alloc(0)).toString()) + ).toEqual(rewritten); + }); + + test("is idempotent across repeated injection", async () => { + const jsPath = writeInline("inline.js", SAMPLE_MAP); + await injectDirectory(dir); + const first = readFileSync(jsPath, "utf-8"); + const second = await injectDirectory(dir); + expect(second[0]?.injected).toBe(false); + expect(readFileSync(jsPath, "utf-8")).toBe(first); + }); + + test("discovers inline maps larger than the 2MB last-line window", async () => { + // Pad the sourcemap so its base64 data URL exceeds 2 MB, forcing the + // backward last-line reader to slide its window. + const bigMap = { + version: 3, + sources: ["a.ts"], + mappings: "AAAA", + sourcesContent: ["x".repeat(3 * 1024 * 1024)], + }; + writeInline("big-inline.js", bigMap); + const results = await injectDirectory(dir, { dryRun: true }); + expect(results).toHaveLength(1); + expect(results[0]?.map.kind).toBe("inline"); + }); + + test("discovers an inline directive followed by a trailing license banner", async () => { + const jsPath = join(dir, "banner.js"); + writeFileSync( + jsPath, + `console.log(1)\n//# sourceMappingURL=${toDataUrl(SAMPLE_MAP)}\n/*! some-lib v1.2.3 | MIT */\n` + ); + const results = await injectDirectory(dir, { dryRun: true }); + expect(results).toHaveLength(1); + expect(results[0]?.map.kind).toBe("inline"); + }); + + test("preserves a hashbang when injecting into an inline-map file", async () => { + const jsPath = writeInline( + "cli.js", + SAMPLE_MAP, + "#!/usr/bin/env node\nconsole.log(1)\n" + ); + await injectDirectory(dir); + const js = readFileSync(jsPath, "utf-8"); + expect(js.startsWith("#!/usr/bin/env node\n")).toBe(true); + // Snippet must follow the hashbang, not precede it. + expect(js.indexOf("sentry-dbid-")).toBeGreaterThan( + js.indexOf("#!/usr/bin/env node") + ); + }); + + test("only rewrites the real directive, not a mid-line false positive", async () => { + // A string literal embeds a fake `//# sourceMappingURL=data:...` mid-line. + // The whole-file regex could match it; line-anchored matching must not. + const fake = `data:application/json;base64,${Buffer.from('{"version":1}').toString("base64")}`; + const jsPath = writeInline( + "twin.js", + SAMPLE_MAP, + `const s = "//# sourceMappingURL=${fake}";\nconsole.log(s)\n` + ); + const results = await injectDirectory(dir); + expect(results[0]?.injected).toBe(true); + const js = readFileSync(jsPath, "utf-8"); + // The fake (version:1) directive in the string literal is untouched. + expect(js).toContain(`const s = "//# sourceMappingURL=${fake}"`); + // The real (last-line) inline map is the one that got the debug ID. + const realLine = js + .split("\n") + .find((l) => l.startsWith("//# sourceMappingURL=data:")); + const realB64 = realLine?.match(/base64,([A-Za-z0-9+/=]+)/)?.[1] ?? ""; + const realMap = JSON.parse( + Buffer.from(realB64, "base64").toString("utf-8") + ); + expect(realMap.debug_id).toBe(results[0]?.debugId); + expect(realMap.version).toBe(3); // the real SAMPLE_MAP, not the fake + }); + + test("skips invalid inline base64 non-fatally and keeps other pairs", async () => { + // Valid inline map. + writeInline("good.js", SAMPLE_MAP); + // Bogus inline directive (terser template-literal false positive). + const bad = join(dir, "bad.js"); + writeFileSync( + bad, + "console.log(2)\n//# sourceMappingURL=data:application/json;base64,@@@nope@@@\n" + ); + + const results = await injectDirectory(dir, { dryRun: true }); + const names = results.map((r) => r.jsPath.slice(dir.length + 1)).sort(); + expect(names).toEqual(["good.js"]); + }); +}); + +describe("injectDirectory — debug ID uniqueness (regression #3350)", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-dbid-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** The empty sourcemap esbuild emits for helper/re-export-only chunks. */ + const EMPTY_MAP = '{"version":3,"sources":[],"names":[],"mappings":""}'; + + /** Build a `data:` URL for a sourcemap object. */ + function toDataUrl(map: unknown): string { + const b64 = Buffer.from(JSON.stringify(map)).toString("base64"); + return `data:application/json;base64,${b64}`; + } + + /** Map results back to a `basename → debugId` object. */ + function debugIdsByName( + results: Awaited> + ): Record { + return Object.fromEntries( + results.map((r) => [r.jsPath.slice(dir.length + 1), r.debugId]) + ); + } + + test("external: distinct chunks with byte-identical empty maps get distinct debug IDs", async () => { + // Exact scenario from getsentry/sentry-cli#3350: two different minified + // chunks whose sourcemaps are the byte-identical empty map. Companion + // `.map` files are auto-discovered by the `.map` convention. + writeFileSync(join(dir, "a.js"), "console.log(1)\n"); + writeFileSync(join(dir, "a.js.map"), EMPTY_MAP); + writeFileSync(join(dir, "b.js"), "console.log(22)\n"); + writeFileSync(join(dir, "b.js.map"), EMPTY_MAP); + + const ids = debugIdsByName(await injectDirectory(dir)); + expect(ids["a.js"]).toMatch(/^[0-9a-f-]{36}$/); + expect(ids["b.js"]).toMatch(/^[0-9a-f-]{36}$/); + expect(ids["a.js"]).not.toBe(ids["b.js"]); + }); + + test("inline: distinct chunks with identical inline maps get distinct debug IDs", async () => { + const emptyInline = JSON.parse(EMPTY_MAP); + writeFileSync( + join(dir, "a.js"), + `console.log(1)\n//# sourceMappingURL=${toDataUrl(emptyInline)}\n` + ); + writeFileSync( + join(dir, "b.js"), + `console.log(22)\n//# sourceMappingURL=${toDataUrl(emptyInline)}\n` + ); + + const ids = debugIdsByName(await injectDirectory(dir)); + expect(ids["a.js"]).toMatch(/^[0-9a-f-]{36}$/); + expect(ids["b.js"]).toMatch(/^[0-9a-f-]{36}$/); + expect(ids["a.js"]).not.toBe(ids["b.js"]); + }); + + test("byte-identical (JS, map) artifacts share a debug ID (determinism preserved)", async () => { + const map = JSON.stringify({ + version: 3, + sources: ["input.ts"], + mappings: "AAAA", + }); + writeFileSync(join(dir, "a.js"), "console.log(1)\n"); + writeFileSync(join(dir, "a.js.map"), map); + writeFileSync(join(dir, "b.js"), "console.log(1)\n"); + writeFileSync(join(dir, "b.js.map"), map); + + const ids = debugIdsByName(await injectDirectory(dir)); + expect(ids["a.js"]).toBe(ids["b.js"]); + }); +}); + +/** + * A debug ID already stamped on the sourcemap by a bundler plugin + * (`sourcemaps.disable: 'disable-upload'`) must be adopted, not replaced. + * Those builds deliberately leave the bundle without a `//# debugId=` comment + * so post-emit rewriting can't invalidate subresource-integrity hashes. + */ +describe("injectDirectory — pre-existing sourcemap debug ID", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-mapid-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** A debug ID a bundler plugin would have minted at build time. */ + const PLUGIN_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + + const BASE_MAP = { + version: 3, + sources: ["a.ts"], + names: [], + mappings: "AAAA", + }; + + /** Build a `data:` URL for a sourcemap object. */ + function toDataUrl(map: unknown): string { + const b64 = Buffer.from(JSON.stringify(map)).toString("base64"); + return `data:application/json;base64,${b64}`; + } + + /** + * Write what the plugin emits: a bundle carrying its own `_sentryDebugIds` + * writer but no `//# debugId=` comment, plus a map holding the ID. + */ + function writePluginPair( + name: string, + mapExtra: Record + ): { jsPath: string; mapPath: string; js: string; map: string } { + const jsPath = join(dir, name); + const mapPath = `${jsPath}.map`; + const js = `${getDebugIdSnippet(PLUGIN_ID)}\nconsole.log(1)\n//# sourceMappingURL=${name}.map\n`; + const map = JSON.stringify({ ...BASE_MAP, ...mapExtra }); + writeFileSync(jsPath, js); + writeFileSync(mapPath, map); + return { jsPath, mapPath, js, map }; + } + + test("adopts a `debug_id` from an external map without touching either file", async () => { + const pair = writePluginPair("bundle.js", { debug_id: PLUGIN_ID }); + + const results = await injectDirectory(dir); + + expect(results).toHaveLength(1); + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); + + test("adopts the camelCase `debugId` spelling", async () => { + const pair = writePluginPair("bundle.js", { debugId: PLUGIN_ID }); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); + + test("prefers `debug_id` when both spellings disagree", async () => { + const other = "11111111-2222-3333-4444-555555555555"; + writePluginPair("bundle.js", { debug_id: PLUGIN_ID, debugId: other }); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + }); + + test("adopts a debug ID carried by an inline map", async () => { + const jsPath = join(dir, "inline.js"); + const js = `${getDebugIdSnippet(PLUGIN_ID)}\nconsole.log(1)\n//# sourceMappingURL=${toDataUrl({ ...BASE_MAP, debug_id: PLUGIN_ID })}\n`; + writeFileSync(jsPath, js); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(jsPath, "utf-8")).toBe(js); + // The map is uploaded exactly as decoded — no snippet, so no line offset. + const uploaded = JSON.parse( + (results[0]?.injectedMapContent ?? Buffer.alloc(0)).toString() + ); + expect(uploaded.debug_id).toBe(PLUGIN_ID); + expect(uploaded.mappings).toBe(BASE_MAP.mappings); + }); + + test("a `//# debugId=` comment in the JS wins over a conflicting map field", async () => { + const jsPath = join(dir, "bundle.js"); + const jsId = "99999999-8888-7777-6666-555555555555"; + writeFileSync( + jsPath, + `console.log(1)\n//# sourceMappingURL=bundle.js.map\n//# debugId=${jsId}\n` + ); + writeFileSync( + `${jsPath}.map`, + JSON.stringify({ ...BASE_MAP, debug_id: PLUGIN_ID }) + ); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(jsId); + expect(results[0]?.injected).toBe(true); + expect(readFileSync(jsPath, "utf-8")).toContain(getDebugIdSnippet(jsId)); + const map = JSON.parse(readFileSync(`${jsPath}.map`, "utf-8")); + expect(map.debug_id).toBe(jsId); + expect(map.debugId).toBe(jsId); + expect(map.mappings).toBe(`;${BASE_MAP.mappings}`); + }); + + test("falls through to minting when the map's debug ID is malformed", async () => { + const pair = writePluginPair("bundle.js", { debug_id: "not-a-uuid" }); + + const results = await injectDirectory(dir); + + expect(results[0]?.injected).toBe(true); + expect(results[0]?.debugId).toMatch(/^[0-9a-f-]{36}$/); + expect(results[0]?.debugId).not.toBe("not-a-uuid"); + const js = readFileSync(pair.jsPath, "utf-8"); + expect(js).toContain(`//# debugId=${results[0]?.debugId}`); + expect(JSON.parse(readFileSync(pair.mapPath, "utf-8")).debug_id).toBe( + results[0]?.debugId + ); + }); + + test("--dry-run reports the adopted ID rather than a pending injection", async () => { + const pair = writePluginPair("bundle.js", { debug_id: PLUGIN_ID }); + + const results = await injectDirectory(dir, { dryRun: true }); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); + + test("repeated runs stay a no-op", async () => { + const pair = writePluginPair("bundle.js", { debug_id: PLUGIN_ID }); + + await injectDirectory(dir); + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); + + test("preserves binary bundles whose map already carries a debug ID", async () => { + const jsPath = join(dir, "main.bundle"); + // Hermes bytecode magic followed by the bytecode version, including NULs. + const bundle = Buffer.from("c61fbc03c103191f60000000", "hex"); + const map = JSON.stringify({ ...BASE_MAP, debugId: PLUGIN_ID }); + writeFileSync(jsPath, bundle); + writeFileSync(`${jsPath}.map`, map); + + for (const dryRun of [true, false]) { + const results = await injectDirectory(dir, { + extensions: [".bundle"], + dryRun, + }); + expect(results[0]).toMatchObject({ + debugId: PLUGIN_ID, + injected: false, + }); + expect(readFileSync(jsPath)).toEqual(bundle); + expect(readFileSync(`${jsPath}.map`, "utf-8")).toBe(map); + } + }); +}); + +describe.each([ + "external", + "inline", +] as const)("injectDirectory — %s map runtime registration", (kind) => { + let dir: string; + const debugId = "11111111-2222-5333-9444-555555555555"; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-runtime-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test.each([ + "legacy", + "formatted", + ])("preserves an existing %s registration without an identifier marker", async (format) => { + const jsPath = join(dir, "main.js"); + // Legacy CLI snippets have no _sentryDebugIdIdentifier marker. + const snippet = + format === "legacy" + ? `!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}")}catch(e){}}();` + : `(() => { + var root = globalThis; + var stack = new root.Error().stack; + root['_sentryDebugIds'] = root['_sentryDebugIds'] || {}; + root['_sentryDebugIds'][stack] = '${debugId}'; +})();`; + const mapContent = JSON.stringify({ + version: 3, + sources: ["main.ts"], + names: [], + mappings: ";AAAA", + debugId, + }); + const mapUrl = + kind === "inline" + ? `data:application/json;base64,${Buffer.from(mapContent).toString("base64")}` + : "main.js.map"; + const js = `${snippet}\n//# debugId=${debugId}\n//# sourceMappingURL=${mapUrl}\n`; + writeFileSync(jsPath, js); + if (kind === "external") { + writeFileSync(`${jsPath}.map`, mapContent); + } + + for (const options of [{ dryRun: true }, {}]) { + expect((await injectDirectory(dir, options))[0]).toMatchObject({ + debugId, + injected: false, + }); + expect(readFileSync(jsPath, "utf-8")).toBe(js); + if (kind === "external") { + expect(readFileSync(`${jsPath}.map`, "utf-8")).toBe(mapContent); + } + } + const sandbox: { _sentryDebugIds?: Record } = {}; + runInNewContext(js, sandbox, { filename: "main.js" }); + expect(Object.values(sandbox._sentryDebugIds ?? {})).toEqual([debugId]); + }); + + test.each([ + "comment", + "debugId", + "debug_id", + "comment and map", + ])("registers an ID from %s exactly once", async (source) => { + const jsPath = join(dir, "main.js"); + const hasComment = source.startsWith("comment"); + const mapContent = JSON.stringify({ + version: 3, + sources: ["main.ts"], + sourcesContent: ["globalThis.answer = 42;"], + names: [], + mappings: "AAAA", + ...(source === "comment" + ? {} + : { [source === "debug_id" ? "debug_id" : "debugId"]: debugId }), + }); + const mapUrl = + kind === "inline" + ? `data:application/json;base64,${Buffer.from(mapContent).toString("base64")}` + : "main.js.map"; + // SDK readers mention the registry without registering a bundle ID. + const js = + "globalThis.answer = 42; globalThis.readIds = () => globalThis._sentryDebugIds;\n" + + (hasComment ? `//# debugId=${debugId}\n` : "") + + `//# sourceMappingURL=${mapUrl}\n`; + writeFileSync(jsPath, js); + if (kind === "external") { + writeFileSync(`${jsPath}.map`, mapContent); + } + + const preview = await injectDirectory(dir, { dryRun: true }); + expect(preview[0]).toMatchObject({ debugId, injected: true }); + expect(readFileSync(jsPath, "utf-8")).toBe(js); + if (kind === "external") { + expect(readFileSync(`${jsPath}.map`, "utf-8")).toBe(mapContent); + } + + const results = await injectDirectory(dir); + expect(results[0]).toMatchObject({ debugId, injected: true }); + const output = readFileSync(jsPath, "utf-8"); + const sandbox: { + answer?: number; + _sentryDebugIds?: Record; + } = {}; + runInNewContext(output, sandbox, { filename: "main.js" }); + expect(sandbox.answer).toBe(42); + expect(Object.values(sandbox._sentryDebugIds ?? {})).toEqual([debugId]); + expect(output.match(/\/\/# debugId=/g)).toHaveLength(1); + + const outputMap = + kind === "external" + ? readFileSync(`${jsPath}.map`, "utf-8") + : results[0]?.injectedMapContent?.toString("utf-8"); + expect(JSON.parse(outputMap ?? "{}")).toMatchObject({ + debugId, + debug_id: debugId, + mappings: ";AAAA", + }); + if (kind === "inline") { + const url = output.match(/\/\/# sourceMappingURL=(\S+)/)?.[1]; + expect(tryDecodeInlineSourcemap(url ?? "")?.json).toBe(outputMap); + } + + const second = await injectDirectory(dir); + expect(second[0]).toMatchObject({ debugId, injected: false }); + expect(readFileSync(jsPath, "utf-8")).toBe(output); + expect( + kind === "external" + ? readFileSync(`${jsPath}.map`, "utf-8") + : second[0]?.injectedMapContent?.toString("utf-8") + ).toBe(outputMap); + expect((await injectDirectory(dir, { dryRun: true }))[0]).toMatchObject({ + debugId, + injected: false, + }); + }); +}); diff --git a/packages/cli/test/lib/sourcemap/inline-sourcemap.test.ts b/packages/cli/test/lib/sourcemap/inline-sourcemap.test.ts new file mode 100644 index 000000000..1537075f1 --- /dev/null +++ b/packages/cli/test/lib/sourcemap/inline-sourcemap.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for inline (data: URL) sourcemap decode/encode. + * + * Core round-trip invariants are exercised with property-based tests; the + * unit tests cover charset preservation, non-fatal failure modes, and the + * cheap prefix predicate. + */ + +import { dictionary, assert as fcAssert, property, string } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + encodeInlineSourcemap, + isInlineSourcemapUrl, + tryDecodeInlineSourcemap, +} from "../../../src/lib/sourcemap/inline-sourcemap.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Build a data URL from a JSON-serializable value. */ +function toDataUrl(value: unknown, charset?: string): string { + const prefix = charset + ? `data:application/json;charset=${charset};base64,` + : "data:application/json;base64,"; + return `${prefix}${Buffer.from(JSON.stringify(value)).toString("base64")}`; +} + +describe("isInlineSourcemapUrl", () => { + test("true for data:application/json URLs", () => { + expect(isInlineSourcemapUrl("data:application/json;base64,e30=")).toBe( + true + ); + expect( + isInlineSourcemapUrl("data:application/json;charset=utf-8;base64,e30=") + ).toBe(true); + }); + + test("false for external/remote URLs", () => { + expect(isInlineSourcemapUrl("app.js.map")).toBe(false); + expect(isInlineSourcemapUrl("https://cdn.example.com/app.js.map")).toBe( + false + ); + }); +}); + +describe("tryDecodeInlineSourcemap", () => { + test("decodes a valid inline map", () => { + const map = { version: 3, mappings: "AAAA", sources: ["a.ts"] }; + const decoded = tryDecodeInlineSourcemap(toDataUrl(map)); + expect(decoded?.map).toEqual(map); + expect(decoded?.json).toBe(JSON.stringify(map)); + }); + + test("preserves the charset prefix", () => { + const decoded = tryDecodeInlineSourcemap(toDataUrl({}, "utf-8")); + expect(decoded?.dataUrlPrefix).toBe( + "data:application/json;charset=utf-8;base64," + ); + }); + + test("returns undefined for invalid base64 (non-fatal)", () => { + expect( + tryDecodeInlineSourcemap("data:application/json;base64,@@@not-base64@@@") + ).toBeUndefined(); + }); + + test("returns undefined when decoded bytes are not JSON (non-fatal)", () => { + // "not json" base64-encoded — decodes cleanly but is not JSON. + const blob = Buffer.from("not json").toString("base64"); + expect( + tryDecodeInlineSourcemap(`data:application/json;base64,${blob}`) + ).toBeUndefined(); + }); + + test("returns undefined for a non-data URL", () => { + expect(tryDecodeInlineSourcemap("app.js.map")).toBeUndefined(); + }); +}); + +describe("property: inline sourcemap round-trip", () => { + test("decode(encode(map)) deep-equals the original map", () => { + fcAssert( + property(dictionary(string(), string()), (obj) => { + const dataUrl = encodeInlineSourcemap( + obj, + "data:application/json;base64," + ); + const decoded = tryDecodeInlineSourcemap(dataUrl); + expect(decoded?.map).toEqual(obj); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("charset prefix survives a round-trip", () => { + fcAssert( + property(dictionary(string(), string()), (obj) => { + const prefix = "data:application/json;charset=utf-8;base64,"; + const decoded = tryDecodeInlineSourcemap( + encodeInlineSourcemap(obj, prefix) + ); + expect(decoded?.dataUrlPrefix).toBe(prefix); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/sourcemap/resolve.test.ts b/packages/cli/test/lib/sourcemap/resolve.test.ts new file mode 100644 index 000000000..987bad152 --- /dev/null +++ b/packages/cli/test/lib/sourcemap/resolve.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for the read-only sourcemap resolution pass that powers + * `sentry sourcemap resolve`. Verifies companion-map detection, + * sourceMappingURL classification (external / inline / remote / missing), + * and debug-ID extraction. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { resolveDirectorySourcemaps } from "../../../src/lib/sourcemap/inject.js"; + +describe("resolveDirectorySourcemaps", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-resolve-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function write(rel: string, content: string): void { + const full = join(dir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } + + test("resolves a convention-named companion map", async () => { + write("app.js", "console.log(1)\n"); + write("app.js.map", "{}\n"); + + const results = await resolveDirectorySourcemaps(dir); + expect(results).toHaveLength(1); + expect(results[0]?.mapPath).toBe(join(dir, "app.js.map")); + expect(results[0]?.inline).toBe(false); + expect(results[0]?.remote).toBe(false); + }); + + test("reports a JS file with no companion map", async () => { + write("orphan.js", "console.log(1)\n"); + + const results = await resolveDirectorySourcemaps(dir); + expect(results).toHaveLength(1); + expect(results[0]?.mapPath).toBeUndefined(); + expect(results[0]?.sourceMappingUrl).toBeUndefined(); + }); + + test("classifies an inline data: sourceMappingURL", async () => { + write( + "inline.js", + "console.log(1)\n//# sourceMappingURL=data:application/json;base64,e30=\n" + ); + + const results = await resolveDirectorySourcemaps(dir); + expect(results).toHaveLength(1); + expect(results[0]?.inline).toBe(true); + expect(results[0]?.mapPath).toBeUndefined(); + }); + + test("classifies a remote sourceMappingURL", async () => { + write( + "remote.js", + "console.log(1)\n//# sourceMappingURL=https://cdn.example.com/remote.js.map\n" + ); + + const results = await resolveDirectorySourcemaps(dir); + expect(results[0]?.remote).toBe(true); + expect(results[0]?.mapPath).toBeUndefined(); + }); + + test("extracts an injected debug ID", async () => { + const debugId = "a1b2c3d4-e5f6-4789-abcd-ef0123456789"; + write("with-id.js", `console.log(1)\n//# debugId=${debugId}\n`); + write("with-id.js.map", "{}\n"); + + const results = await resolveDirectorySourcemaps(dir); + expect(results[0]?.debugId).toBe(debugId); + }); + + test("reports undefined debug ID when not injected", async () => { + write("plain.js", "console.log(1)\n"); + write("plain.js.map", "{}\n"); + + const results = await resolveDirectorySourcemaps(dir); + expect(results[0]?.debugId).toBeUndefined(); + }); + + test("results are sorted by path", async () => { + write("b.js", "1\n"); + write("a.js", "1\n"); + write("c.js", "1\n"); + + const results = await resolveDirectorySourcemaps(dir); + const names = results.map((r) => r.jsPath); + // Byte-wise sort, matching resolveDirectorySourcemaps' path ordering. + expect(names).toEqual([...names].sort()); + }); +}); diff --git a/packages/cli/test/lib/sourcemap/zip.mocked.test.ts b/packages/cli/test/lib/sourcemap/zip.mocked.test.ts new file mode 100644 index 000000000..4ac0630b0 --- /dev/null +++ b/packages/cli/test/lib/sourcemap/zip.mocked.test.ts @@ -0,0 +1,44 @@ +/** + * ZipWriter on Node.js versions without `zlib.crc32` (< 20.15 / < 22.2). + * Kept in a separate file so the node:zlib mock doesn't leak into zip.test.ts. + */ + +import { spawnSync } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +vi.mock("node:zlib", async (importOriginal) => { + const actual = await importOriginal>(); + const withoutCrc32 = { ...actual, crc32: undefined }; + return { ...withoutCrc32, default: withoutCrc32 }; +}); + +// Import AFTER the mock so zip.ts sees a node:zlib without crc32. +import { ZipWriter } from "../../../src/lib/sourcemap/zip.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "zip-no-crc32-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +test("writes archives with valid CRCs when zlib.crc32 is unavailable", async () => { + const zipPath = join(tmpDir, "bundle.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("bundle.js", Buffer.from("console.log(1);\n")); + await zip.addEntry("bundle.js.map", Buffer.from('{"version":3}')); + await zip.finalize(); + + // `unzip -t` recomputes each entry's CRC-32 and fails on a mismatch. + const proc = spawnSync("unzip", ["-t", zipPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.stderr.toString()).toBe(""); + expect(proc.status).toBe(0); +}); diff --git a/packages/cli/test/lib/sourcemap/zip.test.ts b/packages/cli/test/lib/sourcemap/zip.test.ts new file mode 100644 index 000000000..77aac7c6e --- /dev/null +++ b/packages/cli/test/lib/sourcemap/zip.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for the streaming ZIP builder. + * + * Property-based tests verify round-trip integrity (compress → decompress). + * Unit tests verify the ZIP structure is valid and extractable. + */ + +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + asyncProperty, + assert as fcAssert, + string, + uint8Array, +} from "fast-check"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { ZipWriter } from "../../../src/lib/sourcemap/zip.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "zip-test-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe("ZipWriter", () => { + test("produces a valid ZIP file extractable by unzip", async () => { + const zipPath = join(tmpDir, "test.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("hello.txt", Buffer.from("Hello, world!")); + await zip.addEntry("data.json", Buffer.from('{"key":"value"}')); + await zip.finalize(); + + // Verify with system unzip (available on Linux/macOS) + const proc = spawnSync("unzip", ["-t", zipPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.status).toBe(0); + }); + + test("preserves file content through compression", async () => { + const zipPath = join(tmpDir, "content.zip"); + const content = "The quick brown fox jumps over the lazy dog\n"; + + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("test.txt", Buffer.from(content)); + await zip.finalize(); + + // Extract via unzip and verify content matches + const proc = spawnSync("unzip", ["-p", zipPath, "test.txt"], { + stdio: ["pipe", "pipe", "pipe"], + }); + const extracted = proc.stdout.toString(); + expect(extracted).toBe(content); + }); + + test("handles empty files", async () => { + const zipPath = join(tmpDir, "empty.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("empty.txt", Buffer.alloc(0)); + await zip.finalize(); + + const proc = spawnSync("unzip", ["-t", zipPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.status).toBe(0); + }); + + test("handles files with subdirectory paths", async () => { + const zipPath = join(tmpDir, "nested.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("_/_/bundle.js", Buffer.from("var x = 1;")); + await zip.addEntry("_/_/bundle.js.map", Buffer.from("{}")); + await zip.addEntry("manifest.json", Buffer.from("{}")); + await zip.finalize(); + + const proc = spawnSync("unzip", ["-l", zipPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + const output = proc.stdout.toString(); + expect(output).toContain("_/_/bundle.js"); + expect(output).toContain("_/_/bundle.js.map"); + expect(output).toContain("manifest.json"); + }); + + test("handles large content (1 MB)", async () => { + const zipPath = join(tmpDir, "large.zip"); + // 1 MB of pseudo-random data + const content = Buffer.alloc(1024 * 1024); + for (let i = 0; i < content.length; i++) { + content[i] = (i * 31 + 17) % 256; + } + + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("large.bin", content); + await zip.finalize(); + + // Extract and verify content matches byte-for-byte + const proc = spawnSync("unzip", ["-p", zipPath, "large.bin"], { + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: 2 * 1024 * 1024, + }); + expect(Buffer.from(proc.stdout)).toEqual(content); + }); +}); + +describe.each([ + "deflate", + "stored", +] as const)("property: ZipWriter round-trip (%s)", (compression) => { + test("arbitrary string content survives write → extract", async () => { + await fcAssert( + asyncProperty( + string({ minLength: 0, maxLength: 10_000 }), + async (input) => { + const zipPath = join( + tmpDir, + `prop-${compression}-${Date.now()}-${Math.random()}.zip` + ); + const zip = await ZipWriter.create(zipPath, { compression }); + await zip.addEntry("data.txt", Buffer.from(input, "utf-8")); + await zip.finalize(); + + const proc = spawnSync("unzip", ["-p", zipPath, "data.txt"], { + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: 50_000, + }); + expect(proc.status).toBe(0); + expect(proc.stdout.toString()).toBe(input); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("arbitrary binary content survives write → extract", async () => { + await fcAssert( + asyncProperty( + // minLength: 1 — empty files have a separate unit test; + // unzip -p returns exit code 9 for empty entries on some systems + uint8Array({ minLength: 1, maxLength: 10_000 }), + async (input) => { + const zipPath = join( + tmpDir, + `prop-bin-${compression}-${Date.now()}-${Math.random()}.zip` + ); + const zip = await ZipWriter.create(zipPath, { compression }); + await zip.addEntry("data.bin", Buffer.from(input)); + await zip.finalize(); + + const proc = spawnSync("unzip", ["-p", zipPath, "data.bin"], { + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: 50_000, + }); + expect(proc.status).toBe(0); + expect(Buffer.from(proc.stdout)).toEqual(Buffer.from(input)); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("ZipWriter compression mode", () => { + // The local file header records the entry's compression method at + // offset 8 (16-bit LE): 0 = STORED, 8 = DEFLATE. Drift between this + // and the central directory copy would surface as an `unzip -p` + // failure in the extraction tests below. + const SYSB_HEADER_BYTES = 8; + const LOCAL_HEADER_METHOD_OFFSET = SYSB_HEADER_BYTES + 8; + + test("default is DEFLATE (back-compat)", async () => { + const zipPath = join(tmpDir, "default.zip"); + const repeating = "abcdef".repeat(1000); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("data.txt", Buffer.from(repeating)); + await zip.finalize(); + + const data = await readFile(zipPath); + expect(data.readUInt16LE(LOCAL_HEADER_METHOD_OFFSET)).toBe(8); + // Highly-redundant input should compress. + expect(data.length).toBeLessThan(repeating.length); + }); + + test("compression: 'stored' writes entries uncompressed", async () => { + const zipPath = join(tmpDir, "stored.zip"); + const repeating = "abcdef".repeat(1000); + const zip = await ZipWriter.create(zipPath, { compression: "stored" }); + await zip.addEntry("data.txt", Buffer.from(repeating)); + await zip.finalize(); + + const data = await readFile(zipPath); + expect(data.readUInt16LE(LOCAL_HEADER_METHOD_OFFSET)).toBe(0); + // STORED archive contains the raw bytes verbatim plus headers, + // so it must be at least as large as the input. + expect(data.length).toBeGreaterThan(repeating.length); + }); + + test("compression: 'stored' produces an extractable archive", async () => { + const zipPath = join(tmpDir, "stored-extract.zip"); + const content = "The quick brown fox\n".repeat(50); + const zip = await ZipWriter.create(zipPath, { compression: "stored" }); + await zip.addEntry("text.txt", Buffer.from(content)); + await zip.finalize(); + + const proc = spawnSync("unzip", ["-p", zipPath, "text.txt"], { + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.status).toBe(0); + expect(proc.stdout.toString()).toBe(content); + }); +}); + +describe("ZipWriter binary format", () => { + test("starts with SYSB header followed by local file header", async () => { + const zipPath = join(tmpDir, "sig.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("a.txt", Buffer.from("a")); + await zip.finalize(); + + const data = await readFile(zipPath); + // SourceBundle magic header: SYSB + version 2 (LE) + expect(data[0]).toBe(0x53); // S + expect(data[1]).toBe(0x59); // Y + expect(data[2]).toBe(0x53); // S + expect(data[3]).toBe(0x42); // B + expect(data[4]).toBe(0x02); // version 2 (LE) + expect(data[5]).toBe(0x00); + expect(data[6]).toBe(0x00); + expect(data[7]).toBe(0x00); + // Local file header signature follows: PK\x03\x04 + expect(data[8]).toBe(0x50); // P + expect(data[9]).toBe(0x4b); // K + expect(data[10]).toBe(0x03); + expect(data[11]).toBe(0x04); + }); + + test("ends with EOCD signature", async () => { + const zipPath = join(tmpDir, "eocd.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("a.txt", Buffer.from("a")); + await zip.finalize(); + + const data = await readFile(zipPath); + // EOCD is the last 22 bytes, starts with PK\x05\x06 + const eocdStart = data.length - 22; + expect(data[eocdStart]).toBe(0x50); // P + expect(data[eocdStart + 1]).toBe(0x4b); // K + expect(data[eocdStart + 2]).toBe(0x05); + expect(data[eocdStart + 3]).toBe(0x06); + }); + + test("EOCD reports correct number of entries", async () => { + const zipPath = join(tmpDir, "count.zip"); + const zip = await ZipWriter.create(zipPath); + await zip.addEntry("a.txt", Buffer.from("a")); + await zip.addEntry("b.txt", Buffer.from("b")); + await zip.addEntry("c.txt", Buffer.from("c")); + await zip.finalize(); + + const data = await readFile(zipPath); + const eocdStart = data.length - 22; + // Entries on this disk (offset 8 from EOCD start) + const entriesOnDisk = data.readUInt16LE(eocdStart + 8); + // Total entries (offset 10) + const totalEntries = data.readUInt16LE(eocdStart + 10); + expect(entriesOnDisk).toBe(3); + expect(totalEntries).toBe(3); + }); +}); diff --git a/packages/cli/test/lib/span-tree.test.ts b/packages/cli/test/lib/span-tree.test.ts new file mode 100644 index 000000000..e77c48045 --- /dev/null +++ b/packages/cli/test/lib/span-tree.test.ts @@ -0,0 +1,53 @@ +/** + * Tests for getSpanTreeLines time-window wiring into getDetailedTrace. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../src/lib/api-client.js"; +import { getSpanTreeLines } from "../../src/lib/span-tree.js"; +import type { SentryEvent } from "../../src/types/index.js"; + +const TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; + +function eventWithTrace(overrides: Partial = {}): SentryEvent { + return { + eventID: "event-1", + contexts: { trace: { trace_id: TRACE_ID } }, + ...overrides, + }; +} + +describe("getSpanTreeLines", () => { + let getDetailedTraceSpy: ReturnType; + + beforeEach(() => { + getDetailedTraceSpy = vi.spyOn(apiClient, "getDetailedTrace"); + getDetailedTraceSpy.mockResolvedValue([]); + }); + + afterEach(() => { + getDetailedTraceSpy.mockRestore(); + }); + + test("passes dateCreated as unix timestamp when the event time is known", async () => { + const dateCreated = "2026-09-13T22:00:00.000Z"; + await getSpanTreeLines("my-org", eventWithTrace({ dateCreated }), 3); + + expect(getDetailedTraceSpy).toHaveBeenCalledWith("my-org", TRACE_ID, { + timestamp: Date.parse(dateCreated) / 1000, + }); + }); + + test("omits timestamp when dateCreated is missing so lookup can widen", async () => { + await getSpanTreeLines("my-org", eventWithTrace(), 3); + + expect(getDetailedTraceSpy).toHaveBeenCalledWith("my-org", TRACE_ID, {}); + }); + + test("does not fetch when the event has no trace id", async () => { + await getSpanTreeLines("my-org", { eventID: "event-1" }, 3); + + expect(getDetailedTraceSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/telemetry-credentials.test.ts b/packages/cli/test/lib/telemetry-credentials.test.ts new file mode 100644 index 000000000..191376e92 --- /dev/null +++ b/packages/cli/test/lib/telemetry-credentials.test.ts @@ -0,0 +1,189 @@ +import { + _INTERNAL_flushLogsBuffer, + type Envelope, + type ErrorEvent, + type Transport, +} from "@sentry/core"; +import { + captureEvent, + captureException, + getCurrentScope, + getIsolationScope, + logger, + setContext, + startSpan, + withScope, +} from "@sentry/node-core/light"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { extractMessagePrefix } from "../../src/lib/error-reporting.js"; +import { redactTelemetryEnvelope } from "../../src/lib/telemetry/credential-redaction.js"; +// biome-ignore lint/performance/noNamespaceImport: spy on the outbound transport factory +import * as transportModule from "../../src/lib/telemetry/zstd-transport.js"; +import { initSentry } from "../../src/lib/telemetry.js"; + +const token = "sntrys_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET"; +const headerError = `Headers.set: "Bearer ${token}" is an invalid header value.`; +let client: ReturnType; + +/** Intercept the delegated delivery boundary while keeping the real SDK pipeline. */ +function captureTelemetry() { + const send = vi.fn().mockResolvedValue({}); + const flush = vi.fn().mockResolvedValue(true); + vi.spyOn(transportModule, "makeCompressedTransport").mockReturnValue({ + send, + flush, + }); + client = initSentry(false, { libraryMode: true }); + if (!client) { + throw new Error("Expected the telemetry client"); + } + // Enable capture only after every delivery route is intercepted. + client.getOptions().enabled = true; + client.getOptions().enableLogs = true; + client.init(); + function serializedEnvelope(type: string): string { + const envelope = send.mock.calls.find(([entry]) => + entry[1].some(([header]) => header.type === type) + )?.[0]; + if (!envelope) { + throw new Error(`Expected a ${type} envelope`); + } + return JSON.stringify(envelope); + } + return { client, send, flush, serializedEnvelope }; +} + +afterEach(async () => { + await client?.close(0); + getCurrentScope().clear(); + getIsolationScope().clear(); + vi.restoreAllMocks(); +}); + +describe("telemetry credential boundaries", () => { + test("sanitizes exceptions and related fields in the outgoing envelope", async () => { + const capture = captureTelemetry(); + const event: ErrorEvent = { + type: undefined, + message: headerError, + exception: { + values: [ + { type: "TypeError", value: headerError }, + { type: "Error", value: `Wrapping ${token}` }, + ], + }, + tags: { "cli_error.kind": token }, + contexts: { cli_error: { detail: headerError, status: 500 } }, + extra: { stack: headerError }, + breadcrumbs: [{ message: headerError, data: { token } }], + }; + captureEvent(event); + await capture.client.flush(1000); + + const serialized = capture.serializedEnvelope("event"); + expect(serialized).not.toContain("SYNTHETIC"); + expect(serialized).toContain("[REDACTED]"); + expect(serialized).toContain('"status":500'); + expect(event.contexts?.cli_error?.detail).toBe(headerError); + }); + + test("scrubs exception causes in the outgoing Sentry envelope", async () => { + const capture = captureTelemetry(); + captureException( + new TypeError(headerError, { cause: new Error(`Rejected ${token}`) }) + ); + await capture.client.flush(1000); + + const serialized = capture.serializedEnvelope("event"); + expect(serialized).toContain("[REDACTED]"); + expect(serialized).toContain("TypeError"); + expect(serialized).toContain("Rejected"); + expect(serialized).not.toContain("SYNTHETIC"); + }); + + test("scrubs logs after the SDK adds scope attributes and fmt parameters", async () => { + const capture = captureTelemetry(); + const frozen = Object.freeze({ token }); + // biome-ignore lint/style/useConsistentBuiltinInstantiation: regression coverage for boxed log parameters + const boxed = new String(token); + withScope((scope) => { + scope.setAttribute("scope.token", token); + logger.error(logger.fmt`Rejected ${boxed}`, { + nested: frozen, + serialized: { toJSON: () => token }, + status: 500, + }); + }); + _INTERNAL_flushLogsBuffer(capture.client); + await capture.client.flush(1000); + + const serialized = capture.serializedEnvelope("log"); + expect(serialized).not.toContain("SYNTHETIC"); + expect(serialized).toContain("scope.token"); + expect(serialized).toContain("sentry.message.parameter.0"); + expect(serialized).toContain("Rejected [REDACTED]"); + expect(frozen.token).toBe(token); + expect(String(boxed)).toBe(token); + }); + + test("does not traverse live scopes or client options when scrubbing a trace", async () => { + const capture = captureTelemetry(); + const context = Object.freeze({ token }); + setContext("synthetic", context); + capture.client.getOptions().release = token; + startSpan({ name: "synthetic-review", forceTransaction: true }, (span) => { + span.setAttribute("diagnostic", headerError); + }); + capture.flush.mockResolvedValueOnce(false); + await expect(capture.client.flush(1000)).resolves.toBe(false); + + expect(capture.send).toHaveBeenCalledOnce(); + const serialized = capture.serializedEnvelope("transaction"); + expect(serialized).not.toContain("SYNTHETIC"); + expect(context.token).toBe(token); + expect(capture.client.getOptions().release).toBe(token); + expect(capture.flush).toHaveBeenCalledWith(1000); + }); + + test("scrubs SDK internal errors that bypass beforeSend", async () => { + const capture = captureTelemetry(); + setContext("synthetic", { token }); + capture.client.captureException(new Error(headerError), { + data: { __sentry__: true }, + }); + await capture.client.flush(1000); + + expect(capture.send).toHaveBeenCalledOnce(); + const serialized = capture.serializedEnvelope("event"); + expect(serialized).toContain("[REDACTED]"); + expect(serialized).not.toContain("SYNTHETIC"); + }); + + test("redacts before deriving a grouping key from the first line", () => { + expect(extractMessagePrefix(headerError, 4)).toBe( + "Headers.set: is an invalid" + ); + }); + + test("uses SDK serialization fallback for cycles and preserves binary attachments", () => { + const data: { token: string; circular?: unknown } = { token }; + data.circular = data; + const bytes = new Uint8Array([1, 2, 3]); + const envelope: Envelope = [ + {}, + [ + [{ type: "event" }, { extra: data }], + [ + { type: "attachment", length: bytes.length, filename: "test.bin" }, + bytes, + ], + ], + ]; + const redacted = redactTelemetryEnvelope(envelope); + expect(JSON.stringify(redacted)).not.toContain("SYNTHETIC"); + expect(JSON.stringify(redacted)).toContain("[Circular ~]"); + expect(redacted[1][1]?.[1]).toBe(bytes); + expect(data.token).toBe(token); + expect(data.circular).toBe(data); + }); +}); diff --git a/packages/cli/test/lib/telemetry-session.test.ts b/packages/cli/test/lib/telemetry-session.test.ts new file mode 100644 index 000000000..7c10c7c98 --- /dev/null +++ b/packages/cli/test/lib/telemetry-session.test.ts @@ -0,0 +1,216 @@ +/** + * Telemetry Session Tests + * + * Tests for session lifecycle management: + * - beforeExit handler (createBeforeExitHandler) + * - Session crash marking (markSessionCrashed) + * + * These tests are in a separate file because they mock Sentry's scope methods + * (getCurrentScope, getIsolationScope) which conflicts with the SDK's internal + * scope machinery. Running in a separate Bun test worker prevents interference + * with other telemetry tests. + */ + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + createBeforeExitHandler, + markSessionCrashed, +} from "../../src/lib/telemetry.js"; + +describe("createBeforeExitHandler", () => { + /** + * Create a minimal mock LightNodeClient for testing the beforeExit handler. + * Only needs flush() since that's all the handler uses from the client. + */ + function createMockClient(): Sentry.LightNodeClient { + return { + flush: () => Promise.resolve(true), + } as unknown as Sentry.LightNodeClient; + } + + test("ends OK session on beforeExit", () => { + const handler = createBeforeExitHandler(createMockClient()); + + const mockSession = { status: "ok", errors: 0 }; + const getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => mockSession, + } as unknown as Sentry.Scope); + // Mock endSession to prevent it from calling through to real SDK internals + const endSessionSpy = vi + .spyOn(Sentry, "endSession") + .mockImplementation(() => null); + + handler(); + + expect(endSessionSpy).toHaveBeenCalled(); + + getIsolationScopeSpy.mockRestore(); + endSessionSpy.mockRestore(); + }); + + test("does not end non-OK session on beforeExit (SDK handles it)", () => { + const handler = createBeforeExitHandler(createMockClient()); + + const mockSession = { status: "crashed", errors: 1 }; + const getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => mockSession, + } as unknown as Sentry.Scope); + const endSessionSpy = vi + .spyOn(Sentry, "endSession") + .mockImplementation(() => null); + + handler(); + + expect(endSessionSpy).not.toHaveBeenCalled(); + + getIsolationScopeSpy.mockRestore(); + endSessionSpy.mockRestore(); + }); + + test("does not end session when no session exists", () => { + const handler = createBeforeExitHandler(createMockClient()); + + const getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + const endSessionSpy = vi + .spyOn(Sentry, "endSession") + .mockImplementation(() => null); + + handler(); + + expect(endSessionSpy).not.toHaveBeenCalled(); + + getIsolationScopeSpy.mockRestore(); + endSessionSpy.mockRestore(); + }); + + test("re-entry guard prevents double flush", () => { + const handler = createBeforeExitHandler(createMockClient()); + + const mockSession = { status: "ok", errors: 0 }; + const getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => mockSession, + } as unknown as Sentry.Scope); + const endSessionSpy = vi + .spyOn(Sentry, "endSession") + .mockImplementation(() => null); + + // Call twice + handler(); + handler(); + + // endSession should only be called once due to re-entry guard + expect(endSessionSpy).toHaveBeenCalledTimes(1); + + getIsolationScopeSpy.mockRestore(); + endSessionSpy.mockRestore(); + }); + + test("flushes client on beforeExit", () => { + const mockClient = createMockClient(); + const flushSpy = vi.spyOn(mockClient, "flush"); + const handler = createBeforeExitHandler(mockClient); + + const getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + + handler(); + + expect(flushSpy).toHaveBeenCalledWith(3000); + + getIsolationScopeSpy.mockRestore(); + flushSpy.mockRestore(); + }); +}); + +describe("markSessionCrashed", () => { + let getCurrentScopeSpy: ReturnType; + let getIsolationScopeSpy: ReturnType; + + afterEach(() => { + getCurrentScopeSpy?.mockRestore(); + getIsolationScopeSpy?.mockRestore(); + }); + + test("marks session as crashed from current scope", () => { + const mockSession = { status: "ok", errors: 0 }; + + getCurrentScopeSpy = vi.spyOn(Sentry, "getCurrentScope").mockReturnValue({ + getSession: () => mockSession, + } as unknown as Sentry.Scope); + getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + + markSessionCrashed(); + + expect(mockSession.status).toBe("crashed"); + }); + + test("marks session as crashed from isolation scope when current scope has none", () => { + const mockSession = { status: "ok", errors: 0 }; + + getCurrentScopeSpy = vi.spyOn(Sentry, "getCurrentScope").mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => mockSession, + } as unknown as Sentry.Scope); + + markSessionCrashed(); + + expect(mockSession.status).toBe("crashed"); + }); + + test("does nothing when no session exists on either scope", () => { + getCurrentScopeSpy = vi.spyOn(Sentry, "getCurrentScope").mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + + // Should not throw + expect(() => markSessionCrashed()).not.toThrow(); + }); + + test("prefers current scope session over isolation scope", () => { + const currentSession = { status: "ok", errors: 0 }; + const isolationSession = { status: "ok", errors: 0 }; + + getCurrentScopeSpy = vi.spyOn(Sentry, "getCurrentScope").mockReturnValue({ + getSession: () => currentSession, + } as unknown as Sentry.Scope); + getIsolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => isolationSession, + } as unknown as Sentry.Scope); + + markSessionCrashed(); + + // Current scope session should be marked, isolation scope left unchanged + expect(currentSession.status).toBe("crashed"); + expect(isolationSession.status).toBe("ok"); + }); +}); diff --git a/packages/cli/test/lib/telemetry.test.ts b/packages/cli/test/lib/telemetry.test.ts new file mode 100644 index 000000000..fc5e1123d --- /dev/null +++ b/packages/cli/test/lib/telemetry.test.ts @@ -0,0 +1,1372 @@ +/** + * Telemetry Module Tests + * + * Tests for withTelemetry wrapper and opt-out behavior. + */ + +import { chmodSync, mkdirSync, rmSync } from "node:fs"; +import { setTimeout as sleep } from "node:timers/promises"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { formatAuthHeader } from "../../src/lib/auth-header.js"; +import { Database } from "../../src/lib/db/sqlite.js"; +import { + ApiError, + AuthError, + MalformedAuthTokenError, + OutputError, +} from "../../src/lib/errors.js"; +import { + createTracedDatabase, + createWizardPromptTelemetry, + getSentryTracePropagationTargets, + initSentry, + isEbadfError, + isEpipeError, + isUserApiError, + recordApiErrorOnSpan, + resetReadonlyWarning, + setArgsContext, + setCommandSpanName, + setFlagContext, + setOrgProjectContext, + withDbSpan, + withFsSpan, + withHttpSpan, + withSerializeSpan, + withTelemetry, + withTracing, + withTracingSpan, +} from "../../src/lib/telemetry.js"; + +// Snapshot beforeExit listeners before any test calls initSentry(true). +// The ProcessSession integration registers an anonymous handler via setupOnce +// that has no cleanup mechanism. After all tests, we remove any listeners +// that weren't present before to prevent the Bun test runner from hanging. +const preTestListeners = new Set(process.rawListeners("beforeExit")); + +afterAll(() => { + for (const listener of process.rawListeners("beforeExit")) { + if (!preTestListeners.has(listener)) { + process.removeListener( + "beforeExit", + listener as (...args: unknown[]) => void + ); + } + } +}); + +describe("initSentry", () => { + test("returns client with enabled=false when disabled", () => { + const client = initSentry(false); + expect(client?.getOptions().enabled).toBe(false); + }); + + test("returns client with DSN when enabled", () => { + const client = initSentry(true); + expect(client?.getOptions().dsn).toBeDefined(); + expect(client?.getOptions().enabled).toBe(true); + }); + + test("derives environment from CLI_VERSION via getCliEnvironment()", () => { + const client = initSentry(true); + // In test/dev mode, CLI_VERSION is "0.0.0-dev" → "development" + expect(client?.getOptions().environment).toBe("development"); + }); + + test("uses 0.0.0-dev version when SENTRY_CLI_VERSION is not defined", () => { + const client = initSentry(true); + expect(client?.getOptions().release).toBe("0.0.0-dev"); + }); +}); + +describe("withTelemetry", () => { + const ENV_VAR = "SENTRY_CLI_NO_TELEMETRY"; + let originalEnvValue: string | undefined; + + beforeEach(() => { + originalEnvValue = process.env[ENV_VAR]; + }); + + afterEach(() => { + if (originalEnvValue === undefined) { + delete process.env[ENV_VAR]; + } else { + process.env[ENV_VAR] = originalEnvValue; + } + }); + + test("executes callback and returns result", async () => { + const result = await withTelemetry(() => 42); + expect(result).toBe(42); + }); + + test("handles async callbacks", async () => { + const result = await withTelemetry(async () => { + await sleep(1); + return "async result"; + }); + expect(result).toBe("async result"); + }); + + test("propagates errors from callback", async () => { + await expect( + withTelemetry(() => { + throw new Error("test error"); + }) + ).rejects.toThrow("test error"); + }); + + test("propagates async errors", async () => { + await expect( + withTelemetry(async () => { + await sleep(1); + throw new Error("async error"); + }) + ).rejects.toThrow("async error"); + }); + + test("handles complex return types", async () => { + const result = await withTelemetry(() => ({ + status: "ok", + count: 42, + items: [1, 2, 3], + })); + expect(result).toEqual({ status: "ok", count: 42, items: [1, 2, 3] }); + }); + + test("handles void return value", async () => { + let sideEffect = false; + const result = await withTelemetry(() => { + sideEffect = true; + }); + expect(result).toBeUndefined(); + expect(sideEffect).toBe(true); + }); + + test("handles null return value", async () => { + const result = await withTelemetry(() => null); + expect(result).toBeNull(); + }); + + test("respects SENTRY_CLI_NO_TELEMETRY=1 env var", async () => { + process.env[ENV_VAR] = "1"; + let executed = false; + await withTelemetry(() => { + executed = true; + }); + expect(executed).toBe(true); + }); + + test("propagates 4xx ApiError to caller", async () => { + const error = new ApiError("Not found", 404, "Issue not found"); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + }); + + describe("with telemetry enabled", () => { + beforeEach(() => { + delete process.env[ENV_VAR]; + }); + + afterEach(() => { + // Re-init with enabled=false to reset global SDK state. + // Without this, Sentry.isEnabled() returns true for all + // subsequent test files (e.g. feedbackCommand checks it). + initSentry(false); + }); + + test("propagates 4xx ApiError through enabled SDK path", async () => { + const error = new ApiError("Not found", 404, "Issue not found"); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + }); + + test("propagates 5xx ApiError through enabled SDK path", async () => { + const error = new ApiError("Server error", 500, "Internal error"); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + }); + + test("propagates generic Error through enabled SDK path", async () => { + await expect( + withTelemetry(() => { + throw new Error("unexpected bug"); + }) + ).rejects.toThrow("unexpected bug"); + }); + + test("returns result through enabled SDK path", async () => { + const result = await withTelemetry(() => 42); + expect(result).toBe(42); + }); + + test("does not capture OutputError (intentional exit-code mechanism)", async () => { + const captureSpy = vi.spyOn(Sentry, "captureException"); + const error = new OutputError(null); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + expect(captureSpy).not.toHaveBeenCalled(); + captureSpy.mockRestore(); + }); + + test("does not capture OutputError with data", async () => { + const captureSpy = vi.spyOn(Sentry, "captureException"); + const error = new OutputError({ error: "not found" }); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + expect(captureSpy).not.toHaveBeenCalled(); + captureSpy.mockRestore(); + }); + + test("emits cli.error.silenced metric for user API errors", async () => { + const metricSpy = vi.spyOn(Sentry.metrics, "distribution"); + const captureSpy = vi.spyOn(Sentry, "captureException"); + const error = new ApiError( + "Not found", + 404, + "detail", + "/api/0/organizations/foo/" + ); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + // Silenced: no captureException + expect(captureSpy).not.toHaveBeenCalled(); + // Metric emitted with normalized endpoint attribute + const silencedCall = metricSpy.mock.calls.find( + (c) => c[0] === "cli.error.silenced" + ); + expect(silencedCall).toBeDefined(); + expect(silencedCall?.[2]).toMatchObject({ + attributes: expect.objectContaining({ + error_class: "ApiError", + reason: "api_user_error", + api_status: 404, + }), + }); + metricSpy.mockRestore(); + captureSpy.mockRestore(); + }); + + test("captures 5xx ApiError with fingerprint applied", async () => { + const captureSpy = vi.spyOn(Sentry, "captureException"); + const withScopeSpy = vi.spyOn(Sentry, "withScope"); + const error = new ApiError( + "Server error", + 500, + "Internal", + "/api/0/organizations/foo/" + ); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + // Captured via reportCliError → Sentry.withScope + expect(withScopeSpy).toHaveBeenCalled(); + expect(captureSpy).toHaveBeenCalledWith(error); + withScopeSpy.mockRestore(); + captureSpy.mockRestore(); + }); + + test("captures ContextError so its volume stays visible (CLI-3B)", async () => { + const captureSpy = vi.spyOn(Sentry, "captureException"); + const metricSpy = vi.spyOn(Sentry.metrics, "distribution"); + // Seed an OK session so we can assert the crash decision. ContextError is + // captured (see below) but is an expected user-context failure, not a CLI + // crash — marking it crashed would skew release-health for the ~2000 + // affected users, so the session must stay "ok". + const session = { status: "ok", errors: 0 }; + const isolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => session, + } as unknown as Sentry.Scope); + const currentScopeSpy = vi + .spyOn(Sentry, "getCurrentScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + const { ContextError } = await import("../../src/lib/errors.js"); + const error = new ContextError( + "Organization and project", + "sentry issue view //" + ); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + // ContextError is no longer silenced — its volume drives auto-detection + // and UX improvements, so it must be reported to Sentry. + expect(captureSpy).toHaveBeenCalled(); + const silencedCall = metricSpy.mock.calls.find( + (c) => c[0] === "cli.error.silenced" + ); + expect(silencedCall).toBeUndefined(); + // ...but the session must NOT be marked crashed. + expect(session.status).toBe("ok"); + captureSpy.mockRestore(); + metricSpy.mockRestore(); + isolationScopeSpy.mockRestore(); + currentScopeSpy.mockRestore(); + }); + + test("captures malformed tokens without marking the session crashed", async () => { + const captureSpy = vi.spyOn(Sentry, "captureException"); + const metricSpy = vi.spyOn(Sentry.metrics, "distribution"); + const session = { status: "ok", errors: 0 }; + const isolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => session, + } as unknown as Sentry.Scope); + const currentScopeSpy = vi + .spyOn(Sentry, "getCurrentScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + try { + await expect( + withTelemetry(() => formatAuthHeader("first-part\nsecond-part")) + ).rejects.toThrow(MalformedAuthTokenError); + expect(captureSpy).toHaveBeenCalledExactlyOnceWith( + expect.any(MalformedAuthTokenError) + ); + expect( + metricSpy.mock.calls.find((c) => c[0] === "cli.error.silenced") + ).toBeUndefined(); + expect(session.status).toBe("ok"); + } finally { + captureSpy.mockRestore(); + metricSpy.mockRestore(); + isolationScopeSpy.mockRestore(); + currentScopeSpy.mockRestore(); + } + }); + + test("marks session crashed for a genuine CLI bug", async () => { + // A generic Error is neither silenced nor a user error, so the session + // should be marked crashed (the counterpart to the ContextError case). + const session = { status: "ok", errors: 0 }; + const isolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => session, + } as unknown as Sentry.Scope); + const currentScopeSpy = vi + .spyOn(Sentry, "getCurrentScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + const error = new Error("unexpected bug"); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + expect(session.status).toBe("crashed"); + isolationScopeSpy.mockRestore(); + currentScopeSpy.mockRestore(); + }); + + test("marks session crashed for a 5xx ApiError (captured, non-user)", async () => { + // A 5xx is captured (not silenced) and is NOT a user error, so it must + // still mark the session crashed — guards against the isUserError gate + // accidentally widening to swallow server/CLI failures. + const session = { status: "ok", errors: 0 }; + const isolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => session, + } as unknown as Sentry.Scope); + const currentScopeSpy = vi + .spyOn(Sentry, "getCurrentScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + const error = new ApiError("Server error", 500, "Internal"); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + expect(session.status).toBe("crashed"); + isolationScopeSpy.mockRestore(); + currentScopeSpy.mockRestore(); + }); + + test("does not mark session crashed for a deliberate CliError", async () => { + // A bare CliError is a deliberately-thrown, message-carrying failure (the + // CLI decided to stop and told the user why), not an unexpected crash. + // isUserError returns true for it, so it must NOT mark the session + // crashed. On main (before the isUserError gate) this WAS crash-marked, so + // this test documents and guards the intended behavior change. + const session = { status: "ok", errors: 0 }; + const isolationScopeSpy = vi + .spyOn(Sentry, "getIsolationScope") + .mockReturnValue({ + getSession: () => session, + } as unknown as Sentry.Scope); + const currentScopeSpy = vi + .spyOn(Sentry, "getCurrentScope") + .mockReturnValue({ + getSession: () => null, + } as unknown as Sentry.Scope); + const { CliError } = await import("../../src/lib/errors.js"); + const error = new CliError("Internal error: resolved issue missing org"); + await expect( + withTelemetry(() => { + throw error; + }) + ).rejects.toThrow(error); + expect(session.status).toBe("ok"); + isolationScopeSpy.mockRestore(); + currentScopeSpy.mockRestore(); + }); + }); +}); + +describe("isUserApiError", () => { + test("returns false for 400 Bad Request (CLI bug, not user error)", () => { + expect(isUserApiError(new ApiError("Bad request", 400))).toBe(false); + }); + + test("returns false for a 400 search-query parse error (CLI-built bad query)", () => { + // A user's unparseable --query is converted to a ValidationError at the + // command boundary, so a search-query 400 reaching here is a CLI-built bad + // request — a CLI bug, not a user API error. + expect( + isUserApiError( + new ApiError("bad", 400, "Error parsing search query: invalid status") + ) + ).toBe(false); + }); + + test("returns true for 401 Unauthorized", () => { + expect(isUserApiError(new ApiError("Unauthorized", 401))).toBe(true); + }); + + test("returns true for 403 Forbidden", () => { + expect(isUserApiError(new ApiError("Forbidden", 403, "No access"))).toBe( + true + ); + }); + + test("returns true for 404 Not Found", () => { + expect( + isUserApiError(new ApiError("Not found", 404, "Issue not found")) + ).toBe(true); + }); + + test("returns true for 429 Too Many Requests", () => { + expect(isUserApiError(new ApiError("Rate limited", 429))).toBe(true); + }); + + test("returns false for 500 Internal Server Error", () => { + expect(isUserApiError(new ApiError("Server error", 500))).toBe(false); + }); + + test("returns false for 502 Bad Gateway", () => { + expect(isUserApiError(new ApiError("Bad gateway", 502))).toBe(false); + }); + + test("returns false for non-ApiError", () => { + expect(isUserApiError(new Error("generic error"))).toBe(false); + }); + + test("returns false for AuthError", () => { + expect(isUserApiError(new AuthError("not_authenticated"))).toBe(false); + }); + + test("returns false for null/undefined", () => { + expect(isUserApiError(null)).toBe(false); + expect(isUserApiError(undefined)).toBe(false); + }); + + test("returns false for non-Error objects", () => { + expect(isUserApiError({ status: 404 })).toBe(false); + expect(isUserApiError("404")).toBe(false); + }); +}); + +describe("isEpipeError", () => { + test("detects EPIPE in exception message", () => { + const event = { + exception: { values: [{ value: "write EPIPE" }] }, + } as Sentry.ErrorEvent; + expect(isEpipeError(event)).toBe(true); + }); + + test("detects EPIPE in node_system_error context", () => { + const event = { + contexts: { node_system_error: { code: "EPIPE" } }, + } as Sentry.ErrorEvent; + expect(isEpipeError(event)).toBe(true); + }); + + test("returns false for non-EPIPE errors", () => { + const event = { + exception: { values: [{ value: "something else" }] }, + } as Sentry.ErrorEvent; + expect(isEpipeError(event)).toBe(false); + }); +}); + +describe("isEbadfError", () => { + test("detects EBADF in exception message", () => { + const event = { + exception: { + values: [ + { value: "EBADF: bad file descriptor, scandir '//dev/fd/22'" }, + ], + }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(true); + }); + + test("detects EBADF in Bun-style message", () => { + const event = { + exception: { + values: [{ value: "EBADF: bad file descriptor, stat '//dev/fd/10'" }], + }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(true); + }); + + test("detects EBADF in node_system_error context", () => { + const event = { + contexts: { node_system_error: { code: "EBADF" } }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(true); + }); + + test("returns false for non-EBADF errors", () => { + const event = { + exception: { values: [{ value: "EPIPE: broken pipe" }] }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(false); + }); + + test("returns false for events without exceptions or contexts", () => { + expect(isEbadfError({} as Sentry.ErrorEvent)).toBe(false); + }); +}); + +describe("recordApiErrorOnSpan", () => { + function createMockSpan() { + const attributes: Record = {}; + return { + attributes, + setAttribute(key: string, value: string | number) { + attributes[key] = value; + }, + }; + } + + test("sets status and message attributes", () => { + const span = createMockSpan(); + const error = new ApiError("Not found", 404); + recordApiErrorOnSpan(span as never, error); + + expect(span.attributes["api_error.status"]).toBe(404); + expect(span.attributes["api_error.message"]).toBe("Not found"); + expect(span.attributes["api_error.detail"]).toBeUndefined(); + }); + + test("sets detail attribute when present", () => { + const span = createMockSpan(); + const error = new ApiError("Not found", 404, "Issue not found"); + recordApiErrorOnSpan(span as never, error); + + expect(span.attributes["api_error.status"]).toBe(404); + expect(span.attributes["api_error.message"]).toBe("Not found"); + expect(span.attributes["api_error.detail"]).toBe("Issue not found"); + }); + + test("omits detail attribute when empty string", () => { + const span = createMockSpan(); + const error = new ApiError("Bad request", 400, ""); + recordApiErrorOnSpan(span as never, error); + + expect(span.attributes["api_error.status"]).toBe(400); + expect(span.attributes["api_error.detail"]).toBeUndefined(); + }); + + test("handles different 4xx status codes", () => { + const span = createMockSpan(); + const error = new ApiError("Forbidden", 403, "No access"); + recordApiErrorOnSpan(span as never, error); + + expect(span.attributes["api_error.status"]).toBe(403); + expect(span.attributes["api_error.message"]).toBe("Forbidden"); + expect(span.attributes["api_error.detail"]).toBe("No access"); + }); +}); + +describe("setCommandSpanName", () => { + test("handles undefined span gracefully", () => { + // Should not throw when span is undefined + expect(() => setCommandSpanName(undefined, "test.command")).not.toThrow(); + }); +}); + +describe("setOrgProjectContext", () => { + test("handles empty arrays", () => { + expect(() => setOrgProjectContext([], [])).not.toThrow(); + }); + + test("handles single org/project", () => { + expect(() => + setOrgProjectContext(["my-org"], ["my-project"]) + ).not.toThrow(); + }); + + test("handles multiple orgs/projects", () => { + expect(() => + setOrgProjectContext(["org1", "org2"], ["proj1", "proj2"]) + ).not.toThrow(); + }); +}); + +describe("setFlagContext", () => { + let setTagSpy: ReturnType; + + beforeEach(() => { + setTagSpy = vi.spyOn(Sentry, "setTag"); + }); + + afterEach(() => { + setTagSpy.mockRestore(); + }); + + test("does not set tags for empty flags object", () => { + setFlagContext({}); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + test("sets tags for boolean flags when true", () => { + setFlagContext({ verbose: true, debug: true }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.verbose", "true"); + expect(setTagSpy).toHaveBeenCalledWith("flag.debug", "true"); + }); + + test("does not set tags for boolean flags when false", () => { + setFlagContext({ verbose: false, debug: false }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + test("sets tags for string flags with values", () => { + setFlagContext({ output: "json", format: "table" }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.output", "json"); + expect(setTagSpy).toHaveBeenCalledWith("flag.format", "table"); + }); + + test("sets tags for number flags", () => { + setFlagContext({ limit: 10, offset: 5 }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.limit", "10"); + expect(setTagSpy).toHaveBeenCalledWith("flag.offset", "5"); + }); + + test("does not set tags for undefined or null values", () => { + setFlagContext({ value: undefined, other: null }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + test("does not set tags for empty string values", () => { + setFlagContext({ name: "" }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + test("does not set tags for empty array values", () => { + setFlagContext({ items: [] }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + test("sets tags for non-empty array values", () => { + setFlagContext({ projects: ["proj1", "proj2"] }); + expect(setTagSpy).toHaveBeenCalledTimes(1); + expect(setTagSpy).toHaveBeenCalledWith("flag.projects", "proj1,proj2"); + }); + + test("only sets tags for meaningful values in mixed flags", () => { + setFlagContext({ + verbose: true, + quiet: false, + limit: 50, + output: "json", + projects: ["a", "b"], + empty: "", + missing: undefined, + }); + // Should set: verbose, limit, output, projects (4 tags) + // Should skip: quiet (false), empty (""), missing (undefined) + expect(setTagSpy).toHaveBeenCalledTimes(4); + expect(setTagSpy).toHaveBeenCalledWith("flag.verbose", "true"); + expect(setTagSpy).toHaveBeenCalledWith("flag.limit", "50"); + expect(setTagSpy).toHaveBeenCalledWith("flag.output", "json"); + expect(setTagSpy).toHaveBeenCalledWith("flag.projects", "a,b"); + }); + + test("converts camelCase to kebab-case", () => { + setFlagContext({ + noModifyPath: true, + someVeryLongFlagName: "value", + }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.no-modify-path", "true"); + expect(setTagSpy).toHaveBeenCalledWith( + "flag.some-very-long-flag-name", + "value" + ); + }); + + test("truncates long string values to 200 characters", () => { + const longValue = "x".repeat(250); + setFlagContext({ longFlag: longValue }); + expect(setTagSpy).toHaveBeenCalledTimes(1); + expect(setTagSpy).toHaveBeenCalledWith("flag.long-flag", "x".repeat(200)); + }); + + test("redacts sensitive flag values (token)", () => { + setFlagContext({ + token: "sntrys_eyJpYXQiOjE3MDAwMDAwMDAsImF1dGhUb2tlbiI6InNlY3JldCJ9", + }); + expect(setTagSpy).toHaveBeenCalledTimes(1); + expect(setTagSpy).toHaveBeenCalledWith("flag.token", "[REDACTED]"); + }); + + test("still sets the tag when token flag is present (presence is useful signal)", () => { + setFlagContext({ token: "any-token-value" }); + // The tag is set (so we know --token was used), but the value is redacted + expect(setTagSpy).toHaveBeenCalledWith("flag.token", "[REDACTED]"); + }); + + test("does not redact non-sensitive flags alongside token", () => { + setFlagContext({ token: "secret", format: "json" }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.token", "[REDACTED]"); + expect(setTagSpy).toHaveBeenCalledWith("flag.format", "json"); + }); +}); + +describe("setArgsContext", () => { + let setContextSpy: ReturnType; + + beforeEach(() => { + setContextSpy = vi.spyOn(Sentry, "setContext"); + }); + + afterEach(() => { + setContextSpy.mockRestore(); + }); + + test("does not set context for empty args", () => { + setArgsContext([]); + expect(setContextSpy).not.toHaveBeenCalled(); + }); + + test("sets context for string args", () => { + setArgsContext(["PROJECT-123", "my-org"]); + expect(setContextSpy).toHaveBeenCalledTimes(1); + expect(setContextSpy).toHaveBeenCalledWith("args", { + values: ["PROJECT-123", "my-org"], + count: 2, + }); + }); + + test("converts non-string args to JSON", () => { + setArgsContext([123, { key: "value" }]); + expect(setContextSpy).toHaveBeenCalledWith("args", { + values: ["123", '{"key":"value"}'], + count: 2, + }); + }); +}); + +describe("withHttpSpan", () => { + test("executes function and returns result", async () => { + const result = await withHttpSpan("GET", "/test", async () => "success"); + expect(result).toBe("success"); + }); + + test("propagates errors", async () => { + await expect( + withHttpSpan("POST", "/test", async () => { + throw new Error("http error"); + }) + ).rejects.toThrow("http error"); + }); +}); + +describe("withDbSpan", () => { + test("executes function and returns result", () => { + const result = withDbSpan("testOp", () => 42); + expect(result).toBe(42); + }); + + test("propagates errors", () => { + expect(() => + withDbSpan("testOp", () => { + throw new Error("db error"); + }) + ).toThrow("db error"); + }); +}); + +describe("withSerializeSpan", () => { + test("executes function and returns result", () => { + const result = withSerializeSpan("format", () => ({ formatted: true })); + expect(result).toEqual({ formatted: true }); + }); + + test("propagates errors", () => { + expect(() => + withSerializeSpan("format", () => { + throw new Error("serialize error"); + }) + ).toThrow("serialize error"); + }); +}); + +describe("withTracing", () => { + test("executes sync function and returns result", async () => { + const result = await withTracing("test", "test.op", () => 42); + expect(result).toBe(42); + }); + + test("executes async function and returns result", async () => { + const result = await withTracing("test", "test.op", async () => { + await sleep(1); + return "async result"; + }); + expect(result).toBe("async result"); + }); + + test("propagates sync errors", async () => { + await expect( + withTracing("test", "test.op", () => { + throw new Error("sync error"); + }) + ).rejects.toThrow("sync error"); + }); + + test("propagates async errors", async () => { + await expect( + withTracing("test", "test.op", async () => { + await sleep(1); + throw new Error("async error"); + }) + ).rejects.toThrow("async error"); + }); + + test("handles complex return types", async () => { + const result = await withTracing("test", "test.op", () => ({ + status: "ok", + items: [1, 2, 3], + })); + expect(result).toEqual({ status: "ok", items: [1, 2, 3] }); + }); + + test("accepts attributes", async () => { + // This test mainly verifies the call doesn't throw + const result = await withTracing("test", "test.op", () => "success", { + "test.attr": "value", + "test.count": 42, + }); + expect(result).toBe("success"); + }); +}); + +describe("withFsSpan", () => { + test("executes sync function and returns result", async () => { + const result = await withFsSpan("readFile", () => "file content"); + expect(result).toBe("file content"); + }); + + test("executes async function and returns result", async () => { + const result = await withFsSpan("readFile", async () => { + await sleep(1); + return "async content"; + }); + expect(result).toBe("async content"); + }); + + test("propagates errors", async () => { + await expect( + withFsSpan("readFile", () => { + throw new Error("fs error"); + }) + ).rejects.toThrow("fs error"); + }); +}); + +describe("withTracingSpan", () => { + test("passes span to callback", async () => { + let receivedSpan: unknown = null; + await withTracingSpan("test", "test.op", (span) => { + receivedSpan = span; + return "done"; + }); + expect(receivedSpan).not.toBeNull(); + }); + + test("executes async function and returns result", async () => { + const result = await withTracingSpan("test", "test.op", async () => { + await sleep(1); + return "async result"; + }); + expect(result).toBe("async result"); + }); + + test("propagates errors", async () => { + await expect( + withTracingSpan("test", "test.op", () => { + throw new Error("test error"); + }) + ).rejects.toThrow("test error"); + }); + + test("allows callback to set attributes", async () => { + // This test verifies the span is usable for setting attributes + const result = await withTracingSpan("test", "test.op", (span) => { + span.setAttribute("custom.attr", "value"); + span.setAttributes({ "batch.attr1": 1, "batch.attr2": "two" }); + return "success"; + }); + expect(result).toBe("success"); + }); + + test("allows callback to set status without being overridden", async () => { + // Callback sets error status but returns successfully + // withTracingSpan should not override the manually-set status + const result = await withTracingSpan("test", "test.op", (span) => { + span.setStatus({ code: 2, message: "Manual error" }); + return "returned despite error status"; + }); + expect(result).toBe("returned despite error status"); + }); + + test("accepts initial attributes", async () => { + const result = await withTracingSpan("test", "test.op", () => "success", { + "init.attr": "initial", + }); + expect(result).toBe("success"); + }); +}); + +describe("createWizardPromptTelemetry", () => { + test("records individual prompt waits and accumulates root wait time", async () => { + const setMeasurementSpy = vi.spyOn(Sentry, "setMeasurement"); + const metricSpy = vi.spyOn(Sentry.metrics, "distribution"); + let now = 100; + const performanceSpy = vi + .spyOn(globalThis.performance, "now") + .mockImplementation(() => now); + const telemetry = createWizardPromptTelemetry(); + + telemetry.setActiveStep("select-features", true); + const firstResult = await telemetry.tracePrompt("multiselect", async () => { + now = 125; + return ["errorMonitoring"]; + }); + telemetry.setActiveStep("select-features", false); + const secondResult = await telemetry.tracePrompt("confirm", async () => { + now = 140; + return true; + }); + + expect(firstResult).toEqual(["errorMonitoring"]); + expect(secondResult).toBe(true); + expect(setMeasurementSpy).toHaveBeenNthCalledWith( + 1, + "wizard.user_wait_ms", + 25, + "millisecond", + expect.anything() + ); + expect(setMeasurementSpy).toHaveBeenNthCalledWith( + 2, + "wizard.user_wait_ms", + 40, + "millisecond", + expect.anything() + ); + expect(metricSpy).toHaveBeenCalledWith("wizard.user_wait_ms", 25, { + attributes: { + prompt_kind: "multiselect", + workflow_step: "select-features", + }, + }); + expect(metricSpy).toHaveBeenCalledWith("wizard.user_wait_ms", 15, { + attributes: { prompt_kind: "confirm" }, + }); + + performanceSpy.mockRestore(); + metricSpy.mockRestore(); + setMeasurementSpy.mockRestore(); + }); +}); + +describe("createTracedDatabase", () => { + test("wraps database and traces query().get()", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"); + db.exec("INSERT INTO test (id, name) VALUES (1, 'Alice')"); + + const tracedDb = createTracedDatabase(db); + const row = tracedDb.query("SELECT * FROM test WHERE id = ?").get(1) as { + id: number; + name: string; + }; + + expect(row).toEqual({ id: 1, name: "Alice" }); + db.close(); + }); + + test("wraps database and traces query().all()", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"); + db.exec("INSERT INTO test (id, name) VALUES (1, 'Alice'), (2, 'Bob')"); + + const tracedDb = createTracedDatabase(db); + const rows = tracedDb.query("SELECT * FROM test ORDER BY id").all() as { + id: number; + name: string; + }[]; + + expect(rows).toEqual([ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); + db.close(); + }); + + test("wraps database and traces query().run()", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"); + + const tracedDb = createTracedDatabase(db); + tracedDb.query("INSERT INTO test (id, name) VALUES (?, ?)").run(1, "Alice"); + + const row = db.query("SELECT * FROM test WHERE id = 1").get() as { + id: number; + name: string; + }; + expect(row).toEqual({ id: 1, name: "Alice" }); + db.close(); + }); + + test("passes through non-query methods like exec", () => { + const db = new Database(":memory:"); + const tracedDb = createTracedDatabase(db); + + // exec should work without tracing (passes through proxy) + tracedDb.exec("CREATE TABLE test (id INTEGER)"); + tracedDb.exec("INSERT INTO test VALUES (1)"); + + const row = tracedDb.query("SELECT * FROM test").get() as { id: number }; + expect(row).toEqual({ id: 1 }); + db.close(); + }); + + test("passes through close method", () => { + const db = new Database(":memory:"); + const tracedDb = createTracedDatabase(db); + + // Should not throw + expect(() => tracedDb.close()).not.toThrow(); + }); + + test("propagates errors from queries", () => { + const db = new Database(":memory:"); + const tracedDb = createTracedDatabase(db); + + expect(() => { + tracedDb.query("SELECT * FROM nonexistent_table").get(); + }).toThrow(); + + db.close(); + }); + + test("statement non-execution methods pass through", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE test (id INTEGER, name TEXT)"); + const tracedDb = createTracedDatabase(db); + + const stmt = tracedDb.query("SELECT * FROM test WHERE id = ?"); + + // columnNames is bun:sqlite-specific; skip assertion on Node.js + if ("columnNames" in stmt) { + expect(stmt.columnNames).toEqual(["id", "name"]); + } + expect(typeof stmt.toString).toBe("function"); + + db.close(); + }); + + test("statement methods are properly bound for native calls", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE test (id INTEGER, name TEXT)"); + const tracedDb = createTracedDatabase(db); + + const stmt = tracedDb.query("SELECT * FROM test WHERE id = ?"); + + // toString() requires proper 'this' binding to access native private fields. + // bun:sqlite returns the SQL string; Node.js sqlite returns "[object Object]". + const sqlString = stmt.toString(); + if (sqlString !== "[object Object]") { + expect(sqlString).toContain("SELECT * FROM test"); + } + + // finalize() is bun:sqlite-specific; skip on Node.js + if (typeof stmt.finalize === "function") { + expect(() => stmt.finalize()).not.toThrow(); + } + + db.close(); + }); + + describe("readonly database handling", () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(() => { + resetReadonlyWarning(); + + tmpDir = `${import.meta.dirname}/tmp-readonly-${Date.now()}`; + mkdirSync(tmpDir, { recursive: true }); + dbPath = `${tmpDir}/test.db`; + + const setupDb = new Database(dbPath); + setupDb.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)"); + setupDb.exec("INSERT INTO test (id, name) VALUES (1, 'Alice')"); + setupDb.close(); + + chmodSync(dbPath, 0o444); + }); + + afterEach(() => { + try { + chmodSync(dbPath, 0o644); + } catch { + // May already be cleaned up + } + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("does not throw on write to readonly database", () => { + // bun:sqlite opens the file successfully — the error only surfaces on write + const db = new Database(dbPath); + const tracedDb = createTracedDatabase(db); + + expect(() => { + tracedDb + .query("INSERT INTO test (id, name) VALUES (?, ?)") + .run(2, "Bob"); + }).not.toThrow(); + + db.close(); + }); + + test("reads still work on readonly database", () => { + const db = new Database(dbPath); + const tracedDb = createTracedDatabase(db); + + const row = tracedDb.query("SELECT * FROM test WHERE id = ?").get(1) as { + id: number; + name: string; + }; + expect(row).toEqual({ id: 1, name: "Alice" }); + + db.close(); + }); + + test("all() and values() return empty arrays on readonly write", () => { + const db = new Database(dbPath); + const tracedDb = createTracedDatabase(db); + + const allResult = tracedDb + .query("INSERT INTO test (id, name) VALUES (?, ?)") + .all(2, "Bob"); + expect(allResult).toEqual([]); + + // values() is bun:sqlite-specific; skip on Node.js + const stmt = tracedDb.query("INSERT INTO test (id, name) VALUES (?, ?)"); + if (typeof stmt.values === "function") { + const valuesResult = stmt.values(3, "Charlie"); + expect(valuesResult).toEqual([]); + } + + db.close(); + }); + + test("shows readonly warning when auto-repair fails", () => { + // Mock chmodSync to always throw, simulating a file owned by another user. + // This makes tryRepairReadonly fail and fall through to warnReadonlyDatabaseOnce. + const fs = require("node:fs"); + const chmodSpy = vi.spyOn(fs, "chmodSync").mockImplementation(() => { + throw Object.assign(new Error("EPERM: operation not permitted"), { + code: "EPERM", + }); + }); + + const db = new Database(dbPath); + const tracedDb = createTracedDatabase(db); + + const stderrSpy = vi.spyOn(process.stderr, "write"); + + tracedDb.query("INSERT INTO test (id, name) VALUES (?, ?)").run(2, "Bob"); + + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(""); + // When repair fails, the warning message should appear instead + expect(output).toContain("read-only"); + expect(output).toContain("sentry cli fix"); + + stderrSpy.mockRestore(); + chmodSpy.mockRestore(); + db.close(); + }); + }); +}); + +describe("getSentryTracePropagationTargets", () => { + const SENTRY_URL_ENV = "SENTRY_URL"; + const SENTRY_HOST_ENV = "SENTRY_HOST"; + let originalSentryUrl: string | undefined; + let originalSentryHost: string | undefined; + + beforeEach(() => { + originalSentryUrl = process.env[SENTRY_URL_ENV]; + originalSentryHost = process.env[SENTRY_HOST_ENV]; + delete process.env[SENTRY_URL_ENV]; + delete process.env[SENTRY_HOST_ENV]; + }); + + afterEach(() => { + if (originalSentryUrl === undefined) { + delete process.env[SENTRY_URL_ENV]; + } else { + process.env[SENTRY_URL_ENV] = originalSentryUrl; + } + if (originalSentryHost === undefined) { + delete process.env[SENTRY_HOST_ENV]; + } else { + process.env[SENTRY_HOST_ENV] = originalSentryHost; + } + }); + + test("matches SaaS regional URLs", () => { + const targets = getSentryTracePropagationTargets(); + const regexTargets = targets.filter( + (t): t is RegExp => t instanceof RegExp + ); + expect( + regexTargets.some((r) => r.test("https://us.sentry.io/api/0/")) + ).toBe(true); + expect( + regexTargets.some((r) => r.test("https://de.sentry.io/api/0/")) + ).toBe(true); + expect( + regexTargets.some((r) => + r.test("https://o1234.ingest.us.sentry.io/api/0/") + ) + ).toBe(true); + }); + + test("matches bare sentry.io", () => { + const targets = getSentryTracePropagationTargets(); + const regexTargets = targets.filter( + (t): t is RegExp => t instanceof RegExp + ); + expect(regexTargets.some((r) => r.test("https://sentry.io/api/0/"))).toBe( + true + ); + }); + + test("does not match non-sentry URLs", () => { + const targets = getSentryTracePropagationTargets(); + const regexTargets = targets.filter( + (t): t is RegExp => t instanceof RegExp + ); + expect(regexTargets.some((r) => r.test("https://example.com/api/0/"))).toBe( + false + ); + expect( + regexTargets.some((r) => r.test("https://not-sentry.io/api/0/")) + ).toBe(false); + }); + + test("does not match domains beyond sentry.io TLD boundary", () => { + const targets = getSentryTracePropagationTargets(); + const regexTargets = targets.filter( + (t): t is RegExp => t instanceof RegExp + ); + // sentry.io.evil.com should NOT match + expect( + regexTargets.some((r) => r.test("https://sentry.io.evil.com/api/0/")) + ).toBe(false); + // us.sentry.io.evil.com should NOT match + expect( + regexTargets.some((r) => r.test("https://us.sentry.io.evil.com/api/0/")) + ).toBe(false); + }); + + test("includes self-hosted URL when configured", () => { + process.env[SENTRY_URL_ENV] = "https://sentry.mycompany.com"; + const targets = getSentryTracePropagationTargets(); + const stringTargets = targets.filter( + (t): t is string => typeof t === "string" + ); + expect(stringTargets).toContain("https://sentry.mycompany.com"); + }); + + test("does not include SaaS URL as string target", () => { + // Default (no SENTRY_URL) → only RegExp targets, no string targets + const targets = getSentryTracePropagationTargets(); + const stringTargets = targets.filter( + (t): t is string => typeof t === "string" + ); + expect(stringTargets).toHaveLength(0); + }); + + test("does not duplicate SaaS URL when SENTRY_URL is sentry.io", () => { + process.env[SENTRY_URL_ENV] = "https://sentry.io"; + const targets = getSentryTracePropagationTargets(); + // SaaS URLs are already covered by regex — no string target should be added + const stringTargets = targets.filter( + (t): t is string => typeof t === "string" + ); + expect(stringTargets).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/lib/telemetry/zstd-transport.e2e.test.ts b/packages/cli/test/lib/telemetry/zstd-transport.e2e.test.ts new file mode 100644 index 000000000..a6e0ab9af --- /dev/null +++ b/packages/cli/test/lib/telemetry/zstd-transport.e2e.test.ts @@ -0,0 +1,147 @@ +/** + * E2E tests for the zstd transport. + * + * Spins up a real `http.createServer` on `127.0.0.1:0`, points the + * transport at it, and verifies the wire-level behavior: the request + * body is zstd-compressed, the `Content-Encoding` header is correct, + * and the body decompresses back to a valid envelope. + */ + +import { createServer, type IncomingMessage, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { promisify } from "node:util"; +import { zstdDecompress } from "node:zlib"; +import { createEnvelope } from "@sentry/core"; +import { afterEach, describe, expect, test } from "vitest"; +import { + hasZstdSupport, + makeCompressedTransport, +} from "../../../src/lib/telemetry/zstd-transport.js"; + +/** No-op for SDK callbacks that require a function but return nothing meaningful. */ +function noop(): void { + // intentionally empty +} + +type CapturedRequest = { + headers: IncomingMessage["headers"]; + body: Buffer; +}; + +/** + * Track every server we start so they can be closed in teardown. + * Without this, a `let server` shared across tests is overwritten by + * the second test before the first one is closed — silent socket leak. + */ +const startedServers: Server[] = []; + +function startMockIngest( + responder: (req: IncomingMessage) => { + statusCode: number; + headers?: Record; + } +): Promise<{ + url: string; + captures: CapturedRequest[]; +}> { + const captures: CapturedRequest[] = []; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + captures.push({ headers: req.headers, body: Buffer.concat(chunks) }); + const response = responder(req); + res.writeHead(response.statusCode, response.headers ?? {}); + res.end(); + }); + }); + startedServers.push(server); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${addr.port}/api/0/envelope/`, + captures, + }); + }); + }); +} + +describe("makeCompressedTransport (e2e)", () => { + afterEach(async () => { + // Close every server started in this test, in parallel. Lets the + // event loop drain naturally instead of relying on process exit. + await Promise.all( + startedServers.splice(0).map( + (s) => + new Promise((resolve) => { + s.close(() => resolve()); + }) + ) + ); + }); + + test("sends zstd-encoded envelope; server decompresses back to original", async () => { + if (!hasZstdSupport()) { + return; + } + + const { url, captures } = await startMockIngest(() => ({ + statusCode: 200, + })); + + const transport = makeCompressedTransport({ + url, + recordDroppedEvent: noop, + }); + + // Sizable envelope — above the 1 KiB zstd threshold. + const message = "x".repeat(4096); + const envelope: any = createEnvelope({ event_id: "abc" } as any, [ + [{ type: "event" } as any, { message } as any], + ]); + + const response = await transport.send(envelope); + expect(response.statusCode).toBe(200); + + expect(captures).toHaveLength(1); + expect(captures[0]?.headers["content-encoding"]).toBe("zstd"); + + const decompressed = await promisify(zstdDecompress)(captures[0]!.body); + const text = Buffer.from( + decompressed.buffer, + decompressed.byteOffset, + decompressed.byteLength + ).toString("utf-8"); + expect(text).toContain(message); + expect(text).toContain('"type":"event"'); + }); + + test("rate-limit headers flow back into createTransport wrapper", async () => { + const { url, captures } = await startMockIngest(() => ({ + statusCode: 429, + headers: { + "retry-after": "60", + "x-sentry-rate-limits": "60:error:organization", + }, + })); + + const transport = makeCompressedTransport({ + url, + recordDroppedEvent: noop, + }); + + const envelope: any = createEnvelope({ event_id: "a" } as any, [ + [{ type: "event" } as any, { message: "hi" } as any], + ]); + const response = await transport.send(envelope); + + expect(response.statusCode).toBe(429); + expect(response.headers?.["retry-after"]).toBe("60"); + expect(response.headers?.["x-sentry-rate-limits"]).toBe( + "60:error:organization" + ); + expect(captures).toHaveLength(1); + }); +}); diff --git a/packages/cli/test/lib/telemetry/zstd-transport.property.test.ts b/packages/cli/test/lib/telemetry/zstd-transport.property.test.ts new file mode 100644 index 000000000..568295ac9 --- /dev/null +++ b/packages/cli/test/lib/telemetry/zstd-transport.property.test.ts @@ -0,0 +1,134 @@ +/** + * Property tests for the zstd transport's compression pipeline. + * + * Exercises our actual `normalizeBody` + `maybeCompress` helpers (not + * just `Bun.zstdCompress` directly) so we verify the real wire path + * round-trips for arbitrary inputs. + * + * Properties under test: + * 1. zstd-encoded compress → decompress round-trips for any byte sequence. + * 2. gzip-encoded compress → gunzip round-trips for any byte sequence. + * 3. UTF-8 string and equivalent `Uint8Array` inputs produce identical + * wire bytes when fed through `normalizeBody` + `maybeCompress`. + * (This validates our string-vs-bytes normalization, not Bun's + * determinism.) + * 4. Sub-threshold inputs are passthrough — `payload === buf` and + * `encodingApplied === "none"`. + */ + +import { promisify } from "node:util"; +import { gunzipSync, zstdDecompress } from "node:zlib"; +import { + asyncProperty, + assert as fcAssert, + property, + string, + uint8Array, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + maybeCompress, + normalizeBody, +} from "../../../src/lib/telemetry/zstd-transport.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +const ZSTD_THRESHOLD = 1024; +const GZIP_THRESHOLD = 32 * 1024; + +describe("property: maybeCompress round-trip (zstd path)", () => { + test("zstd compress → decompress returns the original bytes", async () => { + await fcAssert( + asyncProperty( + uint8Array({ minLength: ZSTD_THRESHOLD + 1, maxLength: 64 * 1024 }), + async (bytes) => { + const buf = Buffer.from(bytes); + const result = await maybeCompress(buf, "zstd"); + expect(result.encodingApplied).toBe("zstd"); + const decompressed = await promisify(zstdDecompress)(result.payload); + expect(Buffer.from(decompressed).equals(buf)).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: maybeCompress round-trip (gzip path)", () => { + test("gzip compress → gunzip returns the original bytes", async () => { + await fcAssert( + asyncProperty( + uint8Array({ minLength: GZIP_THRESHOLD + 1, maxLength: 96 * 1024 }), + async (bytes) => { + const buf = Buffer.from(bytes); + const result = await maybeCompress(buf, "gzip"); + expect(result.encodingApplied).toBe("gzip"); + expect(gunzipSync(result.payload).equals(buf)).toBe(true); + } + ), + { numRuns: 25 } // gzip on 96 KiB is slower; fewer runs + ); + }); +}); + +describe("property: normalizeBody string/Uint8Array equivalence", () => { + test("string and TextEncoder-encoded bytes normalize to identical Buffers", () => { + fcAssert( + property(string({ minLength: 0, maxLength: 16 * 1024 }), (s) => { + const fromString = normalizeBody(s); + const fromBytes = normalizeBody(new TextEncoder().encode(s)); + expect(fromString.equals(fromBytes)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("string input through full compress pipeline matches bytes input", async () => { + await fcAssert( + asyncProperty( + string({ minLength: ZSTD_THRESHOLD + 1, maxLength: 16 * 1024 }), + async (s) => { + const fromString = await maybeCompress(normalizeBody(s), "zstd"); + const fromBytes = await maybeCompress( + normalizeBody(new TextEncoder().encode(s)), + "zstd" + ); + expect(fromString.encodingApplied).toBe(fromBytes.encodingApplied); + expect(fromString.payload.equals(fromBytes.payload)).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: maybeCompress passthrough below threshold", () => { + test('zstd encoding + body ≤ 1 KiB → encodingApplied="none", payload === buf', async () => { + await fcAssert( + asyncProperty( + uint8Array({ minLength: 0, maxLength: ZSTD_THRESHOLD }), + async (bytes) => { + const buf = Buffer.from(bytes); + const result = await maybeCompress(buf, "zstd"); + expect(result.encodingApplied).toBe("none"); + expect(result.payload).toBe(buf); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test('gzip encoding + body ≤ 32 KiB → encodingApplied="none", payload === buf', async () => { + await fcAssert( + asyncProperty( + uint8Array({ minLength: 0, maxLength: GZIP_THRESHOLD }), + async (bytes) => { + const buf = Buffer.from(bytes); + const result = await maybeCompress(buf, "gzip"); + expect(result.encodingApplied).toBe("none"); + expect(result.payload).toBe(buf); + } + ), + { numRuns: 25 } // 32 KiB arbitrary alloc is slower; fewer runs + ); + }); +}); diff --git a/packages/cli/test/lib/telemetry/zstd-transport.test.ts b/packages/cli/test/lib/telemetry/zstd-transport.test.ts new file mode 100644 index 000000000..78d164e7b --- /dev/null +++ b/packages/cli/test/lib/telemetry/zstd-transport.test.ts @@ -0,0 +1,600 @@ +/** + * Unit tests for {@link makeCompressedTransport}. + * + * Uses the SDK's `options.httpModule` extension point to inject a mock + * that captures the bytes written to the ClientRequest and synthesizes + * a response. We don't touch real sockets. + * + * Each scenario asserts on one of: + * 1. the `content-encoding` header as observed on the wire, + * 2. the compressed body round-tripping back to the input, + * 3. rate-limit response headers surfacing correctly to the + * `createTransport` layer. + */ + +import { EventEmitter } from "node:events"; +import type { ClientRequest, IncomingHttpHeaders } from "node:http"; +import { gunzipSync, zstdDecompressSync } from "node:zlib"; +import { createEnvelope } from "@sentry/core"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + hasZstdSupport, + isNoProxyExempt, + makeCompressedTransport, + maybeCompress, + normalizeBody, + shouldFallbackToDefault, +} from "../../../src/lib/telemetry/zstd-transport.js"; + +/** No-op for SDK callbacks that require a function but return nothing meaningful. */ +function noop(): void { + // intentionally empty +} + +// ── Mock http module ───────────────────────────────────────────────── + +type MockResponseShape = { + statusCode: number; + headers: IncomingHttpHeaders; +}; + +type CapturedRequest = { + chunks: Buffer[]; + options: Record; +}; + +/** + * Build a fake {@link http} module that captures outbound body bytes and + * resolves with a pre-canned response. The returned object exposes both + * the shim and the capture buffer for test assertions. + */ +function buildMockHttpModule(response: MockResponseShape): { + httpModule: { + request: (opts: unknown, cb?: (res: unknown) => void) => ClientRequest; + }; + captured: CapturedRequest; +} { + const captured: CapturedRequest = { chunks: [], options: {} }; + const httpModule = { + request: (opts: unknown, cb?: (res: unknown) => void) => { + captured.options = opts as Record; + + // The ClientRequest must behave as a Writable so that + // `Readable.from(payload).pipe(req)` succeeds. We fake one from + // an EventEmitter and expose `write`/`end` that push into the + // captured buffer. + const req = new EventEmitter() as unknown as ClientRequest & { + write: (chunk: Buffer | string) => boolean; + end: (chunk?: Buffer | string) => void; + }; + req.write = (chunk: Buffer | string) => { + captured.chunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ); + return true; + }; + req.end = (chunk?: Buffer | string) => { + if (chunk !== undefined) { + captured.chunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ); + } + // Fire the response on next tick so the caller has time to + // finish piping before we resolve. The response object is a + // minimal IncomingMessage-like shape that the transport's + // handler uses. + process.nextTick(() => { + const res = Object.assign(new EventEmitter(), { + statusCode: response.statusCode, + headers: response.headers, + setEncoding: noop, + }); + cb?.(res); + // Drive data/end events the transport listens to. + process.nextTick(() => { + res.emit("data", Buffer.alloc(0)); + res.emit("end"); + }); + }); + }; + return req; + }, + }; + return { httpModule: httpModule as never, captured }; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +const BASE_OPTIONS = { + url: "https://ingest.example.com/api/0/envelope/", + headers: { "x-sentry-auth": "Sentry sentry_key=abc" }, + recordDroppedEvent: noop, +}; + +describe("makeCompressedTransport", () => { + test("zstd branch: sets Content-Encoding: zstd and round-trips", async () => { + if (!hasZstdSupport()) { + // On a runtime without zstd this test is meaningless. + return; + } + const { httpModule, captured } = buildMockHttpModule({ + statusCode: 200, + headers: {}, + }); + + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + httpModule, + }); + + // Build a large envelope — above ZSTD_THRESHOLD (1 KiB). + const payload = "x".repeat(4096); + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, { data: payload } as any], + ]); + await transport.send(envelope); + + const headers = captured.options.headers as Record; + expect(headers["content-encoding"]).toBe("zstd"); + + const wire = Buffer.concat(captured.chunks); + expect(wire.length).toBeGreaterThan(0); + + // Decompress and verify the payload body is present + const decompressed = zstdDecompressSync(wire); + const text = decompressed.toString("utf-8"); + expect(text).toContain(payload); + }); + + // Note: the "gzip fallback when zstd is absent" test was removed because + // zstd is now provided by node:zlib (always available in Node 22.15+), + // not by a removable globalThis.Bun.zstdCompress polyfill. + + test("below threshold: no content-encoding header", async () => { + const { httpModule, captured } = buildMockHttpModule({ + statusCode: 200, + headers: {}, + }); + + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + httpModule, + }); + + // Tiny envelope - well below 1 KiB zstd threshold + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, { tiny: "x" } as any], + ]); + await transport.send(envelope); + + const headers = captured.options.headers as Record; + expect(headers["content-encoding"]).toBeUndefined(); + + // The wire body should be exactly the serialized envelope + const wire = Buffer.concat(captured.chunks); + expect(wire.toString("utf-8")).toContain("event"); + }); + + test("rate-limit response headers bubble up (string form)", async () => { + const { httpModule } = buildMockHttpModule({ + statusCode: 429, + headers: { + "retry-after": "60", + "x-sentry-rate-limits": "60:error:organization", + }, + }); + + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + httpModule, + }); + + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, { data: "small" } as any], + ]); + const response = await transport.send(envelope); + + expect(response.statusCode).toBe(429); + expect(response.headers?.["retry-after"]).toBe("60"); + expect(response.headers?.["x-sentry-rate-limits"]).toBe( + "60:error:organization" + ); + }); + + test("rate-limit response headers normalize array form", async () => { + const { httpModule } = buildMockHttpModule({ + statusCode: 429, + // Some proxies emit duplicate headers as arrays + headers: { + "retry-after": "30", + "x-sentry-rate-limits": [ + "30:transaction:organization", + "60:error:organization", + ] as never, + }, + }); + + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + httpModule, + }); + + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, { data: "small" } as any], + ]); + const response = await transport.send(envelope); + + // Array collapsed to first element + expect(response.headers?.["x-sentry-rate-limits"]).toBe( + "30:transaction:organization" + ); + }); + + test("invalid URL: no-op transport returned", async () => { + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + url: "not a url", + }); + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, {} as any], + ]); + // No-op transport resolves with empty response, does not throw. + const response = await transport.send(envelope); + expect(response).toEqual({}); + }); + + test("proxy configured: falls back to SDK's makeNodeTransport (no zstd applied)", async () => { + const savedProxy = process.env.https_proxy; + process.env.https_proxy = "http://proxy.internal:3128"; + try { + // The SDK's makeNodeTransport also honors options.httpModule, so + // we can route both paths through our mock and tell them apart by + // the Content-Encoding header on the wire: the zstd path sets it + // for any body > 1 KiB, while the SDK default only sets gzip for + // bodies > 32 KiB. A 4 KiB body therefore distinguishes the two + // — zstd path stamps "zstd", SDK path stamps nothing. + const { httpModule, captured } = buildMockHttpModule({ + statusCode: 200, + headers: {}, + }); + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + httpModule, + }); + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, { data: "x".repeat(4096) } as any], + ]); + await transport.send(envelope); + + const headers = captured.options.headers as Record; + expect(headers["content-encoding"]).toBeUndefined(); + // And the wire body is the raw envelope (not zstd-compressed). + const wire = Buffer.concat(captured.chunks); + expect(wire.toString("utf-8")).toContain('"type":"event"'); + } finally { + if (savedProxy === undefined) { + delete process.env.https_proxy; + } else { + process.env.https_proxy = savedProxy; + } + } + }); + + test("network error on socket: promise rejects, nothing throws outward", async () => { + // Mock http module whose request() emits an 'error' event instead of + // responding. The executor's `req.on('error', reject)` must surface + // it to the outer promise, which createTransport's wrapper catches + // and records as network_error. + const throwingMod = { + request: (_opts: unknown, _cb?: unknown) => { + const req = new EventEmitter() as unknown as ClientRequest & { + write: (c: unknown) => boolean; + end: () => void; + }; + req.write = () => true; + req.end = () => { + process.nextTick(() => req.emit("error", new Error("ECONNREFUSED"))); + }; + return req; + }, + }; + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + httpModule: throwingMod as never, + }); + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, { data: "x".repeat(4096) } as any], + ]); + // createTransport wraps network errors and re-throws them — a real + // API consumer would swallow this via .catch(). We just assert the + // promise settles (does not hang) and throws an ECONNREFUSED. + await expect(transport.send(envelope)).rejects.toThrow("ECONNREFUSED"); + }); + + test("proxy configured + URL is no_proxy exempt: uses zstd transport", async () => { + const savedProxy = process.env.https_proxy; + const savedNoProxy = process.env.no_proxy; + process.env.https_proxy = "http://proxy.internal:3128"; + process.env.no_proxy = "example.com"; + try { + const { httpModule, captured } = buildMockHttpModule({ + statusCode: 200, + headers: {}, + }); + const transport = makeCompressedTransport({ + ...BASE_OPTIONS, + httpModule, + }); + const envelope: any = createEnvelope({} as any, [ + [{ type: "event" } as any, { data: "small" } as any], + ]); + await transport.send(envelope); + // httpModule was called → we took the zstd path, not the SDK + // fallback (which would have ignored our httpModule mock and + // tried to connect through the proxy). + expect(captured.chunks.length).toBeGreaterThan(0); + } finally { + if (savedProxy === undefined) { + delete process.env.https_proxy; + } else { + process.env.https_proxy = savedProxy; + } + if (savedNoProxy === undefined) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = savedNoProxy; + } + } + }); +}); + +// ── Direct helper tests ────────────────────────────────────────────── + +describe("normalizeBody", () => { + test("string → UTF-8 bytes", () => { + const buf = normalizeBody("hello"); + expect(buf.toString("utf-8")).toBe("hello"); + }); + + test("multi-byte UTF-8 string", () => { + const buf = normalizeBody("café ☕"); + expect(buf.toString("utf-8")).toBe("café ☕"); + }); + + test("Uint8Array → zero-copy Buffer view", () => { + const src = new Uint8Array([1, 2, 3, 4, 5]); + const buf = normalizeBody(src); + expect(buf.length).toBe(5); + expect(Array.from(buf)).toEqual([1, 2, 3, 4, 5]); + }); + + test("Uint8Array with non-zero byteOffset", () => { + const backing = new Uint8Array([9, 9, 1, 2, 3, 9, 9]); + const view = new Uint8Array(backing.buffer, 2, 3); + const buf = normalizeBody(view); + expect(Array.from(buf)).toEqual([1, 2, 3]); + }); + + test("empty string", () => { + expect(normalizeBody("").length).toBe(0); + }); + + test("empty Uint8Array", () => { + expect(normalizeBody(new Uint8Array(0)).length).toBe(0); + }); +}); + +describe("maybeCompress", () => { + test("zstd + body above threshold → zstd-compressed", async () => { + if (!hasZstdSupport()) { + return; + } + const buf = Buffer.from("x".repeat(4096)); + const result = await maybeCompress(buf, "zstd"); + expect(result.encodingApplied).toBe("zstd"); + expect(result.payload.length).toBeLessThan(buf.length); + const decompressed = zstdDecompressSync(result.payload); + expect(decompressed.toString("utf-8")).toBe("x".repeat(4096)); + }); + + test("zstd + body below 1 KiB threshold → passthrough", async () => { + const buf = Buffer.from("x".repeat(512)); + const result = await maybeCompress(buf, "zstd"); + expect(result.encodingApplied).toBe("none"); + expect(result.payload).toBe(buf); + }); + + test("gzip + body above 32 KiB threshold → gzip-compressed", async () => { + const buf = Buffer.from("y".repeat(64 * 1024)); + const result = await maybeCompress(buf, "gzip"); + expect(result.encodingApplied).toBe("gzip"); + expect(result.payload.length).toBeLessThan(buf.length); + const decompressed = gunzipSync(result.payload); + expect(decompressed.toString("utf-8")).toBe("y".repeat(64 * 1024)); + }); + + test("gzip + body below 32 KiB threshold → passthrough", async () => { + const buf = Buffer.from("z".repeat(16 * 1024)); + const result = await maybeCompress(buf, "gzip"); + expect(result.encodingApplied).toBe("none"); + expect(result.payload).toBe(buf); + }); + + // Note: the "zstd mid-flight missing" tests were removed because zstd + // is now provided by node:zlib (always available), not a runtime polyfill + // that could disappear between construction and first send. +}); + +describe("isNoProxyExempt", () => { + let savedNoProxy: string | undefined; + let savedNoProxyUpper: string | undefined; + + beforeEach(() => { + savedNoProxy = process.env.no_proxy; + savedNoProxyUpper = process.env.NO_PROXY; + delete process.env.no_proxy; + delete process.env.NO_PROXY; + }); + + afterEach(() => { + if (savedNoProxy === undefined) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = savedNoProxy; + } + if (savedNoProxyUpper === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = savedNoProxyUpper; + } + }); + + test("no env var set → not exempt", () => { + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(false); + }); + + test("suffix match in no_proxy → exempt", () => { + process.env.no_proxy = "example.com,internal.lan"; + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(true); + }); + + test("no match → not exempt", () => { + process.env.no_proxy = "other.com"; + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(false); + }); + + test("NO_PROXY (uppercase) also recognized", () => { + process.env.NO_PROXY = "example.com"; + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(true); + }); + + test("lowercase takes precedence over uppercase", () => { + process.env.no_proxy = "other.com"; + process.env.NO_PROXY = "example.com"; + // Lowercase wins → uppercase ignored → no match → not exempt + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(false); + }); + + test("trims whitespace around comma-separated entries", () => { + // Common config style: `"a.com, b.com"` — both entries should match + // even with the leading space after the comma. + process.env.no_proxy = "other.com, ingest.example.com"; + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(true); + }); + + test("ignores empty entries (trailing comma, double comma)", () => { + process.env.no_proxy = "other.com,, , example.com,"; + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(true); + // Empty entry must not match every host (would be true if `endsWith("")` + // were ever evaluated). + expect(isNoProxyExempt(new URL("https://other-host.test/"))).toBe(false); + }); + + test("'*' wildcard exempts all hosts", () => { + process.env.no_proxy = "*"; + expect(isNoProxyExempt(new URL("https://ingest.example.com/"))).toBe(true); + expect(isNoProxyExempt(new URL("https://anything.test/"))).toBe(true); + expect(isNoProxyExempt(new URL("http://192.0.2.1:8080/"))).toBe(true); + }); + + test("'*' wildcard alongside other entries still exempts everything", () => { + process.env.no_proxy = "specific.com, *, other.com"; + expect(isNoProxyExempt(new URL("https://unrelated.test/"))).toBe(true); + }); + + test("literal '*' in a host name does not get matched as wildcard", () => { + // The wildcard check requires `*` to be a standalone entry. + // A bizarre value like `"*.example.com"` is treated as a literal + // suffix: `endsWith("*.example.com")` is false for normal hosts. + process.env.no_proxy = "*.example.com"; + expect(isNoProxyExempt(new URL("https://foo.example.com/"))).toBe(false); + }); +}); + +describe("hasZstdSupport", () => { + test("true on Node 22.15+ (node:zlib provides zstdCompress)", () => { + // node:zlib.zstdCompress is always available in our minimum Node version. + expect(hasZstdSupport()).toBe(true); + }); +}); + +describe("shouldFallbackToDefault", () => { + const PROXY_VARS = [ + "http_proxy", + "HTTP_PROXY", + "https_proxy", + "HTTPS_PROXY", + "no_proxy", + "NO_PROXY", + ] as const; + const saved: Partial> = {}; + + beforeEach(() => { + for (const k of PROXY_VARS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of PROXY_VARS) { + const v = saved[k]; + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + }); + + const opts = { url: "https://ingest.example.com/", recordDroppedEvent: noop }; + const httpsUrl = new URL("https://ingest.example.com/"); + const httpUrl = new URL("http://ingest.example.com/"); + + test("no proxy configured → no fallback", () => { + expect(shouldFallbackToDefault(httpsUrl, opts)).toBe(false); + }); + + test("options.proxy wins → fallback", () => { + expect( + shouldFallbackToDefault(httpsUrl, { + ...opts, + proxy: "http://proxy.internal:3128", + }) + ).toBe(true); + }); + + test("lowercase https_proxy → fallback (HTTPS URL)", () => { + process.env.https_proxy = "http://proxy.internal:3128"; + expect(shouldFallbackToDefault(httpsUrl, opts)).toBe(true); + }); + + test("uppercase HTTPS_PROXY → fallback (HTTPS URL)", () => { + process.env.HTTPS_PROXY = "http://proxy.internal:3128"; + expect(shouldFallbackToDefault(httpsUrl, opts)).toBe(true); + }); + + test("lowercase wins over uppercase when both are set", () => { + process.env.https_proxy = "http://winning.proxy:3128"; + process.env.HTTPS_PROXY = "http://losing.proxy:3128"; + // Both trigger fallback; this just asserts the lookup doesn't + // crash on duplicate vars and that the function still returns true. + expect(shouldFallbackToDefault(httpsUrl, opts)).toBe(true); + }); + + test("HTTPS URL falls back to http_proxy when https_proxy is unset (matches SDK precedent)", () => { + process.env.http_proxy = "http://proxy.internal:3128"; + expect(shouldFallbackToDefault(httpsUrl, opts)).toBe(true); + }); + + test("uppercase HTTP_PROXY → fallback (http URL)", () => { + process.env.HTTP_PROXY = "http://proxy.internal:3128"; + expect(shouldFallbackToDefault(httpUrl, opts)).toBe(true); + }); + + test("uppercase NO_PROXY exemption keeps zstd path even with proxy set", () => { + process.env.HTTPS_PROXY = "http://proxy.internal:3128"; + process.env.NO_PROXY = "example.com"; + expect(shouldFallbackToDefault(httpsUrl, opts)).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/time-range.property.test.ts b/packages/cli/test/lib/time-range.property.test.ts new file mode 100644 index 000000000..895ecfb3c --- /dev/null +++ b/packages/cli/test/lib/time-range.property.test.ts @@ -0,0 +1,323 @@ +/** + * Property-based tests for the time-range module. + * + * Tests invariants that should hold for any valid input using fast-check. + */ + +import { + array, + constantFrom, + date, + assert as fcAssert, + integer, + oneof, + property, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + parsePeriod, + serializeTimeRange, + timeRangeToApiParams, + timeRangeToSeconds, +} from "../../src/lib/time-range.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Generate a valid relative period string like "7d", "24h", "1m" */ +const relativePeriodArb = integer({ min: 1, max: 999 }).chain((n) => + constantFrom("m", "h", "d", "w").map((u) => `${n}${u}`) +); + +/** Generate an ISO date string (YYYY-MM-DD) in a sensible range */ +const isoDateArb = date({ + min: new Date("2020-01-02"), + max: new Date("2030-12-30"), + noInvalidDate: true, +}).map((d) => d.toISOString().slice(0, 10)); + +/** Generate a pair of sorted ISO date strings for valid ranges */ +const sortedDatePairArb = array(isoDateArb, { minLength: 2, maxLength: 2 }).map( + (dates) => { + const sorted = [...dates].sort(); + // Ensure they're different (no zero-length range issues) + if (sorted[0] === sorted[1]) { + const d = new Date(sorted[1]!); + d.setDate(d.getDate() + 1); + sorted[1] = d.toISOString().slice(0, 10); + } + return sorted as [string, string]; + } +); + +// --------------------------------------------------------------------------- +// Properties: parsePeriod — relative +// --------------------------------------------------------------------------- + +describe("property: parsePeriod relative", () => { + test("all valid relative periods parse as { type: relative }", () => { + fcAssert( + property(relativePeriodArb, (period) => { + const result = parsePeriod(period); + expect(result.type).toBe("relative"); + if (result.type === "relative") { + expect(result.period).toBe(period); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: parsePeriod — range syntax +// --------------------------------------------------------------------------- + +describe("property: parsePeriod range syntax", () => { + test("full range (date..date) parses as absolute with both bounds", () => { + fcAssert( + property(sortedDatePairArb, ([start, end]) => { + const result = parsePeriod(`${start}..${end}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("open-ended start (date..) parses as absolute with start only", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`${d}..`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("open-ended end (..date) parses as absolute with end only", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`..${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: parsePeriod — comparison operators +// --------------------------------------------------------------------------- + +describe("property: parsePeriod operators", () => { + test(">= parses as absolute with start", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`>=${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("> parses as absolute with start (next day for date-only)", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`>${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeDefined(); + expect(result.end).toBeUndefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("<= parses as absolute with end", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`<=${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("< parses as absolute with end (prev day for date-only)", () => { + fcAssert( + property(isoDateArb, (d) => { + const result = parsePeriod(`<${d}`); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toBeDefined(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("> produces strictly later start than >= for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const excl = parsePeriod(`>${d}`); + const incl = parsePeriod(`>=${d}`); + if (excl.type === "absolute" && incl.type === "absolute") { + const exclTime = new Date(excl.start!).getTime(); + const inclTime = new Date(incl.start!).getTime(); + expect(exclTime).toBeGreaterThan(inclTime); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("< produces strictly earlier end than <= for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const excl = parsePeriod(`<${d}`); + const incl = parsePeriod(`<=${d}`); + if (excl.type === "absolute" && incl.type === "absolute") { + const exclTime = new Date(excl.end!).getTime(); + const inclTime = new Date(incl.end!).getTime(); + expect(exclTime).toBeLessThan(inclTime); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: timeRangeToApiParams — mutual exclusivity +// --------------------------------------------------------------------------- + +describe("property: timeRangeToApiParams mutual exclusivity", () => { + const timeRangeArb = oneof( + relativePeriodArb.map((p) => parsePeriod(p)), + sortedDatePairArb.map(([s, e]) => parsePeriod(`${s}..${e}`)), + isoDateArb.map((d) => parsePeriod(`${d}..`)), + isoDateArb.map((d) => parsePeriod(`..${d}`)), + isoDateArb.map((d) => parsePeriod(`>=${d}`)), + isoDateArb.map((d) => parsePeriod(`<=${d}`)) + ); + + test("statsPeriod and start/end are never both set", () => { + fcAssert( + property(timeRangeArb, (range) => { + const params = timeRangeToApiParams(range); + if (params.statsPeriod) { + expect(params.start).toBeUndefined(); + expect(params.end).toBeUndefined(); + } else { + // At least one of start/end should be defined for absolute + expect(params.start !== undefined || params.end !== undefined).toBe( + true + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: serialization +// --------------------------------------------------------------------------- + +describe("property: serializeTimeRange", () => { + test("deterministic — same input produces same output", () => { + fcAssert( + property(relativePeriodArb, (period) => { + const range = parsePeriod(period); + expect(serializeTimeRange(range)).toBe(serializeTimeRange(range)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test(">= and .. produce same serialization for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const fromOp = parsePeriod(`>=${d}`); + const fromRange = parsePeriod(`${d}..`); + expect(serializeTimeRange(fromOp)).toBe(serializeTimeRange(fromRange)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("<= and ..date produce same serialization for date-only", () => { + fcAssert( + property(isoDateArb, (d) => { + const fromOp = parsePeriod(`<=${d}`); + const fromRange = parsePeriod(`..${d}`); + expect(serializeTimeRange(fromOp)).toBe(serializeTimeRange(fromRange)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Properties: timeRangeToSeconds +// --------------------------------------------------------------------------- + +describe("property: timeRangeToSeconds", () => { + test("relative durations produce positive seconds", () => { + fcAssert( + property(relativePeriodArb, (period) => { + const range = parsePeriod(period); + const seconds = timeRangeToSeconds(range); + expect(seconds).toBeDefined(); + expect(seconds).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("full absolute ranges produce non-negative seconds", () => { + fcAssert( + property(sortedDatePairArb, ([start, end]) => { + const range = parsePeriod(`${start}..${end}`); + const seconds = timeRangeToSeconds(range); + expect(seconds).toBeDefined(); + expect(seconds).toBeGreaterThanOrEqual(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("open-ended ranges return undefined", () => { + fcAssert( + property(isoDateArb, (d) => { + expect(timeRangeToSeconds(parsePeriod(`${d}..`))).toBeUndefined(); + expect(timeRangeToSeconds(parsePeriod(`..${d}`))).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/time-range.test.ts b/packages/cli/test/lib/time-range.test.ts new file mode 100644 index 000000000..a8ca20c03 --- /dev/null +++ b/packages/cli/test/lib/time-range.test.ts @@ -0,0 +1,397 @@ +/** + * Unit tests for the time-range module. + * + * Core invariants (round-trips, operator equivalence, mutual exclusivity) + * are tested via property-based tests in time-range.property.test.ts. + * These tests focus on edge cases, validation errors, and specific + * normalization behavior. + */ + +import { describe, expect, test } from "vitest"; +import { + parseDate, + parsePeriod, + serializeTimeRange, + timeRangeToApiParams, + timeRangeToSeconds, +} from "../../src/lib/time-range.js"; + +// --------------------------------------------------------------------------- +// parsePeriod — relative durations (backward compatibility) +// --------------------------------------------------------------------------- + +describe("parsePeriod: relative durations", () => { + const validPeriods = [ + "1m", + "30m", + "1h", + "24h", + "7d", + "14d", + "30d", + "90d", + "1w", + "2w", + ]; + + for (const period of validPeriods) { + test(`parses "${period}" as relative`, () => { + const result = parsePeriod(period); + expect(result).toEqual({ type: "relative", period }); + }); + } + + test("rejects zero duration", () => { + expect(() => parsePeriod("0d")).toThrow("cannot be zero"); + expect(() => parsePeriod("0h")).toThrow("cannot be zero"); + expect(() => parsePeriod("0m")).toThrow("cannot be zero"); + }); + + test("rejects invalid units", () => { + expect(() => parsePeriod("7x")).toThrow("Invalid period"); + expect(() => parsePeriod("24z")).toThrow("Invalid period"); + }); + + test("rejects empty string", () => { + expect(() => parsePeriod("")).toThrow("Empty period"); + expect(() => parsePeriod(" ")).toThrow("Empty period"); + }); + + test("rejects bare date without operator or range", () => { + expect(() => parsePeriod("2024-01-01")).toThrow("Invalid period"); + }); +}); + +// --------------------------------------------------------------------------- +// parsePeriod — ".." range syntax +// --------------------------------------------------------------------------- + +describe("parsePeriod: range syntax", () => { + test('parses full range "start..end"', () => { + const result = parsePeriod("2024-01-01..2024-02-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-01-01"); + expect(result.end).toContain("2024-02-01"); + } + }); + + test('parses open-ended start "date.."', () => { + const result = parsePeriod("2024-06-01.."); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-06-01"); + expect(result.end).toBeUndefined(); + } + }); + + test('parses open-ended end "..date"', () => { + const result = parsePeriod("..2024-03-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toContain("2024-03-01"); + } + }); + + test('rejects bare ".."', () => { + expect(() => parsePeriod("..")).toThrow("Empty range"); + }); + + test("rejects end before start", () => { + expect(() => parsePeriod("2024-06-01..2024-01-01")).toThrow( + "is after end date" + ); + }); + + test("rejects invalid dates in range", () => { + expect(() => parsePeriod("not-a-date..2024-01-01")).toThrow("Invalid"); + expect(() => parsePeriod("2024-01-01..not-a-date")).toThrow("Invalid"); + }); + + test('rejects mixed relative in range "7d..14d"', () => { + expect(() => parsePeriod("7d..14d")).toThrow("Invalid"); + }); +}); + +// --------------------------------------------------------------------------- +// parsePeriod — comparison operators +// --------------------------------------------------------------------------- + +describe("parsePeriod: comparison operators", () => { + test('">=date" returns absolute with start', () => { + const result = parsePeriod(">=2024-01-15"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-01-15T00:00:00"); + expect(result.end).toBeUndefined(); + } + }); + + test('">date" returns absolute with next-day start', () => { + const result = parsePeriod(">2024-01-15"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toContain("2024-01-16T00:00:00"); + expect(result.end).toBeUndefined(); + } + }); + + test('"<=date" returns absolute with end', () => { + const result = parsePeriod("<=2024-02-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toContain("2024-02-01T23:59:59"); + } + }); + + test('" { + const result = parsePeriod("<2024-02-01"); + expect(result.type).toBe("absolute"); + if (result.type === "absolute") { + expect(result.start).toBeUndefined(); + expect(result.end).toContain("2024-01-31T23:59:59"); + } + }); + + test('"> exclusive" differs from ">= inclusive" for date-only', () => { + const exclusive = parsePeriod(">2024-01-15"); + const inclusive = parsePeriod(">=2024-01-15"); + if (exclusive.type === "absolute" && inclusive.type === "absolute") { + // Exclusive start should be strictly later than inclusive start + expect(new Date(exclusive.start!).getTime()).toBeGreaterThan( + new Date(inclusive.start!).getTime() + ); + } + }); + + test('"< exclusive" differs from "<= inclusive" for date-only', () => { + const exclusive = parsePeriod("<2024-02-01"); + const inclusive = parsePeriod("<=2024-02-01"); + if (exclusive.type === "absolute" && inclusive.type === "absolute") { + // Exclusive end should be strictly earlier than inclusive end + expect(new Date(exclusive.end!).getTime()).toBeLessThan( + new Date(inclusive.end!).getTime() + ); + } + }); + + test("rejects operator without date", () => { + expect(() => parsePeriod(">")).toThrow("Missing date"); + expect(() => parsePeriod(">=")).toThrow("Missing date"); + expect(() => parsePeriod("<")).toThrow("Missing date"); + expect(() => parsePeriod("<=")).toThrow("Missing date"); + }); + + test("datetime with operator passes through as-is (no day shift)", () => { + const result = parsePeriod(">2024-01-01T12:00:00Z"); + if (result.type === "absolute") { + // Datetime with TZ → no next-day shift, converted to ISO + expect(result.start).toBe("2024-01-01T12:00:00.000Z"); + } + }); +}); + +// --------------------------------------------------------------------------- +// parseDate — timezone handling +// --------------------------------------------------------------------------- + +describe("parseDate: output format", () => { + test("date-only outputs UTC ISO string via local interpretation", () => { + const result = parseDate("2024-06-15", "start"); + // Output is always .toISOString() format (UTC, ends with Z) + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(Number.isNaN(new Date(result).getTime())).toBe(false); + // Should represent local midnight of 2024-06-15 converted to UTC + const d = new Date("2024-06-15T00:00:00"); // local time + expect(result).toBe(d.toISOString()); + }); + + test("datetime with Z is normalized to ISO", () => { + const result = parseDate("2024-06-15T12:00:00Z", "start"); + expect(result).toBe("2024-06-15T12:00:00.000Z"); + }); + + test("datetime with +offset is converted to UTC", () => { + const result = parseDate("2024-06-15T12:00:00+05:30", "start"); + // +05:30 means 12:00 local = 06:30 UTC + expect(result).toBe("2024-06-15T06:30:00.000Z"); + }); + + test("datetime with -offset is converted to UTC", () => { + const result = parseDate("2024-06-15T12:00:00-08:00", "start"); + // -08:00 means 12:00 local = 20:00 UTC + expect(result).toBe("2024-06-15T20:00:00.000Z"); + }); + + test("datetime without TZ is treated as local time", () => { + const result = parseDate("2024-06-15T12:00:00", "start"); + // new Date() without Z treats as local time + const expected = new Date("2024-06-15T12:00:00").toISOString(); + expect(result).toBe(expected); + }); + + test("space separator is accepted as T alternative", () => { + const withSpace = parseDate("2024-06-15 12:00:00Z", "start"); + const withT = parseDate("2024-06-15T12:00:00Z", "start"); + expect(withSpace).toBe(withT); + }); + + test("rejects invalid date", () => { + expect(() => parseDate("not-a-date", "start")).toThrow("Invalid"); + }); + + test("rejects empty string", () => { + expect(() => parseDate("", "start")).toThrow("Empty date"); + }); +}); + +// --------------------------------------------------------------------------- +// timeRangeToApiParams +// --------------------------------------------------------------------------- + +describe("timeRangeToApiParams", () => { + test("relative → statsPeriod only", () => { + const params = timeRangeToApiParams({ type: "relative", period: "7d" }); + expect(params).toEqual({ statsPeriod: "7d" }); + expect(params.start).toBeUndefined(); + expect(params.end).toBeUndefined(); + }); + + test("absolute full range → start + end, no statsPeriod", () => { + const params = timeRangeToApiParams({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + end: "2024-02-01T23:59:59Z", + }); + expect(params.statsPeriod).toBeUndefined(); + expect(params.start).toBe("2024-01-01T00:00:00Z"); + expect(params.end).toBe("2024-02-01T23:59:59Z"); + }); + + test("absolute start-only → start + auto-filled end (now)", () => { + const before = new Date(); + const params = timeRangeToApiParams({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + }); + const after = new Date(); + expect(params.start).toBe("2024-01-01T00:00:00Z"); + expect(params.statsPeriod).toBeUndefined(); + // end should be filled with "now" + expect(params.end).toBeDefined(); + const endDate = new Date(params.end!); + expect(endDate.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(endDate.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + test("absolute end-only → end + auto-filled start (90 days before end)", () => { + const params = timeRangeToApiParams({ + type: "absolute", + end: "2024-02-01T23:59:59Z", + }); + expect(params.end).toBe("2024-02-01T23:59:59Z"); + expect(params.statsPeriod).toBeUndefined(); + // start should be filled with 90 days before end + expect(params.start).toBeDefined(); + const startDate = new Date(params.start!); + const expectedStart = new Date("2024-02-01T23:59:59Z"); + expectedStart.setUTCDate(expectedStart.getUTCDate() - 90); + expect(startDate.toISOString()).toBe(expectedStart.toISOString()); + }); +}); + +// --------------------------------------------------------------------------- +// serializeTimeRange +// --------------------------------------------------------------------------- + +describe("serializeTimeRange", () => { + test("relative → 'rel:7d'", () => { + expect(serializeTimeRange({ type: "relative", period: "7d" })).toBe( + "rel:7d" + ); + }); + + test("absolute full range → 'abs:start..end' in UTC", () => { + const result = serializeTimeRange({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + end: "2024-02-01T23:59:59Z", + }); + expect(result).toMatch(/^abs:.*\.\..*$/); + // UTC normalization + expect(result).toContain("2024-01-01T00:00:00.000Z"); + }); + + test("open-ended start → 'abs:start..'", () => { + const result = serializeTimeRange({ + type: "absolute", + start: "2024-06-01T00:00:00Z", + }); + expect(result).toMatch(/^abs:.*\.\.$/); + }); + + test("open-ended end → 'abs:..end'", () => { + const result = serializeTimeRange({ + type: "absolute", + end: "2024-03-01T23:59:59Z", + }); + expect(result).toMatch(/^abs:\.\..*$/); + }); + + test("operator equivalences serialize identically", () => { + // >=2024-01-01 and 2024-01-01.. should produce the same serialization + const fromOp = parsePeriod(">=2024-01-01"); + const fromRange = parsePeriod("2024-01-01.."); + expect(serializeTimeRange(fromOp)).toBe(serializeTimeRange(fromRange)); + }); +}); + +// --------------------------------------------------------------------------- +// timeRangeToSeconds +// --------------------------------------------------------------------------- + +describe("timeRangeToSeconds", () => { + test("relative 7d → 604800", () => { + expect(timeRangeToSeconds({ type: "relative", period: "7d" })).toBe( + 604_800 + ); + }); + + test("relative 24h → 86400", () => { + expect(timeRangeToSeconds({ type: "relative", period: "24h" })).toBe( + 86_400 + ); + }); + + test("relative 1w → 604800", () => { + expect(timeRangeToSeconds({ type: "relative", period: "1w" })).toBe( + 604_800 + ); + }); + + test("absolute 7-day range → 604800", () => { + const result = timeRangeToSeconds({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + end: "2024-01-08T00:00:00Z", + }); + expect(result).toBe(604_800); + }); + + test("open-ended range → undefined", () => { + expect( + timeRangeToSeconds({ + type: "absolute", + start: "2024-01-01T00:00:00Z", + }) + ).toBeUndefined(); + expect( + timeRangeToSeconds({ + type: "absolute", + end: "2024-02-01T00:00:00Z", + }) + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/timezone.mocked.test.ts b/packages/cli/test/lib/timezone.mocked.test.ts new file mode 100644 index 000000000..ddf752d94 --- /dev/null +++ b/packages/cli/test/lib/timezone.mocked.test.ts @@ -0,0 +1,221 @@ +/** + * OS timezone-detection tests for src/lib/timezone.ts with node:fs and + * node:child_process mocked before import. + * + * The detection strategies (`/etc/timezone`, the `/etc/localtime` symlink, + * and `tzutil /g` on Windows) read real OS state, which is not deterministic + * in CI. Mocking the filesystem and the tzutil shell-out lets us exercise + * every branch of `detectOsTimezone` and drive `initTimezone`'s repair path + * through a detected IANA name rather than the fixed-offset fallback. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + /** Contents returned by readFileSync("/etc/timezone"), or an Error to throw. */ + etcTimezone: undefined as string | Error | undefined, + /** Target returned by readlinkSync("/etc/localtime"), or an Error to throw. */ + localtimeLink: undefined as string | Error | undefined, + /** stdout returned by execSync("tzutil /g"), or an Error to throw. */ + tzutil: undefined as string | Error | undefined, +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: vi.fn((path: string, ...rest: unknown[]) => { + if (path === "/etc/timezone") { + if (mocks.etcTimezone instanceof Error) { + throw mocks.etcTimezone; + } + if (mocks.etcTimezone === undefined) { + throw new Error("ENOENT: /etc/timezone"); + } + return mocks.etcTimezone; + } + return (actual.readFileSync as (...a: unknown[]) => unknown)( + path, + ...rest + ); + }), + readlinkSync: vi.fn((path: string, ...rest: unknown[]) => { + if (path === "/etc/localtime") { + if (mocks.localtimeLink instanceof Error) { + throw mocks.localtimeLink; + } + if (mocks.localtimeLink === undefined) { + throw new Error("ENOENT: /etc/localtime"); + } + return mocks.localtimeLink; + } + return (actual.readlinkSync as (...a: unknown[]) => unknown)( + path, + ...rest + ); + }), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn((command: string, ...rest: unknown[]) => { + if (command === "tzutil /g") { + if (mocks.tzutil instanceof Error) { + throw mocks.tzutil; + } + if (mocks.tzutil === undefined) { + throw new Error("tzutil not found"); + } + return mocks.tzutil; + } + return (actual.execSync as (...a: unknown[]) => unknown)( + command, + ...rest + ); + }), + }; +}); + +import { detectOsTimezone, initTimezone } from "../../src/lib/timezone.js"; + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +beforeEach(() => { + mocks.etcTimezone = undefined; + mocks.localtimeLink = undefined; + mocks.tzutil = undefined; +}); + +afterEach(() => { + vi.restoreAllMocks(); + setPlatform(originalPlatform); +}); + +describe("detectOsTimezone on Unix", () => { + beforeEach(() => { + setPlatform("linux"); + }); + + test("prefers a valid /etc/timezone IANA name", () => { + mocks.etcTimezone = "America/Los_Angeles\n"; + expect(detectOsTimezone()).toBe("America/Los_Angeles"); + }); + + test("ignores a UTC /etc/timezone and falls through to the symlink", () => { + mocks.etcTimezone = "UTC"; + mocks.localtimeLink = "/usr/share/zoneinfo/Europe/Berlin"; + expect(detectOsTimezone()).toBe("Europe/Berlin"); + }); + + test("ignores an empty /etc/timezone and falls through to the symlink", () => { + mocks.etcTimezone = " \n"; + mocks.localtimeLink = "/var/db/timezone/zoneinfo/Asia/Tokyo"; + expect(detectOsTimezone()).toBe("Asia/Tokyo"); + }); + + test("reads the IANA name from the /etc/localtime symlink target", () => { + // /etc/timezone absent (throws) → symlink path. + mocks.localtimeLink = "/usr/share/zoneinfo/America/New_York"; + expect(detectOsTimezone()).toBe("America/New_York"); + }); + + test("returns null when the symlink target has no zoneinfo segment", () => { + mocks.localtimeLink = "/some/other/path"; + expect(detectOsTimezone()).toBeNull(); + }); + + test("returns null when the zoneinfo segment is empty", () => { + mocks.localtimeLink = "/usr/share/zoneinfo/"; + expect(detectOsTimezone()).toBeNull(); + }); + + test("returns null when neither source is available", () => { + // Both /etc/timezone and /etc/localtime throw ENOENT. + expect(detectOsTimezone()).toBeNull(); + }); +}); + +describe("detectOsTimezone on Windows", () => { + beforeEach(() => { + setPlatform("win32"); + }); + + test("maps a known Windows zone name to IANA", () => { + mocks.tzutil = "Pacific Standard Time\r\n"; + expect(detectOsTimezone()).toBe("America/Los_Angeles"); + }); + + test("returns null for an unmapped Windows zone name", () => { + mocks.tzutil = "Some Obscure Standard Time"; + expect(detectOsTimezone()).toBeNull(); + }); + + test("returns null when tzutil is unavailable", () => { + mocks.tzutil = new Error("tzutil not on PATH"); + expect(detectOsTimezone()).toBeNull(); + }); +}); + +describe("initTimezone applies a detected IANA zone", () => { + const originalTz = process.env.TZ; + + beforeEach(() => { + setPlatform("linux"); + }); + + afterEach(() => { + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + }); + + test("sets TZ to the OS-detected zone when the runtime fell back to UTC", () => { + delete process.env.TZ; + // Runtime reports UTC ... + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // ... OS clock is elsewhere ... + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + // ... and the OS reports a concrete IANA zone. + mocks.etcTimezone = "America/Los_Angeles"; + + expect(initTimezone()).toBe("America/Los_Angeles"); + expect(process.env.TZ).toBe("America/Los_Angeles"); + }); + + test("falls back to a fixed-offset zone when the OS zone is undetectable", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + // Neither /etc/timezone nor /etc/localtime resolves → fixed-offset path. + expect(initTimezone()).toBe("Etc/GMT+8"); + expect(process.env.TZ).toBe("Etc/GMT+8"); + }); + + test("returns null when detection yields UTC and the offset is sub-hour", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // 30-minute offset: osReportsNonUtc() is true, but detectOsTimezone() + // returns null and fixedOffsetZone(30) is null → no repair applied. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(30); + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/timezone.test.ts b/packages/cli/test/lib/timezone.test.ts new file mode 100644 index 000000000..24a468df4 --- /dev/null +++ b/packages/cli/test/lib/timezone.test.ts @@ -0,0 +1,168 @@ +/** + * Tests for src/lib/timezone.ts + * + * Covers the pure helpers (`formatLogTime`, `fixedOffsetZone`) and the + * decision logic of `initTimezone`. The bug being guarded against: SEA + * binaries fall back to UTC when they cannot resolve the OS zone, so log + * timestamps appear hours off from the user's local clock. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + detectOsTimezone, + fixedOffsetZone, + formatLogTime, + initTimezone, + runtimeTimezone, +} from "../../src/lib/timezone.js"; + +describe("formatLogTime", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders 24-hour HH:MM:SS in local time", () => { + // 2024-01-15T14:28:59Z. Force a +480 (US/Pacific standard, UTC-8) offset so + // the local wall-clock is 06:28:59 regardless of the host machine's zone. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + const date = new Date("2024-01-15T14:28:59.000Z"); + expect(formatLogTime(date)).toBe("06:28:59"); + }); + + test("UTC offset renders the UTC wall-clock", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + const date = new Date("2024-01-15T14:28:59.000Z"); + expect(formatLogTime(date)).toBe("14:28:59"); + }); + + test("east-of-UTC (negative offset) shifts forward", () => { + // Berlin winter is UTC+1 → getTimezoneOffset() === -60. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-60); + const date = new Date("2024-01-15T23:30:00.000Z"); + // 23:30Z + 1h = 00:30 next day → wraps to 00:30. + expect(formatLogTime(date)).toBe("00:30:00"); + }); + + test("zero-pads single-digit components", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + const date = new Date("2024-01-15T01:02:03.000Z"); + expect(formatLogTime(date)).toBe("01:02:03"); + }); + + test("output is always 8 chars in HH:MM:SS shape", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + for (let h = 0; h < 24; h++) { + const date = new Date(Date.UTC(2024, 0, 1, h, h % 60, (h * 2) % 60)); + const out = formatLogTime(date); + expect(out).toMatch(/^\d{2}:\d{2}:\d{2}$/); + } + }); +}); + +describe("fixedOffsetZone", () => { + test("west of UTC (positive offset) → Etc/GMT+N", () => { + // US/Pacific standard is +480 min behind UTC → UTC-8. + expect(fixedOffsetZone(480)).toBe("Etc/GMT+8"); + }); + + test("east of UTC (negative offset) → Etc/GMT-N", () => { + // Berlin winter is -60 min → UTC+1. + expect(fixedOffsetZone(-60)).toBe("Etc/GMT-1"); + }); + + test("returns null for UTC / sub-hour / invalid offsets", () => { + expect(fixedOffsetZone(0)).toBeNull(); + expect(fixedOffsetZone(30)).toBeNull(); + expect(fixedOffsetZone(Number.NaN)).toBeNull(); + }); +}); + +describe("runtimeTimezone", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("returns a non-empty IANA-ish string", () => { + const tz = runtimeTimezone(); + expect(typeof tz).toBe("string"); + expect(tz.length).toBeGreaterThan(0); + }); + + test("falls back to UTC when Intl resolves an empty zone", () => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "" }), + } as unknown as Intl.DateTimeFormat); + expect(runtimeTimezone()).toBe("UTC"); + }); + + test("falls back to UTC when Intl throws", () => { + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("ICU data missing"); + }); + expect(runtimeTimezone()).toBe("UTC"); + }); +}); + +describe("detectOsTimezone", () => { + test("never throws and returns string or null", () => { + const result = detectOsTimezone(); + expect(result === null || typeof result === "string").toBe(true); + }); +}); + +describe("initTimezone", () => { + const originalTz = process.env.TZ; + + afterEach(() => { + vi.restoreAllMocks(); + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + }); + + test("respects an already-set TZ and makes no change", () => { + process.env.TZ = "America/New_York"; + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBe("America/New_York"); + }); + + test("does nothing when the runtime already resolved a real zone", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "America/Los_Angeles" }), + } as unknown as Intl.DateTimeFormat); + + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBeUndefined(); + }); + + test("leaves genuine UTC machines untouched", () => { + delete process.env.TZ; + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // OS offset 0 → machine really is UTC. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + + expect(initTimezone()).toBeNull(); + expect(process.env.TZ).toBeUndefined(); + }); + + test("repairs the UTC fallback using the OS offset", () => { + delete process.env.TZ; + // Runtime fell back to UTC ... + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "UTC" }), + } as unknown as Intl.DateTimeFormat); + // ... but the OS clock is 8h behind UTC (US/Pacific standard). + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + + const applied = initTimezone(); + expect(applied).not.toBeNull(); + expect(process.env.TZ).toBe(applied ?? ""); + // Either a detected IANA name or the fixed-offset fallback. + expect(process.env.TZ?.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/test/lib/token-claims.property.test.ts b/packages/cli/test/lib/token-claims.property.test.ts new file mode 100644 index 000000000..449176357 --- /dev/null +++ b/packages/cli/test/lib/token-claims.property.test.ts @@ -0,0 +1,107 @@ +/** + * Property-based tests for `parseSntrysClaim`. + * + * Invariants under random input: + * + * 1. `parseSntrysClaim` never throws. + * 2. Round-trip: a token minted with a given url+iat parses back to the + * same url. + * 3. Forged claims parse identically to legitimate ones (this is by + * design — see `token-claims.ts` JSDoc — and the property documents + * that the parser is NOT a security primitive). + * 4. Adversarial inputs (random strings, near-prefix matches, malformed + * base64, JSON injection attempts) always return `undefined` instead + * of a partially-trusted result. + */ + +import { + constantFrom, + assert as fcAssert, + property, + string, + stringMatching, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { parseSntrysClaim } from "../../src/lib/token-claims.js"; +import { mintSntrysToken } from "../helpers.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +const mint = mintSntrysToken; + +/** Arbitrary URL-shaped string (https://) */ +const httpsUrlArb = stringMatching( + /^[a-z][a-z0-9-]{0,30}(\.[a-z][a-z0-9-]{0,30}){0,4}$/ +).map((host) => `https://${host}`); + +describe("property: parseSntrysClaim never throws on adversarial input", () => { + test("any string input → returns either undefined or a valid claim", () => { + fcAssert( + property(string({ maxLength: 4000 }), (input) => { + const result = parseSntrysClaim(input); + if (result !== undefined) { + // If it parsed, the claim must have a non-empty string url + expect(typeof result.url).toBe("string"); + expect(result.url.length).toBeGreaterThan(0); + } + }), + { numRuns: DEFAULT_NUM_RUNS * 4 } + ); + }); + + test("strings starting with sntrys_ but with weird content → never throws", () => { + const sntrysPrefixed = string({ maxLength: 1000 }).map( + (rest) => `sntrys_${rest}` + ); + fcAssert( + property(sntrysPrefixed, (input) => { + expect(() => parseSntrysClaim(input)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS * 4 } + ); + }); +}); + +describe("property: round-trip — minted tokens parse back to the same url", () => { + test("any https URL minted into a token parses back identically", () => { + fcAssert( + property(httpsUrlArb, (url) => { + const token = mint({ iat: 1_700_000_000, url, org: "x" }); + expect(parseSntrysClaim(token)?.url).toBe(url); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("any iat > 0 produces a parseable claim", () => { + fcAssert( + property( + tuple(constantFrom(1, 100, 1_700_000_000, Date.now()), httpsUrlArb), + ([iat, url]) => { + const token = mint({ iat, url, org: "x" }); + expect(parseSntrysClaim(token)).toBeDefined(); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: forged claims parse identically (NOT a security signal)", () => { + test("any url an attacker chooses parses back as that url", () => { + // This property documents that parseSntrysClaim does NOT validate + // claim authenticity. Callers MUST treat the result as a hint, not + // a trust source. See `src/lib/token-claims.ts` for the contract. + fcAssert( + property(httpsUrlArb, (forgedUrl) => { + const forged = mint({ + iat: 1_700_000_000, + url: forgedUrl, + org: "anything", + }); + expect(parseSntrysClaim(forged)?.url).toBe(forgedUrl); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/token-claims.test.ts b/packages/cli/test/lib/token-claims.test.ts new file mode 100644 index 000000000..54abf90a5 --- /dev/null +++ b/packages/cli/test/lib/token-claims.test.ts @@ -0,0 +1,218 @@ +/** + * Unit tests for `sntrys_` org-auth-token claim parsing. + * + * Mirrors the server's token format from + * `getsentry/sentry/src/sentry/utils/security/orgauthtoken_token.py`: + * + * sntrys__ + * + * The middle chunk is plaintext base64 (NOT base64url), and the trailing + * chunk is opaque entropy. The CLI's parser must match the server's + * `parse_token` semantics (strict prefix, exactly 2 underscores, valid + * base64 → valid UTF-8 → valid JSON object → truthy `iat`). + */ + +import { describe, expect, test } from "vitest"; +import { parseSntrysClaim } from "../../src/lib/token-claims.js"; +import { mintSntrysToken } from "../helpers.js"; + +describe("parseSntrysClaim", () => { + test("extracts url + region_url from a well-formed token", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.acme.com", + region_url: "https://us.sentry.acme.com", + org: "acme", + }); + expect(parseSntrysClaim(token)).toEqual({ + url: "https://sentry.acme.com", + regionUrl: "https://us.sentry.acme.com", + org: "acme", + }); + }); + + test("returns just url when region_url is absent", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.acme.com", + org: "acme", + }); + expect(parseSntrysClaim(token)).toEqual({ + url: "https://sentry.acme.com", + regionUrl: undefined, + org: "acme", + }); + }); + + test("returns undefined for non-sntrys_ tokens", () => { + expect(parseSntrysClaim("sntryu_abcdef0123456789")).toBeUndefined(); + expect(parseSntrysClaim("sntrya_abcdef0123456789")).toBeUndefined(); + expect(parseSntrysClaim("sntryi_abcdef0123456789")).toBeUndefined(); + // OAuth-style opaque token + expect(parseSntrysClaim("abc123def456")).toBeUndefined(); + }); + + test("returns undefined for undefined/empty input", () => { + expect(parseSntrysClaim(undefined)).toBeUndefined(); + expect(parseSntrysClaim("")).toBeUndefined(); + }); + + test("returns undefined for tokens with wrong underscore count", () => { + // 1 underscore (just the prefix) + expect(parseSntrysClaim("sntrys_aGVsbG8=")).toBeUndefined(); + // 3 underscores + expect(parseSntrysClaim("sntrys_test_token_extra")).toBeUndefined(); + // 0 underscores after prefix strip — caught by prefix check + expect(parseSntrysClaim("sntrysno-underscore")).toBeUndefined(); + }); + + test("returns undefined when payload chunk is not valid base64", () => { + // `not_valid_base64` contains underscore which would actually break + // the underscore count, so this is the format the test preload uses: + expect( + parseSntrysClaim("sntrys_test-token-for-unit-tests_000000") + ).toBeUndefined(); + // Pure invalid base64 character set in middle (with right underscore count) + expect(parseSntrysClaim("sntrys_!!!notbase64!!!_secret")).toBeUndefined(); + }); + + test("returns undefined when payload is valid base64 but not JSON", () => { + // base64 of "hello world" — valid base64 + valid UTF-8 but not JSON + const b64 = Buffer.from("hello world", "utf8").toString("base64"); + expect(parseSntrysClaim(`sntrys_${b64}_secret`)).toBeUndefined(); + }); + + test("returns undefined when JSON is valid but not an object", () => { + const arr = Buffer.from("[1,2,3]", "utf8").toString("base64"); + const num = Buffer.from("42", "utf8").toString("base64"); + const str = Buffer.from('"hello"', "utf8").toString("base64"); + const nul = Buffer.from("null", "utf8").toString("base64"); + expect(parseSntrysClaim(`sntrys_${arr}_secret`)).toBeUndefined(); + expect(parseSntrysClaim(`sntrys_${num}_secret`)).toBeUndefined(); + expect(parseSntrysClaim(`sntrys_${str}_secret`)).toBeUndefined(); + expect(parseSntrysClaim(`sntrys_${nul}_secret`)).toBeUndefined(); + }); + + test("returns undefined when payload has no iat field (matches server semantics)", () => { + const token = mintSntrysToken({ + url: "https://sentry.acme.com", + org: "acme", + // no iat + }); + expect(parseSntrysClaim(token)).toBeUndefined(); + }); + + test("returns undefined when payload has falsy iat", () => { + const token = mintSntrysToken({ + iat: 0, + url: "https://sentry.acme.com", + org: "acme", + }); + expect(parseSntrysClaim(token)).toBeUndefined(); + }); + + test("returns undefined when payload has no url field", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + org: "acme", + // no url + }); + expect(parseSntrysClaim(token)).toBeUndefined(); + }); + + test("returns undefined when url is not a string", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: 12_345, + org: "acme", + }); + expect(parseSntrysClaim(token)).toBeUndefined(); + }); + + test("returns undefined when url is empty string", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "", + org: "acme", + }); + expect(parseSntrysClaim(token)).toBeUndefined(); + }); + + test("rejects oversized tokens without parsing (DoS protection)", () => { + // 4 KB token — well over the 2 KB cap. Should not even attempt + // base64/JSON parsing. + const big = "a".repeat(4096); + expect(parseSntrysClaim(`sntrys_${big}_x`)).toBeUndefined(); + }); + + test("ignores non-string region_url (treats as absent)", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + region_url: 42, + org: "x", + }); + expect(parseSntrysClaim(token)?.regionUrl).toBeUndefined(); + }); + + test("returns org as undefined when org field is missing from payload", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + }); + expect(parseSntrysClaim(token)?.org).toBeUndefined(); + }); + + test("ignores non-string org (treats as absent)", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + org: 42, + }); + expect(parseSntrysClaim(token)?.org).toBeUndefined(); + }); + + test("ignores empty-string org (treats as absent)", () => { + const token = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://sentry.io", + org: "", + }); + expect(parseSntrysClaim(token)?.org).toBeUndefined(); + }); + + test("does NOT throw on adversarial inputs", () => { + // Catch-all: any input must either return undefined or a valid + // claim object — never throw. + const adversarial = [ + "sntrys___", + "sntrys__", + "sntrys_💥_secret", + "sntrys_\u0000_secret", + "sntrys_/=+_secret", + // Base64 of `{` — incomplete JSON + `sntrys_${Buffer.from("{", "utf8").toString("base64")}_secret`, + ]; + for (const input of adversarial) { + expect(() => parseSntrysClaim(input)).not.toThrow(); + } + }); + + test("treats forged claim same as legitimate (we don't verify signatures)", () => { + // CRITICAL: this test documents that parseSntrysClaim does NOT + // validate the claim's authenticity. An attacker can mint a token + // with any url they want and parseSntrysClaim returns it. Callers + // must NEVER use the claim as a primary security signal — see + // `src/lib/token-claims.ts` JSDoc. + const forged = mintSntrysToken({ + iat: 1_700_000_000, + url: "https://evil.com", + org: "victim", + }); + expect(parseSntrysClaim(forged)).toEqual({ + url: "https://evil.com", + regionUrl: undefined, + org: "victim", + }); + }); +}); diff --git a/packages/cli/test/lib/token-host.property.test.ts b/packages/cli/test/lib/token-host.property.test.ts new file mode 100644 index 000000000..734d355d9 --- /dev/null +++ b/packages/cli/test/lib/token-host.property.test.ts @@ -0,0 +1,201 @@ +/** + * Property-based tests for host-scoping trust model. + * + * Verifies invariants that must hold for ANY input: + * - Trust is symmetric within the SaaS equivalence class. + * - Non-SaaS hosts only match exact origin (no subdomain suffix tricks). + * - Normalization is idempotent. + * - Requests with unparseable URLs are never trusted. + * + * Unit tests for specific edge cases live in test/lib/token-host.test.ts. + */ + +import { + constantFrom, + assert as fcAssert, + property, + stringMatching, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { normalizeOrigin } from "../../src/lib/sentry-urls.js"; +import { isHostTrusted } from "../../src/lib/token-host.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Host chars: lowercase alphanumerics + hyphen */ +const HOST_LABEL = stringMatching(/^[a-z0-9][a-z0-9-]{0,20}$/); + +/** Two-label lowercase host (e.g. "example.com") */ +const simpleHostArb = tuple(HOST_LABEL, HOST_LABEL).map( + ([a, b]) => `${a}.${b}` +); + +/** Non-SaaS host: never ends with `sentry.io` */ +const nonSaasHostArb = simpleHostArb.filter( + (h) => h !== "sentry.io" && !h.endsWith(".sentry.io") +); + +/** SaaS host: any subdomain of sentry.io (including bare `sentry.io`) */ +const saasSubdomainArb = HOST_LABEL.filter((label) => { + try { + new URL(`https://${label}.sentry.io/`); + return true; + } catch { + // Some generated labels (e.g. `xn--`) produce invalid WHATWG URLs. + return false; + } +}).map((label) => `${label}.sentry.io`); + +/** Protocol */ +const protoArb = constantFrom("http", "https"); + +describe("property: normalizeOrigin is idempotent", () => { + test("normalize(normalize(x)) === normalize(x)", () => { + fcAssert( + property(protoArb, simpleHostArb, (proto, host) => { + const url = `${proto}://${host}/some/path?q=1`; + const once = normalizeOrigin(url); + if (once === undefined) { + return; + } + expect(normalizeOrigin(once)).toBe(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: SaaS equivalence is reflexive + symmetric", () => { + test("any sentry.io subdomain matches any other sentry.io subdomain", () => { + fcAssert( + property(saasSubdomainArb, saasSubdomainArb, (a, b) => { + // Both a and b are https://