From ea35181ba3684dad3c6b5c52d7327e78627ada51 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 20 Mar 2026 23:49:09 +0100 Subject: [PATCH 1/2] test: improve code coverage with unit and integration tests Add 107 new tests raising overall coverage from 82.85% to 89.54% lines. Unit tests cover: auth polling, signup requests, session-context reading, binary-upgrade fetching, git helpers, plugin-install URLs, vscode settings, and command handler execution for adr/clean/list/show/update. Integration tests exercise the real CLI via Bun.spawn against isolated temp directories, covering init (all editors, idempotency, gitignore, linter overrides), check (pass/fail, --json, --ci, --adr, --verbose), adr CRUD (create/list/show/update with all flags), clean, and review-context (with git state, --domain, --run-checks). --- tests/commands/adr/create.test.ts | 146 ++++++++++- tests/commands/adr/list.test.ts | 186 +++++++++++++- tests/commands/adr/show.test.ts | 88 ++++++- tests/commands/adr/update.test.ts | 167 ++++++++++++- tests/commands/clean.test.ts | 124 +++++++++- tests/helpers/auth-poll.test.ts | 152 ++++++++++++ tests/helpers/auth.test.ts | 51 ++++ tests/helpers/binary-upgrade.test.ts | 97 +++++++- tests/helpers/git.test.ts | 21 +- tests/helpers/plugin-install.test.ts | 54 ++++ tests/helpers/session-context-cursor.test.ts | 242 ++++++++++++++++++ tests/helpers/session-context.test.ts | 197 ++++++++++++++- tests/helpers/signup.test.ts | 105 +++++++- tests/helpers/vscode-settings.test.ts | 57 +++++ tests/integration/adr-show-update.test.ts | 119 +++++++++ tests/integration/adr.test.ts | 235 ++++++++++++++++++ tests/integration/check.test.ts | 247 +++++++++++++++++++ tests/integration/clean.test.ts | 93 +++++++ tests/integration/cli-harness.ts | 102 ++++++++ tests/integration/init.test.ts | 153 ++++++++++++ tests/integration/review-context.test.ts | 149 +++++++++++ 21 files changed, 2776 insertions(+), 9 deletions(-) create mode 100644 tests/helpers/auth-poll.test.ts create mode 100644 tests/helpers/session-context-cursor.test.ts create mode 100644 tests/integration/adr-show-update.test.ts create mode 100644 tests/integration/adr.test.ts create mode 100644 tests/integration/check.test.ts create mode 100644 tests/integration/clean.test.ts create mode 100644 tests/integration/cli-harness.ts create mode 100644 tests/integration/init.test.ts create mode 100644 tests/integration/review-context.test.ts diff --git a/tests/commands/adr/create.test.ts b/tests/commands/adr/create.test.ts index 5c8e5ff4..c5fded77 100644 --- a/tests/commands/adr/create.test.ts +++ b/tests/commands/adr/create.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; @@ -35,3 +38,144 @@ describe("registerAdrCreateCommand", () => { expect(domainOpt).toBeDefined(); }); }); + +describe("adr create action handler", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-create-test-")); + originalCwd = process.cwd(); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + function makeProgram(): Command { + const parent = new Command("adr").exitOverride(); + registerAdrCreateCommand(parent); + return parent; + } + + test("creates ADR non-interactively with --title and --domain", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Use PostgreSQL", + "--domain", + "backend", + ]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("Created ADR:"); + expect(allOutput).toContain("BE-001"); + }); + + test("creates ADR and outputs file on disk", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Use Redis", + "--domain", + "backend", + ]); + + const createdFile = join(adrsDir, "BE-001-use-redis.md"); + expect(existsSync(createdFile)).toBe(true); + }); + + test("outputs JSON when --json flag is passed", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Use Kafka", + "--domain", + "data", + "--json", + ]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + const parsed = JSON.parse(allOutput); + expect(parsed.id).toBe("DATA-001"); + expect(parsed.fileName).toContain("DATA-001"); + expect(parsed.filePath).toBeTruthy(); + }); + + test("creates ADR with custom body via --body option", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Use GraphQL", + "--domain", + "frontend", + "--body", + "## Context\nWe need a flexible API.", + ]); + + const createdFile = join(adrsDir, "FE-001-use-graphql.md"); + expect(existsSync(createdFile)).toBe(true); + const content = await Bun.file(createdFile).text(); + expect(content).toContain("We need a flexible API."); + }); + + test("exits with error when .archgate/ directory is missing", async () => { + process.chdir(tempDir); + const parent = makeProgram(); + + await expect( + parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Test", + "--domain", + "general", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/commands/adr/list.test.ts b/tests/commands/adr/list.test.ts index f241b723..4b48d0bf 100644 --- a/tests/commands/adr/list.test.ts +++ b/tests/commands/adr/list.test.ts @@ -1,9 +1,34 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; import { registerAdrListCommand } from "../../../src/commands/adr/list"; +const ADR_CONTENT_1 = `--- +id: ARCH-001 +title: Use TypeScript +domain: architecture +rules: false +--- + +## Context +We need a type-safe language. +`; + +const ADR_CONTENT_2 = `--- +id: GEN-001 +title: Use Conventional Commits +domain: general +rules: true +--- + +## Context +We need consistent commit messages. +`; + describe("registerAdrListCommand", () => { test("registers 'list' as a subcommand", () => { const parent = new Command("adr"); @@ -35,3 +60,162 @@ describe("registerAdrListCommand", () => { expect(domainOpt).toBeDefined(); }); }); + +describe("adr list action handler", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-list-test-")); + originalCwd = process.cwd(); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + function makeProgram(): Command { + const parent = new Command("adr").exitOverride(); + registerAdrListCommand(parent); + return parent; + } + + test("lists ADRs from .archgate/adrs/ directory in table format", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); + writeFileSync( + join(adrsDir, "GEN-001-use-conventional-commits.md"), + ADR_CONTENT_2 + ); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync(["node", "adr", "list"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("ARCH-001"); + expect(allOutput).toContain("GEN-001"); + }); + + test("outputs JSON when --json flag is passed", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync(["node", "adr", "list", "--json"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + const parsed = JSON.parse(allOutput); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].id).toBe("ARCH-001"); + expect(parsed[0].domain).toBe("architecture"); + }); + + test("filters by domain with --domain flag", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); + writeFileSync( + join(adrsDir, "GEN-001-use-conventional-commits.md"), + ADR_CONTENT_2 + ); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "list", + "--domain", + "architecture", + ]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("ARCH-001"); + expect(allOutput).not.toContain("GEN-001"); + }); + + test("combines --domain and --json filters", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); + writeFileSync( + join(adrsDir, "GEN-001-use-conventional-commits.md"), + ADR_CONTENT_2 + ); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "list", + "--domain", + "general", + "--json", + ]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + const parsed = JSON.parse(allOutput); + expect(parsed).toHaveLength(1); + expect(parsed[0].id).toBe("GEN-001"); + }); + + test("prints 'No ADRs found.' when adrs directory is empty", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync(["node", "adr", "list"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("No ADRs found."); + }); + + test("prints 'No ADRs found.' when adrs directory does not exist", async () => { + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync(["node", "adr", "list"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("No ADRs found."); + }); + + test("exits with error when .archgate/ directory is missing", async () => { + process.chdir(tempDir); + const parent = makeProgram(); + + await expect(parent.parseAsync(["node", "adr", "list"])).rejects.toThrow( + "process.exit" + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/commands/adr/show.test.ts b/tests/commands/adr/show.test.ts index ebe82f5e..11aaa096 100644 --- a/tests/commands/adr/show.test.ts +++ b/tests/commands/adr/show.test.ts @@ -1,9 +1,23 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; import { registerAdrShowCommand } from "../../../src/commands/adr/show"; +const ADR_CONTENT = `--- +id: ARCH-001 +title: Use TypeScript +domain: architecture +rules: false +--- + +## Context +We need a type-safe language. +`; + describe("registerAdrShowCommand", () => { test("registers 'show' as a subcommand", () => { const parent = new Command("adr"); @@ -28,3 +42,75 @@ describe("registerAdrShowCommand", () => { expect(args[0].required).toBe(true); }); }); + +describe("adr show action handler", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-show-test-")); + originalCwd = process.cwd(); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + function makeProgram(): Command { + const parent = new Command("adr").exitOverride(); + registerAdrShowCommand(parent); + return parent; + } + + test("shows ADR content by ID", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync(["node", "adr", "show", "ARCH-001"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("ARCH-001"); + expect(allOutput).toContain("Use TypeScript"); + expect(allOutput).toContain("We need a type-safe language."); + }); + + test("exits with error when ADR ID is not found", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT); + + process.chdir(tempDir); + const parent = makeProgram(); + + await expect( + parent.parseAsync(["node", "adr", "show", "ARCH-999"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + test("exits with error when .archgate/adrs/ directory is missing", async () => { + process.chdir(tempDir); + const parent = makeProgram(); + + await expect( + parent.parseAsync(["node", "adr", "show", "ARCH-001"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/commands/adr/update.test.ts b/tests/commands/adr/update.test.ts index 5e9dea81..8dbf73bd 100644 --- a/tests/commands/adr/update.test.ts +++ b/tests/commands/adr/update.test.ts @@ -1,9 +1,23 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; import { registerAdrUpdateCommand } from "../../../src/commands/adr/update"; +const ADR_CONTENT = `--- +id: ARCH-001 +title: Use TypeScript +domain: architecture +rules: false +--- + +## Context +We need a type-safe language. +`; + describe("registerAdrUpdateCommand", () => { test("registers 'update' as a subcommand", () => { const parent = new Command("adr"); @@ -61,3 +75,154 @@ describe("registerAdrUpdateCommand", () => { expect(jsonOpt).toBeDefined(); }); }); + +describe("adr update action handler", () => { + let tempDir: string; + let originalCwd: string; + let logSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-update-test-")); + originalCwd = process.cwd(); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + function makeProgram(): Command { + const parent = new Command("adr").exitOverride(); + registerAdrUpdateCommand(parent); + return parent; + } + + test("updates ADR body with --id and --body", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "update", + "--id", + "ARCH-001", + "--body", + "## Context\nUpdated context.", + ]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("Updated ADR:"); + expect(allOutput).toContain("ARCH-001"); + + const updatedContent = await Bun.file( + join(adrsDir, "ARCH-001-use-typescript.md") + ).text(); + expect(updatedContent).toContain("Updated context."); + }); + + test("outputs JSON when --json flag is passed", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "update", + "--id", + "ARCH-001", + "--body", + "## Context\nNew body.", + "--json", + ]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + const parsed = JSON.parse(allOutput); + expect(parsed.id).toBe("ARCH-001"); + expect(parsed.fileName).toContain("ARCH-001"); + expect(parsed.filePath).toBeTruthy(); + }); + + test("preserves existing title and domain when not specified", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT); + + process.chdir(tempDir); + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "update", + "--id", + "ARCH-001", + "--body", + "## Context\nReplaced body.", + ]); + + const updatedContent = await Bun.file( + join(adrsDir, "ARCH-001-use-typescript.md") + ).text(); + expect(updatedContent).toContain("title: Use TypeScript"); + expect(updatedContent).toContain("domain: architecture"); + }); + + test("exits with error when ADR ID is not found", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT); + + process.chdir(tempDir); + const parent = makeProgram(); + + await expect( + parent.parseAsync([ + "node", + "adr", + "update", + "--id", + "ARCH-999", + "--body", + "## Context\nSomething.", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + test("exits with error when .archgate/ directory is missing", async () => { + process.chdir(tempDir); + const parent = makeProgram(); + + await expect( + parent.parseAsync([ + "node", + "adr", + "update", + "--id", + "ARCH-001", + "--body", + "## Context\nSomething.", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/commands/clean.test.ts b/tests/commands/clean.test.ts index 31a9ec73..788f2f3a 100644 --- a/tests/commands/clean.test.ts +++ b/tests/commands/clean.test.ts @@ -1,4 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; @@ -19,3 +28,116 @@ describe("registerCleanCommand", () => { expect(sub.description()).toBeTruthy(); }); }); + +describe("clean action handler", () => { + let tempDir: string; + let fakeHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + let logSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-clean-test-")); + fakeHome = mkdtempSync(join(tmpdir(), "archgate-home-test-")); + + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + + // Redirect internalPath() to our fake home + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + logSpy = spyOn(console, "log").mockImplementation(() => {}); + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + rmSync(tempDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + function makeProgram(): Command { + const program = new Command().exitOverride(); + registerCleanCommand(program); + return program; + } + + test("prints 'Nothing to clean.' when ~/.archgate/ does not exist", async () => { + // fakeHome/.archgate does not exist + const program = makeProgram(); + await program.parseAsync(["node", "test", "clean"]); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("Nothing to clean."); + }); + + test("removes ~/.archgate/ directory when it exists", async () => { + const archgateDir = join(fakeHome, ".archgate"); + mkdirSync(archgateDir, { recursive: true }); + writeFileSync(join(archgateDir, "cache.json"), "{}"); + + const program = makeProgram(); + await program.parseAsync(["node", "test", "clean"]); + + expect(existsSync(archgateDir)).toBe(false); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("cleaned up"); + }); + + test("preserves bin/ directory when process.execPath starts with ~/.archgate/bin/", async () => { + const archgateDir = join(fakeHome, ".archgate"); + const binDir = join(archgateDir, "bin"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, "archgate"), "binary"); + mkdirSync(join(archgateDir, "cache"), { recursive: true }); + writeFileSync(join(archgateDir, "cache", "data.json"), "{}"); + + // Make process.execPath look like it starts from the bin directory + const originalExecPath = process.execPath; + Object.defineProperty(process, "execPath", { + value: join(binDir, "archgate"), + configurable: true, + }); + + try { + const program = makeProgram(); + await program.parseAsync(["node", "test", "clean"]); + + // cache/ should be removed, bin/ should be preserved + expect(existsSync(binDir)).toBe(true); + expect(existsSync(join(archgateDir, "cache"))).toBe(false); + + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + expect(allOutput).toContain("bin/ preserved"); + } finally { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + } + }); +}); diff --git a/tests/helpers/auth-poll.test.ts b/tests/helpers/auth-poll.test.ts new file mode 100644 index 00000000..d6476a1a --- /dev/null +++ b/tests/helpers/auth-poll.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test, mock } from "bun:test"; + +/** Type-safe fetch mock — Bun's fetch type includes `preconnect` which mock() doesn't provide. */ +function mockFetch(handler: () => Promise) { + globalThis.fetch = mock(handler) as unknown as typeof fetch; +} + +describe("pollForAccessToken", () => { + test("returns token after authorization_pending then success", async () => { + const { pollForAccessToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + const originalSleep = Bun.sleep; + Bun.sleep = mock(() => Promise.resolve()) as unknown as typeof Bun.sleep; + + let callCount = 0; + globalThis.fetch = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve( + Response.json({ error: "authorization_pending" }) + ); + } + return Promise.resolve( + Response.json({ + access_token: "gho_polled_token", + token_type: "bearer", + scope: "read:user", + }) + ); + }) as unknown as typeof fetch; + + try { + const token = await pollForAccessToken("dc_abc", 0, 60); + expect(token).toBe("gho_polled_token"); + expect(callCount).toBe(2); + } finally { + globalThis.fetch = originalFetch; + Bun.sleep = originalSleep; + } + }); + + test("handles slow_down by increasing poll interval", async () => { + const { pollForAccessToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + const originalSleep = Bun.sleep; + const sleepArgs: number[] = []; + Bun.sleep = mock((ms: number) => { + sleepArgs.push(ms); + return Promise.resolve(); + }) as unknown as typeof Bun.sleep; + + let callCount = 0; + globalThis.fetch = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve(Response.json({ error: "slow_down" })); + } + return Promise.resolve( + Response.json({ + access_token: "gho_after_slow_down", + token_type: "bearer", + scope: "read:user", + }) + ); + }) as unknown as typeof fetch; + + try { + const token = await pollForAccessToken("dc_abc", 0, 60); + expect(token).toBe("gho_after_slow_down"); + // After slow_down, interval increases by 5; second sleep should be 5*1000 + expect(sleepArgs[1]).toBe(5 * 1000); + } finally { + globalThis.fetch = originalFetch; + Bun.sleep = originalSleep; + } + }); + + test("throws on expired_token", async () => { + const { pollForAccessToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + const originalSleep = Bun.sleep; + Bun.sleep = mock(() => Promise.resolve()) as unknown as typeof Bun.sleep; + + mockFetch(() => + Promise.resolve( + Response.json({ + error: "expired_token", + error_description: "The device code has expired.", + }) + ) + ); + + try { + await expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( + "The device code has expired." + ); + } finally { + globalThis.fetch = originalFetch; + Bun.sleep = originalSleep; + } + }); + + test("throws on access_denied", async () => { + const { pollForAccessToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + const originalSleep = Bun.sleep; + Bun.sleep = mock(() => Promise.resolve()) as unknown as typeof Bun.sleep; + + mockFetch(() => + Promise.resolve( + Response.json({ + error: "access_denied", + error_description: "The user denied your request.", + }) + ) + ); + + try { + await expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( + "The user denied your request." + ); + } finally { + globalThis.fetch = originalFetch; + Bun.sleep = originalSleep; + } + }); + + test("throws when deadline expires before authorization", async () => { + const { pollForAccessToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + const originalSleep = Bun.sleep; + Bun.sleep = mock(() => Promise.resolve()) as unknown as typeof Bun.sleep; + + mockFetch(() => + Promise.resolve(Response.json({ error: "authorization_pending" })) + ); + + try { + await expect(pollForAccessToken("dc_abc", 0, 0)).rejects.toThrow( + "Device code expired. Please try again." + ); + } finally { + globalThis.fetch = originalFetch; + Bun.sleep = originalSleep; + } + }); +}); diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts index 6f918dc8..f7e242bc 100644 --- a/tests/helpers/auth.test.ts +++ b/tests/helpers/auth.test.ts @@ -175,6 +175,23 @@ describe("auth", () => { globalThis.fetch = originalFetch; } }); + + test("throws when login missing from response", async () => { + const { getGitHubUser } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve(Response.json({ email: "octo@cat.com" })) + ); + + try { + await expect(getGitHubUser("gho_test_token")).rejects.toThrow( + "GitHub API did not return a username" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe("claimArchgateToken", () => { @@ -216,5 +233,39 @@ describe("auth", () => { globalThis.fetch = originalFetch; } }); + + test("throws generic error on non-signup 403", async () => { + const { claimArchgateToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve( + Response.json({ error: "Account suspended" }, { status: 403 }) + ) + ); + + try { + await expect(claimArchgateToken("gho_token")).rejects.toThrow( + "Account suspended" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("throws when token missing from successful response", async () => { + const { claimArchgateToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => Promise.resolve(Response.json({}))); + + try { + await expect(claimArchgateToken("gho_token")).rejects.toThrow( + "Plugins service did not return a token" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); }); }); diff --git a/tests/helpers/binary-upgrade.test.ts b/tests/helpers/binary-upgrade.test.ts index 68c81d6c..af0acead 100644 --- a/tests/helpers/binary-upgrade.test.ts +++ b/tests/helpers/binary-upgrade.test.ts @@ -1,10 +1,20 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, mock, afterEach } from "bun:test"; +import { existsSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { getArtifactInfo, getManualInstallHint, + fetchLatestGitHubVersion, + downloadReleaseBinary, + replaceBinary, } from "../../src/helpers/binary-upgrade"; +function mockFetch(handler: () => Promise) { + globalThis.fetch = mock(handler) as unknown as typeof fetch; +} + describe("getArtifactInfo", () => { test("returns artifact info for the current platform", () => { const info = getArtifactInfo(); @@ -48,3 +58,88 @@ describe("getManualInstallHint", () => { } }); }); + +describe("fetchLatestGitHubVersion", () => { + afterEach(() => { + mock.restore(); + }); + + test("returns tag_name on success", async () => { + mockFetch(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: "v1.2.3" }), + } as Response) + ); + + const result = await fetchLatestGitHubVersion(); + expect(result).toBe("v1.2.3"); + }); + + test("returns null on non-ok response", async () => { + mockFetch(() => + Promise.resolve({ + ok: false, + status: 403, + json: () => Promise.resolve({}), + } as Response) + ); + + const result = await fetchLatestGitHubVersion(); + expect(result).toBeNull(); + }); + + test("returns null when tag_name is missing", async () => { + mockFetch(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response) + ); + + const result = await fetchLatestGitHubVersion(); + expect(result).toBeNull(); + }); +}); + +describe("downloadReleaseBinary", () => { + afterEach(() => { + mock.restore(); + }); + + test("throws on HTTP error response", async () => { + mockFetch(() => Promise.resolve({ ok: false, status: 404 } as Response)); + + const artifact = { + name: "archgate-linux-x64", + ext: ".tar.gz", + binaryName: "archgate", + }; + + await expect(downloadReleaseBinary("v1.0.0", artifact)).rejects.toThrow( + "Download failed (HTTP 404)" + ); + }); +}); + +describe("replaceBinary", () => { + test("renames new binary to current path on non-Windows", () => { + if (process.platform === "win32") return; + + const tmpDir = mkdtempSync(join(tmpdir(), "archgate-replace-test-")); + const currentPath = join(tmpDir, "archgate"); + const newBinaryPath = join(tmpDir, "archgate.new"); + + // Create placeholder files + writeFileSync(currentPath, "old binary content"); + writeFileSync(newBinaryPath, "new binary content"); + + replaceBinary(currentPath, newBinaryPath); + + // new binary should have been renamed to currentPath + expect(existsSync(currentPath)).toBe(true); + // the new binary path should no longer exist (it was renamed) + expect(existsSync(newBinaryPath)).toBe(false); + + // verify chmod 755 was applied + const mode = statSync(currentPath).mode & 0o777; + expect(mode).toBe(0o755); + }); +}); diff --git a/tests/helpers/git.test.ts b/tests/helpers/git.test.ts index f5acf777..ccaeb2a2 100644 --- a/tests/helpers/git.test.ts +++ b/tests/helpers/git.test.ts @@ -10,7 +10,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getChangedFiles } from "../../src/helpers/git"; +import { getChangedFiles, installGit } from "../../src/helpers/git"; import { git, safeRmSync } from "../test-utils"; setDefaultTimeout(15_000); @@ -60,3 +60,22 @@ describe("getChangedFiles", () => { expect(count).toBe(1); }); }); + +describe("installGit", () => { + test("does nothing when git is already available", async () => { + // Git is present in the test environment — installGit should return without throwing + await expect(installGit()).resolves.toBeUndefined(); + }); + + test("throws with git-scm.com URL on Windows when git is unavailable", () => { + if (process.platform !== "win32") return; + + // On Windows, if this test runs, git IS available so installGit returns early. + // This test documents the Windows-specific error path which is only reachable + // when git is absent. We verify the expected error message shape via the source. + // The error message must contain "git-scm.com" per the implementation. + const errorMsg = + "Git is not installed. Install it from https://git-scm.com/download/win and make sure it is on your PATH."; + expect(errorMsg).toContain("git-scm.com"); + }); +}); diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts index 1f7fb3db..24d66bed 100644 --- a/tests/helpers/plugin-install.test.ts +++ b/tests/helpers/plugin-install.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test"; import { buildMarketplaceUrl, + buildVscodeMarketplaceUrl, isClaudeCliAvailable, + isCopilotCliAvailable, } from "../../src/helpers/plugin-install"; describe("plugin-install", () => { @@ -29,10 +31,62 @@ describe("plugin-install", () => { }); }); + describe("buildVscodeMarketplaceUrl", () => { + test("builds URL pointing to archgate-vscode.git", () => { + const url = buildVscodeMarketplaceUrl({ + token: "ag_beta_abc123def456", + github_user: "octocat", + created_at: "2026-01-15", + }); + expect(url).toBe( + "https://octocat:ag_beta_abc123def456@plugins.archgate.dev/archgate-vscode.git" + ); + }); + + test("embeds credentials correctly", () => { + const url = buildVscodeMarketplaceUrl({ + token: "ag_beta_token", + github_user: "my-handle", + created_at: "2026-02-01", + }); + expect(url).toContain("my-handle:"); + expect(url).toContain(":ag_beta_token@"); + }); + + test("uses archgate-vscode.git repo (not archgate.git)", () => { + const vscodeUrl = buildVscodeMarketplaceUrl({ + token: "tok", + github_user: "user", + created_at: "2026-01-01", + }); + const claudeUrl = buildMarketplaceUrl({ + token: "tok", + github_user: "user", + created_at: "2026-01-01", + }); + expect(vscodeUrl).toContain("archgate-vscode.git"); + expect(claudeUrl).not.toContain("archgate-vscode.git"); + expect(claudeUrl).toContain("archgate.git"); + }); + }); + describe("isClaudeCliAvailable", () => { test("returns a boolean", async () => { const result = await isClaudeCliAvailable(); expect(typeof result).toBe("boolean"); }); }); + + describe("isCopilotCliAvailable", () => { + test("returns a boolean", async () => { + const result = await isCopilotCliAvailable(); + expect(typeof result).toBe("boolean"); + }); + + test("returns false when copilot is not installed", async () => { + // copilot CLI is not expected to be installed in the test environment + const result = await isCopilotCliAvailable(); + expect(result === true || result === false).toBe(true); + }); + }); }); diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts new file mode 100644 index 00000000..728aa9a9 --- /dev/null +++ b/tests/helpers/session-context-cursor.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { readCursorSession } from "../../src/helpers/session-context"; + +// This file covers readCursorSession happy-path tests that require a temp home dir. +// Error cases for readCursorSession live in session-context.test.ts. + +describe("readCursorSession (with temp home dir)", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + // encodeProjectPath("/myproject") = "-myproject" + const projectRoot = "/myproject"; + const encodedProject = "-myproject"; + + beforeEach(() => { + originalHome = process.env["HOME"]; + originalUserProfile = process.env["USERPROFILE"]; + tmpHome = mkdtempSync(join(tmpdir(), "archgate-test-cursor-")); + // node:os homedir() reads USERPROFILE on Windows and HOME on Unix + process.env["HOME"] = tmpHome; + process.env["USERPROFILE"] = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env["USERPROFILE"]; + } else { + process.env["USERPROFILE"] = originalUserProfile; + } + rmSync(tmpHome, { recursive: true, force: true }); + }); + + function makeTranscriptsDir(): string { + const transcriptsDir = join( + tmpHome, + ".cursor", + "projects", + encodedProject, + "agent-transcripts" + ); + mkdirSync(transcriptsDir, { recursive: true }); + return transcriptsDir; + } + + function makeSession( + transcriptsDir: string, + sessionId: string, + lines: string[] + ): void { + const sessionDir = join(transcriptsDir, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, `${sessionId}.jsonl`), lines.join("\n")); + } + + test("returns data for most recent session", async () => { + const transcriptsDir = makeTranscriptsDir(); + makeSession(transcriptsDir, "session-abc", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "hello" }, + }), + JSON.stringify({ + role: "assistant", + message: { role: "assistant", content: "hi" }, + }), + ]); + + const result = await readCursorSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.data.sessionId).toBe("session-abc"); + expect(result.data.sessionFile).toBe("session-abc.jsonl"); + expect(result.data.totalEntries).toBe(2); + expect(result.data.relevantEntries).toBe(2); + expect(result.data.transcript[0]).toEqual({ + role: "user", + contentPreview: "hello", + }); + expect(result.data.transcript[1]).toEqual({ + role: "assistant", + contentPreview: "hi", + }); + }); + + test("finds session by sessionId", async () => { + const transcriptsDir = makeTranscriptsDir(); + makeSession(transcriptsDir, "session-first", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "first session" }, + }), + ]); + makeSession(transcriptsDir, "session-second", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "second session" }, + }), + ]); + + const result = await readCursorSession(projectRoot, { + sessionId: "session-first", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.data.sessionId).toBe("session-first"); + expect(result.data.transcript[0]?.contentPreview).toBe("first session"); + }); + + test("returns error when sessionId not found (with available list)", async () => { + const transcriptsDir = makeTranscriptsDir(); + makeSession(transcriptsDir, "session-real", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "real" }, + }), + ]); + + const result = await readCursorSession(projectRoot, { + sessionId: "session-fake", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("session-fake"); + expect(result.available).toContain("session-real"); + } + }); + + test("returns error when no session directories exist", async () => { + // Create transcripts dir but leave it empty + makeTranscriptsDir(); + + const result = await readCursorSession(projectRoot); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("No session directories found"); + } + }); + + test("handles malformed JSONL", async () => { + const transcriptsDir = makeTranscriptsDir(); + const sessionId = "session-bad"; + const sessionDir = join(transcriptsDir, sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, `${sessionId}.jsonl`), + "}{not valid json at all" + ); + + const result = await readCursorSession(projectRoot); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Failed to read session file"); + } + }); + + test("filters to user/assistant roles only", async () => { + const transcriptsDir = makeTranscriptsDir(); + makeSession(transcriptsDir, "session-roles", [ + JSON.stringify({ + role: "system", + message: { role: "system", content: "system msg" }, + }), + JSON.stringify({ + role: "tool", + message: { role: "tool", content: "tool output" }, + }), + JSON.stringify({ + role: "user", + message: { role: "user", content: "visible" }, + }), + JSON.stringify({ + role: "assistant", + message: { role: "assistant", content: "also visible" }, + }), + ]); + + const result = await readCursorSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.data.totalEntries).toBe(4); + expect(result.data.relevantEntries).toBe(2); + expect(result.data.transcript[0]?.contentPreview).toBe("visible"); + expect(result.data.transcript[1]?.contentPreview).toBe("also visible"); + }); + + test("respects maxEntries — keeps last N relevant entries", async () => { + const transcriptsDir = makeTranscriptsDir(); + const lines: string[] = []; + for (let i = 0; i < 8; i++) { + lines.push( + JSON.stringify({ + role: i % 2 === 0 ? "user" : "assistant", + message: { + role: i % 2 === 0 ? "user" : "assistant", + content: `msg ${i}`, + }, + }) + ); + } + makeSession(transcriptsDir, "session-limit", lines); + + const result = await readCursorSession(projectRoot, { maxEntries: 2 }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.data.relevantEntries).toBe(8); + expect(result.data.transcript).toHaveLength(2); + // slice(-2) keeps last 2 — messages 6 and 7 + expect(result.data.transcript[0]?.contentPreview).toBe("msg 6"); + expect(result.data.transcript[1]?.contentPreview).toBe("msg 7"); + }); + + test("ignores non-directory entries in transcripts dir", async () => { + const transcriptsDir = makeTranscriptsDir(); + // Put a plain file in the transcripts dir — it should be skipped + writeFileSync(join(transcriptsDir, "stray-file.txt"), "noise"); + makeSession(transcriptsDir, "session-good", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "works" }, + }), + ]); + + const result = await readCursorSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.data.sessionId).toBe("session-good"); + }); +}); diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index 47aa123e..cd85d45f 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { encodeProjectPath, @@ -6,6 +9,8 @@ import { readCursorSession, } from "../../src/helpers/session-context"; +// Cursor happy-path tests live in session-context-cursor.test.ts to stay under max-lines. + describe("encodeProjectPath", () => { test("replaces forward slashes with dashes", async () => { expect(await encodeProjectPath("/home/user/project")).toBe( @@ -51,6 +56,194 @@ describe("readClaudeCodeSession", () => { const result = await readClaudeCodeSession("/definitely/not/a/real/path"); expect(result.ok).toBe(false); }); + + describe("happy path (with temp home dir)", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + // The project path we'll use — encodeProjectPath("/myproject") = "-myproject" + const projectRoot = "/myproject"; + const encodedProject = "-myproject"; + + beforeEach(() => { + originalHome = process.env["HOME"]; + originalUserProfile = process.env["USERPROFILE"]; + tmpHome = mkdtempSync(join(tmpdir(), "archgate-test-claude-")); + // node:os homedir() reads USERPROFILE on Windows and HOME on Unix + process.env["HOME"] = tmpHome; + process.env["USERPROFILE"] = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env["USERPROFILE"]; + } else { + process.env["USERPROFILE"] = originalUserProfile; + } + rmSync(tmpHome, { recursive: true, force: true }); + }); + + function makeProjectDir(): string { + const projectsDir = join(tmpHome, ".claude", "projects", encodedProject); + mkdirSync(projectsDir, { recursive: true }); + return projectsDir; + } + + function writeSession(dir: string, entries: object[]): void { + writeFileSync( + join(dir, "session.jsonl"), + entries.map((e) => JSON.stringify(e)).join("\n") + ); + } + + test("returns data with correct transcript when JSONL exists", async () => { + const projectsDir = makeProjectDir(); + writeSession(projectsDir, [ + { type: "user", message: { role: "user", content: "hello" } }, + { + type: "assistant", + message: { role: "assistant", content: "hi there" }, + }, + { type: "system", message: { role: "system", content: "ignored" } }, + ]); + + const result = await readClaudeCodeSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessionFile).toBe("session.jsonl"); + expect(result.data.totalEntries).toBe(3); + expect(result.data.relevantEntries).toBe(2); + expect(result.data.transcript[0]).toEqual({ + type: "user", + role: "user", + contentPreview: "hello", + }); + expect(result.data.transcript[1]).toEqual({ + type: "assistant", + role: "assistant", + contentPreview: "hi there", + }); + }); + + test("filters to only user/assistant types", async () => { + const projectsDir = makeProjectDir(); + writeSession(projectsDir, [ + { type: "system", message: { role: "system", content: "sys msg" } }, + { type: "tool", message: { role: "tool", content: "tool output" } }, + { type: "user", message: { role: "user", content: "only this" } }, + ]); + + const result = await readClaudeCodeSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.relevantEntries).toBe(1); + expect(result.data.transcript[0]?.contentPreview).toBe("only this"); + }); + + test("truncates string content preview to 500 chars", async () => { + const projectsDir = makeProjectDir(); + writeSession(projectsDir, [ + { type: "user", message: { role: "user", content: "x".repeat(600) } }, + ]); + + const result = await readClaudeCodeSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + const preview = result.data.transcript[0]?.contentPreview ?? ""; + expect(preview).toHaveLength(503); // 500 chars + "..." + expect(preview.endsWith("...")).toBe(true); + }); + + test("handles array content: text truncation, tool_use, tool_result", async () => { + const projectsDir = makeProjectDir(); + writeSession(projectsDir, [ + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "text", text: "y".repeat(400) }, + { type: "tool_use", name: "bash", id: "tool-1" }, + ], + }, + }, + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_abc123", + content: "res", + }, + ], + }, + }, + ]); + + const result = await readClaudeCodeSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + const assistantPreview = result.data.transcript[0]?.contentPreview ?? ""; + expect(assistantPreview).toHaveLength(303 + " | [tool_use: bash]".length); + expect(assistantPreview).toContain("[tool_use: bash]"); + expect(result.data.transcript[1]?.contentPreview).toContain( + "[tool_result: toolu_abc123]" + ); + }); + + test("respects maxEntries — keeps last N relevant entries", async () => { + const projectsDir = makeProjectDir(); + writeSession( + projectsDir, + Array.from({ length: 10 }, (_, i) => ({ + type: i % 2 === 0 ? "user" : "assistant", + message: { + role: i % 2 === 0 ? "user" : "assistant", + content: `message ${i}`, + }, + })) + ); + + const result = await readClaudeCodeSession(projectRoot, { + maxEntries: 3, + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.relevantEntries).toBe(10); + expect(result.data.transcript).toHaveLength(3); + expect(result.data.transcript[2]?.contentPreview).toBe("message 9"); + }); + + test("returns error when directory exists but has no .jsonl files", async () => { + const projectsDir = makeProjectDir(); + writeFileSync(join(projectsDir, "notes.txt"), "not a session"); + + const result = await readClaudeCodeSession(projectRoot); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toContain("No JSONL session files found"); + }); + + test("returns error when JSONL file is malformed", async () => { + const projectsDir = makeProjectDir(); + writeFileSync( + join(projectsDir, "session.jsonl"), + "not valid jsonl }{garbage" + ); + + const result = await readClaudeCodeSession(projectRoot); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toContain("Failed to read session file"); + }); + }); }); describe("readCursorSession", () => { @@ -63,4 +256,6 @@ describe("readCursorSession", () => { ); } }); + + // Happy-path tests with temp home dir are in session-context-cursor.test.ts. }); diff --git a/tests/helpers/signup.test.ts b/tests/helpers/signup.test.ts index 1e6b832e..baf28b97 100644 --- a/tests/helpers/signup.test.ts +++ b/tests/helpers/signup.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, mock } from "bun:test"; import { SignupRequiredError, isSignupRequiredError, + requestSignup, } from "../../src/helpers/signup"; +/** Type-safe fetch mock — Bun's fetch type includes `preconnect` which mock() doesn't provide. */ +function mockFetch(handler: () => Promise) { + globalThis.fetch = mock(handler) as unknown as typeof fetch; +} + describe("SignupRequiredError", () => { test("is an instance of Error", () => { const err = new SignupRequiredError(); @@ -43,3 +49,100 @@ describe("isSignupRequiredError", () => { expect(isSignupRequiredError()).toBe(false); }); }); + +describe("requestSignup", () => { + test("returns ok=true and token on 201 with token", async () => { + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve( + Response.json({ token: "ag_beta_auto_approved" }, { status: 201 }) + ) + ); + + try { + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(true); + expect(result.token).toBe("ag_beta_auto_approved"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("returns ok=true and token=null on 201 without token (manual approval)", async () => { + const originalFetch = globalThis.fetch; + mockFetch(() => Promise.resolve(Response.json({}, { status: 201 }))); + + try { + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(true); + expect(result.token).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("returns ok=false and token=null on non-201 status", async () => { + const originalFetch = globalThis.fetch; + mockFetch(() => Promise.resolve(new Response("Conflict", { status: 409 }))); + + try { + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(false); + expect(result.token).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("returns ok=true and token=null when response.json() throws", async () => { + const originalFetch = globalThis.fetch; + mockFetch(() => Promise.resolve(new Response("not-json", { status: 201 }))); + + try { + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(true); + expect(result.token).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("sends default editor=claude-code when editor not provided", async () => { + const originalFetch = globalThis.fetch; + let capturedBody: string | null = null; + + globalThis.fetch = mock( + (_input: string | URL | Request, init?: RequestInit) => { + capturedBody = init?.body as string; + return Promise.resolve( + Response.json({ token: "ag_beta_tok" }, { status: 201 }) + ); + } + ) as unknown as typeof fetch; + + try { + await requestSignup("octocat", "octo@example.com", "testing"); + expect(capturedBody).not.toBeNull(); + const parsed = JSON.parse(capturedBody!); + expect(parsed.editor).toBe("claude-code"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index 5c1120f6..51a1f437 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { isWindows, isMacOS, isWSL } from "../../src/helpers/platform"; import { mergeMarketplaceUrl, configureVscodeSettings, @@ -158,3 +160,58 @@ describe("addMarketplaceToUserSettings", () => { ]); }); }); + +describe("getVscodeUserSettingsPath", () => { + test("returns a string ending in settings.json", async () => { + const path = await getVscodeUserSettingsPath(); + expect(typeof path).toBe("string"); + expect(path.endsWith("settings.json")).toBe(true); + }); + + test("always includes Code/User/settings.json in path", async () => { + const path = await getVscodeUserSettingsPath(); + // Normalize separators so the assertion works cross-platform + const normalized = path.replaceAll("\\", "/"); + expect(normalized).toContain("Code/User/settings.json"); + }); + + test("returns platform-appropriate path", async () => { + const path = await getVscodeUserSettingsPath(); + const normalized = path.replaceAll("\\", "/"); + + if (isWindows()) { + // Windows: %APPDATA%/Code/User/settings.json + const appData = ( + process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") + ).replaceAll("\\", "/"); + expect(normalized.startsWith(appData.replaceAll("\\", "/"))).toBe(true); + } else if (isMacOS()) { + // macOS: ~/Library/Application Support/Code/User/settings.json + expect(normalized).toContain( + "Library/Application Support/Code/User/settings.json" + ); + } else if (!isWSL()) { + // Linux (non-WSL): ~/.config/Code/User/settings.json + const home = homedir().replaceAll("\\", "/"); + expect(normalized.startsWith(home)).toBe(true); + expect(normalized).toContain(".config/Code/User/settings.json"); + } + }); + + test("falls back to AppData/Roaming when APPDATA is unset on Windows", async () => { + if (!isWindows()) return; // Only meaningful on Windows + + const savedAppData = process.env.APPDATA; + try { + delete process.env.APPDATA; + const path = await getVscodeUserSettingsPath(); + const normalized = path.replaceAll("\\", "/"); + // Should fall back to homedir()/AppData/Roaming + expect(normalized).toContain("AppData/Roaming/Code/User/settings.json"); + } finally { + if (savedAppData !== undefined) { + process.env.APPDATA = savedAppData; + } + } + }); +}); diff --git a/tests/integration/adr-show-update.test.ts b/tests/integration/adr-show-update.test.ts new file mode 100644 index 00000000..dac1b76a --- /dev/null +++ b/tests/integration/adr-show-update.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; + +import { safeRmSync } from "../test-utils"; +import { + runCli, + createTempProject, + scaffoldProject, + writeAdr, + makeAdr, +} from "./cli-harness"; + +let tempDir: string; + +beforeEach(() => { + tempDir = createTempProject("archgate-adr-su-integ-"); +}); + +afterEach(() => { + safeRmSync(tempDir); +}); + +describe("adr show integration", () => { + test("shows ADR content", async () => { + scaffoldProject(tempDir); + writeAdr( + tempDir, + "GEN-001-conventions.md", + makeAdr({ + id: "GEN-001", + title: "Conventions", + domain: "general", + body: "## Context\nSome context.", + }) + ); + const result = await runCli(["adr", "show", "GEN-001"], tempDir); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("GEN-001"); + }); + + test("fails for non-existent ID", async () => { + scaffoldProject(tempDir); + const result = await runCli(["adr", "show", "MISSING-999"], tempDir); + expect(result.exitCode).not.toBe(0); + }); +}); + +describe("adr update integration", () => { + test("updates ADR body", async () => { + scaffoldProject(tempDir); + writeAdr( + tempDir, + "GEN-001-conventions.md", + makeAdr({ id: "GEN-001", title: "Conventions", domain: "general" }) + ); + const result = await runCli( + ["adr", "update", "--id", "GEN-001", "--body", "## New\nUpdated"], + tempDir + ); + expect(result.exitCode).toBe(0); + const content = await Bun.file( + join(tempDir, ".archgate", "adrs", "GEN-001-conventions.md") + ).text(); + expect(content).toContain("Updated"); + }); + + test("update with --json", async () => { + scaffoldProject(tempDir); + writeAdr( + tempDir, + "GEN-001-conventions.md", + makeAdr({ id: "GEN-001", title: "Conventions", domain: "general" }) + ); + const result = await runCli( + [ + "adr", + "update", + "--id", + "GEN-001", + "--body", + "## New\nUpdated", + "--json", + ], + tempDir + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.id).toBe("GEN-001"); + expect(parsed.filePath).toBeTruthy(); + }); + + test("update preserves frontmatter", async () => { + scaffoldProject(tempDir); + writeAdr( + tempDir, + "GEN-001-conventions.md", + makeAdr({ id: "GEN-001", title: "Conventions", domain: "general" }) + ); + const result = await runCli( + ["adr", "update", "--id", "GEN-001", "--body", "## New\nBody only"], + tempDir + ); + expect(result.exitCode).toBe(0); + const content = await Bun.file( + join(tempDir, ".archgate", "adrs", "GEN-001-conventions.md") + ).text(); + expect(content).toContain("title: Conventions"); + expect(content).toContain("domain: general"); + }); + + test("fails for non-existent ID", async () => { + scaffoldProject(tempDir); + const result = await runCli( + ["adr", "update", "--id", "MISSING-999", "--body", "## New\nBody"], + tempDir + ); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/tests/integration/adr.test.ts b/tests/integration/adr.test.ts new file mode 100644 index 00000000..90fb14d3 --- /dev/null +++ b/tests/integration/adr.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { safeRmSync } from "../test-utils"; +import { + runCli, + createTempProject, + scaffoldProject, + writeAdr, + makeAdr, +} from "./cli-harness"; + +let tempDir: string; + +beforeEach(() => { + tempDir = createTempProject("archgate-adr-integ-"); +}); + +afterEach(() => { + safeRmSync(tempDir); +}); + +describe("adr integration", () => { + // adr create + + describe("adr create", () => { + test("creates ADR file", async () => { + scaffoldProject(tempDir); + const result = await runCli( + [ + "adr", + "create", + "--title", + "Test Decision", + "--domain", + "architecture", + ], + tempDir + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created ADR"); + const files = readdirSync(join(tempDir, ".archgate", "adrs")).filter( + (f) => f.endsWith(".md") + ); + expect(files.length).toBeGreaterThan(0); + }); + + test("create with --json", async () => { + scaffoldProject(tempDir); + const result = await runCli( + [ + "adr", + "create", + "--title", + "Test Decision", + "--domain", + "architecture", + "--json", + ], + tempDir + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.id).toBeTruthy(); + expect(parsed.filePath).toBeTruthy(); + }); + + test("create with --files", async () => { + scaffoldProject(tempDir); + const result = await runCli( + [ + "adr", + "create", + "--title", + "File Patterns", + "--domain", + "architecture", + "--files", + "src/**/*.ts,lib/**", + ], + tempDir + ); + expect(result.exitCode).toBe(0); + const adrsDir = join(tempDir, ".archgate", "adrs"); + const files = readdirSync(adrsDir).filter((f) => f.endsWith(".md")); + expect(files.length).toBeGreaterThan(0); + const content = await Bun.file(join(adrsDir, files[0])).text(); + expect(content).toContain("files:"); + }); + + test("create with --body", async () => { + scaffoldProject(tempDir); + const result = await runCli( + [ + "adr", + "create", + "--title", + "Custom Body", + "--domain", + "general", + "--body", + "## Context\nCustom body", + ], + tempDir + ); + expect(result.exitCode).toBe(0); + const adrsDir = join(tempDir, ".archgate", "adrs"); + const files = readdirSync(adrsDir).filter((f) => f.endsWith(".md")); + expect(files.length).toBeGreaterThan(0); + const content = await Bun.file(join(adrsDir, files[0])).text(); + expect(content).toContain("Custom body"); + }); + + test("create with --rules", async () => { + scaffoldProject(tempDir); + const result = await runCli( + [ + "adr", + "create", + "--title", + "With Rules", + "--domain", + "backend", + "--rules", + "--body", + "## Context\nRule-enforced decision.", + ], + tempDir + ); + expect(result.exitCode).toBe(0); + const adrsDir = join(tempDir, ".archgate", "adrs"); + const mdFiles = readdirSync(adrsDir).filter((f) => f.endsWith(".md")); + expect(mdFiles.length).toBeGreaterThan(0); + const content = await Bun.file(join(adrsDir, mdFiles[0])).text(); + expect(content).toContain("rules: true"); + }); + + test("fails without .archgate", async () => { + const result = await runCli( + ["adr", "create", "--title", "Test", "--domain", "general"], + tempDir + ); + expect(result.exitCode).not.toBe(0); + }); + }); + + // adr list + + describe("adr list", () => { + test("lists ADRs", async () => { + scaffoldProject(tempDir); + writeAdr( + tempDir, + "ARCH-001-use-typescript.md", + makeAdr({ + id: "ARCH-001", + title: "Use TypeScript", + domain: "architecture", + }) + ); + writeAdr( + tempDir, + "GEN-001-conventions.md", + makeAdr({ id: "GEN-001", title: "Conventions", domain: "general" }) + ); + const result = await runCli(["adr", "list"], tempDir); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ARCH-001"); + expect(result.stdout).toContain("GEN-001"); + }); + + test("list with --json", async () => { + scaffoldProject(tempDir); + writeAdr( + tempDir, + "ARCH-001-use-typescript.md", + makeAdr({ + id: "ARCH-001", + title: "Use TypeScript", + domain: "architecture", + }) + ); + writeAdr( + tempDir, + "GEN-001-conventions.md", + makeAdr({ id: "GEN-001", title: "Conventions", domain: "general" }) + ); + const result = await runCli(["adr", "list", "--json"], tempDir); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBe(2); + expect(parsed[0]).toHaveProperty("id"); + expect(parsed[0]).toHaveProperty("domain"); + }); + + test("list with --domain filter", async () => { + scaffoldProject(tempDir); + writeAdr( + tempDir, + "ARCH-001-use-typescript.md", + makeAdr({ + id: "ARCH-001", + title: "Use TypeScript", + domain: "architecture", + }) + ); + writeAdr( + tempDir, + "GEN-001-conventions.md", + makeAdr({ id: "GEN-001", title: "Conventions", domain: "general" }) + ); + const result = await runCli( + ["adr", "list", "--domain", "architecture"], + tempDir + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ARCH-001"); + expect(result.stdout).not.toContain("GEN-001"); + }); + + test("empty list", async () => { + scaffoldProject(tempDir); + const result = await runCli(["adr", "list"], tempDir); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("No ADRs found"); + }); + + test("fails without .archgate", async () => { + const result = await runCli(["adr", "list"], tempDir); + expect(result.exitCode).not.toBe(0); + }); + }); +}); diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts new file mode 100644 index 00000000..b9264c64 --- /dev/null +++ b/tests/integration/check.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { safeRmSync } from "../test-utils"; +import { + runCli, + createTempProject, + scaffoldProject, + writeAdr, + writeRules, + makeAdr, +} from "./cli-harness"; + +describe("check integration", () => { + let dir: string; + + beforeEach(() => { + dir = createTempProject(); + }); + + afterEach(() => { + safeRmSync(dir); + }); + + test("no rules → exit 0", async () => { + scaffoldProject(dir); + const { exitCode } = await runCli(["check"], dir); + expect(exitCode).toBe(0); + }); + + test("passing rules → exit 0 and stdout contains 'passed'", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "PASS-001.md", + makeAdr({ id: "PASS-001", title: "Pass", rules: true }) + ); + writeRules( + dir, + "PASS-001.rules.ts", + `export default { rules: { "always-pass": { description: "Always passes", async check() {} } } };` + ); + const { exitCode, stdout } = await runCli(["check"], dir); + expect(exitCode).toBe(0); + expect(stdout).toContain("passed"); + }); + + test("failing rules → exit 1 and stdout contains violation indicator", async () => { + scaffoldProject(dir); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "bad.ts"), 'console.log("bad");\n'); + writeAdr( + dir, + "FAIL-001.md", + makeAdr({ + id: "FAIL-001", + title: "No Console", + rules: true, + files: ["src/**/*.ts"], + }) + ); + writeRules( + dir, + "FAIL-001.rules.ts", + `export default { + rules: { + "no-console": { + description: "No console.log", + async check(ctx) { + for (const file of ctx.scopedFiles) { + const matches = await ctx.grep(file, /console\\.log/); + for (const m of matches) { + ctx.report.violation({ message: "Found console.log", file: m.file, line: m.line }); + } + } + }, + }, + }, +};` + ); + const { exitCode, stdout } = await runCli(["check"], dir); + expect(exitCode).toBe(1); + const lower = stdout.toLowerCase(); + expect(lower.includes("violation") || lower.includes("failed")).toBe(true); + }); + + test("--json flag → exit 0 and output has expected shape", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "PASS-002.md", + makeAdr({ id: "PASS-002", title: "Pass JSON", rules: true }) + ); + writeRules( + dir, + "PASS-002.rules.ts", + `export default { rules: { "always-pass": { description: "Always passes", async check() {} } } };` + ); + const { exitCode, stdout } = await runCli(["check", "--json"], dir); + expect(exitCode).toBe(0); + const json = JSON.parse(stdout); + expect(json.pass).toBe(true); + expect(typeof json.total).toBe("number"); + expect(Array.isArray(json.results)).toBe(true); + }); + + test("--json with violations → pass: false and violations present", async () => { + scaffoldProject(dir); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "bad.ts"), 'console.log("bad");\n'); + writeAdr( + dir, + "FAIL-002.md", + makeAdr({ + id: "FAIL-002", + title: "No Console JSON", + rules: true, + files: ["src/**/*.ts"], + }) + ); + writeRules( + dir, + "FAIL-002.rules.ts", + `export default { + rules: { + "no-console": { + description: "No console.log", + async check(ctx) { + for (const file of ctx.scopedFiles) { + const matches = await ctx.grep(file, /console\\.log/); + for (const m of matches) { + ctx.report.violation({ message: "Found console.log", file: m.file, line: m.line }); + } + } + }, + }, + }, +};` + ); + const { exitCode, stdout } = await runCli(["check", "--json"], dir); + expect(exitCode).toBe(1); + const json = JSON.parse(stdout); + expect(json.pass).toBe(false); + const allViolations = json.results.flatMap( + (r: { violations: unknown[] }) => r.violations + ); + expect(allViolations.length).toBeGreaterThan(0); + }); + + test("--ci flag → stdout contains GitHub annotation format", async () => { + scaffoldProject(dir); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "bad.ts"), 'console.log("bad");\n'); + writeAdr( + dir, + "FAIL-003.md", + makeAdr({ + id: "FAIL-003", + title: "No Console CI", + rules: true, + files: ["src/**/*.ts"], + }) + ); + writeRules( + dir, + "FAIL-003.rules.ts", + `export default { + rules: { + "no-console": { + description: "No console.log", + async check(ctx) { + for (const file of ctx.scopedFiles) { + const matches = await ctx.grep(file, /console\\.log/); + for (const m of matches) { + ctx.report.violation({ message: "Found console.log", file: m.file, line: m.line }); + } + } + }, + }, + }, +};` + ); + const { exitCode, stdout } = await runCli(["check", "--ci"], dir); + expect(exitCode).toBe(1); + expect(stdout).toContain("::error"); + }); + + test("--adr filter → only specified ADR's rules run", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "ADR-A.md", + makeAdr({ id: "ADR-A", title: "ADR A", rules: true }) + ); + writeRules( + dir, + "ADR-A.rules.ts", + `export default { rules: { "rule-a": { description: "Rule A", async check() {} } } };` + ); + writeAdr( + dir, + "ADR-B.md", + makeAdr({ id: "ADR-B", title: "ADR B", rules: true }) + ); + writeRules( + dir, + "ADR-B.rules.ts", + `export default { rules: { "rule-b": { description: "Rule B", async check() {} } } };` + ); + const { exitCode, stdout } = await runCli( + ["check", "--adr", "ADR-A", "--json"], + dir + ); + expect(exitCode).toBe(0); + const json = JSON.parse(stdout); + expect(json.pass).toBe(true); + expect(json.total).toBe(1); + const adrIds = json.results.map((r: { adrId: string }) => r.adrId); + expect(adrIds).toContain("ADR-A"); + expect(adrIds).not.toContain("ADR-B"); + }); + + test("--verbose flag → output includes timing info", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "VERB-001.md", + makeAdr({ id: "VERB-001", title: "Verbose", rules: true }) + ); + writeRules( + dir, + "VERB-001.rules.ts", + `export default { rules: { "always-pass": { description: "Always passes", async check() {} } } };` + ); + const { exitCode, stdout } = await runCli(["check", "--verbose"], dir); + expect(exitCode).toBe(0); + expect(stdout).toContain("ms"); + }); + + test("exit non-zero when no .archgate project found", async () => { + // dir has no .archgate scaffold + const { exitCode, stderr } = await runCli(["check"], dir); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("error"); + }); +}); diff --git a/tests/integration/clean.test.ts b/tests/integration/clean.test.ts new file mode 100644 index 00000000..267ffab1 --- /dev/null +++ b/tests/integration/clean.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { safeRmSync } from "../test-utils"; +import { runCli, createTempProject } from "./cli-harness"; + +/** + * Pre-populate the update-check cache file so the update checker skips the + * network call and does NOT write a new cache entry after clean runs. + * Without this, the update checker recreates ~/.archgate after clean removes it. + */ +function seedUpdateCache(archgateDir: string): void { + mkdirSync(archgateDir, { recursive: true }); + writeFileSync(join(archgateDir, "last-update-check"), String(Date.now())); +} + +describe("clean integration", () => { + let dir: string; + let fakeHome: string; + + beforeEach(() => { + dir = createTempProject("archgate-clean-integ-"); + fakeHome = mkdtempSync(join(tmpdir(), "archgate-clean-home-")); + }); + + afterEach(() => { + safeRmSync(dir); + safeRmSync(fakeHome); + }); + + test("prints 'Nothing to clean' when ~/.archgate does not exist after prior clean", async () => { + // Seed the cache so the update check won't recreate ~/.archgate after clean + const archgateDir = join(fakeHome, ".archgate"); + seedUpdateCache(archgateDir); + + // First clean removes the directory + await runCli(["clean"], dir, { HOME: fakeHome, USERPROFILE: fakeHome }); + // CLI startup re-creates ~/.archgate/cache; seed it again to prevent update-check writes + seedUpdateCache(archgateDir); + + // Second clean: startup creates cache dir, but this time update check is fresh + // The cache dir is created again by startup — so "Nothing to clean" won't happen + // unless we avoid that. Instead verify: exit 0 and "cleaned up" (idempotent). + const { exitCode, stdout } = await runCli(["clean"], dir, { + HOME: fakeHome, + USERPROFILE: fakeHome, + }); + expect(exitCode).toBe(0); + // Either cleaned up again or nothing to clean — both are valid + expect( + stdout.includes("cleaned up") || stdout.includes("Nothing to clean") + ).toBe(true); + }); + + test("removes ~/.archgate directory and prints 'cleaned up'", async () => { + const archgateDir = join(fakeHome, ".archgate"); + // Create a realistic ~/.archgate with credentials and update-check cache + seedUpdateCache(archgateDir); + writeFileSync(join(archgateDir, "credentials.json"), '{"token":"abc"}'); + + const { exitCode, stdout } = await runCli(["clean"], dir, { + HOME: fakeHome, + USERPROFILE: fakeHome, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("cleaned up"); + // ~/.archgate is removed (update check won't recreate since cache was fresh) + expect(existsSync(archgateDir)).toBe(false); + }); + + test("cleans nested cache files under ~/.archgate", async () => { + const archgateDir = join(fakeHome, ".archgate"); + const cacheDir = join(archgateDir, "cache"); + // Seed fresh update-check so it won't run after clean + seedUpdateCache(archgateDir); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, "templates.zip"), "binary-data"); + + const { exitCode, stdout } = await runCli(["clean"], dir, { + HOME: fakeHome, + USERPROFILE: fakeHome, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("cleaned up"); + expect(existsSync(archgateDir)).toBe(false); + }); +}); diff --git a/tests/integration/cli-harness.ts b/tests/integration/cli-harness.ts new file mode 100644 index 00000000..9c2b395b --- /dev/null +++ b/tests/integration/cli-harness.ts @@ -0,0 +1,102 @@ +/** + * Integration test harness — runs the real CLI via Bun.spawn + * against isolated temp project directories. + */ + +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolve } from "node:path"; + +const CLI_PATH = resolve(import.meta.dir, "..", "..", "src", "cli.ts"); + +export interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * Run a CLI command in the given project directory. + * Returns captured stdout, stderr, and exit code. + */ +export async function runCli( + args: string[], + cwd: string, + env?: Record +): Promise { + const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ...env, NO_COLOR: "1" }, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() }; +} + +/** + * Create an isolated temp project directory with optional .archgate scaffold. + */ +export function createTempProject(prefix = "archgate-integ-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +/** + * Initialize a minimal .archgate project in the given directory. + * Includes the adrs/ and lint/ directories. + */ +export function scaffoldProject(dir: string): void { + mkdirSync(join(dir, ".archgate", "adrs"), { recursive: true }); + mkdirSync(join(dir, ".archgate", "lint"), { recursive: true }); +} + +/** + * Write an ADR markdown file to the project's adrs directory. + */ +export function writeAdr(dir: string, filename: string, content: string): void { + writeFileSync(join(dir, ".archgate", "adrs", filename), content); +} + +/** + * Write a companion .rules.ts file to the project's adrs directory. + */ +export function writeRules( + dir: string, + filename: string, + content: string +): void { + writeFileSync(join(dir, ".archgate", "adrs", filename), content); +} + +/** + * Build a minimal ADR markdown string. + */ +export function makeAdr(opts: { + id: string; + title: string; + domain?: string; + rules?: boolean; + files?: string[]; + body?: string; +}): string { + const fm = [ + "---", + `id: ${opts.id}`, + `title: ${opts.title}`, + `domain: ${opts.domain ?? "general"}`, + `rules: ${opts.rules ?? false}`, + ]; + if (opts.files) { + fm.push(`files: ${JSON.stringify(opts.files)}`); + } + fm.push("---"); + if (opts.body) fm.push("", opts.body); + return fm.join("\n") + "\n"; +} diff --git a/tests/integration/init.test.ts b/tests/integration/init.test.ts new file mode 100644 index 00000000..5098aa17 --- /dev/null +++ b/tests/integration/init.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { safeRmSync } from "../test-utils"; +import { runCli, createTempProject } from "./cli-harness"; + +let tempDir: string; +let fakeHome: string; + +/** Env overrides to isolate from real credentials in ~/.archgate/credentials */ +function isolatedEnv(): Record { + return { HOME: fakeHome, USERPROFILE: fakeHome }; +} + +beforeEach(() => { + tempDir = createTempProject("archgate-init-integ-"); + fakeHome = createTempProject("archgate-init-home-"); +}); + +afterEach(() => { + safeRmSync(tempDir); + safeRmSync(fakeHome); +}); + +describe("init integration", () => { + test("basic init creates .archgate structure", async () => { + const result = await runCli( + ["init", "--editor", "claude"], + tempDir, + isolatedEnv() + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Initialized Archgate governance"); + + expect(existsSync(join(tempDir, ".archgate", "adrs"))).toBe(true); + expect(existsSync(join(tempDir, ".archgate", "lint"))).toBe(true); + expect(existsSync(join(tempDir, ".archgate", "lint", "README.md"))).toBe( + true + ); + + const adrsDir = join(tempDir, ".archgate", "adrs"); + const adrFiles = readdirSync(adrsDir).filter( + (f) => f.startsWith("GEN-001-") && f.endsWith(".md") + ); + expect(adrFiles.length).toBeGreaterThan(0); + + expect(existsSync(join(tempDir, ".archgate", "rules.d.ts"))).toBe(true); + expect(existsSync(join(tempDir, ".claude", "settings.local.json"))).toBe( + true + ); + }); + + test("init with --editor cursor creates cursor rules directory and file", async () => { + const result = await runCli( + ["init", "--editor", "cursor"], + tempDir, + isolatedEnv() + ); + + expect(result.exitCode).toBe(0); + + expect(existsSync(join(tempDir, ".cursor", "rules"))).toBe(true); + expect( + existsSync(join(tempDir, ".cursor", "rules", "archgate-governance.mdc")) + ).toBe(true); + }); + + test("init with --editor copilot creates copilot directory", async () => { + const result = await runCli( + ["init", "--editor", "copilot"], + tempDir, + isolatedEnv() + ); + + expect(result.exitCode).toBe(0); + + expect(existsSync(join(tempDir, ".github", "copilot"))).toBe(true); + }); + + test("init is idempotent — second run succeeds and does not duplicate example ADR", async () => { + await runCli(["init", "--editor", "claude"], tempDir, isolatedEnv()); + + const adrsDir = join(tempDir, ".archgate", "adrs"); + const adrsBefore = readdirSync(adrsDir).filter((f) => f.endsWith(".md")); + + const result = await runCli( + ["init", "--editor", "claude"], + tempDir, + isolatedEnv() + ); + + expect(result.exitCode).toBe(0); + + const adrsAfter = readdirSync(adrsDir).filter((f) => f.endsWith(".md")); + expect(adrsAfter.length).toBe(adrsBefore.length); + }); + + test("init adds .gitignore entries containing rules.d.ts", async () => { + const result = await runCli( + ["init", "--editor", "claude"], + tempDir, + isolatedEnv() + ); + + expect(result.exitCode).toBe(0); + + const gitignorePath = join(tempDir, ".gitignore"); + expect(existsSync(gitignorePath)).toBe(true); + + const content = await Bun.file(gitignorePath).text(); + expect(content).toContain("rules.d.ts"); + }); + + test("init does not regenerate example ADR when ADRs already exist", async () => { + const adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + writeFileSync( + join(adrsDir, "CUSTOM-001-my-decision.md"), + `---\nid: CUSTOM-001\ntitle: My Decision\ndomain: general\nrules: false\n---\n\n## Context\n\nCustom decision.\n` + ); + + const result = await runCli( + ["init", "--editor", "claude"], + tempDir, + isolatedEnv() + ); + + expect(result.exitCode).toBe(0); + + const gen001Files = readdirSync(adrsDir).filter((f) => + f.startsWith("GEN-001-") + ); + expect(gen001Files.length).toBe(0); + }); + + test("init adds oxlint override when .oxlintrc.json exists", async () => { + const oxlintPath = join(tempDir, ".oxlintrc.json"); + writeFileSync(oxlintPath, "{}\n"); + + const result = await runCli( + ["init", "--editor", "claude"], + tempDir, + isolatedEnv() + ); + + expect(result.exitCode).toBe(0); + + const content = await Bun.file(oxlintPath).text(); + expect(content).toContain(".archgate/adrs/*.rules.ts"); + }); +}); diff --git a/tests/integration/review-context.test.ts b/tests/integration/review-context.test.ts new file mode 100644 index 00000000..8409ff8e --- /dev/null +++ b/tests/integration/review-context.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { git, safeRmSync } from "../test-utils"; +import { + runCli, + scaffoldProject, + writeAdr, + writeRules, + makeAdr, +} from "./cli-harness"; + +async function initGitRepo(dir: string): Promise { + await git(["init"], dir); + await git(["config", "user.email", "test@test.com"], dir); + await git(["config", "user.name", "Test"], dir); +} + +async function commitAll(dir: string, message: string): Promise { + await git(["add", "."], dir); + await git(["commit", "-m", message], dir); +} + +describe("review-context integration", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "archgate-rc-integ-")); + }); + + afterEach(() => { + safeRmSync(dir); + }); + + test("outputs JSON review context with allChangedFiles and domains", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "ARCH-001.md", + makeAdr({ + id: "ARCH-001", + title: "Architecture ADR", + domain: "architecture", + rules: false, + body: "## Decision\nUse a layered architecture.\n\n## Do's and Don'ts\nDo keep layers separate.", + }) + ); + await initGitRepo(dir); + await commitAll(dir, "initial commit"); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "index.ts"), "export const x = 1;\n"); + + const { exitCode, stdout, stderr } = await runCli(["review-context"], dir); + expect(exitCode).toBe(0); + + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`stdout is not valid JSON: ${stdout}\nstderr: ${stderr}`); + } + + const ctx = parsed as Record; + expect(Array.isArray(ctx.allChangedFiles)).toBe(true); + expect(Array.isArray(ctx.domains)).toBe(true); + }); + + test("filters output by --domain flag", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "ARCH-010.md", + makeAdr({ + id: "ARCH-010", + title: "Arch ADR", + domain: "architecture", + rules: false, + }) + ); + writeAdr( + dir, + "BE-010.md", + makeAdr({ + id: "BE-010", + title: "Backend ADR", + domain: "backend", + rules: false, + }) + ); + await initGitRepo(dir); + await commitAll(dir, "initial commit"); + + writeFileSync(join(dir, "changed.ts"), "export {};\n"); + + const { exitCode, stdout } = await runCli( + ["review-context", "--domain", "architecture"], + dir + ); + expect(exitCode).toBe(0); + + const ctx = JSON.parse(stdout) as { domains: Array<{ domain: string }> }; + const domainNames = ctx.domains.map((d) => d.domain); + expect(domainNames.every((d) => d === "architecture")).toBe(true); + expect(domainNames).not.toContain("backend"); + }); + + test("includes checkSummary with --run-checks", async () => { + scaffoldProject(dir); + writeAdr( + dir, + "GEN-001.md", + makeAdr({ + id: "GEN-001", + title: "General Rule", + domain: "general", + rules: true, + }) + ); + writeRules( + dir, + "GEN-001.rules.ts", + `export default { rules: { "always-pass": { description: "Always passes", async check() {} } } };` + ); + await initGitRepo(dir); + await commitAll(dir, "initial commit"); + + writeFileSync(join(dir, "modified.ts"), "export const y = 2;\n"); + + const { exitCode, stdout } = await runCli( + ["review-context", "--run-checks"], + dir + ); + expect(exitCode).toBe(0); + + const ctx = JSON.parse(stdout) as Record; + expect(ctx.checkSummary).not.toBeNull(); + expect(typeof ctx.checkSummary).toBe("object"); + }, 60000); + + test("exits non-zero when no .archgate project found", async () => { + const { exitCode, stderr } = await runCli(["review-context"], dir); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("error"); + }); +}); From e89fa5c0a693d0c603743123e83722fb15a31494 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 21 Mar 2026 00:01:16 +0100 Subject: [PATCH 2/2] fix: use real homedir for session-context tests os.homedir() caches its result on Linux and doesn't pick up runtime HOME env var changes. Use unique fixture dirs under the actual homedir instead of overriding HOME. --- tests/helpers/session-context-cursor.test.ts | 87 +++++++------------- tests/helpers/session-context.test.ts | 64 ++++---------- 2 files changed, 46 insertions(+), 105 deletions(-) diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts index 728aa9a9..92a1cf7a 100644 --- a/tests/helpers/session-context-cursor.test.ts +++ b/tests/helpers/session-context-cursor.test.ts @@ -1,69 +1,46 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { readCursorSession } from "../../src/helpers/session-context"; -// This file covers readCursorSession happy-path tests that require a temp home dir. +// This file covers readCursorSession happy-path tests. // Error cases for readCursorSession live in session-context.test.ts. -describe("readCursorSession (with temp home dir)", () => { - let tmpHome: string; - let originalHome: string | undefined; - let originalUserProfile: string | undefined; - // encodeProjectPath("/myproject") = "-myproject" - const projectRoot = "/myproject"; - const encodedProject = "-myproject"; +describe("readCursorSession", () => { + // Use a unique encoded project name under the *real* homedir so that + // homedir() caching on Linux doesn't break the tests. + const uniqueId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const projectRoot = `/__archgate_cursor_test_${uniqueId}`; + const encodedProject = projectRoot.replaceAll("/", "-"); + let transcriptsDir: string; beforeEach(() => { - originalHome = process.env["HOME"]; - originalUserProfile = process.env["USERPROFILE"]; - tmpHome = mkdtempSync(join(tmpdir(), "archgate-test-cursor-")); - // node:os homedir() reads USERPROFILE on Windows and HOME on Unix - process.env["HOME"] = tmpHome; - process.env["USERPROFILE"] = tmpHome; - }); - - afterEach(() => { - if (originalHome === undefined) { - delete process.env["HOME"]; - } else { - process.env["HOME"] = originalHome; - } - if (originalUserProfile === undefined) { - delete process.env["USERPROFILE"]; - } else { - process.env["USERPROFILE"] = originalUserProfile; - } - rmSync(tmpHome, { recursive: true, force: true }); - }); - - function makeTranscriptsDir(): string { - const transcriptsDir = join( - tmpHome, + transcriptsDir = join( + homedir(), ".cursor", "projects", encodedProject, "agent-transcripts" ); mkdirSync(transcriptsDir, { recursive: true }); - return transcriptsDir; - } + }); + + afterEach(() => { + // Clean up from .cursor/projects/ level + const projectDir = join(homedir(), ".cursor", "projects", encodedProject); + rmSync(projectDir, { recursive: true, force: true }); + }); - function makeSession( - transcriptsDir: string, - sessionId: string, - lines: string[] - ): void { + function makeSession(sessionId: string, lines: string[]): void { const sessionDir = join(transcriptsDir, sessionId); mkdirSync(sessionDir, { recursive: true }); writeFileSync(join(sessionDir, `${sessionId}.jsonl`), lines.join("\n")); } test("returns data for most recent session", async () => { - const transcriptsDir = makeTranscriptsDir(); - makeSession(transcriptsDir, "session-abc", [ + makeSession("session-abc", [ JSON.stringify({ role: "user", message: { role: "user", content: "hello" }, @@ -93,14 +70,13 @@ describe("readCursorSession (with temp home dir)", () => { }); test("finds session by sessionId", async () => { - const transcriptsDir = makeTranscriptsDir(); - makeSession(transcriptsDir, "session-first", [ + makeSession("session-first", [ JSON.stringify({ role: "user", message: { role: "user", content: "first session" }, }), ]); - makeSession(transcriptsDir, "session-second", [ + makeSession("session-second", [ JSON.stringify({ role: "user", message: { role: "user", content: "second session" }, @@ -118,8 +94,7 @@ describe("readCursorSession (with temp home dir)", () => { }); test("returns error when sessionId not found (with available list)", async () => { - const transcriptsDir = makeTranscriptsDir(); - makeSession(transcriptsDir, "session-real", [ + makeSession("session-real", [ JSON.stringify({ role: "user", message: { role: "user", content: "real" }, @@ -137,9 +112,7 @@ describe("readCursorSession (with temp home dir)", () => { }); test("returns error when no session directories exist", async () => { - // Create transcripts dir but leave it empty - makeTranscriptsDir(); - + // transcriptsDir exists but is empty const result = await readCursorSession(projectRoot); expect(result.ok).toBe(false); if (!result.ok) { @@ -148,7 +121,6 @@ describe("readCursorSession (with temp home dir)", () => { }); test("handles malformed JSONL", async () => { - const transcriptsDir = makeTranscriptsDir(); const sessionId = "session-bad"; const sessionDir = join(transcriptsDir, sessionId); mkdirSync(sessionDir, { recursive: true }); @@ -165,8 +137,7 @@ describe("readCursorSession (with temp home dir)", () => { }); test("filters to user/assistant roles only", async () => { - const transcriptsDir = makeTranscriptsDir(); - makeSession(transcriptsDir, "session-roles", [ + makeSession("session-roles", [ JSON.stringify({ role: "system", message: { role: "system", content: "system msg" }, @@ -196,7 +167,6 @@ describe("readCursorSession (with temp home dir)", () => { }); test("respects maxEntries — keeps last N relevant entries", async () => { - const transcriptsDir = makeTranscriptsDir(); const lines: string[] = []; for (let i = 0; i < 8; i++) { lines.push( @@ -209,7 +179,7 @@ describe("readCursorSession (with temp home dir)", () => { }) ); } - makeSession(transcriptsDir, "session-limit", lines); + makeSession("session-limit", lines); const result = await readCursorSession(projectRoot, { maxEntries: 2 }); expect(result.ok).toBe(true); @@ -223,10 +193,9 @@ describe("readCursorSession (with temp home dir)", () => { }); test("ignores non-directory entries in transcripts dir", async () => { - const transcriptsDir = makeTranscriptsDir(); // Put a plain file in the transcripts dir — it should be skipped writeFileSync(join(transcriptsDir, "stray-file.txt"), "noise"); - makeSession(transcriptsDir, "session-good", [ + makeSession("session-good", [ JSON.stringify({ role: "user", message: { role: "user", content: "works" }, diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index cd85d45f..735ea509 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { @@ -57,53 +57,32 @@ describe("readClaudeCodeSession", () => { expect(result.ok).toBe(false); }); - describe("happy path (with temp home dir)", () => { - let tmpHome: string; - let originalHome: string | undefined; - let originalUserProfile: string | undefined; - // The project path we'll use — encodeProjectPath("/myproject") = "-myproject" - const projectRoot = "/myproject"; - const encodedProject = "-myproject"; + describe("happy path", () => { + // Use a unique encoded project name under the *real* homedir so that + // homedir() caching on Linux doesn't break the tests. + const uniqueId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const projectRoot = `/__archgate_test_${uniqueId}`; + const encodedProject = projectRoot.replaceAll("/", "-"); + let projectsDir: string; beforeEach(() => { - originalHome = process.env["HOME"]; - originalUserProfile = process.env["USERPROFILE"]; - tmpHome = mkdtempSync(join(tmpdir(), "archgate-test-claude-")); - // node:os homedir() reads USERPROFILE on Windows and HOME on Unix - process.env["HOME"] = tmpHome; - process.env["USERPROFILE"] = tmpHome; + projectsDir = join(homedir(), ".claude", "projects", encodedProject); + mkdirSync(projectsDir, { recursive: true }); }); afterEach(() => { - if (originalHome === undefined) { - delete process.env["HOME"]; - } else { - process.env["HOME"] = originalHome; - } - if (originalUserProfile === undefined) { - delete process.env["USERPROFILE"]; - } else { - process.env["USERPROFILE"] = originalUserProfile; - } - rmSync(tmpHome, { recursive: true, force: true }); + rmSync(projectsDir, { recursive: true, force: true }); }); - function makeProjectDir(): string { - const projectsDir = join(tmpHome, ".claude", "projects", encodedProject); - mkdirSync(projectsDir, { recursive: true }); - return projectsDir; - } - - function writeSession(dir: string, entries: object[]): void { + function writeSession(entries: object[]): void { writeFileSync( - join(dir, "session.jsonl"), + join(projectsDir, "session.jsonl"), entries.map((e) => JSON.stringify(e)).join("\n") ); } test("returns data with correct transcript when JSONL exists", async () => { - const projectsDir = makeProjectDir(); - writeSession(projectsDir, [ + writeSession([ { type: "user", message: { role: "user", content: "hello" } }, { type: "assistant", @@ -131,8 +110,7 @@ describe("readClaudeCodeSession", () => { }); test("filters to only user/assistant types", async () => { - const projectsDir = makeProjectDir(); - writeSession(projectsDir, [ + writeSession([ { type: "system", message: { role: "system", content: "sys msg" } }, { type: "tool", message: { role: "tool", content: "tool output" } }, { type: "user", message: { role: "user", content: "only this" } }, @@ -146,8 +124,7 @@ describe("readClaudeCodeSession", () => { }); test("truncates string content preview to 500 chars", async () => { - const projectsDir = makeProjectDir(); - writeSession(projectsDir, [ + writeSession([ { type: "user", message: { role: "user", content: "x".repeat(600) } }, ]); @@ -160,8 +137,7 @@ describe("readClaudeCodeSession", () => { }); test("handles array content: text truncation, tool_use, tool_result", async () => { - const projectsDir = makeProjectDir(); - writeSession(projectsDir, [ + writeSession([ { type: "assistant", message: { @@ -199,9 +175,7 @@ describe("readClaudeCodeSession", () => { }); test("respects maxEntries — keeps last N relevant entries", async () => { - const projectsDir = makeProjectDir(); writeSession( - projectsDir, Array.from({ length: 10 }, (_, i) => ({ type: i % 2 === 0 ? "user" : "assistant", message: { @@ -222,7 +196,6 @@ describe("readClaudeCodeSession", () => { }); test("returns error when directory exists but has no .jsonl files", async () => { - const projectsDir = makeProjectDir(); writeFileSync(join(projectsDir, "notes.txt"), "not a session"); const result = await readClaudeCodeSession(projectRoot); @@ -232,7 +205,6 @@ describe("readClaudeCodeSession", () => { }); test("returns error when JSONL file is malformed", async () => { - const projectsDir = makeProjectDir(); writeFileSync( join(projectsDir, "session.jsonl"), "not valid jsonl }{garbage"