diff --git a/.archgate/adrs/ARCH-025-idiomatic-bun-test-parametrization-and-matchers.md b/.archgate/adrs/ARCH-025-idiomatic-bun-test-parametrization-and-matchers.md new file mode 100644 index 00000000..d02c9781 --- /dev/null +++ b/.archgate/adrs/ARCH-025-idiomatic-bun-test-parametrization-and-matchers.md @@ -0,0 +1,138 @@ +--- +id: ARCH-025 +title: Idiomatic bun:test Parametrization and Matchers +domain: architecture +rules: false +files: + - "tests/**/*.test.ts" +--- + +## Context + +ARCH-005 governs test isolation, lifecycle, and hygiene (temp directories, spy/env restoration, assertion presence) but does not prescribe how a test author should express "the same check against many inputs" or "a derived true/false fact." Bun's own testing guidance () is prescriptive on both points, and an audit of every file under `tests/**/*.test.ts` in this repository found the two gaps recurring widely enough to be a convention problem rather than isolated mistakes: + +1. **Manual loops standing in for `test.each()`/`describe.each()`.** Recurred across a cluster of files — for example `tests/formats/project-config-fuzz.test.ts` and `tests/engine/rule-scanner-escapes.test.ts` each looped over an array of cases and either called `test()` inside the loop body to register cases dynamically, or called `expect()` once per item inside a single test for logically independent scenarios. Bun's docs name this exact shape — "manual loops instead of `test.each()`" — as an anti-pattern. +2. **Generic boolean assertions instead of specific matchers.** Recurred even more broadly, across command, engine, and helper test suites alike — `expect(x === y).toBe(true)`, `expect(arr.some(...)).toBe(true)`, `expect(Array.isArray(x)).toBe(true)`, `expect(arr.every(...)).toBe(true)`. Bun's docs explicitly recommend matchers such as `.toHaveLength()`, `.toContain()`, and `.toBeGreaterThanOrEqual()` over wrapping a derived boolean in `.toBe(true)`/`.toBe(false)`. + +Both patterns compile, pass `oxlint`, and pass `bun test` — they are not caught by any existing automated check, which is exactly why they spread undetected across many independently-written files. They also share a common cost: a manual loop reports one pass/fail for N cases, so a regression in one case is invisible in the test summary and must be found by reading the loop body; a boolean-collapsed assertion reports `expected true, got false` with no indication of which value or array element was actually wrong. + +**Alternatives considered:** + +- **Add these as more Do's/Don'ts to ARCH-005** — Rejected: ARCH-005's own Compliance section already documents that its Do's and Don'ts section exceeds the `archgate review-context` briefing budget; appending more would push more of it out of every future briefing, including the parts already there. +- **Enforce via a new oxlint plugin immediately** — Deferred, not rejected: this repo already has a precedent (`lint/expect-expect.ts`, `lint/no-bare-env-restore.ts`) for exactly this kind of AST-detectable test-shape rule, and it is the right enforcement layer for both patterns (syntax-detectable, not behavioral). Building it is out of scope for the session that produced this ADR, which only fixed existing instances; the rule is recorded as future work in Compliance and Enforcement below. +- **Leave undocumented, rely on code review alone** — Rejected: the instances this ADR responds to were already written by reviewed, merged PRs; undocumented convention did not prevent the drift. + +## Decision + +Tests under `tests/**/*.test.ts` MUST express "the same assertion logic against multiple inputs" with `test.each()`/`describe.each()`, and MUST assert derived facts with the most specific matcher available rather than collapsing a comparison into a boolean passed to `.toBe(true)`/`.toBe(false)`. + +**Scope:** This ADR covers test-case parametrization and matcher choice only. It does not cover test isolation, lifecycle hooks, or fixture/temp-directory handling — those remain governed by ARCH-005. + +A loop is "standing in for `test.each()`" when either: + +- its body calls `test()`, `it()`, or `describe()` to register a case dynamically, or +- it calls `expect()` once per iteration inside a single test, for items that represent logically independent scenarios (each could fail or pass independently of the others). + +A loop that only builds shared setup data or fixtures for a single overall assertion is not covered by this decision. + +## Do's and Don'ts + +### Do + +- **DO** use `test.each(cases)("", (...args) => { ... })` when the same check runs against an array of independent inputs, so each case is its own named, independently-reportable test. +- **DO** use `describe.each(cases)(...)` when each case needs its own group of multiple related tests, not just one assertion. +- **DO** pass array rows (`[a, b, expected]`) for positional destructuring and object rows (`{a, b, expected}`) when the case is easier to read as named fields (format the title with `$field`). +- **DO** assert a derived comparison directly on the values being compared: `expect(actual).toBe(expected)`, `expect(actual).toEqual(expected)`. +- **DO** use the matcher that matches the shape of the check: `.toContain()`/`.toMatch()` for substrings, `.toBeInstanceOf()` for type checks (including `Array.isArray` replacements), `.toHaveLength()` for counts, `.find(...)` + `.toBeDefined()`/`.toBeUndefined()` for "does at least one item satisfy X." +- **DO** keep the loop→`test.each()` conversion 1:1 — every assertion that ran per-iteration in the original loop must still run per-case in the converted version; a conversion MUST NOT silently drop or merge assertions. + +### Don't + +- **DON'T** write `for (const case of cases) { test(...); }` or `for (const case of cases) { expect(...); }` inside a single test — use `test.each()`/`describe.each()` instead. +- **DON'T** write `expect(a === b).toBe(true)` or `expect(a === b).toBe(false)` — assert `expect(a).toBe(b)` / `expect(a).not.toBe(b)` directly. +- **DON'T** write `expect(arr.some(predicate)).toBe(true)` or `expect(arr.every(predicate)).toBe(true)` — these collapse an array-wide check into an opaque boolean that reports `false !== true` on failure with no indication of which element (or none) satisfied the predicate. Use `.find(predicate)` + `.toBeDefined()`, a per-item loop of `expect(item).toBe(...)` calls, or a matcher over the mapped values (e.g. `expect(arr.map(x => x.message)).toContain(...)`). +- **DON'T** write `expect(Array.isArray(x)).toBe(true)` — use `expect(x).toBeInstanceOf(Array)`. +- **DON'T** precompute a boolean in a local variable purely to assert it (`const equal = JSON.stringify(a) === JSON.stringify(b); expect(equal).toBe(true);`) — assert on `a` and `b` (or their comparable projections) directly with `.toEqual()`/`.toBe()` so a failure shows the actual diff. + +## Implementation Pattern + +### Good Example + +```typescript +// tests/engine/rule-scanner.test.ts +const bannedModules = ["node:fs", "fs", "node:child_process", "child_process"]; + +test.each(bannedModules)("blocks %s import", (mod) => { + const violations = scanRuleSource(`import x from "${mod}";`); + expect(violations.length).toBeGreaterThan(0); +}); + +test("reports a specific banned-global message", () => { + const violations = scanRuleSource(`fetch("https://x")`); + const match = violations.find((v) => v.message.includes('"fetch" global')); + expect(match).toBeDefined(); +}); +``` + +### Bad Example + +```typescript +// BAD: a manual loop registers one test per module — a regression in any +// single module's assertion doesn't produce a per-module reported failure +// the same way test.each() would, and adding/removing a case means editing +// loop internals rather than a data table. +for (const mod of ["node:fs", "fs", "node:child_process", "child_process"]) { + test(`blocks ${mod} import`, () => { + const violations = scanRuleSource(`import x from "${mod}";`); + expect(violations.length).toBeGreaterThan(0); + }); +} + +// BAD: collapses "does any violation mention fetch" into an opaque boolean — +// on failure this reports "expected true, got false" with no indication of +// what the violations actually were. +expect(violations.some((v) => v.message.includes('"fetch" global'))).toBe(true); +``` + +## Consequences + +### Positive + +- **Per-case failure reporting**: `test.each()` gives each input its own named test result, so a regression in one case is immediately identified by name instead of requiring a developer to read a loop body to find which iteration failed. +- **Readable failure diffs**: asserting on the actual compared values (instead of a boolean) means a failing test's output shows what was expected vs. what was received, cutting debugging time. +- **Data-as-table**: adding or removing a case becomes a one-line change to an array/object literal rather than an edit to loop logic. + +### Negative + +- **More verbose for one-off checks**: a single ad-hoc assertion inside `.some()`/`.every()` is sometimes fewer characters than the specific-matcher equivalent; this ADR accepts that verbosity in exchange for diagnosability. +- **`test.each()` output naming requires care**: a poorly chosen format string (e.g. always `%s` regardless of row shape) produces indistinguishable test names across cases, which undoes the per-case reporting benefit this decision is meant to provide. + +### Risks + +- **No automated enforcement yet**: neither pattern is currently caught by `oxlint` or `archgate check`, so new instances can still be introduced and merged undetected, the same way the instances motivating this ADR were. + - **Mitigation:** tracked as future work in Compliance and Enforcement below (a candidate `oxlint` plugin following the existing `bun-test/expect-expect` precedent); until it exists, code review is the only enforcement layer, so reviewers MUST check new/changed test files against the Do's and Don'ts above. +- **Existing violations elsewhere in the codebase may resurface**: this ADR's Context reflects a repository-wide audit at a point in time; files not covered by that audit, or added afterward, may still contain either anti-pattern. + - **Mitigation:** treat any test file touched for an unrelated change as an opportunity to fix nearby instances of either pattern, consistent with ARCH-005's existing test-hygiene expectations. + +## Compliance and Enforcement + +### Automated Enforcement + +- **Not yet implemented.** No `oxlint` rule or `archgate` rule currently detects either anti-pattern. A future `oxlint` plugin (e.g. `bun-test/no-loop-test-cases` and `bun-test/no-boolean-collapse`, registered via `jsPlugins` in `.oxlintrc.json` alongside the existing `bun-test/expect-expect` and `test-isolation/no-bare-env-restore` plugins) is the intended enforcement layer once written, matching this repo's existing pattern of using oxlint for syntax-detectable test-shape rules and reserving `archgate` rules for structural/governance checks. This ADR's `rules: false` reflects that no companion `.rules.ts` exists. + +### Manual Enforcement + +Code reviewers MUST verify, for any new or changed file under `tests/**/*.test.ts`: + +1. No `for`/`.forEach` loop registers a `test()`/`it()`/`describe()` call, and no `for`/`.forEach` loop inside a single test calls `expect()` once per logically independent case — either shape MUST be `test.each()`/`describe.each()` instead. +2. No `expect(<comparison>).toBe(true)` or `.toBe(false)` where `<comparison>` is itself a boolean expression (`===`, `.some()`, `.every()`, `Array.isArray()`, `.includes()`) — the assertion MUST target the underlying values with a specific matcher. +3. A `test.each()`/`describe.each()` conversion preserves every assertion that ran in the original loop — none dropped, none merged into a single case. + +### Exceptions + +None currently approved. A test file with a genuine reason to keep a loop (e.g. a fuzz/property check verifying one invariant across many generated inputs, where the inputs are not independently meaningful test cases) is not a violation of this ADR in the first place — see the Decision section's definition of what counts as "standing in for `test.each()`." + +## References + +- [ARCH-005 — Testing Standards](./ARCH-005-testing-standards.md) — governs test isolation, lifecycle hooks, and assertion presence; this ADR covers parametrization and matcher choice specifically and does not restate ARCH-005's conventions. +- [Bun test runner — Writing Tests](https://bun.sh/docs/test/writing-tests) — source of the `test.each()`/`describe.each()` API and the anti-pattern guidance this ADR codifies. diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 41a2d0f0..c124bf12 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -24,9 +24,11 @@ Exceptions: minor follow-up tweaks after validation already passed, and non-code - **Fire-test a guard in BOTH directions** — that it blocks the bad case AND still permits the legitimate one. A green suite proves only that the gate closes, not that it isn't over-rejecting. - **Verify a review agent's claim before acting on it.** They misquote both ADRs and the files they have just read; `grep` the exact quoted string first. A governance finding citing no ADR cannot block on governance grounds — but a demonstrated defect blocks on its own merits. - **Content filtering blocks policy/legal boilerplate** — generating a Contributor Covenant or license text can trip API filtering. Ask the user to copy it from the official source. +- **Files written under `/tmp` by this agent's own Bash/Write calls can vanish between tool calls** — observed for several scratch files with no deleting command run. Write anything that must survive several calls to a real Windows path instead (e.g. `C:/Users/<user>/AppData/Local/Temp/<task-name>/`); Bun/Node on Windows don't resolve Git-Bash-style `/c/Users/...` paths. ## Topic files +- [Verify agents on TS changes must typecheck](feedback_verify_agents_run_typecheck.md) — `bun test`+lint+format missed a `noUnusedParameters` error a subagent self-reported as clean - [Pick the right enforcement layer](feedback_prefer_tests_over_adr_rules.md) — syntax → lint rule; behaviour → test; governance → ADR rule; CLI behaviour → built-in - [Answer every review finding on its own thread](feedback_reply_on_review_threads.md) — declines especially; a summary comment does not close the loop - [Throw UserError in boundary-wrapped guards](feedback_throw_usererror_in_guards.md) — not `logError` + `exitWith(1)` diff --git a/.claude/agent-memory/archgate-developer/feedback_verify_agents_run_typecheck.md b/.claude/agent-memory/archgate-developer/feedback_verify_agents_run_typecheck.md new file mode 100644 index 00000000..a1234683 --- /dev/null +++ b/.claude/agent-memory/archgate-developer/feedback_verify_agents_run_typecheck.md @@ -0,0 +1,11 @@ +--- +name: verify-agents-run-typecheck +description: Verify/review subagents on TypeScript changes must run typecheck, not just lint+test +metadata: + type: feedback +--- + +A subagent verifying a TS change must run `bun run typecheck`, not just `bun run lint` + `bun run test` + `bun run format:check`. + +- During a 41-file `test.each`/`describe.each` conversion, 7 of 14 fix+verify agent pairs self-reported clean after lint+test but missed `TS6133: 'label' is declared but its value is never read` — a destructured `test.each` row param used only in the title's `%s` substitution, not the callback body. `noUnusedParameters: true` catches it; neither oxlint nor `bun test` does. +- Still run the full `bun run validate` yourself as the final gate regardless of what subagents reported — self-reported per-file checks miss project-wide issues a full `tsc --build` surfaces. diff --git a/tests/commands/adr/create.test.ts b/tests/commands/adr/create.test.ts index d900fcbb..2e1863b1 100644 --- a/tests/commands/adr/create.test.ts +++ b/tests/commands/adr/create.test.ts @@ -43,12 +43,14 @@ describe("registerAdrCreateCommand", () => { describe("adr create action handler", () => { let tempDir: string; + let adrsDir: string; let originalCwd: string; let logSpy: ReturnType<typeof spyOn>; let exitSpy: ReturnType<typeof spyOn>; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-create-test-")); + adrsDir = join(tempDir, ".archgate", "adrs"); originalCwd = process.cwd(); // Prevent findProjectRoot() from walking above the temp dir Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; @@ -56,6 +58,7 @@ describe("adr create action handler", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); + process.chdir(tempDir); }); afterEach(() => { @@ -72,101 +75,198 @@ describe("adr create action handler", () => { return parent; } - test("creates ADR non-interactively with --title and --domain", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); + describe("with .archgate/adrs scaffolded", () => { + beforeEach(() => { + 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 non-interactively with --title and --domain", async () => { + 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 parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Use Redis", + "--domain", + "backend", + ]); - test("creates ADR and outputs file on disk", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); + const createdFile = join(adrsDir, "BE-001-use-redis.md"); + expect(existsSync(createdFile)).toBe(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 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("outputs JSON when --json flag is passed", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); + test("creates ADR with custom body via --body option", async () => { + 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."); + }); - 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("parses comma-separated --files patterns into frontmatter", async () => { + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Scoped Rule", + "--domain", + "architecture", + "--files", + "src/**/*.ts, tests/**/*.ts", + "--body", + "## Context\nScoped to specific files.", + ]); + + const createdFile = join(adrsDir, "ARCH-001-scoped-rule.md"); + expect(existsSync(createdFile)).toBe(true); + const content = await Bun.file(createdFile).text(); + expect(content).toContain("src/**/*.ts"); + expect(content).toContain("tests/**/*.ts"); + }); - test("creates ADR with custom body via --body option", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); + test("sets rules: true in frontmatter when --rules flag is passed", async () => { + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "Enforced Rule", + "--domain", + "general", + "--rules", + "--body", + "## Context\nThis ADR has rules.", + ]); + + const createdFile = join(adrsDir, "GEN-001-enforced-rule.md"); + expect(existsSync(createdFile)).toBe(true); + const content = await Bun.file(createdFile).text(); + expect(content).toContain("rules: 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("generates companion .rules.ts file when --rules flag is passed", async () => { + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "With Rules", + "--domain", + "backend", + "--rules", + "--body", + "## Context\nHas companion rules.", + ]); + + const rulesFile = join(adrsDir, "BE-001-with-rules.rules.ts"); + expect(existsSync(rulesFile)).toBe(true); + }); + + test("does not generate .rules.ts file when --rules is omitted", async () => { + const parent = makeProgram(); + await parent.parseAsync([ + "node", + "adr", + "create", + "--title", + "No Rules", + "--domain", + "backend", + "--body", + "## Context\nNo rules needed.", + ]); + + const rulesFile = join(adrsDir, "BE-001-no-rules.rules.ts"); + expect(existsSync(rulesFile)).toBe(false); + }); + + test("increments ADR ID when existing ADRs are present", async () => { + const parent1 = makeProgram(); + await parent1.parseAsync([ + "node", + "adr", + "create", + "--title", + "First ADR", + "--domain", + "backend", + ]); + + const parent2 = makeProgram(); + await parent2.parseAsync([ + "node", + "adr", + "create", + "--title", + "Second ADR", + "--domain", + "backend", + ]); + + expect(existsSync(join(adrsDir, "BE-001-first-adr.md"))).toBe(true); + expect(existsSync(join(adrsDir, "BE-002-second-adr.md"))).toBe(true); + }); }); test("exits with error when .archgate/ directory is missing", async () => { - process.chdir(tempDir); const parent = makeProgram(); await expect( @@ -183,132 +283,4 @@ describe("adr create action handler", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); - - test("parses comma-separated --files patterns into frontmatter", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); - - process.chdir(tempDir); - const parent = makeProgram(); - await parent.parseAsync([ - "node", - "adr", - "create", - "--title", - "Scoped Rule", - "--domain", - "architecture", - "--files", - "src/**/*.ts, tests/**/*.ts", - "--body", - "## Context\nScoped to specific files.", - ]); - - const createdFile = join(adrsDir, "ARCH-001-scoped-rule.md"); - expect(existsSync(createdFile)).toBe(true); - const content = await Bun.file(createdFile).text(); - expect(content).toContain("src/**/*.ts"); - expect(content).toContain("tests/**/*.ts"); - }); - - test("sets rules: true in frontmatter when --rules 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", - "Enforced Rule", - "--domain", - "general", - "--rules", - "--body", - "## Context\nThis ADR has rules.", - ]); - - const createdFile = join(adrsDir, "GEN-001-enforced-rule.md"); - expect(existsSync(createdFile)).toBe(true); - const content = await Bun.file(createdFile).text(); - expect(content).toContain("rules: true"); - }); - - test("generates companion .rules.ts file when --rules 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", - "With Rules", - "--domain", - "backend", - "--rules", - "--body", - "## Context\nHas companion rules.", - ]); - - const rulesFile = join(adrsDir, "BE-001-with-rules.rules.ts"); - expect(existsSync(rulesFile)).toBe(true); - }); - - test("does not generate .rules.ts file when --rules is omitted", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); - - process.chdir(tempDir); - const parent = makeProgram(); - await parent.parseAsync([ - "node", - "adr", - "create", - "--title", - "No Rules", - "--domain", - "backend", - "--body", - "## Context\nNo rules needed.", - ]); - - const rulesFile = join(adrsDir, "BE-001-no-rules.rules.ts"); - expect(existsSync(rulesFile)).toBe(false); - }); - - test("increments ADR ID when existing ADRs are present", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); - - process.chdir(tempDir); - const parent1 = makeProgram(); - await parent1.parseAsync([ - "node", - "adr", - "create", - "--title", - "First ADR", - "--domain", - "backend", - ]); - - const parent2 = makeProgram(); - await parent2.parseAsync([ - "node", - "adr", - "create", - "--title", - "Second ADR", - "--domain", - "backend", - ]); - - expect(existsSync(join(adrsDir, "BE-001-first-adr.md"))).toBe(true); - expect(existsSync(join(adrsDir, "BE-002-second-adr.md"))).toBe(true); - }); }); diff --git a/tests/commands/adr/list.test.ts b/tests/commands/adr/list.test.ts index 58922c09..63a5a93a 100644 --- a/tests/commands/adr/list.test.ts +++ b/tests/commands/adr/list.test.ts @@ -78,12 +78,14 @@ describe("registerAdrListCommand", () => { describe("adr list action handler", () => { let tempDir: string; + let adrsDir: string; let originalCwd: string; let logSpy: ReturnType<typeof spyOn>; let exitSpy: ReturnType<typeof spyOn>; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-list-test-")); + adrsDir = join(tempDir, ".archgate", "adrs"); originalCwd = process.cwd(); // Prevent findProjectRoot() from walking above the temp dir Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; @@ -91,6 +93,7 @@ describe("adr list action handler", () => { exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); + process.chdir(tempDir); }); afterEach(() => { @@ -107,141 +110,128 @@ describe("adr list action handler", () => { 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"); - }); + describe("with .archgate/adrs scaffolded", () => { + beforeEach(() => { + mkdirSync(adrsDir, { recursive: true }); + }); - test("JSON output carries identity fields only, omitting files globs", async () => { - const adrsDir = join(tempDir, ".archgate", "adrs"); - mkdirSync(adrsDir, { recursive: true }); - writeFileSync( - join(adrsDir, "BE-001-api-response-envelope.md"), - ADR_CONTENT_WITH_FILES - ); + test("lists ADRs from .archgate/adrs/ directory in table format", async () => { + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); + writeFileSync( + join(adrsDir, "GEN-001-use-conventional-commits.md"), + ADR_CONTENT_2 + ); + + 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"); + }); - 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); - - // `files`/`respectGitignore` stay out of the listing so large ADR sets - // remain small enough for agents to read inline — `adr show` has the rest. - expect(Object.keys(parsed[0]).toSorted()).toEqual([ - "domain", - "id", - "rules", - "title", - ]); - expect(allOutput).not.toContain("src/api/**"); - }); + test("outputs JSON when --json flag is passed", async () => { + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); - 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 - ); + const parent = makeProgram(); + await parent.parseAsync(["node", "adr", "list", "--json"]); - 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"); - }); + const allOutput = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join("\n"); + const parsed = JSON.parse(allOutput); + expect(parsed).toBeInstanceOf(Array); + expect(parsed[0].id).toBe("ARCH-001"); + expect(parsed[0].domain).toBe("architecture"); + }); - 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 - ); + test("JSON output carries identity fields only, omitting files globs", async () => { + writeFileSync( + join(adrsDir, "BE-001-api-response-envelope.md"), + ADR_CONTENT_WITH_FILES + ); + + 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); + + // `files`/`respectGitignore` stay out of the listing so large ADR sets + // remain small enough for agents to read inline — `adr show` has the rest. + expect(Object.keys(parsed[0]).toSorted()).toEqual([ + "domain", + "id", + "rules", + "title", + ]); + expect(allOutput).not.toContain("src/api/**"); + }); - 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("filters by domain with --domain flag", async () => { + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); + writeFileSync( + join(adrsDir, "GEN-001-use-conventional-commits.md"), + ADR_CONTENT_2 + ); + + 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("prints 'No ADRs found.' when adrs directory is empty", async () => { - mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); + test("combines --domain and --json filters", async () => { + writeFileSync(join(adrsDir, "ARCH-001-use-typescript.md"), ADR_CONTENT_1); + writeFileSync( + join(adrsDir, "GEN-001-use-conventional-commits.md"), + ADR_CONTENT_2 + ); + + 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"); + }); - process.chdir(tempDir); - const parent = makeProgram(); - await parent.parseAsync(["node", "adr", "list"]); + test("prints 'No ADRs found.' when adrs directory is empty", async () => { + 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."); + 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( diff --git a/tests/commands/review-context.test.ts b/tests/commands/review-context.test.ts index 08e3d48f..b81dab28 100644 --- a/tests/commands/review-context.test.ts +++ b/tests/commands/review-context.test.ts @@ -158,7 +158,7 @@ Test decision. .join(""); const parsed = JSON.parse(output); // With no git changes, domains should still be populated but with no changed files - expect(Array.isArray(parsed.domains)).toBe(true); + expect(parsed.domains).toBeInstanceOf(Array); expect(parsed.allChangedFiles).toEqual([]); }); diff --git a/tests/commands/session-context.test.ts b/tests/commands/session-context.test.ts index 55857ee7..c41b35e5 100644 --- a/tests/commands/session-context.test.ts +++ b/tests/commands/session-context.test.ts @@ -103,7 +103,7 @@ describe("registerSessionContextCommand", () => { expect(opts).not.toContain("--skip"); }); - test("each editor subcommand has list and show children", () => { + test("session-context has exactly the four editor subcommands", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -116,29 +116,41 @@ describe("registerSessionContextCommand", () => { "cursor", "opencode", ]); - for (const editor of ["claude-code", "copilot", "cursor", "opencode"]) { + }); + + test.each(["claude-code", "copilot", "cursor", "opencode"])( + "%s subcommand has list and show children", + (editor) => { + const program = new Command(); + registerSessionContextCommand(program); + const parent = program.commands.find( + (c) => c.name() === "session-context" + )!; const sub = parent.commands.find((c) => c.name() === editor)!; const children = sub.commands.map((c) => c.name()).sort(); expect(children).toEqual(["list", "show"]); } - }); + ); - test("only opencode show has --root", () => { + test.each([ + ["claude-code", false], + ["copilot", false], + ["cursor", false], + ["opencode", true], + ] as const)("%s show has --root: %p", (editor, hasRoot) => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( (c) => c.name() === "session-context" )!; - for (const editor of ["claude-code", "copilot", "cursor", "opencode"]) { - const sub = parent.commands.find((c) => c.name() === editor)!; - const show = sub.commands.find((c) => c.name() === "show")!; - const opts = show.options.map((o) => o.long); - expect(opts).toContain("--max-entries"); - if (editor === "opencode") { - expect(opts).toContain("--root"); - } else { - expect(opts).not.toContain("--root"); - } + const sub = parent.commands.find((c) => c.name() === editor)!; + const show = sub.commands.find((c) => c.name() === "show")!; + const opts = show.options.map((o) => o.long); + expect(opts).toContain("--max-entries"); + if (hasRoot) { + expect(opts).toContain("--root"); + } else { + expect(opts).not.toContain("--root"); } }); diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index 5a45ec9b..e895b992 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -50,6 +50,7 @@ describe("claude-code action handler", () => { let errorSpy: ReturnType<typeof spyOn>; let exitSpy: ReturnType<typeof spyOn>; let readSpy: ReturnType<typeof spyOn>; + let listSpy: ReturnType<typeof spyOn>; /** Minimal complete summary for the default happy-path spy. */ function emptySummary() { @@ -73,6 +74,7 @@ describe("claude-code action handler", () => { readSpy = spyOn(sessionContextHelpers, "readClaudeCodeSession"); readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -85,6 +87,7 @@ describe("claude-code action handler", () => { delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); readSpy.mockRestore(); + listSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -162,52 +165,42 @@ describe("claude-code action handler", () => { }); test("list subcommand prints sessions", async () => { - const listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); - try { - listSpy.mockResolvedValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ + listSpy.mockResolvedValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + }); + + test("list subcommand exits 1 on error result", async () => { + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync([ "node", "session-context", "claude-code", "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls - .map((c: unknown[]) => String(c[0])) - .join(""); - expect(JSON.parse(output).sessions[0].id).toBe("abc"); - } finally { - listSpy.mockRestore(); - } - }); + ]) + ).rejects.toThrow("process.exit"); - test("list subcommand exits 1 on error result", async () => { - const listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); - try { - listSpy.mockResolvedValue({ ok: false, error: "store missing" }); - - await expect( - makeProgram().parseAsync([ - "node", - "session-context", - "claude-code", - "list", - ]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls - .map((c: unknown[]) => c.join(" ")) - .join(" "); - expect(errorOutput).toContain("store missing"); - } finally { - listSpy.mockRestore(); - } + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); }); test("show subcommand reads the given session id", async () => { diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index 5790953b..e6e26a2c 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -49,6 +49,7 @@ describe("copilot action handler", () => { let errorSpy: ReturnType<typeof spyOn>; let exitSpy: ReturnType<typeof spyOn>; let readSpy: ReturnType<typeof spyOn>; + let listSpy: ReturnType<typeof spyOn>; /** Minimal complete summary for the default happy-path spy. */ function emptySummary() { @@ -74,6 +75,7 @@ describe("copilot action handler", () => { readSpy = spyOn(copilotHelpers, "readCopilotSession"); readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + listSpy = spyOn(copilotHelpers, "listCopilotSessions"); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -86,6 +88,7 @@ describe("copilot action handler", () => { delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); readSpy.mockRestore(); + listSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -162,47 +165,37 @@ describe("copilot action handler", () => { }); test("list subcommand prints sessions", async () => { - const listSpy = spyOn(copilotHelpers, "listCopilotSessions"); - try { - listSpy.mockResolvedValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "copilot", - "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls - .map((c: unknown[]) => String(c[0])) - .join(""); - expect(JSON.parse(output).sessions[0].id).toBe("abc"); - } finally { - listSpy.mockRestore(); - } + listSpy.mockResolvedValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); }); test("list subcommand exits 1 on error result", async () => { - const listSpy = spyOn(copilotHelpers, "listCopilotSessions"); - try { - listSpy.mockResolvedValue({ ok: false, error: "store missing" }); - - await expect( - makeProgram().parseAsync(["node", "session-context", "copilot", "list"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls - .map((c: unknown[]) => c.join(" ")) - .join(" "); - expect(errorOutput).toContain("store missing"); - } finally { - listSpy.mockRestore(); - } + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync(["node", "session-context", "copilot", "list"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); }); test("show subcommand reads the given session id", async () => { diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 4ecb56c6..2f433a0e 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -49,6 +49,7 @@ describe("cursor action handler", () => { let errorSpy: ReturnType<typeof spyOn>; let exitSpy: ReturnType<typeof spyOn>; let readSpy: ReturnType<typeof spyOn>; + let listSpy: ReturnType<typeof spyOn>; /** Minimal complete summary for the default happy-path spy. */ function emptySummary() { @@ -74,6 +75,7 @@ describe("cursor action handler", () => { readSpy = spyOn(sessionContextHelpers, "readCursorSession"); readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -86,6 +88,7 @@ describe("cursor action handler", () => { delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); readSpy.mockRestore(); + listSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -162,47 +165,37 @@ describe("cursor action handler", () => { }); test("list subcommand prints sessions", async () => { - const listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); - try { - listSpy.mockResolvedValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "cursor", - "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls - .map((c: unknown[]) => String(c[0])) - .join(""); - expect(JSON.parse(output).sessions[0].id).toBe("abc"); - } finally { - listSpy.mockRestore(); - } + listSpy.mockResolvedValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); }); test("list subcommand exits 1 on error result", async () => { - const listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); - try { - listSpy.mockResolvedValue({ ok: false, error: "store missing" }); - - await expect( - makeProgram().parseAsync(["node", "session-context", "cursor", "list"]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls - .map((c: unknown[]) => c.join(" ")) - .join(" "); - expect(errorOutput).toContain("store missing"); - } finally { - listSpy.mockRestore(); - } + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync(["node", "session-context", "cursor", "list"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); }); test("show subcommand reads the given session id", async () => { diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index d688178b..e63120cc 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -54,6 +54,7 @@ describe("opencode action handler", () => { let errorSpy: ReturnType<typeof spyOn>; let exitSpy: ReturnType<typeof spyOn>; let readSpy: ReturnType<typeof spyOn>; + let listSpy: ReturnType<typeof spyOn>; /** Minimal complete summary for the default happy-path spy. */ function emptySummary() { @@ -78,6 +79,7 @@ describe("opencode action handler", () => { readSpy = spyOn(opencodeHelpers, "readOpencodeSession"); readSpy.mockReturnValue({ ok: true, data: emptySummary() }); + listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -90,6 +92,7 @@ describe("opencode action handler", () => { delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); readSpy.mockRestore(); + listSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -170,52 +173,37 @@ describe("opencode action handler", () => { }); test("list subcommand prints sessions", async () => { - const listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); - try { - listSpy.mockReturnValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "list", - ]); - - expect(listSpy).toHaveBeenCalledWith(tempDir); - const output = logSpy.mock.calls - .map((c: unknown[]) => String(c[0])) - .join(""); - expect(JSON.parse(output).sessions[0].id).toBe("abc"); - } finally { - listSpy.mockRestore(); - } + listSpy.mockReturnValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); }); test("list subcommand exits 1 on error result", async () => { - const listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); - try { - listSpy.mockReturnValue({ ok: false, error: "store missing" }); - - await expect( - makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "list", - ]) - ).rejects.toThrow("process.exit"); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errorOutput = errorSpy.mock.calls - .map((c: unknown[]) => c.join(" ")) - .join(" "); - expect(errorOutput).toContain("store missing"); - } finally { - listSpy.mockRestore(); - } + listSpy.mockReturnValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync(["node", "session-context", "opencode", "list"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); }); test("show subcommand reads the given session id", async () => { diff --git a/tests/commands/upgrade.test.ts b/tests/commands/upgrade.test.ts index 4fcb73e7..d60ada42 100644 --- a/tests/commands/upgrade.test.ts +++ b/tests/commands/upgrade.test.ts @@ -200,16 +200,18 @@ describe("install method detection", () => { // --------------------------------------------------------------------------- describe("_formatBytes", () => { - test("formats bytes, KB, and MB ranges", () => { - expect(_formatBytes(0)).toBe("0 B"); - expect(_formatBytes(512)).toBe("512 B"); - expect(_formatBytes(1023)).toBe("1023 B"); - expect(_formatBytes(1024)).toBe("1.0 KB"); - expect(_formatBytes(1536)).toBe("1.5 KB"); - expect(_formatBytes(1024 * 100)).toBe("100.0 KB"); - expect(_formatBytes(1024 * 1024)).toBe("1.0 MB"); - expect(_formatBytes(1024 * 1024 * 5.5)).toBe("5.5 MB"); - expect(_formatBytes(1024 * 1024 * 100)).toBe("100.0 MB"); + test.each([ + [0, "0 B"], + [512, "512 B"], + [1023, "1023 B"], + [1024, "1.0 KB"], + [1536, "1.5 KB"], + [1024 * 100, "100.0 KB"], + [1024 * 1024, "1.0 MB"], + [1024 * 1024 * 5.5, "5.5 MB"], + [1024 * 1024 * 100, "100.0 MB"], + ])("formats %p bytes as %p", (input, expected) => { + expect(_formatBytes(input)).toBe(expected); }); }); @@ -274,11 +276,10 @@ describe("upgrade action handler", () => { // package.json version is 0.36.3; returning same version = up-to-date mockGitHubRelease("v0.36.3"); const program = makeProgram(); - try { - await program.parseAsync(["node", "test", "upgrade"]); - } catch { - // exitWith(0) → process.exit(0) → throws "process.exit" - } + // exitWith(0) → process.exit(0) → throws "process.exit" + await expect( + program.parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); const out = logSpy.mock.calls .map((c: unknown[]) => String(c[0])) .join("\n"); @@ -288,22 +289,20 @@ describe("upgrade action handler", () => { test("prints error and exits 1 when version fetch fails", async () => { mockGitHubRelease(null); const program = makeProgram(); - try { - await program.parseAsync(["node", "test", "upgrade"]); - } catch { - // exitWith(1) → process.exit(1) → throws "process.exit" - } + // exitWith(1) → process.exit(1) → throws "process.exit" + await expect( + program.parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); expect(exitSpy).toHaveBeenCalledWith(1); }); test("treats older remote version as up-to-date", async () => { mockGitHubRelease("v0.1.0"); const program = makeProgram(); - try { - await program.parseAsync(["node", "test", "upgrade"]); - } catch { - // exitWith(0) → process.exit(0) → throws "process.exit" - } + // exitWith(0) → process.exit(0) → throws "process.exit" + await expect( + program.parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); expect(exitSpy).toHaveBeenCalledWith(0); const out = logSpy.mock.calls .map((c: unknown[]) => String(c[0])) @@ -315,11 +314,10 @@ describe("upgrade action handler", () => { globalThis.fetch = (() => Promise.reject(new Error("network error"))) as unknown as typeof fetch; const program = makeProgram(); - try { - await program.parseAsync(["node", "test", "upgrade"]); - } catch { - // exitWith(2) → process.exit(2) → throws - } + // exitWith(2) → process.exit(2) → throws "process.exit" + await expect( + program.parseAsync(["node", "test", "upgrade"]) + ).rejects.toThrow("process.exit"); expect(exitSpy).toHaveBeenCalledWith(2); }); }); diff --git a/tests/engine/check-case.test.ts b/tests/engine/check-case.test.ts index 136bc577..892ff54e 100644 --- a/tests/engine/check-case.test.ts +++ b/tests/engine/check-case.test.ts @@ -51,28 +51,27 @@ describe("checkCase", () => { }, }; - for (const [scheme, { pass, fail }] of Object.entries(cases) as [ - CaseScheme, - { pass: string[]; fail: string[] }, - ][]) { - test(`${scheme}: accepts conforming strings`, () => { - for (const value of pass) { - expect( - checkCase(value, scheme), - `"${value}" should match ${scheme}` - ).toBe(true); - } - }); + const passCases = ( + Object.entries(cases) as [CaseScheme, { pass: string[]; fail: string[] }][] + ).flatMap(([scheme, { pass }]) => pass.map((value) => ({ scheme, value }))); - test(`${scheme}: rejects non-conforming strings`, () => { - for (const value of fail) { - expect( - checkCase(value, scheme), - `"${value}" should NOT match ${scheme}` - ).toBe(false); - } - }); - } + const failCases = ( + Object.entries(cases) as [CaseScheme, { pass: string[]; fail: string[] }][] + ).flatMap(([scheme, { fail }]) => fail.map((value) => ({ scheme, value }))); + + test.each(passCases)( + "$scheme accepts conforming string $value", + ({ scheme, value }) => { + expect(checkCase(value, scheme)).toBe(true); + } + ); + + test.each(failCases)( + "$scheme rejects non-conforming string $value", + ({ scheme, value }) => { + expect(checkCase(value, scheme)).toBe(false); + } + ); test("empty string matches no scheme", () => { for (const scheme of Object.keys(cases) as CaseScheme[]) { @@ -91,21 +90,18 @@ describe("checkCase", () => { ); }); - test("inherited property names are unknown schemes, not TypeErrors", () => { - // A truthiness guard would let these through: they resolve to functions on - // Object.prototype, then blow up on `.test()` instead of reporting the - // documented unknown-scheme error. - for (const inherited of [ - "constructor", - "toString", - "hasOwnProperty", - "valueOf", - "__proto__", - ]) { - expect( - () => checkCase("value", inherited as CaseScheme), - `"${inherited}" should report an unknown scheme` - ).toThrow(new RegExp(`Unknown case scheme "${inherited}"`, "u")); - } + // A truthiness guard would let these through: they resolve to functions on + // Object.prototype, then blow up on `.test()` instead of reporting the + // documented unknown-scheme error. + test.each([ + "constructor", + "toString", + "hasOwnProperty", + "valueOf", + "__proto__", + ])("%s is reported as an unknown scheme", (inherited) => { + expect(() => checkCase("value", inherited as CaseScheme)).toThrow( + new RegExp(`Unknown case scheme "${inherited}"`, "u") + ); }); }); diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index 0ecbd6c6..ba9de120 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -88,27 +88,24 @@ describe("git-files", () => { expect(ref).toBeNull(); }); - test("detects local main branch", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await git(["add", "file.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - const ref = await detectBaseRef(tempDir); - expect(ref).toBe("main"); - }); + describe.each(["main", "master"] as const)( + "with a local %s branch", + (branch) => { + beforeEach(async () => { + await git(["init", `--initial-branch=${branch}`], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + }); - test("detects local master branch", async () => { - await git(["init", "--initial-branch=master"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await git(["add", "file.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - const ref = await detectBaseRef(tempDir); - expect(ref).toBe("master"); - }); + test("detects the branch", async () => { + const ref = await detectBaseRef(tempDir); + expect(ref).toBe(branch); + }); + } + ); }); describe("resolveBaseRef", () => { @@ -141,57 +138,47 @@ describe("git-files", () => { expect(ref).toBeUndefined(); }); - test("falls back to detectBaseRef and returns detected branch", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await git(["add", "file.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - - const ref = await resolveBaseRef(tempDir, {}); - expect(ref).toBe("main"); - }, 15_000); - - test("lazy-saves detected base branch to config.json", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await git(["add", "file.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - - // No configBase — triggers detectBaseRef + lazy-save - await resolveBaseRef(tempDir, {}); - - const configPath = join(tempDir, ".archgate", "config.json"); - expect(existsSync(configPath)).toBe(true); - const config = JSON.parse(await Bun.file(configPath).text()); - expect(config.baseBranch).toBe("main"); - }, 15_000); - - test("does not overwrite existing baseBranch on lazy-save", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await git(["add", "file.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); + describe("with a base commit on main", () => { + beforeEach(async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + }); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - await Bun.write( - join(tempDir, ".archgate", "config.json"), - JSON.stringify({ baseBranch: "develop" }, null, 2) + "\n" - ); + test("falls back to detectBaseRef and returns detected branch", async () => { + const ref = await resolveBaseRef(tempDir, {}); + expect(ref).toBe("main"); + }, 15_000); + + test("lazy-saves detected base branch to config.json", async () => { + // No configBase — triggers detectBaseRef + lazy-save + await resolveBaseRef(tempDir, {}); + + const configPath = join(tempDir, ".archgate", "config.json"); + expect(existsSync(configPath)).toBe(true); + const config = JSON.parse(await Bun.file(configPath).text()); + expect(config.baseBranch).toBe("main"); + }, 15_000); + + test("does not overwrite existing baseBranch on lazy-save", async () => { + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + await Bun.write( + join(tempDir, ".archgate", "config.json"), + JSON.stringify({ baseBranch: "develop" }, null, 2) + "\n" + ); - // configBase is null (simulating caller didn't find it) but config has baseBranch - await resolveBaseRef(tempDir, {}); + // configBase is null (simulating caller didn't find it) but config has baseBranch + await resolveBaseRef(tempDir, {}); - const config = JSON.parse( - await Bun.file(join(tempDir, ".archgate", "config.json")).text() - ); - expect(config.baseBranch).toBe("develop"); - }, 15_000); + const config = JSON.parse( + await Bun.file(join(tempDir, ".archgate", "config.json")).text() + ); + expect(config.baseBranch).toBe("develop"); + }, 15_000); + }); }); describe("getFilesChangedSinceRef", () => { @@ -200,102 +187,81 @@ describe("git-files", () => { expect(files).toEqual([]); }); - test("returns files changed on a feature branch", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); - await git(["add", "base.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - await git(["checkout", "-b", "feature"], tempDir); - writeFileSync(join(tempDir, "new-file.ts"), "export const y = 2;"); - await git(["add", "new-file.ts"], tempDir); - await git(["commit", "-m", "add new file"], tempDir); - const files = await getFilesChangedSinceRef(tempDir, "main"); - expect(files).toContain("new-file.ts"); - expect(files).not.toContain("base.ts"); - }, 15_000); - - test("returns empty when on the base branch with no new commits", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); - await git(["add", "base.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - const files = await getFilesChangedSinceRef(tempDir, "main"); - expect(files).toEqual([]); - }); - - // Regression: archgate/cli#403 — base...HEAD only sees committed - // changes, so uncommitted working-tree edits must be unioned in - // whenever a base ref is detected (i.e., almost always). - test("includes uncommitted edits to tracked files (regression archgate/cli#403)", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); - await git(["add", "base.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - await git(["checkout", "-b", "feature"], tempDir); - writeFileSync(join(tempDir, "committed.ts"), "export const y = 2;"); - await git(["add", "committed.ts"], tempDir); - await git(["commit", "-m", "add committed file"], tempDir); - // Unstaged edit to a tracked file — not committed, not staged - writeFileSync(join(tempDir, "base.ts"), "export const x = 99;"); - const files = await getFilesChangedSinceRef(tempDir, "main"); - expect(files).toContain("committed.ts"); - expect(files).toContain("base.ts"); - }, 15_000); - - test("includes staged-but-uncommitted files", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); - await git(["add", "base.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - await git(["checkout", "-b", "feature"], tempDir); - writeFileSync(join(tempDir, "staged.ts"), "export const s = 1;"); - await git(["add", "staged.ts"], tempDir); - const files = await getFilesChangedSinceRef(tempDir, "main"); - expect(files).toContain("staged.ts"); - }, 15_000); + describe("with a base commit on main", () => { + beforeEach(async () => { + await git(["init", "--initial-branch=main"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); + writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); + await git(["add", "base.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); + }); - test("includes untracked files but not gitignored ones", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); - writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); - await git(["add", "."], tempDir); - await git(["commit", "-m", "init"], tempDir); - await git(["checkout", "-b", "feature"], tempDir); - // Untracked new file — never staged - writeFileSync(join(tempDir, "untracked.ts"), "export const u = 1;"); - // Gitignored file — must stay excluded - mkdirSync(join(tempDir, "dist"), { recursive: true }); - writeFileSync(join(tempDir, "dist", "out.js"), "var u = 1;"); - const files = await getFilesChangedSinceRef(tempDir, "main"); - expect(files).toContain("untracked.ts"); - expect(files).not.toContain("dist/out.js"); - }, 15_000); + test("returns files changed on a feature branch", async () => { + await git(["checkout", "-b", "feature"], tempDir); + writeFileSync(join(tempDir, "new-file.ts"), "export const y = 2;"); + await git(["add", "new-file.ts"], tempDir); + await git(["commit", "-m", "add new file"], tempDir); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toContain("new-file.ts"); + expect(files).not.toContain("base.ts"); + }, 15_000); + + test("returns empty when on the base branch with no new commits", async () => { + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toEqual([]); + }); - test("returns multiple changed files sorted", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "base.ts"), "export const x = 1;"); - await git(["add", "base.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); - await git(["checkout", "-b", "feature"], tempDir); - writeFileSync(join(tempDir, "z-file.ts"), "export const z = 3;"); - writeFileSync(join(tempDir, "a-file.ts"), "export const a = 1;"); - await git(["add", "."], tempDir); - await git(["commit", "-m", "add files"], tempDir); - const files = await getFilesChangedSinceRef(tempDir, "main"); - expect(files).toEqual(["a-file.ts", "z-file.ts"]); - }, 15_000); + // Regression: archgate/cli#403 — base...HEAD only sees committed + // changes, so uncommitted working-tree edits must be unioned in + // whenever a base ref is detected (i.e., almost always). + test("includes uncommitted edits to tracked files (regression archgate/cli#403)", async () => { + await git(["checkout", "-b", "feature"], tempDir); + writeFileSync(join(tempDir, "committed.ts"), "export const y = 2;"); + await git(["add", "committed.ts"], tempDir); + await git(["commit", "-m", "add committed file"], tempDir); + // Unstaged edit to a tracked file — not committed, not staged + writeFileSync(join(tempDir, "base.ts"), "export const x = 99;"); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toContain("committed.ts"); + expect(files).toContain("base.ts"); + }, 15_000); + + test("includes staged-but-uncommitted files", async () => { + await git(["checkout", "-b", "feature"], tempDir); + writeFileSync(join(tempDir, "staged.ts"), "export const s = 1;"); + await git(["add", "staged.ts"], tempDir); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toContain("staged.ts"); + }, 15_000); + + test("includes untracked files but not gitignored ones", async () => { + // Committed separately from the base.ts hook commit — .gitignore + // just needs to exist on main before the feature branch is cut. + writeFileSync(join(tempDir, ".gitignore"), "dist/\n"); + await git(["add", ".gitignore"], tempDir); + await git(["commit", "-m", "add gitignore"], tempDir); + await git(["checkout", "-b", "feature"], tempDir); + // Untracked new file — never staged + writeFileSync(join(tempDir, "untracked.ts"), "export const u = 1;"); + // Gitignored file — must stay excluded + mkdirSync(join(tempDir, "dist"), { recursive: true }); + writeFileSync(join(tempDir, "dist", "out.js"), "var u = 1;"); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toContain("untracked.ts"); + expect(files).not.toContain("dist/out.js"); + }, 15_000); + + test("returns multiple changed files sorted", async () => { + await git(["checkout", "-b", "feature"], tempDir); + writeFileSync(join(tempDir, "z-file.ts"), "export const z = 3;"); + writeFileSync(join(tempDir, "a-file.ts"), "export const a = 1;"); + await git(["add", "."], tempDir); + await git(["commit", "-m", "add files"], tempDir); + const files = await getFilesChangedSinceRef(tempDir, "main"); + expect(files).toEqual(["a-file.ts", "z-file.ts"]); + }, 15_000); + }); }); describe("resolveScopedFiles", () => { diff --git a/tests/engine/reporter.test.ts b/tests/engine/reporter.test.ts index ff898007..714e8aeb 100644 --- a/tests/engine/reporter.test.ts +++ b/tests/engine/reporter.test.ts @@ -196,9 +196,10 @@ describe("reporter", () => { ], }) ); - expect(logs.some((l) => l.includes("::error"))).toBe(true); - expect(logs.some((l) => l.includes("file=src/foo.ts"))).toBe(true); - expect(logs.some((l) => l.includes("line=10"))).toBe(true); + const output = logs.join("\n"); + expect(output).toContain("::error"); + expect(output).toContain("file=src/foo.ts"); + expect(output).toContain("line=10"); }); test("outputs warning annotations", () => { @@ -214,7 +215,7 @@ describe("reporter", () => { ], }) ); - expect(logs.some((l) => l.includes("::warning"))).toBe(true); + expect(logs.join("\n")).toContain("::warning"); }); test("outputs notice for info severity", () => { @@ -225,14 +226,14 @@ describe("reporter", () => { ], }) ); - expect(logs.some((l) => l.includes("::notice"))).toBe(true); + expect(logs.join("\n")).toContain("::notice"); }); }); describe("reportConsole", () => { test("outputs passing summary", () => { reportConsole(makeResult(), false); - expect(logs.some((l) => l.includes("passed"))).toBe(true); + expect(logs.join("\n")).toContain("passed"); }); test("outputs failing violations", () => { @@ -249,7 +250,7 @@ describe("reporter", () => { }), false ); - expect(logs.some((l) => l.includes("bad thing"))).toBe(true); + expect(logs.join("\n")).toContain("bad thing"); }); }); diff --git a/tests/engine/rule-scanner-escapes.test.ts b/tests/engine/rule-scanner-escapes.test.ts index 81253a7e..57220ba7 100644 --- a/tests/engine/rule-scanner-escapes.test.ts +++ b/tests/engine/rule-scanner-escapes.test.ts @@ -23,19 +23,15 @@ describe("rule sandbox escapes", () => { // `await import("node:child_process")` executes at import time and `check` // still reports the ADR as passing. describe("dynamic import with a literal specifier", () => { - for (const mod of [ - "node:child_process", - "child_process", - "node:fs", - "bun", - ]) { - test(`blocks dynamic import of ${mod}`, () => { + test.each(["node:child_process", "child_process", "node:fs", "bun"])( + "blocks dynamic import of %s", + (mod) => { const violations = scanRuleSource(`const m = await import("${mod}");`); expect(violations).toHaveLength(1); expect(violations[0].message).toContain(`"${mod}"`); expect(violations[0].message).toContain("blocked"); - }); - } + } + ); test("reports the line of a blocked dynamic import", () => { const violations = scanRuleSource( @@ -67,11 +63,9 @@ describe("rule sandbox escapes", () => { ["re-export named", `export { spawn } from "node:child_process";`], ]; - for (const [label, source] of escapes) { - test(`blocks ${label}`, () => { - expect(scanRuleSource(source).length).toBeGreaterThan(0); - }); - } + test.each(escapes)("blocks %s", (_label, source) => { + expect(scanRuleSource(source).length).toBeGreaterThan(0); + }); // `path` resolves to a node_modules package if the target project ships // one, handing execution back to the untrusted code this scanner contains; @@ -190,11 +184,9 @@ p["binding"]("spawn_sync");`, ], ]; - for (const [label, source] of spellings) { - test(`blocks ${label}`, () => { - expect(scanRuleSource(source).length).toBeGreaterThan(0); - }); - } + test.each(spellings)("blocks %s", (_label, source) => { + expect(scanRuleSource(source).length).toBeGreaterThan(0); + }); test("still allows ordinary computed access on a plain object", () => { expect( @@ -287,11 +279,9 @@ const b = 2${RLO};`); ], ]; - for (const [label, source] of obfuscated) { - test(`blocks specifier hidden by ${label}`, () => { - expect(scanRuleSource(source).length).toBeGreaterThan(0); - }); - } + test.each(obfuscated)("blocks specifier hidden by %s", (_label, source) => { + expect(scanRuleSource(source).length).toBeGreaterThan(0); + }); test("escaped identifiers resolve too", () => { expect(scanRuleSource(IDENT).length).toBeGreaterThan(0); @@ -319,12 +309,10 @@ const b = 2${RLO};`); ["Reflect.get(process, ...)", `Reflect.get(process, "binding")("x");`], ]; - for (const [label, source] of reachSpawn) { - test(`blocks ${label}`, () => { - const violations = scanRuleSource(source); - expect(violations.length).toBeGreaterThan(0); - }); - } + test.each(reachSpawn)("blocks %s", (_label, source) => { + const violations = scanRuleSource(source); + expect(violations.length).toBeGreaterThan(0); + }); // Every eval-equivalent identifier is banned, and — crucially — so is // aliasing it, which a callee-name check alone would miss. @@ -340,11 +328,9 @@ const b = 2${RLO};`); ["EventSource", `new EventSource("http://x");`], ]; - for (const [label, source] of codegen) { - test(`blocks ${label}`, () => { - expect(scanRuleSource(source).length).toBeGreaterThan(0); - }); - } + test.each(codegen)("blocks %s", (_label, source) => { + expect(scanRuleSource(source).length).toBeGreaterThan(0); + }); // `.constructor` reaches the Function constructor (= eval) from any object, // which would otherwise bypass every check including the module allowlist. @@ -374,13 +360,14 @@ const b = 2${RLO};`); ["shorthand key", `const { constructor } = (() => {});`], ]; - for (const [label, source] of destructured) { - test(`blocks .constructor destructured via ${label}`, () => { + test.each(destructured)( + "blocks .constructor destructured via %s", + (_label, source) => { const violations = scanRuleSource(source); expect(violations.length).toBeGreaterThan(0); expect(violations[0].message).toContain("constructor"); - }); - } + } + ); // The computed-*variable* key is the same static-analysis residual as the // member form above: `{ [c]: F }` with `c` bound at runtime is unknowable @@ -449,11 +436,9 @@ const b = 2${RLO};`); ], ]; - for (const [label, source] of escapes) { - test(`scans a ${label}`, () => { - expect(scanRuleSource(source).length).toBeGreaterThan(0); - }); - } + test.each(escapes)("scans a %s", (_label, source) => { + expect(scanRuleSource(source).length).toBeGreaterThan(0); + }); // Positive controls: the literals themselves are perfectly legal in a rule // file — the scanner keeps the node in the walk without flagging it. diff --git a/tests/engine/rule-scanner-positions.test.ts b/tests/engine/rule-scanner-positions.test.ts index 7af5c95b..20bc99e9 100644 --- a/tests/engine/rule-scanner-positions.test.ts +++ b/tests/engine/rule-scanner-positions.test.ts @@ -293,8 +293,8 @@ describe("scanRuleSource position remapping", () => { expect(violations).toHaveLength(3); // Each names the `Bun` global; the Nth code occurrence maps to the Nth line. expect(violations.map((v) => v.line)).toEqual([1, 2, 3]); - expect(violations.every((v) => v.message.includes(`"Bun" global`))).toBe( - true + expect(violations.map((v) => v.message)).toEqual( + violations.map(() => expect.stringContaining(`"Bun" global`)) ); }); }); diff --git a/tests/engine/rule-scanner.test.ts b/tests/engine/rule-scanner.test.ts index b094caf6..7263b0c3 100644 --- a/tests/engine/rule-scanner.test.ts +++ b/tests/engine/rule-scanner.test.ts @@ -7,6 +7,8 @@ import { scanRuleSource, } from "../../src/engine/rule-scanner"; +const SAFE_MODULES = ["node:path", "node:url", "node:util", "node:crypto"]; + describe("scanRuleSource", () => { describe("banned imports", () => { const bannedModules = [ @@ -26,23 +28,17 @@ describe("scanRuleSource", () => { "bun", ]; - for (const mod of bannedModules) { - test(`blocks ${mod} import`, () => { - const violations = scanRuleSource(`import x from "${mod}";`); - expect(violations).toHaveLength(1); - expect(violations[0].message).toContain(`"${mod}"`); - expect(violations[0].message).toContain("blocked"); - }); - } - - const safeModules = ["node:path", "node:url", "node:util", "node:crypto"]; - - for (const mod of safeModules) { - test(`allows ${mod} import`, () => { - const violations = scanRuleSource(`import x from "${mod}";`); - expect(violations).toHaveLength(0); - }); - } + test.each(bannedModules)(`blocks %s import`, (mod) => { + const violations = scanRuleSource(`import x from "${mod}";`); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain(`"${mod}"`); + expect(violations[0].message).toContain("blocked"); + }); + + test.each(SAFE_MODULES)(`allows %s import`, (mod) => { + const violations = scanRuleSource(`import x from "${mod}";`); + expect(violations).toHaveLength(0); + }); }); // Bun/process/globalThis and the eval-equivalents are blocked by naming the @@ -69,13 +65,11 @@ describe("scanRuleSource", () => { ["process.env assignment", `process.env = {};`, "process"], ]; - for (const [label, source, global] of cases) { - test(`blocks ${label}`, () => { - const violations = scanRuleSource(source); - expect(violations).toHaveLength(1); - expect(violations[0].message).toContain(`"${global}" global`); - }); - } + test.each(cases)(`blocks %s`, (_label, source, global) => { + const violations = scanRuleSource(source); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain(`"${global}" global`); + }); }); describe("dynamic imports", () => { @@ -101,16 +95,18 @@ describe("scanRuleSource", () => { describe("top-level export declarations are scanned", () => { test("blocks a banned global inside `export function`", () => { const violations = scanRuleSource(`export function h() { fetch("x"); }`); - expect(violations.some((v) => v.message.includes(`"fetch" global`))).toBe( - true + const found = violations.find((v) => + v.message.includes(`"fetch" global`) ); + expect(found).toBeDefined(); }); test("blocks a banned global inside `export const`", () => { const violations = scanRuleSource(`export const x = fetch("evil");`); - expect(violations.some((v) => v.message.includes(`"fetch" global`))).toBe( - true + const found = violations.find((v) => + v.message.includes(`"fetch" global`) ); + expect(found).toBeDefined(); }); // A specifier-only local export (`export { x }`, no `from`) also carries @@ -261,14 +257,13 @@ describe("scanImportedRuleSource", () => { "WebSocket", ], ]; - for (const [label, source, global] of cases) { - test(`blocks ${label}`, () => { - const violations = scanImportedRuleSource(source); - expect( - violations.some((v) => v.message.includes(`"${global}" global`)) - ).toBe(true); - }); - } + test.each(cases)(`blocks %s`, (_label, source, global) => { + const violations = scanImportedRuleSource(source); + const found = violations.find((v) => + v.message.includes(`"${global}" global`) + ); + expect(found).toBeDefined(); + }); test("reports a banned global once, not twice", () => { const violations = scanImportedRuleSource(`const mod = require("x");`); @@ -281,8 +276,8 @@ describe("scanImportedRuleSource", () => { const messages = scanImportedRuleSource( `import { readFileSync } from "node:fs";\nconst token = Bun.env.TOKEN;` ).map((v) => v.message); - expect(messages.some((m) => m.includes('"node:fs"'))).toBe(true); - expect(messages.some((m) => m.includes(`"Bun" global`))).toBe(true); + expect(messages.find((m) => m.includes('"node:fs"'))).toBeDefined(); + expect(messages.find((m) => m.includes(`"Bun" global`))).toBeDefined(); }); }); @@ -324,14 +319,10 @@ export default { }); describe("safe module imports remain allowed", () => { - const safeModules = ["node:path", "node:url", "node:util", "node:crypto"]; - - for (const mod of safeModules) { - test(`allows ${mod} import in imported rules`, () => { - const violations = scanImportedRuleSource(`import x from "${mod}";`); - expect(violations).toHaveLength(0); - }); - } + test.each(SAFE_MODULES)(`allows %s import in imported rules`, (mod) => { + const violations = scanImportedRuleSource(`import x from "${mod}";`); + expect(violations).toHaveLength(0); + }); }); describe("violation location for imported checks", () => { diff --git a/tests/engine/runner-ast-base.test.ts b/tests/engine/runner-ast-base.test.ts index d7723233..a3ca53ee 100644 --- a/tests/engine/runner-ast-base.test.ts +++ b/tests/engine/runner-ast-base.test.ts @@ -264,7 +264,8 @@ describe("runChecks ctx.fileAtBase() / ctx.ast({ rev: 'base' })", () => { "// a new explanatory comment\nexport const v = 1;\n" ); - let equal = false; + let baseStructure: unknown; + let headStructure: unknown; const loaded = makeLoadedAdr({ rules: { r: { @@ -278,16 +279,18 @@ describe("runChecks ctx.fileAtBase() / ctx.ast({ rev: 'base' })", () => { // the location-free trees match when only a comment was added. // Compare the WHOLE tree (not just top-level node types) so that a // value change like `v = 1` vs `v = 2` would be detected too. - equal = - JSON.stringify(esStructure(baseTree.body)) === - JSON.stringify(esStructure(headTree.body)); + baseStructure = esStructure(baseTree.body); + headStructure = esStructure(headTree.body); }, }, }, }); - await runChecks(dir, [loaded], { base: "HEAD" }); - expect(equal).toBe(true); + const result = await runChecks(dir, [loaded], { base: "HEAD" }); + expect(result.results[0].error).toBeUndefined(); + expect(baseStructure).toBeDefined(); + expect(headStructure).toBeDefined(); + expect(baseStructure).toEqual(headStructure); }); test("javascript: base vs working-tree dispatch; comment-only edit is structurally identical", async () => { @@ -297,7 +300,8 @@ describe("runChecks ctx.fileAtBase() / ctx.ast({ rev: 'base' })", () => { "// a new explanatory comment\nexport const v = 1;\n" ); - let equal = false; + let baseStructure: unknown; + let headStructure: unknown; let bodyLen = 0; const loaded = makeLoadedAdr({ rules: { @@ -309,17 +313,19 @@ describe("runChecks ctx.fileAtBase() / ctx.ast({ rev: 'base' })", () => { }); const headTree = await ctx.ast("src/a.js", "javascript"); bodyLen = headTree.body.length; - equal = - JSON.stringify(esStructure(baseTree.body)) === - JSON.stringify(esStructure(headTree.body)); + baseStructure = esStructure(baseTree.body); + headStructure = esStructure(headTree.body); }, }, }, }); - await runChecks(dir, [loaded], { base: "HEAD" }); + const result = await runChecks(dir, [loaded], { base: "HEAD" }); + expect(result.results[0].error).toBeUndefined(); + expect(baseStructure).toBeDefined(); + expect(headStructure).toBeDefined(); expect(bodyLen).toBe(1); - expect(equal).toBe(true); + expect(baseStructure).toEqual(headStructure); }); test.skipIf(!rubyInterpreter)( @@ -327,8 +333,8 @@ describe("runChecks ctx.fileAtBase() / ctx.ast({ rev: 'base' })", () => { async () => { await commitThenEdit("src/a.rb", "def foo\nend\n", "def bar\nend\n"); - let baseHasFoo = false; - let headHasBar = false; + let baseTreeJson = ""; + let headTreeJson = ""; const loaded = makeLoadedAdr({ rules: { r: { @@ -339,16 +345,16 @@ describe("runChecks ctx.fileAtBase() / ctx.ast({ rev: 'base' })", () => { }); const headTree = await ctx.ast("src/a.rb", "ruby"); // Ripper's s-expression carries the method name as a string. - baseHasFoo = JSON.stringify(baseTree).includes("foo"); - headHasBar = JSON.stringify(headTree).includes("bar"); + baseTreeJson = JSON.stringify(baseTree); + headTreeJson = JSON.stringify(headTree); }, }, }, }); await runChecks(dir, [loaded], { base: "HEAD" }); - expect(baseHasFoo).toBe(true); - expect(headHasBar).toBe(true); + expect(baseTreeJson).toContain("foo"); + expect(headTreeJson).toContain("bar"); } ); diff --git a/tests/engine/runner-ast-cache.test.ts b/tests/engine/runner-ast-cache.test.ts index 3575f613..e5d2a777 100644 --- a/tests/engine/runner-ast-cache.test.ts +++ b/tests/engine/runner-ast-cache.test.ts @@ -79,7 +79,7 @@ describe("runChecks ctx.ast() per-run parse cache", () => { }); const result = await runChecks(tempDir, [loaded]); - expect(result.results.every((r) => r.error === undefined)).toBe(true); + for (const r of result.results) expect(r.error).toBeUndefined(); expect(trees).toHaveLength(3); expect(trees[1]).toBe(trees[0]); expect(trees[2]).toBe(trees[0]); @@ -88,17 +88,17 @@ describe("runChecks ctx.ast() per-run parse cache", () => { test("concurrent identical calls collapse into one in-flight parse", async () => { writeFileSync(join(tempDir, "src", "b.ts"), "export const n = 2;\n"); - let same = false; + let treeOne: unknown; + let treeTwo: unknown; const loaded = makeLoadedAdr({ rules: { concurrent: { description: "two overlapping parses of the same file", async check(ctx) { - const [t1, t2] = await Promise.all([ + [treeOne, treeTwo] = await Promise.all([ ctx.ast("src/b.ts", "typescript"), ctx.ast("src/b.ts", "typescript"), ]); - same = t1 === t2; }, }, }, @@ -106,7 +106,7 @@ describe("runChecks ctx.ast() per-run parse cache", () => { const result = await runChecks(tempDir, [loaded]); expect(result.results[0].error).toBeUndefined(); - expect(same).toBe(true); + expect(treeOne).toBe(treeTwo); }); test.skipIf(!pythonInterpreter)( @@ -129,7 +129,7 @@ describe("runChecks ctx.ast() per-run parse cache", () => { }); const result = await runChecks(tempDir, [loaded]); - expect(result.results.every((r) => r.error === undefined)).toBe(true); + for (const r of result.results) expect(r.error).toBeUndefined(); // Three rules, one subprocess: the parse promise is shared. expect(countAstSpawns(spawnSpy)).toBe(1); expect(trees[1]).toBe(trees[0]); diff --git a/tests/engine/runner-ast.test.ts b/tests/engine/runner-ast.test.ts index 0f6686c7..83cb661c 100644 --- a/tests/engine/runner-ast.test.ts +++ b/tests/engine/runner-ast.test.ts @@ -462,33 +462,29 @@ describe("runChecks ctx.ast()", () => { } ); - test("plausibility guardrail: wrong extension is refused for every language", async () => { - writeFileSync(join(tempDir, "data.json"), "{}"); - const languages = ["python", "ruby", "typescript", "javascript"] as const; + test.each(["python", "ruby", "typescript", "javascript"] as const)( + "plausibility guardrail: wrong extension is refused for %s", + async (language) => { + writeFileSync(join(tempDir, "data.json"), "{}"); - const loaded = makeLoadedAdr( - {}, - { - rules: Object.fromEntries( - languages.map((language) => [ - `json-as-${language}`, - { + const loaded = makeLoadedAdr( + {}, + { + rules: { + [`json-as-${language}`]: { description: `Attempt to parse JSON as ${language}`, async check(ctx: RuleContext) { await ctx.ast("data.json", language); }, }, - ]) - ), - } - ); + }, + } + ); - const result = await runChecks(tempDir, [loaded]); - const byId = new Map(result.results.map((r) => [r.ruleId, r])); - for (const language of languages) { - expect(byId.get(`json-as-${language}`)?.error).toContain( + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain( `does not look like ${language}` ); } - }); + ); }); diff --git a/tests/engine/runner-gitignore.test.ts b/tests/engine/runner-gitignore.test.ts index 69c8ee4b..732f3512 100644 --- a/tests/engine/runner-gitignore.test.ts +++ b/tests/engine/runner-gitignore.test.ts @@ -152,56 +152,51 @@ describe("runChecks gitignore filtering", () => { expect(matchedFiles).toEqual(["dist/app.js", "src/app.ts"]); }); - test("warns when respectGitignore is false without files scope", async () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); - - const loaded = makeLoadedAdr( - { respectGitignore: false }, - { rules: { "noop-rule": { description: "No-op", async check() {} } } } - ); - - await runChecks(tempDir, [loaded]); - const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); - expect( - warnCalls.some((msg) => - msg.includes("respectGitignore is false without a files scope") - ) - ).toBe(true); - warnSpy.mockRestore(); - }); - - test("warns when file patterns match only gitignored files", async () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); - - const loaded = makeLoadedAdr( - { files: ["dist/**/*.js"] }, - { rules: { "noop-rule": { description: "No-op", async check() {} } } } - ); - - await runChecks(tempDir, [loaded]); - const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); - expect( - warnCalls.some((msg) => msg.includes("all are excluded by .gitignore")) - ).toBe(true); - warnSpy.mockRestore(); - }); - - test("does not warn when file patterns match tracked files", async () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); - - const loaded = makeLoadedAdr( - { files: ["src/**/*.ts"] }, - { rules: { "noop-rule": { description: "No-op", async check() {} } } } - ); - - await runChecks(tempDir, [loaded]); - const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); - expect( - warnCalls.some((msg) => msg.includes("excluded by .gitignore")) - ).toBe(false); - expect( - warnCalls.some((msg) => msg.includes("respectGitignore is false")) - ).toBe(false); - warnSpy.mockRestore(); + describe("console warnings", () => { + let warnSpy: ReturnType<typeof spyOn<Console, "warn">>; + + beforeEach(() => { + warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test("warns when respectGitignore is false without files scope", async () => { + const loaded = makeLoadedAdr( + { respectGitignore: false }, + { rules: { "noop-rule": { description: "No-op", async check() {} } } } + ); + + await runChecks(tempDir, [loaded]); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect(warnCalls.join("\n")).toContain( + "respectGitignore is false without a files scope" + ); + }); + + test("warns when file patterns match only gitignored files", async () => { + const loaded = makeLoadedAdr( + { files: ["dist/**/*.js"] }, + { rules: { "noop-rule": { description: "No-op", async check() {} } } } + ); + + await runChecks(tempDir, [loaded]); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect(warnCalls.join("\n")).toContain("all are excluded by .gitignore"); + }); + + test("does not warn when file patterns match tracked files", async () => { + const loaded = makeLoadedAdr( + { files: ["src/**/*.ts"] }, + { rules: { "noop-rule": { description: "No-op", async check() {} } } } + ); + + await runChecks(tempDir, [loaded]); + const warnCalls = warnSpy.mock.calls.map((args) => args.join(" ")); + expect(warnCalls.join("\n")).not.toContain("excluded by .gitignore"); + expect(warnCalls.join("\n")).not.toContain("respectGitignore is false"); + }); }); }); diff --git a/tests/engine/yaml-utils.test.ts b/tests/engine/yaml-utils.test.ts index 146546c1..7ad90480 100644 --- a/tests/engine/yaml-utils.test.ts +++ b/tests/engine/yaml-utils.test.ts @@ -70,20 +70,18 @@ describe("parseYamlDocument — frontmatter files (everything else)", () => { expect(result.frontmatter).toBeNull(); }); - test("rejects a malformed closing fence instead of parsing a partial block", () => { - // `----` / `---note` must NOT terminate the block — a bare `\n---` match - // would parse a truncated block and leave the stray chars in the body. - for (const fence of ["----", "---note", "--- note"]) { + // `----` / `---note` must NOT terminate the block — a bare `\n---` match + // would parse a truncated block and leave the stray chars in the body. + test.each(["----", "---note", "--- note"])( + "rejects malformed closing fence: %s", + (fence) => { const result = parseYamlDocument( `---\ntitle: x\n${fence}\nBody\n`, "doc.md" ); - expect( - result.frontmatter, - `fence "${fence}" must not terminate` - ).toBeNull(); + expect(result.frontmatter).toBeNull(); } - }); + ); test("tolerates trailing spaces/tabs on either fence", () => { const result = parseYamlDocument("--- \ntitle: x\n---\t\nBody\n", "doc.md"); diff --git a/tests/formats/project-config-fuzz.test.ts b/tests/formats/project-config-fuzz.test.ts index 8346f5ed..b7f80c14 100644 --- a/tests/formats/project-config-fuzz.test.ts +++ b/tests/formats/project-config-fuzz.test.ts @@ -28,41 +28,37 @@ describe("DomainNameSchema fuzz", () => { ); }); - test("boundary cases for length constraints (min 2, max 32)", () => { - const cases = [ - "", // too short - "a", // 1 char — too short - "ab", // 2 chars — minimum - "a".repeat(32), // 32 chars — maximum - "a".repeat(33), // 33 chars — over limit - "a".repeat(1000), - ]; - for (const c of cases) { - const result = DomainNameSchema.safeParse(c); - expect(result).toHaveProperty("success"); - } + describe("boundary cases for length constraints (min 2, max 32)", () => { + test.each([ + ["", false], // too short + ["a", false], // 1 char — too short + ["ab", true], // 2 chars — minimum + ["a".repeat(32), true], // 32 chars — maximum + ["a".repeat(33), false], // 33 chars — over limit + ["a".repeat(1000), false], + ] as const)("%p -> success=%p", (input, expectSuccess) => { + expect(DomainNameSchema.safeParse(input).success).toBe(expectSuccess); + }); }); - test("regex boundary cases for kebab-case", () => { - const cases = [ - "backend", // valid - "ml-ops", // valid - "a1", // valid — letter then digit - "1abc", // invalid — starts with digit - "-abc", // invalid — starts with hyphen - "abc-", // valid — ends with hyphen (regex allows it) - "ABC", // invalid — uppercase - "aB", // invalid — mixed case - "ab cd", // invalid — space - "ab_cd", // invalid — underscore - "ab.cd", // invalid — dot - "ab--cd", // valid — double hyphen - "a-b-c-d-e-f", // valid — many hyphens - ]; - for (const c of cases) { - const result = DomainNameSchema.safeParse(c); - expect(result).toHaveProperty("success"); - } + describe("regex boundary cases for kebab-case", () => { + test.each([ + ["backend", true], // valid + ["ml-ops", true], // valid + ["a1", true], // valid — letter then digit + ["1abc", false], // invalid — starts with digit + ["-abc", false], // invalid — starts with hyphen + ["abc-", true], // valid — ends with hyphen (regex allows it) + ["ABC", false], // invalid — uppercase + ["aB", false], // invalid — mixed case + ["ab cd", false], // invalid — space + ["ab_cd", false], // invalid — underscore + ["ab.cd", false], // invalid — dot + ["ab--cd", true], // valid — double hyphen + ["a-b-c-d-e-f", true], // valid — many hyphens + ] as const)("%p -> success=%p", (input, expectSuccess) => { + expect(DomainNameSchema.safeParse(input).success).toBe(expectSuccess); + }); }); test("rejects non-string types", () => { @@ -102,38 +98,34 @@ describe("DomainPrefixSchema fuzz", () => { ); }); - test("boundary cases for length constraints (min 2, max 10)", () => { - const cases = [ - "", - "A", // 1 char — too short - "AB", // 2 chars — minimum - "A".repeat(10), // 10 chars — maximum - "A".repeat(11), // 11 chars — over limit - "A".repeat(1000), - ]; - for (const c of cases) { - const result = DomainPrefixSchema.safeParse(c); - expect(result).toHaveProperty("success"); - } + describe("boundary cases for length constraints (min 2, max 10)", () => { + test.each([ + ["", false], + ["A", false], // 1 char — too short + ["AB", true], // 2 chars — minimum + ["A".repeat(10), true], // 10 chars — maximum + ["A".repeat(11), false], // 11 chars — over limit + ["A".repeat(1000), false], + ] as const)("%p -> success=%p", (input, expectSuccess) => { + expect(DomainPrefixSchema.safeParse(input).success).toBe(expectSuccess); + }); }); - test("regex boundary cases for uppercase pattern", () => { - const cases = [ - "GEN", // valid - "MLOPS", // valid - "ML_OPS", // valid — underscore allowed - "A1", // valid — letter then digit - "1ABC", // invalid — starts with digit - "_ABC", // invalid — starts with underscore - "abc", // invalid — lowercase - "Ab", // invalid — mixed case - "AB CD", // invalid — space - "AB-CD", // invalid — hyphen - ]; - for (const c of cases) { - const result = DomainPrefixSchema.safeParse(c); - expect(result).toHaveProperty("success"); - } + describe("regex boundary cases for uppercase pattern", () => { + test.each([ + ["GEN", true], // valid + ["MLOPS", true], // valid + ["ML_OPS", true], // valid — underscore allowed + ["A1", true], // valid — letter then digit + ["1ABC", false], // invalid — starts with digit + ["_ABC", false], // invalid — starts with underscore + ["abc", false], // invalid — lowercase + ["Ab", false], // invalid — mixed case + ["AB CD", false], // invalid — space + ["AB-CD", false], // invalid — hyphen + ] as const)("%p -> success=%p", (input, expectSuccess) => { + expect(DomainPrefixSchema.safeParse(input).success).toBe(expectSuccess); + }); }); }); @@ -178,70 +170,54 @@ describe("ProjectConfigSchema fuzz", () => { expect(result).toHaveProperty("success"); }); - test("handles wrong shapes for domains field", () => { - const cases = [ - { domains: null }, - { domains: "not-an-object" }, - { domains: 42 }, - { domains: [] }, - { domains: true }, - { domains: { valid: 123 } }, - { domains: { valid: null } }, - { domains: { valid: undefined } }, - { domains: { "": "" } }, - ]; - for (const c of cases) { - const result = ProjectConfigSchema.safeParse(c); - expect(result).toHaveProperty("success"); - } + test.each([ + { domains: null }, + { domains: "not-an-object" }, + { domains: 42 }, + { domains: [] }, + { domains: true }, + { domains: { valid: 123 } }, + { domains: { valid: null } }, + { domains: { valid: undefined } }, + { domains: { "": "" } }, + ])("rejects domains shaped as %j", (c) => { + expect(ProjectConfigSchema.safeParse(c).success).toBe(false); }); - test("defaults correctly for missing or empty input", () => { - const cases = [undefined, {}, { domains: {} }]; - for (const c of cases) { - const result = ProjectConfigSchema.safeParse(c); + test.each([undefined, {}, { domains: {} }])( + "defaults domains to {} for %j", + (input) => { + const result = ProjectConfigSchema.safeParse(input); expect(result.success).toBe(true); if (result.success) { expect(result.data.domains).toEqual({}); } } - }); + ); - test("accepts config with valid paths", () => { - const cases = [ - { domains: {}, paths: { adrs: "docs/adrs" } }, - { domains: {}, paths: { rules: "custom/rules" } }, - { domains: {}, paths: { adrs: "docs/adrs", rules: "docs/adrs" } }, - { domains: {}, paths: {} }, - ]; - for (const c of cases) { - const result = ProjectConfigSchema.safeParse(c); - expect(result.success).toBe(true); - } + test.each([ + { domains: {}, paths: { adrs: "docs/adrs" } }, + { domains: {}, paths: { rules: "custom/rules" } }, + { domains: {}, paths: { adrs: "docs/adrs", rules: "docs/adrs" } }, + { domains: {}, paths: {} }, + ])("accepts %j", (c) => { + expect(ProjectConfigSchema.safeParse(c).success).toBe(true); }); - test("rejects config with absolute paths", () => { - const cases = [ - { domains: {}, paths: { adrs: "/absolute/path" } }, - { domains: {}, paths: { rules: "/etc/rules" } }, - { domains: {}, paths: { adrs: "C:\\absolute\\path" } }, - ]; - for (const c of cases) { - const result = ProjectConfigSchema.safeParse(c); - expect(result.success).toBe(false); - } + test.each([ + { domains: {}, paths: { adrs: "/absolute/path" } }, + { domains: {}, paths: { rules: "/etc/rules" } }, + { domains: {}, paths: { adrs: "C:\\absolute\\path" } }, + ])("rejects absolute path %j", (c) => { + expect(ProjectConfigSchema.safeParse(c).success).toBe(false); }); - test("rejects config with '..' path segments", () => { - const cases = [ - { domains: {}, paths: { adrs: "../escape" } }, - { domains: {}, paths: { adrs: "docs/../../escape" } }, - { domains: {}, paths: { rules: ".." } }, - ]; - for (const c of cases) { - const result = ProjectConfigSchema.safeParse(c); - expect(result.success).toBe(false); - } + test.each([ + { domains: {}, paths: { adrs: "../escape" } }, + { domains: {}, paths: { adrs: "docs/../../escape" } }, + { domains: {}, paths: { rules: ".." } }, + ])("rejects '..' path segments in %j", (c) => { + expect(ProjectConfigSchema.safeParse(c).success).toBe(false); }); }); @@ -263,35 +239,26 @@ describe("PathsConfigSchema fuzz", () => { ); }); - test("accepts valid relative paths", () => { - const valid = [ - "docs/adrs", - "custom", - "a/b/c/d", - "src/governance/adrs", - "adrs", - ]; - for (const p of valid) { - const result = PathsConfigSchema.safeParse({ adrs: p }); - expect(result.success).toBe(true); + test.each(["docs/adrs", "custom", "a/b/c/d", "src/governance/adrs", "adrs"])( + "accepts %s as adrs path", + (p) => { + expect(PathsConfigSchema.safeParse({ adrs: p }).success).toBe(true); } - }); + ); - test("rejects absolute paths", () => { - const absolute = ["/root", "\\root", "C:\\path", "D:/path"]; - for (const p of absolute) { - const result = PathsConfigSchema.safeParse({ adrs: p }); - expect(result.success).toBe(false); + test.each(["/root", "\\root", "C:\\path", "D:/path"])( + "rejects absolute path %s", + (p) => { + expect(PathsConfigSchema.safeParse({ adrs: p }).success).toBe(false); } - }); + ); - test("rejects paths with '..' segments", () => { - const escaping = ["..", "../foo", "foo/../bar", "foo/.."]; - for (const p of escaping) { - const result = PathsConfigSchema.safeParse({ adrs: p }); - expect(result.success).toBe(false); + test.each(["..", "../foo", "foo/../bar", "foo/.."])( + "rejects path with '..' segment %s", + (p) => { + expect(PathsConfigSchema.safeParse({ adrs: p }).success).toBe(false); } - }); + ); test("rejects empty strings", () => { const result = PathsConfigSchema.safeParse({ adrs: "" }); diff --git a/tests/helpers/adr-import.test.ts b/tests/helpers/adr-import.test.ts index 6eae62cf..a53e4323 100644 --- a/tests/helpers/adr-import.test.ts +++ b/tests/helpers/adr-import.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, @@ -55,6 +55,10 @@ describe("rewriteAdrId", () => { describe("buildIdMap", () => { let tempDir: string; + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-idmap-test-")); + }); + afterEach(() => { if (tempDir) { try { @@ -66,7 +70,6 @@ describe("buildIdMap", () => { }); test("assigns sequential IDs for a single domain prefix", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-idmap-test-")); const adrs: AdrToImport[] = [ { sourcePath: "/tmp/a.md", @@ -95,7 +98,6 @@ describe("buildIdMap", () => { }); test("skips existing IDs in the adrs directory", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-idmap-test-")); writeFileSync( join(tempDir, "ARCH-001-existing.md"), "---\nid: ARCH-001\n---\n" @@ -117,7 +119,6 @@ describe("buildIdMap", () => { }); test("falls back to ARCH prefix when domain has no mapping", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-idmap-test-")); const adrs: AdrToImport[] = [ { sourcePath: "/tmp/a.md", @@ -134,7 +135,6 @@ describe("buildIdMap", () => { }); test("handles multiple domain prefixes independently", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-idmap-test-")); const adrs: AdrToImport[] = [ { sourcePath: "/tmp/a.md", @@ -243,6 +243,11 @@ describe("updateImportsManifest", () => { describe("loadImportsManifest / saveImportsManifest", () => { let tempDir: string; + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-manifest-test-")); + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + }); + afterEach(() => { if (tempDir) { try { @@ -254,15 +259,11 @@ describe("loadImportsManifest / saveImportsManifest", () => { }); test("returns empty manifest when imports.json does not exist", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-manifest-test-")); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); const manifest = await loadImportsManifest(tempDir); expect(manifest.imports).toEqual([]); }); test("round-trips a manifest through save and load", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-manifest-test-")); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); const original: ImportsManifest = { imports: [ { diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts index 37831264..604ebd50 100644 --- a/tests/helpers/auth.test.ts +++ b/tests/helpers/auth.test.ts @@ -18,6 +18,7 @@ describe("auth", () => { let originalUserProfile: string | undefined; let originalGitConfigNoSystem: string | undefined; let originalGitConfigGlobal: string | undefined; + let originalFetch: typeof fetch; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-auth-test-")); @@ -32,6 +33,7 @@ describe("auth", () => { const emptyGitConfig = join(tempDir, ".gitconfig"); writeFileSync(emptyGitConfig, ""); Bun.env.GIT_CONFIG_GLOBAL = emptyGitConfig; + originalFetch = globalThis.fetch; }); afterEach(() => { @@ -45,6 +47,7 @@ describe("auth", () => { restoreEnv("GIT_CONFIG_NOSYSTEM", originalGitConfigNoSystem); restoreEnv("GIT_CONFIG_GLOBAL", originalGitConfigGlobal); rmSync(tempDir, { recursive: true, force: true }); + globalThis.fetch = originalFetch; }); describe("saveCredentials / loadCredentials", () => { @@ -116,7 +119,6 @@ describe("auth", () => { test("sends POST to GitHub device code endpoint", async () => { const { requestDeviceCode } = await import("../../src/helpers/auth"); - const originalFetch = globalThis.fetch; mockFetch(() => Promise.resolve( Response.json({ @@ -129,30 +131,21 @@ describe("auth", () => { ) ); - try { - const result = await requestDeviceCode(); - expect(result.device_code).toBe("dc_123"); - expect(result.user_code).toBe("ABCD-1234"); - expect(result.verification_uri).toBe("https://github.com/login/device"); - expect(result.interval).toBe(5); - } finally { - globalThis.fetch = originalFetch; - } + const result = await requestDeviceCode(); + expect(result.device_code).toBe("dc_123"); + expect(result.user_code).toBe("ABCD-1234"); + expect(result.verification_uri).toBe("https://github.com/login/device"); + expect(result.interval).toBe(5); }); test("throws on non-200 response", async () => { const { requestDeviceCode } = await import("../../src/helpers/auth"); - const originalFetch = globalThis.fetch; mockFetch(() => Promise.resolve(new Response("Bad Request", { status: 400 })) ); - try { - await expect(requestDeviceCode()).rejects.toThrow("HTTP 400"); - } finally { - globalThis.fetch = originalFetch; - } + await expect(requestDeviceCode()).rejects.toThrow("HTTP 400"); }); }); @@ -160,52 +153,37 @@ describe("auth", () => { test("returns login from GitHub API", async () => { const { getGitHubUser } = await import("../../src/helpers/auth"); - const originalFetch = globalThis.fetch; mockFetch(() => Promise.resolve( Response.json({ login: "octocat", email: "octo@cat.com" }) ) ); - try { - const user = await getGitHubUser("gho_test_token"); - expect(user.login).toBe("octocat"); - expect(user.email).toBe("octo@cat.com"); - } finally { - globalThis.fetch = originalFetch; - } + const user = await getGitHubUser("gho_test_token"); + expect(user.login).toBe("octocat"); + expect(user.email).toBe("octo@cat.com"); }); test("throws when GitHub API returns non-200", async () => { const { getGitHubUser } = await import("../../src/helpers/auth"); - const originalFetch = globalThis.fetch; mockFetch(() => Promise.resolve(new Response("Unauthorized", { status: 401 })) ); - try { - await expect(getGitHubUser("bad_token")).rejects.toThrow("HTTP 401"); - } finally { - globalThis.fetch = originalFetch; - } + await expect(getGitHubUser("bad_token")).rejects.toThrow("HTTP 401"); }); 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; - } + await expect(getGitHubUser("gho_test_token")).rejects.toThrow( + "GitHub API did not return a username" + ); }); }); @@ -213,24 +191,18 @@ describe("auth", () => { test("returns token from plugins service", async () => { const { claimArchgateToken } = await import("../../src/helpers/auth"); - const originalFetch = globalThis.fetch; mockFetch(() => Promise.resolve(Response.json({ token: "ag_beta_claimed_token" })) ); - try { - const token = await claimArchgateToken("gho_github_token"); - expect(token).toBe("ag_beta_claimed_token"); - } finally { - globalThis.fetch = originalFetch; - } + const token = await claimArchgateToken("gho_github_token"); + expect(token).toBe("ag_beta_claimed_token"); }); test("throws SignupRequiredError on 403 with no approved signup", async () => { const { claimArchgateToken } = await import("../../src/helpers/auth"); const { SignupRequiredError } = await import("../../src/helpers/signup"); - const originalFetch = globalThis.fetch; mockFetch(() => Promise.resolve( Response.json( @@ -240,56 +212,50 @@ describe("auth", () => { ) ); - try { - await expect(claimArchgateToken("gho_token")).rejects.toBeInstanceOf( - SignupRequiredError - ); - } finally { - globalThis.fetch = originalFetch; - } + await expect(claimArchgateToken("gho_token")).rejects.toBeInstanceOf( + SignupRequiredError + ); }); 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; - } + await expect(claimArchgateToken("gho_token")).rejects.toThrow( + "Account suspended" + ); }); 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; - } + await expect(claimArchgateToken("gho_token")).rejects.toThrow( + "Plugins service did not return a token" + ); }); }); describe("pollForAccessToken", () => { + let originalSleep: typeof Bun.sleep; + + beforeEach(() => { + originalSleep = Bun.sleep; + }); + + afterEach(() => { + Bun.sleep = originalSleep; + }); + 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; @@ -309,21 +275,14 @@ describe("auth", () => { ); }) 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; - } + const token = await pollForAccessToken("dc_abc", 0, 60); + expect(token).toBe("gho_polled_token"); + expect(callCount).toBe(2); }); 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); @@ -345,22 +304,15 @@ describe("auth", () => { ); }) 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; - } + 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); }); 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(() => @@ -372,21 +324,14 @@ describe("auth", () => { ) ); - try { - await expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( - "The device code has expired." - ); - } finally { - globalThis.fetch = originalFetch; - Bun.sleep = originalSleep; - } + await expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( + "The device code has expired." + ); }); 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(() => @@ -398,35 +343,23 @@ describe("auth", () => { ) ); - try { - await expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( - "The user denied your request." - ); - } finally { - globalThis.fetch = originalFetch; - Bun.sleep = originalSleep; - } + await expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( + "The user denied your request." + ); }); 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; - } + await expect(pollForAccessToken("dc_abc", 0, 0)).rejects.toThrow( + "Device code expired. Please try again." + ); }); }); }); diff --git a/tests/helpers/binary-upgrade.test.ts b/tests/helpers/binary-upgrade.test.ts index 02b55390..0ea7655b 100644 --- a/tests/helpers/binary-upgrade.test.ts +++ b/tests/helpers/binary-upgrade.test.ts @@ -30,16 +30,19 @@ function mockFetch(handler: () => Promise<Response>) { } describe("getArtifactInfo", () => { - test("returns artifact info for the current platform", () => { - const info = getArtifactInfo(); - - // Should return non-null for any supported CI platform - if (info === null) return; + test.skipIf(getArtifactInfo() === null)( + "returns artifact info for the current platform", + () => { + const info = getArtifactInfo(); - expect(info.name).toMatch(/^archgate-(darwin-arm64|linux-x64|win32-x64)$/u); - expect(info.ext).toMatch(/^\.(tar\.gz|zip)$/u); - expect(info.binaryName).toMatch(/^archgate(\.exe)?$/u); - }); + expect(info).not.toBeNull(); + expect(info!.name).toMatch( + /^archgate-(darwin-arm64|linux-x64|win32-x64)$/u + ); + expect(info!.ext).toMatch(/^\.(tar\.gz|zip)$/u); + expect(info!.binaryName).toMatch(/^archgate(\.exe)?$/u); + } + ); test.skipIf(process.platform !== "win32")( "returns .zip extension for win32", @@ -463,31 +466,33 @@ describe("cleanupStaleBinary", () => { restoreEnv("HOME", savedHome); }); - test("deletes the .old binary when present", async () => { - const artifact = getArtifactInfo(); - if (!artifact) return; // unsupported platform - - const tmpDir = mkdtempSync(join(tmpdir(), "archgate-cleanup-test-")); - Bun.env.HOME = tmpDir; + test.skipIf(getArtifactInfo() === null)( + "deletes the .old binary when present", + async () => { + const artifact = getArtifactInfo()!; - // Recreate the ~/.archgate/bin/ structure - const binDir = join(tmpDir, ".archgate", "bin"); - mkdirSync(binDir, { recursive: true }); - const oldPath = join(binDir, `${artifact.binaryName}.old`); - writeFileSync(oldPath, "stale binary"); + const tmpDir = mkdtempSync(join(tmpdir(), "archgate-cleanup-test-")); + Bun.env.HOME = tmpDir; - await cleanupStaleBinary(); + // Recreate the ~/.archgate/bin/ structure + const binDir = join(tmpDir, ".archgate", "bin"); + mkdirSync(binDir, { recursive: true }); + const oldPath = join(binDir, `${artifact.binaryName}.old`); + writeFileSync(oldPath, "stale binary"); - expect(existsSync(oldPath)).toBe(false); - }); + await cleanupStaleBinary(); - test("resolves silently when no .old file exists", async () => { - const artifact = getArtifactInfo(); - if (!artifact) return; // unsupported platform + expect(existsSync(oldPath)).toBe(false); + } + ); - const tmpDir = mkdtempSync(join(tmpdir(), "archgate-cleanup-test-")); - Bun.env.HOME = tmpDir; + test.skipIf(getArtifactInfo() === null)( + "resolves silently when no .old file exists", + async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "archgate-cleanup-test-")); + Bun.env.HOME = tmpDir; - await expect(cleanupStaleBinary()).resolves.toBeUndefined(); - }); + await expect(cleanupStaleBinary()).resolves.toBeUndefined(); + } + ); }); diff --git a/tests/helpers/claude-settings.test.ts b/tests/helpers/claude-settings.test.ts index 52fddeae..ab17869d 100644 --- a/tests/helpers/claude-settings.test.ts +++ b/tests/helpers/claude-settings.test.ts @@ -61,7 +61,7 @@ describe("mergeClaudeSettings", () => { ); expect(result.permissions?.deny).toEqual(["Bash(rm -rf *)"]); - expect(Array.isArray(result.permissions?.allow)).toBe(true); + expect(result.permissions?.allow).toBeInstanceOf(Array); }); test("preserves unknown top-level keys", () => { diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index cc448af8..a3dd8e7b 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -98,10 +98,7 @@ describe("credential-store", () => { // Either the "approve failed" or "could not be verified" warning fires. expect(warnSpy).toHaveBeenCalled(); const allArgs = warnSpy.mock.calls.flat().join(" "); - const hasVerifyWarning = - allArgs.includes("could not be verified") || - allArgs.includes("approve failed"); - expect(hasVerifyWarning).toBe(true); + expect(allArgs).toMatch(/could not be verified|approve failed/u); } finally { warnSpy.mockRestore(); } @@ -201,11 +198,8 @@ describe("credential-store", () => { // With a working helper, verification succeeds — no warning about // "could not be verified". - const verifyWarning = warnSpy.mock.calls - .flat() - .join(" ") - .includes("could not be verified"); - expect(verifyWarning).toBe(false); + const allArgsJoined = warnSpy.mock.calls.flat().join(" "); + expect(allArgsJoined).not.toContain("could not be verified"); } finally { warnSpy.mockRestore(); } diff --git a/tests/helpers/doctor.test.ts b/tests/helpers/doctor.test.ts index b52ed906..040e7ffc 100644 --- a/tests/helpers/doctor.test.ts +++ b/tests/helpers/doctor.test.ts @@ -29,7 +29,7 @@ describe("doctor", () => { expect(report.project).toBeDefined(); expect(typeof report.project.has_project).toBe("boolean"); expect(typeof report.project.adr_count).toBe("number"); - expect(Array.isArray(report.project.domains)).toBe(true); + expect(report.project.domains).toBeInstanceOf(Array); expect(report.editors).toBeDefined(); expect(typeof report.editors.git).toBe("boolean"); diff --git a/tests/helpers/editor-detect.test.ts b/tests/helpers/editor-detect.test.ts index 10b581de..e531766d 100644 --- a/tests/helpers/editor-detect.test.ts +++ b/tests/helpers/editor-detect.test.ts @@ -67,89 +67,50 @@ describe("editor-detect", () => { // Cursor reset is part of the Windows-only withPromptFix() workaround. // These tests only run on Windows where the fix is active. describe.skipIf(process.platform !== "win32")( - "promptEditorSelection — cursor reset (Windows)", + "cursor reset (Windows)", () => { - const originalIsTTY = process.stdout.isTTY; - - beforeEach(() => { - mockCursorTo.mockClear(); - }); - - afterEach(() => { - Object.defineProperty(process.stdout, "isTTY", { - value: originalIsTTY, - writable: true, - configurable: true, + describe.each([ + ["promptEditorSelection", promptEditorSelection], + ["promptSingleEditorSelection", promptSingleEditorSelection], + ] as const)("%s", (_name, promptFn) => { + const originalIsTTY = process.stdout.isTTY; + + beforeEach(() => { + mockCursorTo.mockClear(); }); - }); - test("resets cursor to column 0 after prompt when stdout is TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: true, - writable: true, - configurable: true, + afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + writable: true, + configurable: true, + }); }); - await promptEditorSelection(MOCK_DETECTED); + test("resets cursor to column 0 after prompt when stdout is TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); - expect(mockCursorTo).toHaveBeenCalledTimes(1); - expect(mockCursorTo).toHaveBeenCalledWith(process.stdout, 0); - }); + await promptFn(MOCK_DETECTED); - test("does not call cursorTo when stdout is not TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: undefined, - writable: true, - configurable: true, + expect(mockCursorTo).toHaveBeenCalledTimes(1); + expect(mockCursorTo).toHaveBeenCalledWith(process.stdout, 0); }); - await promptEditorSelection(MOCK_DETECTED); - - expect(mockCursorTo).not.toHaveBeenCalled(); - }); - } - ); + test("does not call cursorTo when stdout is not TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: undefined, + writable: true, + configurable: true, + }); - describe.skipIf(process.platform !== "win32")( - "promptSingleEditorSelection — cursor reset (Windows)", - () => { - const originalIsTTY = process.stdout.isTTY; + await promptFn(MOCK_DETECTED); - beforeEach(() => { - mockCursorTo.mockClear(); - }); - - afterEach(() => { - Object.defineProperty(process.stdout, "isTTY", { - value: originalIsTTY, - writable: true, - configurable: true, - }); - }); - - test("resets cursor to column 0 after prompt when stdout is TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: true, - writable: true, - configurable: true, + expect(mockCursorTo).not.toHaveBeenCalled(); }); - - await promptSingleEditorSelection(MOCK_DETECTED); - - expect(mockCursorTo).toHaveBeenCalledTimes(1); - expect(mockCursorTo).toHaveBeenCalledWith(process.stdout, 0); - }); - - test("does not call cursorTo when stdout is not TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: undefined, - writable: true, - configurable: true, - }); - - await promptSingleEditorSelection(MOCK_DETECTED); - - expect(mockCursorTo).not.toHaveBeenCalled(); }); } ); diff --git a/tests/helpers/init-base-branch.test.ts b/tests/helpers/init-base-branch.test.ts index 07fb8bce..05ed7def 100644 --- a/tests/helpers/init-base-branch.test.ts +++ b/tests/helpers/init-base-branch.test.ts @@ -8,6 +8,15 @@ import { join } from "node:path"; import { initProject } from "../../src/helpers/init-project"; import { git, safeRmSync } from "../test-utils"; +async function initGitRepoWithCommit(dir: string): Promise<void> { + await git(["init", "--initial-branch=main"], dir); + await git(["config", "user.email", "test@test.com"], dir); + await git(["config", "user.name", "Test"], dir); + writeFileSync(join(dir, "file.ts"), "export const x = 1;"); + await git(["add", "file.ts"], dir); + await git(["commit", "-m", "init"], dir); +} + describe("initProject — baseBranch auto-detection", () => { let tempDir: string; @@ -20,12 +29,7 @@ describe("initProject — baseBranch auto-detection", () => { }); test("saves detected baseBranch in config.json during init in a git repo", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await git(["add", "file.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); + await initGitRepoWithCommit(tempDir); await initProject(tempDir); @@ -36,12 +40,7 @@ describe("initProject — baseBranch auto-detection", () => { }, 15_000); test("does not overwrite existing baseBranch on re-init", async () => { - await git(["init", "--initial-branch=main"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await git(["add", "file.ts"], tempDir); - await git(["commit", "-m", "init"], tempDir); + await initGitRepoWithCommit(tempDir); await initProject(tempDir); diff --git a/tests/helpers/install-info.test.ts b/tests/helpers/install-info.test.ts index d641a628..2b75918b 100644 --- a/tests/helpers/install-info.test.ts +++ b/tests/helpers/install-info.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -64,52 +64,41 @@ describe("install-info", () => { expect(ctx.domains).toEqual(sorted); }); - test("returns zero counts when adrsDir does not exist", () => { - let tempDir: string | undefined; - const originalCwd = process.cwd(); - try { + describe("with an isolated temp project directory", () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); tempDir = mkdtempSync(join(tmpdir(), "archgate-installinfo-test-")); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("returns zero counts when adrsDir does not exist", () => { // Create .archgate dir but NOT .archgate/adrs/ mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - // Change cwd to the temp project - process.chdir(tempDir); - const ctx = getProjectContext(); expect(ctx.hasProject).toBe(true); expect(ctx.adrCount).toBe(0); expect(ctx.adrWithRulesCount).toBe(0); expect(ctx.domains).toEqual([]); - } finally { - process.chdir(originalCwd); - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test("returns hasProject false when .archgate dir does not exist", () => { - let tempDir: string | undefined; - const originalCwd = process.cwd(); - try { - tempDir = mkdtempSync(join(tmpdir(), "archgate-installinfo-test-")); - - process.chdir(tempDir); + }); + test("returns hasProject false when .archgate dir does not exist", () => { const ctx = getProjectContext(); expect(ctx.hasProject).toBe(false); expect(ctx.adrCount).toBe(0); expect(ctx.adrWithRulesCount).toBe(0); expect(ctx.domains).toEqual([]); - } finally { - process.chdir(originalCwd); - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); - } - }); + }); - test("counts ADR files with different domain prefixes correctly", () => { - let tempDir: string | undefined; - const originalCwd = process.cwd(); - try { - tempDir = mkdtempSync(join(tmpdir(), "archgate-installinfo-test-")); + test("counts ADR files with different domain prefixes correctly", () => { const adrsDir = join(tempDir, ".archgate", "adrs"); mkdirSync(adrsDir, { recursive: true }); @@ -140,39 +129,24 @@ describe("install-info", () => { "export default {};" ); - process.chdir(tempDir); - const ctx = getProjectContext(); expect(ctx.hasProject).toBe(true); expect(ctx.adrCount).toBe(4); expect(ctx.adrWithRulesCount).toBe(2); expect(ctx.domains).toEqual(["ARCH", "CI", "LEGAL"]); - } finally { - process.chdir(originalCwd); - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); - } - }); + }); - test("handles readdirSync errors gracefully", () => { - let tempDir: string | undefined; - const originalCwd = process.cwd(); - try { - tempDir = mkdtempSync(join(tmpdir(), "archgate-installinfo-test-")); + test("handles readdirSync errors gracefully", () => { mkdirSync(join(tempDir, ".archgate"), { recursive: true }); // Create adrsDir as a file instead of a directory to cause readdirSync to throw writeFileSync(join(tempDir, ".archgate", "adrs"), "not a directory"); - process.chdir(tempDir); - const ctx = getProjectContext(); expect(ctx.hasProject).toBe(true); expect(ctx.adrCount).toBe(0); expect(ctx.adrWithRulesCount).toBe(0); expect(ctx.domains).toEqual([]); - } finally { - process.chdir(originalCwd); - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); - } + }); }); }); }); diff --git a/tests/helpers/pack-recommend.test.ts b/tests/helpers/pack-recommend.test.ts index 084cfe5d..5180f8a4 100644 --- a/tests/helpers/pack-recommend.test.ts +++ b/tests/helpers/pack-recommend.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -15,6 +15,10 @@ import { safeRmSync } from "../test-utils"; describe("recommendPacksFromDir", () => { let tempDir: string; + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); + }); + afterEach(() => { if (tempDir) safeRmSync(tempDir); }); @@ -49,7 +53,6 @@ describe("recommendPacksFromDir", () => { } test("returns high relevance for language match", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "typescript-strict", { tags: ["language:typescript"], adrCount: 4, @@ -70,7 +73,6 @@ describe("recommendPacksFromDir", () => { }); test("returns medium relevance for concern tags", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "security", { tags: ["concern:security"], adrCount: 3, @@ -89,7 +91,6 @@ describe("recommendPacksFromDir", () => { }); test("sorts high relevance before medium", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "security", { tags: ["concern:security"], adrCount: 3, @@ -112,7 +113,6 @@ describe("recommendPacksFromDir", () => { }); test("matches framework tags with high relevance", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "nextjs-app", { tags: ["framework:nextjs", "language:typescript"], adrCount: 3, @@ -132,7 +132,6 @@ describe("recommendPacksFromDir", () => { }); test("excludes packs with no matching tags", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "rust-safety", { tags: ["language:rust"], adrCount: 2, @@ -149,8 +148,6 @@ describe("recommendPacksFromDir", () => { }); test("returns empty array when no packs directory exists", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); - const stack: DetectedStack = { languages: ["typescript"], runtimes: [], @@ -162,7 +159,6 @@ describe("recommendPacksFromDir", () => { }); test("counts ADR files correctly", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "testing", { tags: ["concern:testing"], adrCount: 5 }); const stack: DetectedStack = { @@ -177,7 +173,6 @@ describe("recommendPacksFromDir", () => { }); test("matches runtime tags", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "bun-best-practices", { tags: ["runtime:bun"], adrCount: 2, @@ -196,7 +191,6 @@ describe("recommendPacksFromDir", () => { }); test("alphabetical sort within same relevance", () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-recommend-")); createPack(tempDir, "zebra", { tags: ["concern:zebra"], adrCount: 1 }); createPack(tempDir, "alpha", { tags: ["concern:alpha"], adrCount: 1 }); diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index 5f522654..71ac803d 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -15,69 +15,28 @@ import { // 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( - "-home-user-project" - ); - }); - - test("handles paths without slashes", async () => { - expect(await encodeProjectPath("project")).toBe("project"); - }); - - test("handles empty string", async () => { - expect(await encodeProjectPath("")).toBe(""); - }); - - test("replaces multiple consecutive slashes", async () => { - expect(await encodeProjectPath("/a//b")).toBe("-a--b"); - }); - - test("replaces backslashes and colons with dashes (Windows paths)", async () => { - expect(await encodeProjectPath("C:\\Users\\user\\project")).toBe( - "C--Users-user-project" - ); - }); - - test("handles mixed slashes", async () => { - expect(await encodeProjectPath("C:\\Users/user\\project")).toBe( - "C--Users-user-project" - ); - }); - - test("replaces dots with dashes", async () => { - expect(await encodeProjectPath("/home/user/.config/project")).toBe( - "-home-user--config-project" - ); - }); - - test("encodes Windows worktree path (colons, backslashes, dots)", async () => { - expect( - await encodeProjectPath( - "E:\\archgate\\cli\\.claude\\worktrees\\fancy-prancing-sedgewick" - ) - ).toBe("E--archgate-cli--claude-worktrees-fancy-prancing-sedgewick"); - }); - - test("cursor target strips colons instead of replacing with dashes", async () => { - expect(await encodeProjectPath("C:\\Users\\user\\project", "cursor")).toBe( - "C-Users-user-project" - ); - }); - - test("cursor target handles mixed slashes", async () => { - expect(await encodeProjectPath("C:\\Users/user\\project", "cursor")).toBe( - "C-Users-user-project" - ); - }); - - test("cursor target encodes Windows worktree path", async () => { - expect( - await encodeProjectPath( - "E:\\archgate\\cli\\.claude\\worktrees\\fancy-prancing-sedgewick", - "cursor" - ) - ).toBe("E-archgate-cli--claude-worktrees-fancy-prancing-sedgewick"); + test.each<[string, "cursor" | undefined, string]>([ + ["/home/user/project", undefined, "-home-user-project"], + ["project", undefined, "project"], + ["", undefined, ""], + ["/a//b", undefined, "-a--b"], + ["C:\\Users\\user\\project", undefined, "C--Users-user-project"], + ["C:\\Users/user\\project", undefined, "C--Users-user-project"], + ["/home/user/.config/project", undefined, "-home-user--config-project"], + [ + "E:\\archgate\\cli\\.claude\\worktrees\\fancy-prancing-sedgewick", + undefined, + "E--archgate-cli--claude-worktrees-fancy-prancing-sedgewick", + ], + ["C:\\Users\\user\\project", "cursor", "C-Users-user-project"], + ["C:\\Users/user\\project", "cursor", "C-Users-user-project"], + [ + "E:\\archgate\\cli\\.claude\\worktrees\\fancy-prancing-sedgewick", + "cursor", + "E-archgate-cli--claude-worktrees-fancy-prancing-sedgewick", + ], + ])("encodes %p (target=%p) -> %p", async (input, target, expected) => { + expect(await encodeProjectPath(input, target)).toBe(expected); }); test("cursor target produces same result as default for Unix paths", async () => { @@ -187,7 +146,7 @@ describe("readClaudeCodeSession", () => { 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); + expect(preview).toEndWith("..."); }); test("handles array content: text truncation, tool_use, tool_result", async () => { diff --git a/tests/helpers/signup.test.ts b/tests/helpers/signup.test.ts index e34fbaa1..67973b01 100644 --- a/tests/helpers/signup.test.ts +++ b/tests/helpers/signup.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, mock } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test, mock } from "bun:test"; import { SignupRequiredError, @@ -29,104 +29,89 @@ describe("SignupRequiredError", () => { }); describe("isSignupRequiredError", () => { - test("matches 'No approved signup found'", () => { - expect( - isSignupRequiredError("No approved signup found for this GitHub account") - ).toBe(true); - }); - - test("matches 'not registered'", () => { - expect(isSignupRequiredError("User is not registered")).toBe(true); + test.each<[string | undefined, boolean]>([ + ["No approved signup found for this GitHub account", true], + ["User is not registered", true], + ["NO APPROVED SIGNUP", true], + ["Token expired", false], + [undefined, false], + ])("returns %p for %p", (input, expected) => { + expect(isSignupRequiredError(input)).toBe(expected); }); +}); - test("is case-insensitive", () => { - expect(isSignupRequiredError("NO APPROVED SIGNUP")).toBe(true); - }); +describe("requestSignup", () => { + let originalFetch: typeof fetch; - test("returns false for unrelated messages", () => { - expect(isSignupRequiredError("Token expired")).toBe(false); + beforeEach(() => { + originalFetch = globalThis.fetch; }); - test("returns false for no argument", () => { - expect(isSignupRequiredError()).toBe(false); + afterEach(() => { + globalThis.fetch = originalFetch; }); -}); -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; - } + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(true); + expect(result.token).toBe("ag_beta_auto_approved"); }); 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; - } + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(true); + expect(result.token).toBeNull(); }); 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; - } + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(false); + expect(result.token).toBeNull(); }); 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; - } + const result = await requestSignup( + "octocat", + "octo@example.com", + "testing" + ); + expect(result.ok).toBe(true); + expect(result.token).toBeNull(); + }); + + test("propagates rejection when fetch fails (e.g. network error or timeout)", async () => { + mockFetch(() => Promise.reject(new Error("network down"))); + + await expect( + requestSignup("octocat", "octo@example.com", "testing") + ).rejects.toThrow("network down"); }); test("sends default editor=claude-code when editor not provided", async () => { - const originalFetch = globalThis.fetch; let capturedBody: string | null = null; globalThis.fetch = mock( @@ -138,13 +123,9 @@ describe("requestSignup", () => { } ) 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; - } + await requestSignup("octocat", "octo@example.com", "testing"); + expect(capturedBody).not.toBeNull(); + const parsed = JSON.parse(capturedBody!); + expect(parsed.editor).toBe("claude-code"); }); }); diff --git a/tests/helpers/stack-detect-frameworks.test.ts b/tests/helpers/stack-detect-frameworks.test.ts index 1281f000..5c0b6ca8 100644 --- a/tests/helpers/stack-detect-frameworks.test.ts +++ b/tests/helpers/stack-detect-frameworks.test.ts @@ -6,7 +6,7 @@ // stack-detect.test.ts so each file stays under the 500-line lint limit. // --------------------------------------------------------------------------- -import { describe, expect, test, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,277 +17,244 @@ import { safeRmSync } from "../test-utils"; describe("detectStack — frameworks", () => { let tempDir: string; - afterEach(() => { - if (tempDir) safeRmSync(tempDir); - }); - - // --------------------------------------------------------------------------- - // JS/TS dependency-based - // --------------------------------------------------------------------------- - - test("detects Express from package.json dependencies", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { express: "^4" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("express"); - }); - - test("detects Vue from package.json dependencies", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { vue: "^3" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("vue"); - }); - - test("detects Angular from @angular/core", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "@angular/core": "^17" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("angular"); - }); - - test("detects Solid from solid-js", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "solid-js": "^1" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("solid"); - }); - - test("detects NestJS from @nestjs/core", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "@nestjs/core": "^10" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("nestjs"); - }); - - test("detects Koa", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { koa: "^2" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("koa"); - }); - - test("detects Elysia", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { elysia: "^1" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("elysia"); - }); - - // --------------------------------------------------------------------------- - // Tailwind, MUI, TanStack - // --------------------------------------------------------------------------- - - test("detects Tailwind CSS from tailwind.config.ts", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); - writeFileSync(join(tempDir, "tailwind.config.ts"), "export default {}"); - expect((await detectStack(tempDir)).frameworks).toContain("tailwindcss"); - }); - - test("detects MUI from @mui/material", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "@mui/material": "^5" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("mui"); - }); - - test("detects TanStack Query from @tanstack/react-query", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ - name: "t", - dependencies: { "@tanstack/react-query": "^5" }, - }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("tanstack-query"); - }); - - test("detects TanStack Router", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ - name: "t", - dependencies: { "@tanstack/react-router": "^1" }, - }) - ); - expect((await detectStack(tempDir)).frameworks).toContain( - "tanstack-router" - ); - }); - - test("detects TanStack Start", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", dependencies: { "@tanstack/start": "^1" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("tanstack-start"); - }); - - test("detects TanStack Form", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ - name: "t", - dependencies: { "@tanstack/react-form": "^0" }, - }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("tanstack-form"); - }); - - // --------------------------------------------------------------------------- - // Non-JS ecosystems - // --------------------------------------------------------------------------- - - test("detects Rails from bin/rails", async () => { + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "Gemfile"), 'gem "rails"'); - mkdirSync(join(tempDir, "bin"), { recursive: true }); - writeFileSync(join(tempDir, "bin", "rails"), "#!/usr/bin/env ruby"); - const s = await detectStack(tempDir); - expect(s.languages).toContain("ruby"); - expect(s.frameworks).toContain("rails"); - }); - - test("detects Rails from config/routes.rb", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "Gemfile"), 'gem "rails"'); - mkdirSync(join(tempDir, "config"), { recursive: true }); - writeFileSync(join(tempDir, "config", "routes.rb"), "Rails.routes {}"); - expect((await detectStack(tempDir)).frameworks).toContain("rails"); - }); - - test("detects Django from manage.py", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "pyproject.toml"), "[project]\nname = 't'"); - writeFileSync(join(tempDir, "manage.py"), "#!/usr/bin/env python"); - const s = await detectStack(tempDir); - expect(s.languages).toContain("python"); - expect(s.frameworks).toContain("django"); - }); - - test("detects Laravel from artisan file", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "composer.json"), '{"name":"v/p"}'); - writeFileSync(join(tempDir, "artisan"), "#!/usr/bin/env php"); - const s = await detectStack(tempDir); - expect(s.languages).toContain("php"); - expect(s.frameworks).toContain("laravel"); - }); - - test("detects Flutter from pubspec.yaml", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "pubspec.yaml"), - "name: app\ndependencies:\n flutter:\n sdk: flutter\n" - ); - const s = await detectStack(tempDir); - expect(s.languages).toContain("dart"); - expect(s.frameworks).toContain("flutter"); }); - test("does not detect Flutter for plain Dart", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "pubspec.yaml"), "name: cli\ndependencies:\n"); - const s = await detectStack(tempDir); - expect(s.languages).toContain("dart"); - expect(s.frameworks).not.toContain("flutter"); - }); - - test("detects Phoenix from mix.exs", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "mix.exs"), - 'defmodule App do\n [{:phoenix, "~> 1.7"}]\nend' - ); - const s = await detectStack(tempDir); - expect(s.languages).toContain("elixir"); - expect(s.frameworks).toContain("phoenix"); - }); - - test("does not detect Phoenix for plain Elixir", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "mix.exs"), "defmodule App do\nend"); - const s = await detectStack(tempDir); - expect(s.languages).toContain("elixir"); - expect(s.frameworks).not.toContain("phoenix"); + afterEach(() => { + if (tempDir) safeRmSync(tempDir); }); // --------------------------------------------------------------------------- - // Python frameworks (FastAPI, Streamlit, Flask) + // Single-signal framework detections: write one fixture, expect one + // framework to show up in `frameworks`. // --------------------------------------------------------------------------- - test("detects FastAPI from requirements.txt", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "requirements.txt"), "fastapi>=0.100\n"); - expect((await detectStack(tempDir)).frameworks).toContain("fastapi"); - }); - - test("detects Streamlit from requirements.txt", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "requirements.txt"), "streamlit==1.30.0\n"); - expect((await detectStack(tempDir)).frameworks).toContain("streamlit"); - }); - - test("detects FastAPI from pyproject.toml", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "pyproject.toml"), - '[project]\nname = "api"\ndependencies = ["fastapi>=0.100"]\n' - ); - expect((await detectStack(tempDir)).frameworks).toContain("fastapi"); - }); - - test("detects Flask from requirements.txt", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync(join(tempDir, "requirements.txt"), "flask==3.0.0\n"); - expect((await detectStack(tempDir)).frameworks).toContain("flask"); + const packageJsonWith = + ( + deps: Record<string, string>, + key: "dependencies" | "devDependencies" = "dependencies" + ) => + (dir: string) => { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "t", [key]: deps }) + ); + }; + + const frameworkCases: Array<{ + name: string; + setup: (dir: string) => void; + framework: string; + }> = [ + { + name: "Express from package.json dependencies", + setup: packageJsonWith({ express: "^4" }), + framework: "express", + }, + { + name: "Vue from package.json dependencies", + setup: packageJsonWith({ vue: "^3" }), + framework: "vue", + }, + { + name: "Angular from @angular/core", + setup: packageJsonWith({ "@angular/core": "^17" }), + framework: "angular", + }, + { + name: "Solid from solid-js", + setup: packageJsonWith({ "solid-js": "^1" }), + framework: "solid", + }, + { + name: "NestJS from @nestjs/core", + setup: packageJsonWith({ "@nestjs/core": "^10" }), + framework: "nestjs", + }, + { name: "Koa", setup: packageJsonWith({ koa: "^2" }), framework: "koa" }, + { + name: "Elysia", + setup: packageJsonWith({ elysia: "^1" }), + framework: "elysia", + }, + { + name: "Tailwind CSS from tailwind.config.ts", + setup: (dir) => { + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "t" })); + writeFileSync(join(dir, "tailwind.config.ts"), "export default {}"); + }, + framework: "tailwindcss", + }, + { + name: "MUI from @mui/material", + setup: packageJsonWith({ "@mui/material": "^5" }), + framework: "mui", + }, + { + name: "TanStack Query from @tanstack/react-query", + setup: packageJsonWith({ "@tanstack/react-query": "^5" }), + framework: "tanstack-query", + }, + { + name: "TanStack Router", + setup: packageJsonWith({ "@tanstack/react-router": "^1" }), + framework: "tanstack-router", + }, + { + name: "TanStack Start", + setup: packageJsonWith({ "@tanstack/start": "^1" }), + framework: "tanstack-start", + }, + { + name: "TanStack Form", + setup: packageJsonWith({ "@tanstack/react-form": "^0" }), + framework: "tanstack-form", + }, + { + name: "FastAPI from requirements.txt", + setup: (dir) => + writeFileSync(join(dir, "requirements.txt"), "fastapi>=0.100\n"), + framework: "fastapi", + }, + { + name: "Streamlit from requirements.txt", + setup: (dir) => + writeFileSync(join(dir, "requirements.txt"), "streamlit==1.30.0\n"), + framework: "streamlit", + }, + { + name: "FastAPI from pyproject.toml", + setup: (dir) => + writeFileSync( + join(dir, "pyproject.toml"), + '[project]\nname = "api"\ndependencies = ["fastapi>=0.100"]\n' + ), + framework: "fastapi", + }, + { + name: "Flask from requirements.txt", + setup: (dir) => + writeFileSync(join(dir, "requirements.txt"), "flask==3.0.0\n"), + framework: "flask", + }, + { + name: "Prisma from devDependencies", + setup: packageJsonWith({ prisma: "^5" }, "devDependencies"), + framework: "prisma", + }, + { + name: "Playwright from @playwright/test", + setup: packageJsonWith({ "@playwright/test": "^1" }, "devDependencies"), + framework: "playwright", + }, + ]; + + test.each(frameworkCases)("detects $name", async ({ setup, framework }) => { + setup(tempDir); + expect((await detectStack(tempDir)).frameworks).toContain(framework); }); // --------------------------------------------------------------------------- - // Testing & tooling + // Non-JS ecosystems: these also assert on detected `languages`, and a + // couple assert a framework is deliberately *not* detected without the + // framework's own marker file present. // --------------------------------------------------------------------------- - test("detects Prisma from devDependencies", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ name: "t", devDependencies: { prisma: "^5" } }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("prisma"); - }); - - test("detects Playwright from @playwright/test", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - writeFileSync( - join(tempDir, "package.json"), - JSON.stringify({ - name: "t", - devDependencies: { "@playwright/test": "^1" }, - }) - ); - expect((await detectStack(tempDir)).frameworks).toContain("playwright"); - }); + const ecosystemCases: Array<{ + name: string; + setup: (dir: string) => void; + language?: string; + framework?: string; + notFramework?: string; + }> = [ + { + name: "Rails from bin/rails", + setup: (dir) => { + writeFileSync(join(dir, "Gemfile"), 'gem "rails"'); + mkdirSync(join(dir, "bin"), { recursive: true }); + writeFileSync(join(dir, "bin", "rails"), "#!/usr/bin/env ruby"); + }, + language: "ruby", + framework: "rails", + }, + { + name: "Rails from config/routes.rb", + setup: (dir) => { + writeFileSync(join(dir, "Gemfile"), 'gem "rails"'); + mkdirSync(join(dir, "config"), { recursive: true }); + writeFileSync(join(dir, "config", "routes.rb"), "Rails.routes {}"); + }, + framework: "rails", + }, + { + name: "Django from manage.py", + setup: (dir) => { + writeFileSync(join(dir, "pyproject.toml"), "[project]\nname = 't'"); + writeFileSync(join(dir, "manage.py"), "#!/usr/bin/env python"); + }, + language: "python", + framework: "django", + }, + { + name: "Laravel from artisan file", + setup: (dir) => { + writeFileSync(join(dir, "composer.json"), '{"name":"v/p"}'); + writeFileSync(join(dir, "artisan"), "#!/usr/bin/env php"); + }, + language: "php", + framework: "laravel", + }, + { + name: "Flutter from pubspec.yaml", + setup: (dir) => { + writeFileSync( + join(dir, "pubspec.yaml"), + "name: app\ndependencies:\n flutter:\n sdk: flutter\n" + ); + }, + language: "dart", + framework: "flutter", + }, + { + name: "does not detect Flutter for plain Dart", + setup: (dir) => { + writeFileSync(join(dir, "pubspec.yaml"), "name: cli\ndependencies:\n"); + }, + language: "dart", + notFramework: "flutter", + }, + { + name: "Phoenix from mix.exs", + setup: (dir) => { + writeFileSync( + join(dir, "mix.exs"), + 'defmodule App do\n [{:phoenix, "~> 1.7"}]\nend' + ); + }, + language: "elixir", + framework: "phoenix", + }, + { + name: "does not detect Phoenix for plain Elixir", + setup: (dir) => { + writeFileSync(join(dir, "mix.exs"), "defmodule App do\nend"); + }, + language: "elixir", + notFramework: "phoenix", + }, + ]; + + test.each(ecosystemCases)( + "$name", + async ({ setup, language, framework, notFramework }) => { + setup(tempDir); + const s = await detectStack(tempDir); + if (language) expect(s.languages).toContain(language); + if (framework) expect(s.frameworks).toContain(framework); + if (notFramework) expect(s.frameworks).not.toContain(notFramework); + } + ); }); // --------------------------------------------------------------------------- diff --git a/tests/helpers/stack-detect.test.ts b/tests/helpers/stack-detect.test.ts index 85391a9a..c60a3b9d 100644 --- a/tests/helpers/stack-detect.test.ts +++ b/tests/helpers/stack-detect.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,10 @@ import { safeRmSync } from "../test-utils"; describe("detectStack", () => { let tempDir: string; + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); + }); + afterEach(() => { if (tempDir) safeRmSync(tempDir); }); @@ -20,7 +24,6 @@ describe("detectStack", () => { // --------------------------------------------------------------------------- test("detects TypeScript from tsconfig.json", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "tsconfig.json"), "{}"); writeFileSync( join(tempDir, "package.json"), @@ -34,7 +37,6 @@ describe("detectStack", () => { }); test("detects TypeScript from devDependencies", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( join(tempDir, "package.json"), JSON.stringify({ @@ -49,7 +51,6 @@ describe("detectStack", () => { }); test("detects JavaScript when package.json exists without TypeScript", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( join(tempDir, "package.json"), JSON.stringify({ name: "test" }) @@ -65,7 +66,6 @@ describe("detectStack", () => { // --------------------------------------------------------------------------- test("detects Python from pyproject.toml", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "pyproject.toml"), "[project]\nname = 'test'"); const stack = await detectStack(tempDir); @@ -73,7 +73,6 @@ describe("detectStack", () => { }); test("detects Python from requirements.txt", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "requirements.txt"), "flask==2.0.0"); const stack = await detectStack(tempDir); @@ -81,7 +80,6 @@ describe("detectStack", () => { }); test("detects Go from go.mod", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "go.mod"), "module example.com/test"); const stack = await detectStack(tempDir); @@ -89,7 +87,6 @@ describe("detectStack", () => { }); test("detects Rust from Cargo.toml", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "Cargo.toml"), '[package]\nname = "test"'); const stack = await detectStack(tempDir); @@ -101,7 +98,6 @@ describe("detectStack", () => { // --------------------------------------------------------------------------- test("detects Ruby from Gemfile", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "Gemfile"), 'source "https://rubygems.org"'); const stack = await detectStack(tempDir); @@ -109,7 +105,6 @@ describe("detectStack", () => { }); test("detects Ruby from .ruby-version", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, ".ruby-version"), "3.3.0"); const stack = await detectStack(tempDir); @@ -117,7 +112,6 @@ describe("detectStack", () => { }); test("detects Java from pom.xml", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "pom.xml"), "<project></project>"); const stack = await detectStack(tempDir); @@ -125,7 +119,6 @@ describe("detectStack", () => { }); test("detects Java from build.gradle", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "build.gradle"), "plugins {}"); const stack = await detectStack(tempDir); @@ -133,7 +126,6 @@ describe("detectStack", () => { }); test("detects Java from build.gradle.kts", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "build.gradle.kts"), "plugins {}"); const stack = await detectStack(tempDir); @@ -141,7 +133,6 @@ describe("detectStack", () => { }); test("detects PHP from composer.json", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( join(tempDir, "composer.json"), JSON.stringify({ name: "vendor/pkg" }) @@ -152,7 +143,6 @@ describe("detectStack", () => { }); test("detects Swift from Package.swift", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "Package.swift"), "// swift-tools-version:5.9"); const stack = await detectStack(tempDir); @@ -160,7 +150,6 @@ describe("detectStack", () => { }); test("detects Elixir from mix.exs", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( join(tempDir, "mix.exs"), "defmodule MyApp.MixProject do\nend" @@ -171,7 +160,6 @@ describe("detectStack", () => { }); test("detects Dart from pubspec.yaml", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "pubspec.yaml"), "name: my_app"); const stack = await detectStack(tempDir); @@ -179,7 +167,6 @@ describe("detectStack", () => { }); test("detects C# from global.json", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( join(tempDir, "global.json"), JSON.stringify({ sdk: { version: "8.0.0" } }) @@ -190,7 +177,6 @@ describe("detectStack", () => { }); test("detects C# from Directory.Build.props", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "Directory.Build.props"), "<Project />"); const stack = await detectStack(tempDir); @@ -198,7 +184,6 @@ describe("detectStack", () => { }); test("detects Scala from build.sbt", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "build.sbt"), 'name := "my-app"'); const stack = await detectStack(tempDir); @@ -206,7 +191,6 @@ describe("detectStack", () => { }); test("detects Zig from build.zig", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "build.zig"), 'const std = @import("std");'); const stack = await detectStack(tempDir); @@ -218,7 +202,6 @@ describe("detectStack", () => { // --------------------------------------------------------------------------- test("detects Bun runtime from bun.lock", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); writeFileSync(join(tempDir, "bun.lock"), "# bun lockfile"); @@ -228,7 +211,6 @@ describe("detectStack", () => { }); test("detects Deno runtime from deno.json", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "deno.json"), "{}"); const stack = await detectStack(tempDir); @@ -240,7 +222,6 @@ describe("detectStack", () => { // --------------------------------------------------------------------------- test("detects Next.js framework from next.config.ts", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync( join(tempDir, "package.json"), JSON.stringify({ name: "t", dependencies: { react: "^18" } }) @@ -253,7 +234,6 @@ describe("detectStack", () => { }); test("detects Vite framework from vite.config.ts", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); writeFileSync(join(tempDir, "vite.config.ts"), "export default {}"); @@ -262,7 +242,6 @@ describe("detectStack", () => { }); test("detects Nuxt framework from nuxt.config.ts", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); writeFileSync(join(tempDir, "nuxt.config.ts"), "export default {}"); @@ -271,7 +250,6 @@ describe("detectStack", () => { }); test("detects Astro framework from astro.config.mjs", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); writeFileSync(join(tempDir, "astro.config.mjs"), "export default {}"); @@ -280,7 +258,6 @@ describe("detectStack", () => { }); test("detects Svelte from svelte.config.js", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "package.json"), JSON.stringify({ name: "t" })); writeFileSync(join(tempDir, "svelte.config.js"), "export default {}"); @@ -293,8 +270,6 @@ describe("detectStack", () => { // --------------------------------------------------------------------------- test("returns empty arrays for empty directory", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); - const stack = await detectStack(tempDir); expect(stack.languages).toEqual([]); expect(stack.runtimes).toEqual([]); @@ -302,7 +277,6 @@ describe("detectStack", () => { }); test("detects multiple languages in a polyglot project", async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-stack-")); writeFileSync(join(tempDir, "tsconfig.json"), "{}"); writeFileSync( join(tempDir, "package.json"), diff --git a/tests/helpers/user-error.test.ts b/tests/helpers/user-error.test.ts index 804722ed..e32f56ac 100644 --- a/tests/helpers/user-error.test.ts +++ b/tests/helpers/user-error.test.ts @@ -32,7 +32,7 @@ describe("UserError", () => { test("is distinguishable from plain Error via instanceof", () => { const userErr = new UserError("expected"); const plainErr = new Error("unexpected"); - expect(userErr instanceof UserError).toBe(true); - expect(plainErr instanceof UserError).toBe(false); + expect(userErr).toBeInstanceOf(UserError); + expect(plainErr).not.toBeInstanceOf(UserError); }); }); diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index d0917577..2c6a3d05 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -327,17 +327,18 @@ describe("getVscodeUserSettingsPath", () => { } ); - 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("\\", "/"); - expect(normalized).toContain("AppData/Roaming/Code/User/settings.json"); - } finally { - restoreEnv("APPDATA", savedAppData); + test.skipIf(!isWindows())( + "falls back to AppData/Roaming when APPDATA is unset on Windows", + async () => { + const savedAppData = process.env.APPDATA; + try { + delete process.env.APPDATA; + const path = await getVscodeUserSettingsPath(); + const normalized = path.replaceAll("\\", "/"); + expect(normalized).toContain("AppData/Roaming/Code/User/settings.json"); + } finally { + restoreEnv("APPDATA", savedAppData); + } } - }); + ); }); diff --git a/tests/integration/adr.test.ts b/tests/integration/adr.test.ts index 90289d00..6a499ddd 100644 --- a/tests/integration/adr.test.ts +++ b/tests/integration/adr.test.ts @@ -187,7 +187,7 @@ describe("adr integration", () => { 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).toBeInstanceOf(Array); expect(parsed.length).toBe(2); expect(parsed[0]).toHaveProperty("id"); expect(parsed[0]).toHaveProperty("domain"); diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index ca891af5..59d9c423 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -84,7 +84,7 @@ describe("check integration", () => { const { exitCode, stdout } = await runCli(["check"], dir); expect(exitCode).toBe(1); const lower = stdout.toLowerCase(); - expect(lower.includes("violation") || lower.includes("failed")).toBe(true); + expect(lower).toMatch(/violation|failed/u); }); test("--json flag → exit 0 and output has expected shape", async () => { @@ -104,7 +104,7 @@ describe("check integration", () => { const json = JSON.parse(stdout); expect(json.pass).toBe(true); expect(typeof json.total).toBe("number"); - expect(Array.isArray(json.results)).toBe(true); + expect(json.results).toBeInstanceOf(Array); }); test("--json with violations → pass: false and violations present", async () => { diff --git a/tests/integration/clean.test.ts b/tests/integration/clean.test.ts index d1df4539..8c3a52d5 100644 --- a/tests/integration/clean.test.ts +++ b/tests/integration/clean.test.ts @@ -52,9 +52,7 @@ describe("clean integration", () => { }); 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); + expect(stdout).toMatch(/cleaned up|Nothing to clean/u); }); test("removes ~/.archgate directory and prints 'cleaned up'", async () => { diff --git a/tests/integration/review-context.test.ts b/tests/integration/review-context.test.ts index b1a64f63..a382b5cf 100644 --- a/tests/integration/review-context.test.ts +++ b/tests/integration/review-context.test.ts @@ -67,8 +67,8 @@ describe("review-context integration", () => { } const ctx = parsed as Record<string, unknown>; - expect(Array.isArray(ctx.allChangedFiles)).toBe(true); - expect(Array.isArray(ctx.domains)).toBe(true); + expect(ctx.allChangedFiles).toBeInstanceOf(Array); + expect(ctx.domains).toBeInstanceOf(Array); }); test("filters output by --domain flag", async () => { @@ -109,7 +109,7 @@ describe("review-context integration", () => { // Assert non-empty first: `.every()` is vacuously true on an empty array, so // without this the test passes even when no domains are returned at all. expect(domainNames.length).toBeGreaterThan(0); - expect(domainNames.every((d) => d === "architecture")).toBe(true); + expect(new Set(domainNames)).toEqual(new Set(["architecture"])); expect(domainNames).not.toContain("backend"); });