From 38530f3f92e979e30b635e1b5bc91dd4f8db411b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 01:31:25 +0200 Subject: [PATCH 01/11] chore(ci): replace Codecov with GitHub-native coverage reporting Codecov was unreliable and required an external service. Replace it with inline lcov parsing that posts coverage as a GitHub Actions job summary and a sticky PR comment via the gh CLI. - Parse lcov.info with awk for line coverage stats - Write per-directory breakdown to job summary - Post/update sticky PR comment (non-fork PRs only) - Remove codecov.yml config and codecov-action references - Simplify test:coverage script (drop JUnit reporter) - Switch release workflow to validate (no coverage needed) Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 90 +++++++++++++++++++------ .github/workflows/release.yml | 23 +------ codecov.yml | 25 ------- package.json | 2 +- 4 files changed, 71 insertions(+), 69 deletions(-) delete mode 100644 codecov.yml diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index a812b1d4..bf964ece 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -13,7 +13,7 @@ on: permissions: contents: read - id-token: write + pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -61,27 +61,75 @@ jobs: - name: Validate id: validate run: bun run validate:coverage - - name: Upload coverage to Codecov + - name: Coverage report if: always() && steps.validate.outcome == 'success' - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 - with: - use_oidc: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - token: ${{ secrets.CODECOV_TOKEN }} - slug: archgate/cli - files: coverage/lcov.info - disable_search: true - fail_ci_if_error: false - - name: Upload test results to Codecov - if: always() && steps.validate.outcome != 'skipped' - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 - with: - use_oidc: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - token: ${{ secrets.CODECOV_TOKEN }} - slug: archgate/cli - files: coverage/junit.xml - disable_search: true - report_type: test_results - fail_ci_if_error: false + env: + GH_TOKEN: ${{ github.token }} + run: | + # Parse lcov for summary stats + LINES_FOUND=$(awk -F: '/^LF:/{s+=$2} END{print s+0}' coverage/lcov.info) + LINES_HIT=$(awk -F: '/^LH:/{s+=$2} END{print s+0}' coverage/lcov.info) + if [ "$LINES_FOUND" -gt 0 ]; then + COVERAGE=$(awk "BEGIN{printf \"%.1f\", ($LINES_HIT/$LINES_FOUND)*100}") + else + COVERAGE="0.0" + fi + + # Per-directory coverage table + DIR_TABLE=$(awk -F: ' + /^SF:/ { + file = $2 + n = split(file, p, "/") + dir = (n >= 3) ? p[1] "/" p[2] : (n == 2 ? p[1] : file) + } + /^LF:/ { lf[dir] += $2 } + /^LH:/ { lh[dir] += $2 } + END { + for (d in lf) { + pct = (lf[d] > 0) ? (lh[d] / lf[d]) * 100 : 0 + printf "| `%s/` | %.1f%% | %d / %d |\n", d, pct, lh[d], lf[d] + } + } + ' coverage/lcov.info | sort) + + # Build report markdown + { + echo "## Code Coverage" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| **Lines** | **${COVERAGE}%** (${LINES_HIT} / ${LINES_FOUND}) |" + echo "" + echo "
" + echo "Per-directory breakdown" + echo "" + echo "| Directory | Coverage | Lines |" + echo "|-----------|----------|-------|" + echo "${DIR_TABLE}" + echo "" + echo "
" + } > /tmp/coverage-report.md + + # Write to GitHub Actions job summary + cat /tmp/coverage-report.md >> "$GITHUB_STEP_SUMMARY" + + # Post/update PR comment (skipped for fork PRs and non-PR events) + if [ "${{ github.event_name }}" = "pull_request" ] && \ + [ "${{ github.event.pull_request.head.repo.fork }}" != "true" ]; then + PR_NUM="${{ github.event.pull_request.number }}" + MARKER="" + BODY="$(printf '%s\n%s' "${MARKER}" "$(cat /tmp/coverage-report.md)")" + + EXISTING=$(gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \ + --jq '[.[] | select(.body | startswith(""))][0].id // empty' 2>/dev/null || true) + + if [ -n "${EXISTING}" ]; then + gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments/${EXISTING}" \ + -X PATCH -f body="${BODY}" > /dev/null + else + gh pr comment "${PR_NUM}" --body "${BODY}" + fi + fi - name: Save Bun Cache uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 if: steps.validate.outcome == 'success' && steps.restore-bun-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b084e50..9969575c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,28 +152,7 @@ jobs: run: bun install --frozen-lockfile - name: Validate id: validate - run: bun run validate:coverage - - name: Upload coverage to Codecov - if: always() && steps.validate.outcome == 'success' - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 - with: - use_oidc: true - token: ${{ secrets.CODECOV_TOKEN }} - slug: archgate/cli - files: coverage/lcov.info - disable_search: true - fail_ci_if_error: false - - name: Upload test results to Codecov - if: always() && steps.validate.outcome != 'skipped' - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 - with: - use_oidc: true - token: ${{ secrets.CODECOV_TOKEN }} - slug: archgate/cli - files: coverage/junit.xml - disable_search: true - report_type: test_results - fail_ci_if_error: false + run: bun run validate - name: Ensure main is up-to-date before release run: git pull --rebase origin main - name: Release diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index d82e50f2..00000000 --- a/codecov.yml +++ /dev/null @@ -1,25 +0,0 @@ -codecov: - require_ci_to_pass: true - -coverage: - status: - project: - default: - target: auto - threshold: 1% - patch: - default: - target: 80% - -comment: - layout: "header, diff, flags, components" - behavior: default - require_changes: true - require_base: false - require_head: true - -ignore: - - "docs/**" - - "scripts/**" - - "tests/**" - - ".archgate/**" diff --git a/package.json b/package.json index 789c22a5..588e8dbb 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "knip": "knip", "lint": "oxlint --deny-warnings .", "test": "bun test --timeout 60000", - "test:coverage": "bun test --timeout 60000 --coverage --reporter=junit --reporter-outfile=coverage/junit.xml", + "test:coverage": "bun test --timeout 60000 --coverage", "test:watch": "bun test --watch --timeout 60000", "typecheck": "tsc --build", "validate": "bun run lint && bun run typecheck && bun run format:check && bun run test && bun run check && bun run knip && bun run build:check", From 8a1c21a9cd8836208515e4367825b0f2caad8cb3 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 01:40:48 +0200 Subject: [PATCH 02/11] chore(ci): add HTML coverage report as downloadable artifact Generate an interactive HTML coverage report via genhtml (lcov) and upload it as a GitHub Actions artifact (30-day retention). The PR comment and job summary now link to the artifact for detailed per-file coverage inspection. Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index bf964ece..a0830fd6 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -61,7 +61,19 @@ jobs: - name: Validate id: validate run: bun run validate:coverage - - name: Coverage report + - name: Generate HTML coverage report + if: always() && steps.validate.outcome == 'success' + run: | + sudo apt-get install -y -qq lcov > /dev/null 2>&1 + genhtml coverage/lcov.info --output-directory coverage/html --quiet + - name: Upload coverage report + if: always() && steps.validate.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-report + path: coverage/html + retention-days: 30 + - name: Coverage summary if: always() && steps.validate.outcome == 'success' env: GH_TOKEN: ${{ github.token }} @@ -92,6 +104,8 @@ jobs: } ' coverage/lcov.info | sort) + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + # Build report markdown { echo "## Code Coverage" @@ -100,6 +114,8 @@ jobs: echo "|--------|-------|" echo "| **Lines** | **${COVERAGE}%** (${LINES_HIT} / ${LINES_FOUND}) |" echo "" + echo "Full HTML report available in [workflow artifacts](${RUN_URL}#artifacts)." + echo "" echo "
" echo "Per-directory breakdown" echo "" From 8cc1f132df8be66211aeeeb6d524ac4821a51a6c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 01:51:59 +0200 Subject: [PATCH 03/11] fix(ci): filter lcov to src/ and use gh pr comment for sticky updates - Filter lcov.info to src/* via lcov --extract before genhtml, fixing permission errors from test temp dir paths (../../../tmp/...) - Use gh pr comment --edit-last instead of the issues REST API, which returned 404 because pull-requests:write does not cover it - Read coverage stats from the filtered lcov-src.info Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index a0830fd6..37d41b9f 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -65,7 +65,8 @@ jobs: if: always() && steps.validate.outcome == 'success' run: | sudo apt-get install -y -qq lcov > /dev/null 2>&1 - genhtml coverage/lcov.info --output-directory coverage/html --quiet + lcov --extract coverage/lcov.info 'src/*' --output-file coverage/lcov-src.info --quiet + genhtml coverage/lcov-src.info --output-directory coverage/html --quiet - name: Upload coverage report if: always() && steps.validate.outcome == 'success' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -78,9 +79,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # Parse lcov for summary stats - LINES_FOUND=$(awk -F: '/^LF:/{s+=$2} END{print s+0}' coverage/lcov.info) - LINES_HIT=$(awk -F: '/^LH:/{s+=$2} END{print s+0}' coverage/lcov.info) + # Parse filtered lcov for summary stats (src/ only, excludes test temp dirs) + LINES_FOUND=$(awk -F: '/^LF:/{s+=$2} END{print s+0}' coverage/lcov-src.info) + LINES_HIT=$(awk -F: '/^LH:/{s+=$2} END{print s+0}' coverage/lcov-src.info) if [ "$LINES_FOUND" -gt 0 ]; then COVERAGE=$(awk "BEGIN{printf \"%.1f\", ($LINES_HIT/$LINES_FOUND)*100}") else @@ -102,7 +103,7 @@ jobs: printf "| `%s/` | %.1f%% | %d / %d |\n", d, pct, lh[d], lf[d] } } - ' coverage/lcov.info | sort) + ' coverage/lcov-src.info | sort) RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" @@ -133,18 +134,12 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ] && \ [ "${{ github.event.pull_request.head.repo.fork }}" != "true" ]; then PR_NUM="${{ github.event.pull_request.number }}" - MARKER="" - BODY="$(printf '%s\n%s' "${MARKER}" "$(cat /tmp/coverage-report.md)")" + BODY=" + $(cat /tmp/coverage-report.md)" - EXISTING=$(gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments" \ - --jq '[.[] | select(.body | startswith(""))][0].id // empty' 2>/dev/null || true) - - if [ -n "${EXISTING}" ]; then - gh api "repos/${{ github.repository }}/issues/${PR_NUM}/comments/${EXISTING}" \ - -X PATCH -f body="${BODY}" > /dev/null - else + # Update existing comment or create a new one + gh pr comment "${PR_NUM}" --body "${BODY}" --edit-last 2>/dev/null || \ gh pr comment "${PR_NUM}" --body "${BODY}" - fi fi - name: Save Bun Cache uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 From b5c9afc7ae4370269f269beb7bad71a426ebc679 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 02:15:20 +0200 Subject: [PATCH 04/11] test(check): add action handler tests for the check command Add 19 tests exercising the check command's action handler via parseAsync(), covering: no project root, no rules, load errors, ExitPromptError re-throw, output format selection (console/JSON/CI), --verbose/--staged/--adr option forwarding, file arguments, agent context auto-JSON, exit code propagation, and telemetry tracking. Split into check-action.test.ts to stay within the 500-line limit. Signed-off-by: Rhuan Barreto --- tests/commands/check-action.test.ts | 403 ++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 tests/commands/check-action.test.ts diff --git a/tests/commands/check-action.test.ts b/tests/commands/check-action.test.ts new file mode 100644 index 00000000..94d0dd7a --- /dev/null +++ b/tests/commands/check-action.test.ts @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate + +// --------------------------------------------------------------------------- +// Action handler tests — exercise the check command via parseAsync() to cover +// the action handler code in check.ts (error paths, output format selection, +// option forwarding, telemetry tracking). +// --------------------------------------------------------------------------- + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerCheckCommand } from "../../src/commands/check"; +import * as loaderModule from "../../src/engine/loader"; +import type { ReportSummary } from "../../src/engine/reporter"; +import * as reporterModule from "../../src/engine/reporter"; +import type { CheckResult } from "../../src/engine/runner"; +import * as runnerModule from "../../src/engine/runner"; +import * as exitModule from "../../src/helpers/exit"; +import * as pathsModule from "../../src/helpers/paths"; +import * as telemetryModule from "../../src/helpers/telemetry"; + +// --------------------------------------------------------------------------- +// Shared mock data +// --------------------------------------------------------------------------- + +const MOCK_CHECK_RESULT: CheckResult = { + results: [ + { + ruleId: "test-rule", + adrId: "TEST-001", + description: "Test rule", + violations: [], + durationMs: 10, + }, + ], + totalDurationMs: 50, +}; + +const MOCK_SUMMARY: ReportSummary = { + pass: true, + total: 1, + passed: 1, + failed: 0, + warnings: 0, + errors: 0, + infos: 0, + ruleErrors: 0, + truncated: false, + results: [ + { + adrId: "TEST-001", + ruleId: "test-rule", + description: "Test rule", + status: "pass", + totalViolations: 0, + shownViolations: 0, + violations: [], + durationMs: 10, + }, + ], + durationMs: 50, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("check action handler", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + let findProjectRootSpy: ReturnType; + let loadRuleAdrsSpy: ReturnType; + let runChecksSpy: ReturnType; + let buildSummarySpy: ReturnType; + let getExitCodeSpy: ReturnType; + let reportConsoleSpy: ReturnType; + let reportJSONSpy: ReturnType; + let reportCISpy: ReturnType; + let trackCheckResultSpy: ReturnType; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + exitSpy = spyOn(exitModule, "exitWith").mockImplementation(() => { + throw new Error("process.exit"); + }); + + // Default mocks: project found, one rule loaded, all pass + findProjectRootSpy = spyOn(pathsModule, "findProjectRoot").mockReturnValue( + "/fake/project" + ); + loadRuleAdrsSpy = spyOn(loaderModule, "loadRuleAdrs").mockResolvedValue([ + { type: "loaded", value: {} }, + ] as never); + runChecksSpy = spyOn(runnerModule, "runChecks").mockResolvedValue( + MOCK_CHECK_RESULT + ); + buildSummarySpy = spyOn(reporterModule, "buildSummary").mockReturnValue( + MOCK_SUMMARY + ); + getExitCodeSpy = spyOn(reporterModule, "getExitCode").mockReturnValue(0); + reportConsoleSpy = spyOn( + reporterModule, + "reportConsole" + ).mockImplementation(() => {}); + reportJSONSpy = spyOn(reporterModule, "reportJSON").mockImplementation( + () => {} + ); + reportCISpy = spyOn(reporterModule, "reportCI").mockImplementation( + () => {} + ); + trackCheckResultSpy = spyOn( + telemetryModule, + "trackCheckResult" + ).mockImplementation(() => {}); + + // Ensure TTY mode for predictable output format detection + originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + findProjectRootSpy.mockRestore(); + loadRuleAdrsSpy.mockRestore(); + runChecksSpy.mockRestore(); + buildSummarySpy.mockRestore(); + getExitCodeSpy.mockRestore(); + reportConsoleSpy.mockRestore(); + reportJSONSpy.mockRestore(); + reportCISpy.mockRestore(); + trackCheckResultSpy.mockRestore(); + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + }); + + function makeProgram(): Command { + const program = new Command().exitOverride(); + registerCheckCommand(program); + return program; + } + + // -- No project root -- + + test("no project root logs error and exits 1", async () => { + findProjectRootSpy.mockReturnValue(null); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(errOutput).toContain("archgate init"); + }); + + // -- No rules -- + + test("no rules outputs text message and exits 0", async () => { + loadRuleAdrsSpy.mockResolvedValue([]); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(0); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(output).toContain("No rules to check"); + }); + + test("no rules with --json outputs empty JSON result", async () => { + loadRuleAdrsSpy.mockResolvedValue([]); + + await expect( + makeProgram().parseAsync(["node", "test", "check", "--json"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(0); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + const parsed = JSON.parse(output); + expect(parsed.pass).toBe(true); + expect(parsed.total).toBe(0); + expect(parsed.results).toEqual([]); + }); + + // -- Load errors -- + + test("load error logs message and exits 1", async () => { + loadRuleAdrsSpy.mockRejectedValue(new Error("failed to load rules")); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(errOutput).toContain("failed to load rules"); + }); + + test("load error re-throws ExitPromptError", async () => { + const exitPromptError = new Error("user cancelled"); + exitPromptError.name = "ExitPromptError"; + loadRuleAdrsSpy.mockRejectedValue(exitPromptError); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("user cancelled"); + + // exitWith should NOT have been called — ExitPromptError is re-thrown + expect(exitSpy).not.toHaveBeenCalled(); + }); + + // -- Output formats -- + + test("default output calls reportConsole", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(reportConsoleSpy).toHaveBeenCalledTimes(1); + expect(reportJSONSpy).not.toHaveBeenCalled(); + expect(reportCISpy).not.toHaveBeenCalled(); + }); + + test("--json calls reportJSON", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check", "--json"]) + ).rejects.toThrow("process.exit"); + + expect(reportJSONSpy).toHaveBeenCalledTimes(1); + expect(reportConsoleSpy).not.toHaveBeenCalled(); + expect(reportCISpy).not.toHaveBeenCalled(); + }); + + test("--ci calls reportCI", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check", "--ci"]) + ).rejects.toThrow("process.exit"); + + expect(reportCISpy).toHaveBeenCalledTimes(1); + expect(reportConsoleSpy).not.toHaveBeenCalled(); + expect(reportJSONSpy).not.toHaveBeenCalled(); + }); + + test("--verbose is forwarded to reportConsole", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check", "--verbose"]) + ).rejects.toThrow("process.exit"); + + expect(reportConsoleSpy).toHaveBeenCalledTimes(1); + // Second arg to reportConsole is the verbose flag + expect(reportConsoleSpy.mock.calls[0][1]).toBe(true); + }); + + test("agent context auto-selects JSON output", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: false, + configurable: true, + }); + const origCI = Bun.env.CI; + Bun.env.CI = ""; + + try { + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(reportJSONSpy).toHaveBeenCalledTimes(1); + expect(reportConsoleSpy).not.toHaveBeenCalled(); + } finally { + Bun.env.CI = origCI; + } + }); + + // -- Exit codes -- + + test("exits with code from getExitCode when rules fail", async () => { + getExitCodeSpy.mockReturnValue(1); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + test("exits 0 when all rules pass", async () => { + getExitCodeSpy.mockReturnValue(0); + + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + // -- Options forwarding -- + + test("--staged passes staged option to runChecks", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check", "--staged"]) + ).rejects.toThrow("process.exit"); + + expect(runChecksSpy).toHaveBeenCalledTimes(1); + const opts = runChecksSpy.mock.calls[0][2]; + expect(opts.staged).toBe(true); + }); + + test("--adr passes filter to loadRuleAdrs", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check", "--adr", "ARCH-001"]) + ).rejects.toThrow("process.exit"); + + expect(loadRuleAdrsSpy).toHaveBeenCalledTimes(1); + expect(loadRuleAdrsSpy.mock.calls[0][1]).toBe("ARCH-001"); + }); + + test("file arguments are passed to runChecks", async () => { + await expect( + makeProgram().parseAsync([ + "node", + "test", + "check", + "src/a.ts", + "src/b.ts", + ]) + ).rejects.toThrow("process.exit"); + + expect(runChecksSpy).toHaveBeenCalledTimes(1); + const opts = runChecksSpy.mock.calls[0][2]; + expect(opts.files).toEqual(["src/a.ts", "src/b.ts"]); + }); + + // -- Telemetry -- + + test("trackCheckResult is called with summary data", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check"]) + ).rejects.toThrow("process.exit"); + + expect(trackCheckResultSpy).toHaveBeenCalledTimes(1); + const data = trackCheckResultSpy.mock.calls[0][0]; + expect(data.total_rules).toBe(1); + expect(data.passed).toBe(1); + expect(data.failed).toBe(0); + expect(data.pass).toBe(true); + expect(data.output_format).toBe("console"); + }); + + test("telemetry records json output format", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check", "--json"]) + ).rejects.toThrow("process.exit"); + + const data = trackCheckResultSpy.mock.calls[0][0]; + expect(data.output_format).toBe("json"); + }); + + test("telemetry records ci output format", async () => { + await expect( + makeProgram().parseAsync(["node", "test", "check", "--ci"]) + ).rejects.toThrow("process.exit"); + + const data = trackCheckResultSpy.mock.calls[0][0]; + expect(data.output_format).toBe("ci"); + }); + + test("telemetry records --staged and --adr usage", async () => { + await expect( + makeProgram().parseAsync([ + "node", + "test", + "check", + "--staged", + "--adr", + "X-001", + ]) + ).rejects.toThrow("process.exit"); + + const data = trackCheckResultSpy.mock.calls[0][0]; + expect(data.used_staged).toBe(true); + expect(data.used_adr_filter).toBe(true); + }); +}); From f0fa3739b29e663e73766d1629a19e5840e63a03 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 02:25:21 +0200 Subject: [PATCH 05/11] test(upgrade): add action handler tests for the upgrade command Add 9 tests exercising the upgrade command's action handler via parseAsync(), covering: successful binary download and replace, telemetry tracking (success and failure), version comparison (already up-to-date, null fetch), unsupported platform, download failure with manual hint, ExitPromptError re-throw, and plugin update offer. Signed-off-by: Rhuan Barreto --- tests/commands/upgrade-action.test.ts | 240 ++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 tests/commands/upgrade-action.test.ts diff --git a/tests/commands/upgrade-action.test.ts b/tests/commands/upgrade-action.test.ts new file mode 100644 index 00000000..cab6fe41 --- /dev/null +++ b/tests/commands/upgrade-action.test.ts @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate + +// --------------------------------------------------------------------------- +// Action handler tests — exercise the upgrade command via parseAsync() to +// cover the action handler's upgrade flow: binary downloads, telemetry +// tracking, and error paths. +// --------------------------------------------------------------------------- + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerUpgradeCommand } from "../../src/commands/upgrade"; +import * as binaryUpgrade from "../../src/helpers/binary-upgrade"; +import * as credentialStore from "../../src/helpers/credential-store"; +import * as exitModule from "../../src/helpers/exit"; +import { internalPath } from "../../src/helpers/paths"; +import * as telemetryModule from "../../src/helpers/telemetry"; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("upgrade action handler (upgrade flow)", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + let fetchVersionSpy: ReturnType; + let getArtifactSpy: ReturnType; + let downloadSpy: ReturnType; + let replaceSpy: ReturnType; + let trackSpy: ReturnType; + let credsSpy: ReturnType; + let originalExecPath: string; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + exitSpy = spyOn(exitModule, "exitWith").mockImplementation(() => { + throw new Error("process.exit"); + }); + + // Default: newer version available, binary install, artifact found + fetchVersionSpy = spyOn( + binaryUpgrade, + "fetchLatestGitHubVersion" + ).mockResolvedValue("v99.0.0"); + getArtifactSpy = spyOn(binaryUpgrade, "getArtifactInfo").mockReturnValue({ + name: "archgate-test-x64", + ext: ".tar.gz", + binaryName: "archgate", + }); + downloadSpy = spyOn( + binaryUpgrade, + "downloadReleaseBinary" + ).mockResolvedValue("/tmp/new-binary"); + replaceSpy = spyOn(binaryUpgrade, "replaceBinary").mockImplementation( + () => {} + ); + trackSpy = spyOn(telemetryModule, "trackUpgradeResult").mockImplementation( + () => {} + ); + // Prevent real credential store lookups in maybeUpdatePlugins + credsSpy = spyOn(credentialStore, "loadCredentials").mockResolvedValue( + null + ); + + // Set execPath to ~/.archgate/bin/ so isBinaryInstall() returns true + originalExecPath = process.execPath; + Object.defineProperty(process, "execPath", { + value: join(internalPath("bin"), "archgate"), + writable: true, + configurable: true, + }); + + // Non-TTY stdin to skip interactive prompts in maybeUpdatePlugins + originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + fetchVersionSpy.mockRestore(); + getArtifactSpy.mockRestore(); + downloadSpy.mockRestore(); + replaceSpy.mockRestore(); + trackSpy.mockRestore(); + credsSpy.mockRestore(); + Object.defineProperty(process, "execPath", { + value: originalExecPath, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdin, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + }); + + function makeProgram(): Command { + const program = new Command().exitOverride(); + registerUpgradeCommand(program); + return program; + } + + // -- Successful binary upgrade -- + + test("downloads and replaces binary when newer version available", async () => { + await makeProgram().parseAsync(["node", "test", "upgrade"]); + + expect(downloadSpy).toHaveBeenCalledTimes(1); + expect(replaceSpy).toHaveBeenCalledTimes(1); + + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(output).toContain("upgraded to 99.0.0"); + }); + + // -- Telemetry -- + + test("tracks successful upgrade with version info", async () => { + await makeProgram().parseAsync(["node", "test", "upgrade"]); + + expect(trackSpy).toHaveBeenCalledTimes(1); + const data = trackSpy.mock.calls[0][0]; + expect(data.to_version).toBe("99.0.0"); + expect(data.install_method).toBe("binary"); + expect(data.success).toBe(true); + }); + + test("tracks failed upgrade on download error", async () => { + downloadSpy.mockRejectedValue(new Error("download failed")); + + await expect( + makeProgram().parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const failCall = trackSpy.mock.calls.find( + (c: unknown[]) => (c[0] as { success: boolean }).success === false + ); + expect(failCall).toBeDefined(); + }); + + // -- Version checks -- + + test("already up-to-date when remote equals current", async () => { + const pkg = await import("../../package.json"); + fetchVersionSpy.mockResolvedValue(`v${pkg.default.version}`); + + await expect( + makeProgram().parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(0); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(output).toContain("already up-to-date"); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + + test("null version from GitHub exits 1", async () => { + fetchVersionSpy.mockResolvedValue(null); + + await expect( + makeProgram().parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(errOutput).toContain("Failed to fetch release info"); + }); + + // -- Binary upgrade error paths -- + + test("unsupported platform exits 1", async () => { + getArtifactSpy.mockReturnValue(null); + + await expect( + makeProgram().parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(errOutput).toContain("Unsupported platform"); + }); + + test("download failure logs error with manual hint", async () => { + downloadSpy.mockRejectedValue(new Error("connection reset")); + + await expect( + makeProgram().parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); + + const errOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(errOutput).toContain("Failed to upgrade binary"); + expect(errOutput).toContain("connection reset"); + }); + + // -- ExitPromptError -- + + test("re-throws ExitPromptError from download", async () => { + const exitPromptError = new Error("user cancelled"); + exitPromptError.name = "ExitPromptError"; + downloadSpy.mockRejectedValue(exitPromptError); + + await expect( + makeProgram().parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("user cancelled"); + + expect(exitSpy).not.toHaveBeenCalled(); + }); + + // -- Plugin update after upgrade -- + + test("offers plugin update after successful upgrade", async () => { + await makeProgram().parseAsync(["node", "test", "upgrade"]); + + // With stdin not TTY and no --plugins flag, maybeUpdatePlugins + // still runs but loadCredentials returns null (mocked), so it + // logs "Not logged in" and returns + expect(credsSpy).toHaveBeenCalledTimes(1); + }); +}); From cc8726d7594e46c6eafe3322afb845de0ba9998a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 02:31:42 +0200 Subject: [PATCH 06/11] feat(ci): merge Linux + Windows test coverage Add --coverage to the Windows smoke test and upload its lcov as an artifact. Move coverage reporting to a dedicated job that downloads both platform lcov files, normalizes Windows backslash paths, merges them with lcov --add-tracefile, and generates a combined HTML report. The PR comment and job summary now show merged cross-platform coverage with a "Platforms" row indicating which platforms contributed. If Windows coverage is unavailable (skipped/failed), the report gracefully falls back to Linux-only data. Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 107 ++++++++++++++++------- .github/workflows/smoke-test-windows.yml | 10 ++- 2 files changed, 84 insertions(+), 33 deletions(-) diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 37d41b9f..754b966b 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -61,27 +61,73 @@ jobs: - name: Validate id: validate run: bun run validate:coverage - - name: Generate HTML coverage report - if: always() && steps.validate.outcome == 'success' - run: | - sudo apt-get install -y -qq lcov > /dev/null 2>&1 - lcov --extract coverage/lcov.info 'src/*' --output-file coverage/lcov-src.info --quiet - genhtml coverage/lcov-src.info --output-directory coverage/html --quiet - - name: Upload coverage report + - name: Upload coverage if: always() && steps.validate.outcome == 'success' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: coverage-report - path: coverage/html - retention-days: 30 - - name: Coverage summary - if: always() && steps.validate.outcome == 'success' + name: coverage-linux + path: coverage/lcov.info + retention-days: 1 + - name: Save Bun Cache + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + if: steps.validate.outcome == 'success' && steps.restore-bun-cache.outputs.cache-hit != 'true' + with: + path: /home/runner/.bun/install/cache + key: ${{ steps.restore-bun-cache.outputs.cache-primary-key }} + + smoke-windows: + name: Smoke Test (Windows) + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + uses: ./.github/workflows/smoke-test-windows.yml + + smoke-linux: + name: Smoke Test (Linux) + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + uses: ./.github/workflows/smoke-test-linux.yml + + coverage: + name: Coverage Report + runs-on: ubuntu-latest + if: always() && needs.validate.result == 'success' + needs: [validate, smoke-windows] + steps: + - name: Download Linux coverage + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: coverage-linux + path: coverage-linux + - name: Download Windows coverage + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: coverage-windows + path: coverage-windows + continue-on-error: true + - name: Merge coverage and generate report env: GH_TOKEN: ${{ github.token }} run: | - # Parse filtered lcov for summary stats (src/ only, excludes test temp dirs) - LINES_FOUND=$(awk -F: '/^LF:/{s+=$2} END{print s+0}' coverage/lcov-src.info) - LINES_HIT=$(awk -F: '/^LH:/{s+=$2} END{print s+0}' coverage/lcov-src.info) + sudo apt-get install -y -qq lcov > /dev/null 2>&1 + + # Start with Linux coverage + cp coverage-linux/lcov.info merged.info + + # Add Windows coverage if available (normalize backslash paths first) + if [ -f coverage-windows/lcov.info ]; then + sed -i 's|\\|/|g' coverage-windows/lcov.info + lcov --add-tracefile coverage-linux/lcov.info \ + --add-tracefile coverage-windows/lcov.info \ + --output-file merged.info --quiet + fi + + # Filter to src/ only (excludes test temp dirs) + lcov --extract merged.info 'src/*' --output-file coverage.info --quiet + + # Generate HTML report + genhtml coverage.info --output-directory coverage-html --quiet + + # Parse summary stats + LINES_FOUND=$(awk -F: '/^LF:/{s+=$2} END{print s+0}' coverage.info) + LINES_HIT=$(awk -F: '/^LH:/{s+=$2} END{print s+0}' coverage.info) if [ "$LINES_FOUND" -gt 0 ]; then COVERAGE=$(awk "BEGIN{printf \"%.1f\", ($LINES_HIT/$LINES_FOUND)*100}") else @@ -103,7 +149,14 @@ jobs: printf "| `%s/` | %.1f%% | %d / %d |\n", d, pct, lh[d], lf[d] } } - ' coverage/lcov-src.info | sort) + ' coverage.info | sort) + + # Check if Windows coverage was merged + if [ -f coverage-windows/lcov.info ]; then + PLATFORMS="Linux + Windows" + else + PLATFORMS="Linux only" + fi RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" @@ -114,6 +167,7 @@ jobs: echo "| Metric | Value |" echo "|--------|-------|" echo "| **Lines** | **${COVERAGE}%** (${LINES_HIT} / ${LINES_FOUND}) |" + echo "| **Platforms** | ${PLATFORMS} |" echo "" echo "Full HTML report available in [workflow artifacts](${RUN_URL}#artifacts)." echo "" @@ -137,26 +191,15 @@ jobs: BODY=" $(cat /tmp/coverage-report.md)" - # Update existing comment or create a new one gh pr comment "${PR_NUM}" --body "${BODY}" --edit-last 2>/dev/null || \ gh pr comment "${PR_NUM}" --body "${BODY}" fi - - name: Save Bun Cache - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - if: steps.validate.outcome == 'success' && steps.restore-bun-cache.outputs.cache-hit != 'true' + - name: Upload coverage report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - path: /home/runner/.bun/install/cache - key: ${{ steps.restore-bun-cache.outputs.cache-primary-key }} - - smoke-windows: - name: Smoke Test (Windows) - if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - uses: ./.github/workflows/smoke-test-windows.yml - - smoke-linux: - name: Smoke Test (Linux) - if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - uses: ./.github/workflows/smoke-test-linux.yml + name: coverage-report + path: coverage-html + retention-days: 30 # Gate job — single required status check for branch protection. status: diff --git a/.github/workflows/smoke-test-windows.yml b/.github/workflows/smoke-test-windows.yml index 3c49a72d..f3b7e17d 100644 --- a/.github/workflows/smoke-test-windows.yml +++ b/.github/workflows/smoke-test-windows.yml @@ -37,7 +37,15 @@ jobs: run: bun run format:check - name: Tests - run: bun test --timeout 60000 + id: tests + run: bun test --timeout 60000 --coverage + - name: Upload coverage + if: always() && steps.tests.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-windows + path: coverage/lcov.info + retention-days: 1 - name: Build Windows binary run: bun build src/cli.ts --compile --bytecode --outfile dist/archgate-smoke-test From 2919a55e015daaa37e38403664411987600cd16f Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 02:41:29 +0200 Subject: [PATCH 07/11] fix(ci): add checkout step to coverage job for genhtml source mapping genhtml needs the source files to generate line-level coverage annotations in the HTML report. The coverage job was missing a checkout step, causing "does not exist" errors. Signed-off-by: Rhuan Barreto --- .github/workflows/code-pull-request.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 754b966b..b027476e 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -91,6 +91,8 @@ jobs: if: always() && needs.validate.result == 'success' needs: [validate, smoke-windows] steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download Linux coverage uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: From c570f97d7b8f6edae4c677d67c4b26192b94b22e Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 03:03:18 +0200 Subject: [PATCH 08/11] fix(test): replace cache-busted dynamic imports with static imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade test hooks were imported via `import(...?t=${Date.now()})` to get a "fresh module instance", but upgrade.ts has no module-level state — all functions read process.execPath and env vars at call time. The cache-bust created separate module instances that Bun's coverage instrumenter couldn't attribute back to the source file, silently dropping coverage from 17+ tests. Switch to static imports in both upgrade.test.ts and plugin/shared.test.ts. Also narrow discriminated union types that the cache-bust was silently bypassing (returned `any`), and update the JSDoc comment on the test hook exports. Signed-off-by: Rhuan Barreto --- src/commands/upgrade.ts | 2 +- tests/commands/plugin/shared.test.ts | 10 +---- tests/commands/upgrade.test.ts | 60 +++++++++++----------------- 3 files changed, 25 insertions(+), 47 deletions(-) diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 4847633c..7da5b0c8 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -449,7 +449,7 @@ export function registerUpgradeCommand(program: Command) { }); } -/** @internal test hooks — consumed via dynamic import() in upgrade.test.ts */ +/** Test hooks — exported for unit tests in upgrade.test.ts */ export { isBinaryInstall as _isBinaryInstall, isProtoInstall as _isProtoInstall, diff --git a/tests/commands/plugin/shared.test.ts b/tests/commands/plugin/shared.test.ts index daef6018..7990b933 100644 --- a/tests/commands/plugin/shared.test.ts +++ b/tests/commands/plugin/shared.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as pluginInstall from "../../../src/commands/plugin/install"; +import { _maybeUpdatePlugins } from "../../../src/commands/upgrade"; import * as credentialStore from "../../../src/helpers/credential-store"; import * as editorDetect from "../../../src/helpers/editor-detect"; @@ -65,10 +66,6 @@ afterEach(() => { // Helpers // --------------------------------------------------------------------------- -function importUpgrade() { - return import(`../../../src/commands/upgrade?t=${Date.now()}`); -} - function setTTY(value: boolean | undefined) { Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }); } @@ -82,7 +79,6 @@ describe("maybeUpdatePlugins", () => { setTTY(false); credSpy.mockResolvedValue(null); - const { _maybeUpdatePlugins } = await importUpgrade(); await _maybeUpdatePlugins(true); expect(logSpy).toHaveBeenCalled(); @@ -101,7 +97,6 @@ describe("maybeUpdatePlugins", () => { { id: "cursor" as const, label: "Cursor", available: false }, ]); - const { _maybeUpdatePlugins } = await importUpgrade(); await _maybeUpdatePlugins(true); expect(logSpy).toHaveBeenCalled(); @@ -121,7 +116,6 @@ describe("maybeUpdatePlugins", () => { { id: "cursor" as const, label: "Cursor", available: false }, ]); - const { _maybeUpdatePlugins } = await importUpgrade(); await _maybeUpdatePlugins(true); expect(installSpy).toHaveBeenCalledTimes(2); @@ -137,7 +131,6 @@ describe("maybeUpdatePlugins", () => { { id: "claude" as const, label: "Claude Code", available: true }, ]); - const { _maybeUpdatePlugins } = await importUpgrade(); await _maybeUpdatePlugins(false); expect(installSpy).toHaveBeenCalledTimes(1); @@ -153,7 +146,6 @@ describe("maybeUpdatePlugins", () => { ]); installSpy.mockRejectedValue(new Error("install failed")); - const { _maybeUpdatePlugins } = await importUpgrade(); await _maybeUpdatePlugins(true); expect(errorSpy).toHaveBeenCalled(); diff --git a/tests/commands/upgrade.test.ts b/tests/commands/upgrade.test.ts index ed2ef1ff..c8a057ea 100644 --- a/tests/commands/upgrade.test.ts +++ b/tests/commands/upgrade.test.ts @@ -7,17 +7,20 @@ import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -import { registerUpgradeCommand } from "../../src/commands/upgrade"; +import { + registerUpgradeCommand, + _isBinaryInstall, + _isProtoInstall, + _isLocalInstall, + _detectInstallMethod, + _formatBytes, + _createDownloadProgress, +} from "../../src/commands/upgrade"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** Dynamic import with cache-busting for modules with process-level state. */ -function importUpgrade() { - return import(`../../src/commands/upgrade?t=${Date.now()}`); -} - function setExecPath(path: string) { Object.defineProperty(process, "execPath", { value: path, @@ -74,53 +77,46 @@ describe("install method detection", () => { }); describe("_isBinaryInstall", () => { - test("returns true when execPath is under ~/.archgate/bin/", async () => { + test("returns true when execPath is under ~/.archgate/bin/", () => { setExecPath(join(tempDir, ".archgate", "bin", "archgate")); - const { _isBinaryInstall } = await importUpgrade(); expect(_isBinaryInstall()).toBe(true); }); - test("returns false when execPath is elsewhere", async () => { + test("returns false when execPath is elsewhere", () => { setExecPath(join(tempDir, "usr", "local", "bin", "archgate")); - const { _isBinaryInstall } = await importUpgrade(); expect(_isBinaryInstall()).toBe(false); }); }); describe("_isProtoInstall", () => { - test("returns true when execPath is under ~/.proto/tools/archgate/", async () => { + test("returns true when execPath is under ~/.proto/tools/archgate/", () => { setExecPath( join(tempDir, ".proto", "tools", "archgate", "0.13.0", "archgate") ); - const { _isProtoInstall } = await importUpgrade(); expect(_isProtoInstall()).toBe(true); }); - test("respects PROTO_HOME env var", async () => { + test("respects PROTO_HOME env var", () => { const customProto = join(tempDir, "custom-proto"); process.env.PROTO_HOME = customProto; setExecPath(join(customProto, "tools", "archgate", "0.13.0", "archgate")); - const { _isProtoInstall } = await importUpgrade(); expect(_isProtoInstall()).toBe(true); }); - test("returns false when execPath is elsewhere", async () => { + test("returns false when execPath is elsewhere", () => { setExecPath(join(tempDir, "usr", "local", "bin", "archgate")); - const { _isProtoInstall } = await importUpgrade(); expect(_isProtoInstall()).toBe(false); }); }); describe("_isLocalInstall", () => { - test("returns true when execPath contains node_modules", async () => { + test("returns true when execPath contains node_modules", () => { setExecPath(join(tempDir, "project", "node_modules", ".bin", "archgate")); - const { _isLocalInstall } = await importUpgrade(); expect(_isLocalInstall()).toBe(true); }); - test("returns false when execPath has no node_modules", async () => { + test("returns false when execPath has no node_modules", () => { setExecPath(join(tempDir, "usr", "local", "bin", "archgate")); - const { _isLocalInstall } = await importUpgrade(); expect(_isLocalInstall()).toBe(false); }); }); @@ -129,7 +125,6 @@ describe("install method detection", () => { test("detects binary install", async () => { const fakeBinary = join(tempDir, ".archgate", "bin", "archgate"); setExecPath(fakeBinary); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("binary"); expect(method).toHaveProperty("binaryPath", fakeBinary); @@ -139,7 +134,6 @@ describe("install method detection", () => { setExecPath( join(tempDir, ".proto", "tools", "archgate", "0.13.0", "archgate") ); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("proto"); expect(method).toHaveProperty("protoCmd"); @@ -151,10 +145,9 @@ describe("install method detection", () => { writeFileSync(join(dir, "package.json"), "{}"); writeFileSync(join(dir, "bun.lock"), ""); setExecPath(join(dir, "node_modules", ".bin", "archgate")); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("local"); - expect(method.manualHint).toContain("bun"); + if (method.type === "local") expect(method.manualHint).toContain("bun"); }); test("detects local install with pnpm-lock.yaml", async () => { @@ -163,10 +156,9 @@ describe("install method detection", () => { writeFileSync(join(dir, "package.json"), "{}"); writeFileSync(join(dir, "pnpm-lock.yaml"), ""); setExecPath(join(dir, "node_modules", ".bin", "archgate")); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("local"); - expect(method.manualHint).toContain("pnpm"); + if (method.type === "local") expect(method.manualHint).toContain("pnpm"); }); test("detects local install with yarn.lock", async () => { @@ -175,10 +167,9 @@ describe("install method detection", () => { writeFileSync(join(dir, "package.json"), "{}"); writeFileSync(join(dir, "yarn.lock"), ""); setExecPath(join(dir, "node_modules", ".bin", "archgate")); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("local"); - expect(method.manualHint).toContain("yarn"); + if (method.type === "local") expect(method.manualHint).toContain("yarn"); }); test("detects local install with package-lock.json", async () => { @@ -187,22 +178,19 @@ describe("install method detection", () => { writeFileSync(join(dir, "package.json"), "{}"); writeFileSync(join(dir, "package-lock.json"), "{}"); setExecPath(join(dir, "node_modules", ".bin", "archgate")); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("local"); - expect(method.manualHint).toContain("npm"); + if (method.type === "local") expect(method.manualHint).toContain("npm"); }); test("falls back to package-manager for unknown location", async () => { setExecPath(join(tempDir, "some", "random", "path", "archgate")); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("package-manager"); }); test("binary detection takes priority over other methods", async () => { setExecPath(join(tempDir, ".archgate", "bin", "archgate")); - const { _detectInstallMethod } = await importUpgrade(); const method = await _detectInstallMethod(); expect(method.type).toBe("binary"); }); @@ -214,8 +202,7 @@ describe("install method detection", () => { // --------------------------------------------------------------------------- describe("_formatBytes", () => { - test("formats bytes, KB, and MB ranges", async () => { - const { _formatBytes } = await importUpgrade(); + test("formats bytes, KB, and MB ranges", () => { // Bytes expect(_formatBytes(0)).toBe("0 B"); expect(_formatBytes(512)).toBe("512 B"); @@ -232,8 +219,7 @@ describe("_formatBytes", () => { }); describe("_createDownloadProgress", () => { - test("returns undefined when stderr is not a TTY", async () => { - const { _createDownloadProgress } = await importUpgrade(); + test("returns undefined when stderr is not a TTY", () => { const originalIsTTY = process.stderr.isTTY; try { Object.defineProperty(process.stderr, "isTTY", { @@ -284,7 +270,7 @@ describe("upgrade action handler", () => { function mockGitHubRelease(tag: string | null) { globalThis.fetch = (() => Promise.resolve({ - ok: tag === null ? false : true, + ok: tag !== null, status: tag === null ? 500 : 200, json: () => Promise.resolve(tag ? { tag_name: tag } : {}), })) as unknown as typeof fetch; From 13f8ce4d21456adeaac988514b373089d6308905 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 03:23:39 +0200 Subject: [PATCH 09/11] test(init-project): add tryInstallPlugin coverage for all editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 12 tests covering the previously untested tryInstallPlugin function (0% → covered), exercising: no credentials, cursor marketplace URL, vscode auto-install, claude/copilot/opencode with CLI available/unavailable, and install failure fallback paths. Signed-off-by: Rhuan Barreto --- tests/helpers/init-project.test.ts | 252 ++++++++++++++++++++++++++++- 1 file changed, 251 insertions(+), 1 deletion(-) diff --git a/tests/helpers/init-project.test.ts b/tests/helpers/init-project.test.ts index b43465b9..6f6bf601 100644 --- a/tests/helpers/init-project.test.ts +++ b/tests/helpers/init-project.test.ts @@ -1,11 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as credentialStore from "../../src/helpers/credential-store"; import { initProject } from "../../src/helpers/init-project"; +import * as pluginInstall from "../../src/helpers/plugin-install"; describe("initProject", () => { let tempDir: string; @@ -237,3 +247,243 @@ describe("initProject", () => { } }); }); + +// --------------------------------------------------------------------------- +// tryInstallPlugin — exercises the plugin install path triggered by +// initProject(root, { installPlugin: true, editor: ... }) +// --------------------------------------------------------------------------- + +describe("tryInstallPlugin via initProject", () => { + let tempDir: string; + let credSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-initproj-plugin-")); + credSpy = spyOn(credentialStore, "loadCredentials").mockResolvedValue(null); + }); + + afterEach(() => { + credSpy.mockRestore(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("no credentials returns installed: false", async () => { + credSpy.mockResolvedValue(null); + const result = await initProject(tempDir, { installPlugin: true }); + expect(result.plugin).toBeDefined(); + expect(result.plugin!.installed).toBe(false); + expect(result.plugin!.detail).toContain("No stored credentials"); + }); + + test("cursor returns marketplace URL", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const urlSpy = spyOn( + pluginInstall, + "buildCursorMarketplaceUrl" + ).mockReturnValue("https://cursor.example"); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "cursor", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.detail).toBe("https://cursor.example"); + } finally { + urlSpy.mockRestore(); + } + }); + + test("vscode returns auto-installed", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const result = await initProject(tempDir, { + installPlugin: true, + editor: "vscode", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.autoInstalled).toBe(true); + }); + + test("claude with CLI available auto-installs", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isClaudeCliAvailable" + ).mockResolvedValue(true); + const installSpy = spyOn( + pluginInstall, + "installClaudePlugin" + ).mockResolvedValue(); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "claude", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.autoInstalled).toBe(true); + expect(installSpy).toHaveBeenCalledTimes(1); + } finally { + cliSpy.mockRestore(); + installSpy.mockRestore(); + } + }); + + test("claude without CLI falls back to marketplace URL", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isClaudeCliAvailable" + ).mockResolvedValue(false); + const urlSpy = spyOn(pluginInstall, "buildMarketplaceUrl").mockReturnValue( + "https://marketplace.example" + ); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "claude", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.detail).toBe("https://marketplace.example"); + expect(result.plugin!.autoInstalled).toBeUndefined(); + } finally { + cliSpy.mockRestore(); + urlSpy.mockRestore(); + } + }); + + test("claude install failure falls back to marketplace URL", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isClaudeCliAvailable" + ).mockResolvedValue(true); + const installSpy = spyOn( + pluginInstall, + "installClaudePlugin" + ).mockRejectedValue(new Error("install failed")); + const urlSpy = spyOn(pluginInstall, "buildMarketplaceUrl").mockReturnValue( + "https://marketplace.example" + ); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "claude", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.detail).toBe("https://marketplace.example"); + } finally { + cliSpy.mockRestore(); + installSpy.mockRestore(); + urlSpy.mockRestore(); + } + }); + + test("copilot with CLI available auto-installs", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isCopilotCliAvailable" + ).mockResolvedValue(true); + const installSpy = spyOn( + pluginInstall, + "installCopilotPlugin" + ).mockResolvedValue(); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "copilot", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.autoInstalled).toBe(true); + } finally { + cliSpy.mockRestore(); + installSpy.mockRestore(); + } + }); + + test("copilot without CLI falls back to marketplace URL", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isCopilotCliAvailable" + ).mockResolvedValue(false); + const urlSpy = spyOn( + pluginInstall, + "buildVscodeMarketplaceUrl" + ).mockReturnValue("https://vscode.example"); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "copilot", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.detail).toBe("https://vscode.example"); + } finally { + cliSpy.mockRestore(); + urlSpy.mockRestore(); + } + }); + + test("opencode with CLI available auto-installs", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isOpencodeCliAvailable" + ).mockResolvedValue(true); + const installSpy = spyOn( + pluginInstall, + "installOpencodePlugin" + ).mockResolvedValue(); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "opencode", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.autoInstalled).toBe(true); + } finally { + cliSpy.mockRestore(); + installSpy.mockRestore(); + } + }); + + test("opencode without CLI returns cli-not-found", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isOpencodeCliAvailable" + ).mockResolvedValue(false); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "opencode", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.detail).toBe("cli-not-found"); + } finally { + cliSpy.mockRestore(); + } + }); + + test("opencode install failure returns error detail", async () => { + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + const cliSpy = spyOn( + pluginInstall, + "isOpencodeCliAvailable" + ).mockResolvedValue(true); + const installSpy = spyOn( + pluginInstall, + "installOpencodePlugin" + ).mockRejectedValue(new Error("network timeout")); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "opencode", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.detail).toBe("network timeout"); + } finally { + cliSpy.mockRestore(); + installSpy.mockRestore(); + } + }); +}); From eb8ae32fc581857ea575c9fb7fc2501b9c1f2e09 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 03:26:50 +0200 Subject: [PATCH 10/11] docs(telemetry): document intentionally uncovered code sections Add coverage notes explaining why private environment detection functions (detectCiProvider, detectShell, detectLocale) and PostHog SDK init/capture internals show 0% coverage: tests run with ARCHGATE_TELEMETRY=0 so the client is never initialized. These are validated via the PostHog dashboard, not unit tests. Signed-off-by: Rhuan Barreto --- src/helpers/telemetry.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index f64ec97b..c41a3353 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -59,14 +59,12 @@ let distinctId = ""; let repoContextSnapshot: RepoContext | null = null; // --------------------------------------------------------------------------- -// Environment enrichment +// Environment enrichment — intentionally uncovered in unit tests. +// These private functions only run when the PostHog client is live +// (ARCHGATE_TELEMETRY=0 in tests disables init). Validated via dashboard. // --------------------------------------------------------------------------- -/** - * Best-effort classification of the CI environment. PostHog already tells us - * `is_ci`, but knowing whether a user is on GitHub Actions vs. GitLab CI vs. - * a self-hosted runner is load-bearing context for understanding usage. - */ +/** Best-effort CI environment classification. */ function detectCiProvider(): string | null { if (Bun.env.GITHUB_ACTIONS) return "github-actions"; if (Bun.env.GITLAB_CI) return "gitlab-ci"; @@ -198,6 +196,8 @@ export async function initTelemetry(): Promise { try { // Lazy-load the PostHog SDK so the `ARCHGATE_TELEMETRY=0` path never pays // the module-parse cost (noticeable on cold starts / WSL). + // SDK init + custom fetch wrapper below are intentionally uncovered — + // validated via PostHog dashboard, not by mocking the SDK constructor. const { PostHog } = await import("posthog-node"); client = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST, From c99e80cd95dfef2d2c9a21c864ece21aee22b013 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 03:40:47 +0200 Subject: [PATCH 11/11] chore: exclude comments from max-lines lint rule Add skipComments: true to the max-lines rule in .oxlintrc.json so that comments and JSDoc blocks don't count toward the 500-line limit. Code documentation should never be penalized by line budget. Restores the full JSDoc on detectCiProvider that was trimmed to fit. Signed-off-by: Rhuan Barreto --- .oxlintrc.json | 2 +- src/helpers/telemetry.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 052bcb9f..518f0ab9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,7 +5,7 @@ "perf": "error" }, "rules": { - "max-lines": ["error", { "max": 500 }], + "max-lines": ["error", { "max": 500, "skipComments": true }], "max-lines-per-function": "off", "max-nested-callbacks": "off", "max-depth": "off", diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index c41a3353..6b203efd 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -64,7 +64,11 @@ let repoContextSnapshot: RepoContext | null = null; // (ARCHGATE_TELEMETRY=0 in tests disables init). Validated via dashboard. // --------------------------------------------------------------------------- -/** Best-effort CI environment classification. */ +/** + * Best-effort classification of the CI environment. PostHog already tells us + * `is_ci`, but knowing whether a user is on GitHub Actions vs. GitLab CI vs. + * a self-hosted runner is load-bearing context for understanding usage. + */ function detectCiProvider(): string | null { if (Bun.env.GITHUB_ACTIONS) return "github-actions"; if (Bun.env.GITLAB_CI) return "gitlab-ci";