From 1917835230e30e36e0a531c7ac879a4ca62e95e2 Mon Sep 17 00:00:00 2001 From: Alan Alwakeel Date: Fri, 3 Apr 2026 16:07:47 -0400 Subject: [PATCH] fix(test): add test environment isolation for worktree and RTK tests - worktree.ts: Added _resetServiceCache() to allow tests to clear cached GitServiceImpl - tests/integration/test-isolation.ts: Shared test isolation utilities - 5 worktree test files: Isolate from global ~/.gsd/preferences.md by resetting service cache and overriding HOME - 2 RTK test files: Clear GSD_RTK_DISABLED before running, restore after These 13 tests pass in CI (clean environment) but fail on developer machines where: - Global preferences set git.main_branch: master but test repos use main - GSD_RTK_DISABLED=1 is set, causing RTK snapshot tests to fail --- .../gsd/tests/integration/test-isolation.ts | 53 +++++++++++++++++++ .../gsd/tests/stale-worktree-cwd.test.ts | 13 +++++ .../gsd/tests/stash-pop-gsd-conflict.test.ts | 21 ++++++++ .../tests/stash-queued-context-files.test.ts | 21 ++++++++ .../gsd/tests/worktree-integration.test.ts | 16 ++++++ .../extensions/gsd/tests/worktree.test.ts | 35 ++++++++---- src/resources/extensions/gsd/worktree.ts | 10 ++++ src/tests/rtk-session-stats.test.ts | 20 ++++++- src/tests/rtk.test.ts | 20 ++++++- 9 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/integration/test-isolation.ts diff --git a/src/resources/extensions/gsd/tests/integration/test-isolation.ts b/src/resources/extensions/gsd/tests/integration/test-isolation.ts new file mode 100644 index 0000000000..bc8270222b --- /dev/null +++ b/src/resources/extensions/gsd/tests/integration/test-isolation.ts @@ -0,0 +1,53 @@ +/** + * Test isolation utilities for integration tests. + * + * Integration tests often call `mergeMilestoneToMain` and other functions that + * load preferences. If the user's global ~/.gsd/preferences.md has + * `git.main_branch: master`, tests fail because test repos use `main`. + * + * These utilities isolate tests from the user's global environment. + */ + +import { mkdtempSync, rmSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { _resetServiceCache } from "../../worktree.ts"; +import { _clearGsdRootCache } from "../../paths.ts"; + +let originalHome: string | undefined; +let fakeHome: string | null = null; + +/** + * Isolate the test environment from user's global preferences. + * Creates a fake HOME directory so loadEffectiveGSDPreferences() returns + * empty global preferences instead of the user's ~/.gsd/preferences.md. + * + * Call this in a test.before() hook. + */ +export function isolateFromGlobalPreferences(): void { + originalHome = process.env.HOME; + fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-test-home-"))); + process.env.HOME = fakeHome; + _clearGsdRootCache(); + _resetServiceCache(); +} + +/** + * Restore the original HOME and clean up the fake home directory. + * + * Call this in a test.after() hook. + */ +export function restoreGlobalPreferences(): void { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + _clearGsdRootCache(); + _resetServiceCache(); + if (fakeHome) { + rmSync(fakeHome, { recursive: true, force: true }); + fakeHome = null; + } +} diff --git a/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts b/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts index 163b0a8044..def9d71075 100644 --- a/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +++ b/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts @@ -17,6 +17,8 @@ import { teardownAutoWorktree, mergeMilestoneToMain, } from "../auto-worktree.ts"; +import { _resetServiceCache } from "../worktree.ts"; +import { _clearGsdRootCache } from "../paths.ts"; function run(command: string, cwd: string): string { return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); @@ -62,6 +64,13 @@ test("mergeMilestoneToMain restores cwd to project root", () => { const savedCwd = process.cwd(); let tempDir = ""; + // Isolate from user's global preferences (which may have git.main_branch set) + const originalHome = process.env.HOME; + const fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-fake-home-"))); + process.env.HOME = fakeHome; + _clearGsdRootCache(); + _resetServiceCache(); + try { tempDir = createTempRepo(); @@ -97,9 +106,13 @@ test("mergeMilestoneToMain restores cwd to project root", () => { assert.ok(!existsSync(wtPath), "worktree directory removed after merge"); } finally { process.chdir(savedCwd); + process.env.HOME = originalHome; + _clearGsdRootCache(); + _resetServiceCache(); if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } + rmSync(fakeHome, { recursive: true, force: true }); } }); diff --git a/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts b/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts index 89ad125ae2..f295c8f0fc 100644 --- a/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +++ b/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts @@ -15,6 +15,27 @@ import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; import { createAutoWorktree, mergeMilestoneToMain } from "../auto-worktree.ts"; +import { _resetServiceCache } from "../worktree.ts"; +import { _clearGsdRootCache } from "../paths.ts"; + +// Isolate from user's global preferences (which may have git.main_branch set) +let originalHome: string | undefined; +let fakeHome: string; + +test.before(() => { + originalHome = process.env.HOME; + fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-fake-home-"))); + process.env.HOME = fakeHome; + _clearGsdRootCache(); + _resetServiceCache(); +}); + +test.after(() => { + process.env.HOME = originalHome; + _clearGsdRootCache(); + _resetServiceCache(); + rmSync(fakeHome, { recursive: true, force: true }); +}); function run(cmd: string, cwd: string): string { return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); diff --git a/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts b/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts index cb59fa5e82..ad45919089 100644 --- a/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +++ b/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts @@ -27,6 +27,27 @@ import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; import { createAutoWorktree, mergeMilestoneToMain } from "../auto-worktree.ts"; +import { _resetServiceCache } from "../worktree.ts"; +import { _clearGsdRootCache } from "../paths.ts"; + +// Isolate from user's global preferences (which may have git.main_branch set) +let originalHome: string | undefined; +let fakeHome: string; + +test.before(() => { + originalHome = process.env.HOME; + fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-fake-home-"))); + process.env.HOME = fakeHome; + _clearGsdRootCache(); + _resetServiceCache(); +}); + +test.after(() => { + process.env.HOME = originalHome; + _clearGsdRootCache(); + _resetServiceCache(); + rmSync(fakeHome, { recursive: true, force: true }); +}); function run(cmd: string, cwd: string): string { return execSync(cmd, { diff --git a/src/resources/extensions/gsd/tests/worktree-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-integration.test.ts index 9c350ff13b..ab00308722 100644 --- a/src/resources/extensions/gsd/tests/worktree-integration.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-integration.test.ts @@ -26,9 +26,11 @@ import { getSliceBranchName, autoCommitCurrentBranch, SLICE_BRANCH_RE, + _resetServiceCache, } from "../worktree.ts"; import { deriveState } from "../state.ts"; +import { _clearGsdRootCache } from "../paths.ts"; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; @@ -74,6 +76,14 @@ run("git add .", base); run('git commit -m "chore: init"', base); describe('worktree-integration', async () => { + // Isolate from user's global preferences (which may have git.main_branch set). + // Reset caches so getService() creates a fresh instance with empty preferences. + const originalHome = process.env.HOME; + const fakeHome = mkdtempSync(join(tmpdir(), "gsd-fake-home-")); + process.env.HOME = fakeHome; + _clearGsdRootCache(); + _resetServiceCache(); + // ── Verify main tree baseline ────────────────────────────────────────────── console.log("\n=== Main tree baseline ==="); @@ -197,4 +207,10 @@ describe('worktree-integration', async () => { assert.deepStrictEqual(listWorktrees(base).length, 0, "all worktrees removed"); rmSync(base, { recursive: true, force: true }); + + // Restore HOME and reset caches + process.env.HOME = originalHome; + _clearGsdRootCache(); + _resetServiceCache(); + rmSync(fakeHome, { recursive: true, force: true }); }); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 71dd32be7c..a23f925eb2 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -14,9 +14,11 @@ import { resolveProjectRoot, setActiveMilestoneId, SLICE_BRANCH_RE, + _resetServiceCache, } from "../worktree.ts"; import { readIntegrationBranch } from "../git-service.ts"; import { _resetHasChangesCache } from "../native-git-bridge.ts"; +import { _clearGsdRootCache } from "../paths.ts"; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; @@ -165,15 +167,30 @@ describe('worktree', async () => { run("git checkout -b my-feature", repo); captureIntegrationBranch(repo, "M001"); - // Without milestone set, getMainBranch returns "main" - setActiveMilestoneId(repo, null); - assert.deepStrictEqual(getMainBranch(repo), "main", - "getMainBranch returns main without milestone set"); - - // With milestone set, getMainBranch returns feature branch - setActiveMilestoneId(repo, "M001"); - assert.deepStrictEqual(getMainBranch(repo), "my-feature", - "getMainBranch returns integration branch with milestone set"); + // Isolate from user's global preferences (which may have git.main_branch set). + // Reset caches so getService() creates a fresh instance with empty preferences. + const originalHome = process.env.HOME; + const fakeHome = mkdtempSync(join(tmpdir(), "gsd-fake-home-")); + process.env.HOME = fakeHome; + _clearGsdRootCache(); + _resetServiceCache(); + + try { + // Without milestone set, getMainBranch returns "main" + setActiveMilestoneId(repo, null); + assert.deepStrictEqual(getMainBranch(repo), "main", + "getMainBranch returns main without milestone set"); + + // With milestone set, getMainBranch returns feature branch + setActiveMilestoneId(repo, "M001"); + assert.deepStrictEqual(getMainBranch(repo), "my-feature", + "getMainBranch returns integration branch with milestone set"); + } finally { + process.env.HOME = originalHome; + _clearGsdRootCache(); + _resetServiceCache(); + rmSync(fakeHome, { recursive: true, force: true }); + } rmSync(repo, { recursive: true, force: true }); } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 0f84166aed..c6bbf6af2b 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -42,6 +42,16 @@ function getService(basePath: string): GitServiceImpl { return cachedService; } +/** + * Clear the cached GitServiceImpl. For testing only — forces the next + * getService() call to re-read preferences and create a fresh instance. + * @internal + */ +export function _resetServiceCache(): void { + cachedService = null; + cachedBasePath = null; +} + /** * Set the active milestone ID on the cached GitServiceImpl. * This enables integration branch resolution in getMainBranch(). diff --git a/src/tests/rtk-session-stats.test.ts b/src/tests/rtk-session-stats.test.ts index 88a14e9445..5b6f1791d7 100644 --- a/src/tests/rtk-session-stats.test.ts +++ b/src/tests/rtk-session-stats.test.ts @@ -1,4 +1,4 @@ -import test from "node:test"; +import test, { beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; @@ -12,6 +12,24 @@ import { } from "../resources/extensions/shared/rtk-session-stats.ts"; import { createFakeRtk } from "./rtk-test-utils.ts"; +// Store original env values for restoration +let originalRtkDisabled: string | undefined; + +beforeEach(() => { + // Save and clear GSD_RTK_DISABLED so tests can use fake RTK binaries + originalRtkDisabled = process.env.GSD_RTK_DISABLED; + delete process.env.GSD_RTK_DISABLED; +}); + +afterEach(() => { + // Restore original env + if (originalRtkDisabled !== undefined) { + process.env.GSD_RTK_DISABLED = originalRtkDisabled; + } else { + delete process.env.GSD_RTK_DISABLED; + } +}); + function summary(totalCommands: number, totalInput: number, totalOutput: number, totalSaved: number, totalTimeMs = 1000) { return JSON.stringify({ summary: { diff --git a/src/tests/rtk.test.ts b/src/tests/rtk.test.ts index c51e2d7cf0..8c9c760715 100644 --- a/src/tests/rtk.test.ts +++ b/src/tests/rtk.test.ts @@ -1,4 +1,4 @@ -import test from "node:test"; +import test, { beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -19,6 +19,24 @@ import { } from "../rtk.ts"; import { createFakeRtk } from "./rtk-test-utils.ts"; +// Store original env values for restoration +let originalRtkDisabled: string | undefined; + +beforeEach(() => { + // Save and clear GSD_RTK_DISABLED so tests can use fake RTK binaries + originalRtkDisabled = process.env.GSD_RTK_DISABLED; + delete process.env.GSD_RTK_DISABLED; +}); + +afterEach(() => { + // Restore original env + if (originalRtkDisabled !== undefined) { + process.env.GSD_RTK_DISABLED = originalRtkDisabled; + } else { + delete process.env.GSD_RTK_DISABLED; + } +}); + test("resolveRtkAssetName maps supported release assets correctly", () => { assert.equal(resolveRtkAssetName("darwin", "arm64"), "rtk-aarch64-apple-darwin.tar.gz"); assert.equal(resolveRtkAssetName("darwin", "x64"), "rtk-x86_64-apple-darwin.tar.gz");