diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index a812b1d4..b027476e 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,13 @@ jobs: - name: Validate id: validate run: bun run validate:coverage - - name: Upload coverage to Codecov + - name: Upload coverage if: always() && steps.validate.outcome == 'success' - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 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 + 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' @@ -99,6 +85,124 @@ jobs: 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: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - 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: | + 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 + 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.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 }}" + + # Build report markdown + { + echo "## Code Coverage" + echo "" + 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 "" + 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 }}" + BODY=" + $(cat /tmp/coverage-report.md)" + + gh pr comment "${PR_NUM}" --body "${BODY}" --edit-last 2>/dev/null || \ + gh pr comment "${PR_NUM}" --body "${BODY}" + fi + - name: Upload coverage report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-report + path: coverage-html + retention-days: 30 + # Gate job — single required status check for branch protection. status: name: Validate Code 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/.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 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/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", 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/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index f64ec97b..6b203efd 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -59,7 +59,9 @@ 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. // --------------------------------------------------------------------------- /** @@ -198,6 +200,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, 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); + }); +}); 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-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); + }); +}); 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; 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(); + } + }); +});