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();
+ }
+ });
+});