From 4c9a144b710cb86f2f5283c21a186a7370274216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Can=20G=C3=BCm=C3=BC=C5=9F?= Date: Sat, 24 Oct 2026 18:12:58 +0300 Subject: [PATCH] Guard tests against tracked file changes (#1807) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/acceptance/acceptance.test.ts | 4 +- test/acceptance/features/init-command.feature | 7 ++- test/acceptance/steps/cli-steps.ts | 31 +++++++++- test/casting-legacy-migration.test.ts | 3 +- test/casting.test.ts | 3 +- test/cli-packaging-smoke.test.ts | 6 ++ test/cli.test.ts | 10 ++-- test/cli/cast.test.ts | 7 ++- test/cli/legacy-casting-upgrade.test.ts | 5 +- test/cli/state-mcp-lifecycle.test.ts | 3 +- test/init.test.ts | 11 ++-- test/memory-governance.test.ts | 3 +- test/memory-providers.test.ts | 3 +- test/presets.test.ts | 4 +- test/setup/tracked-worktree-guard.ts | 57 +++++++++++++++++++ test/speed-gates.test.ts | 3 +- test/tracked-worktree-guard.test.ts | 53 +++++++++++++++++ vitest.config.ts | 1 + 18 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 test/setup/tracked-worktree-guard.ts create mode 100644 test/tracked-worktree-guard.test.ts diff --git a/test/acceptance/acceptance.test.ts b/test/acceptance/acceptance.test.ts index 5323609f5..88dde6d4c 100644 --- a/test/acceptance/acceptance.test.ts +++ b/test/acceptance/acceptance.test.ts @@ -5,9 +5,8 @@ import { afterEach } from 'vitest'; import { join } from 'node:path'; -import { rmSync } from 'node:fs'; import { createRegistry, runFeature } from './support/runner.js'; -import { registerCLISteps } from './steps/cli-steps.js'; +import { cleanupAcceptanceTempDirs, registerCLISteps } from './steps/cli-steps.js'; import { TerminalHarness } from './harness.js'; // Create step definition registry @@ -20,6 +19,7 @@ afterEach(async (context) => { if (harness) { await harness.close(); } + cleanupAcceptanceTempDirs(); }); // Run all feature files diff --git a/test/acceptance/features/init-command.feature b/test/acceptance/features/init-command.feature index aa854f338..5c7757e21 100644 --- a/test/acceptance/features/init-command.feature +++ b/test/acceptance/features/init-command.feature @@ -1,12 +1,13 @@ Feature: Init command Scenario: Init in existing project shows ready message - Given the current directory has a ".squad" directory - When I run "squad init" + Given a temporary project with an existing ".squad" directory + When I run "squad init" in the temp directory Then the output contains "Squad initialized" And the output contains "already exists" And the exit code is 0 Scenario: Init exit code is zero on success - When I run "squad init" + Given a directory without a ".squad" directory + When I run "squad init" in the temp directory Then the exit code is 0 diff --git a/test/acceptance/steps/cli-steps.ts b/test/acceptance/steps/cli-steps.ts index a69a74853..ca45ed20b 100644 --- a/test/acceptance/steps/cli-steps.ts +++ b/test/acceptance/steps/cli-steps.ts @@ -1,10 +1,23 @@ import { expect } from 'vitest'; -import { existsSync, mkdtempSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { TerminalHarness } from '../harness.js'; import { StepDefinitions, registerStep } from '../support/runner.js'; +const tempDirs = new Set(); + +export function cleanupAcceptanceTempDirs(): void { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + tempDirs.clear(); +} + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'squad-e2e-')); + tempDirs.add(dir); + return dir; +} + /** * CLI step definitions for Gherkin acceptance tests. */ @@ -32,14 +45,26 @@ export function registerCLISteps(registry: StepDefinitions): void { registerStep( 'Given', - /a directory without a "\.squad" directory/, + /a temporary project with an existing "\.squad" directory/, async (_stepText, context) => { - const tempDir = mkdtempSync(join(tmpdir(), 'squad-e2e-')); + const tempDir = makeTempDir(); + const squadDir = join(tempDir, '.squad'); + mkdirSync(squadDir, { recursive: true }); + writeFileSync(join(squadDir, 'team.md'), '# Acceptance team\n'); context.tempDir = tempDir; }, registry ); + registerStep( + 'Given', + /a directory without a "\.squad" directory/, + async (_stepText, context) => { + context.tempDir = makeTempDir(); + }, + registry + ); + // --- When steps --- registerStep( diff --git a/test/casting-legacy-migration.test.ts b/test/casting-legacy-migration.test.ts index 4cb4321ed..7ee623f9b 100644 --- a/test/casting-legacy-migration.test.ts +++ b/test/casting-legacy-migration.test.ts @@ -1,5 +1,6 @@ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from 'vitest'; import { _setCastingDurabilityHooksForTesting, @@ -25,7 +26,7 @@ function fixture() { } function castingDir(): string { - const root = mkdtempSync(join(process.cwd(), '.test-legacy-migration-')); + const root = mkdtempSync(join(tmpdir(), '.test-legacy-migration-')); roots.push(root); const dir = join(root, 'casting'); mkdirSync(dir); diff --git a/test/casting.test.ts b/test/casting.test.ts index e37c8d293..1b7255382 100644 --- a/test/casting.test.ts +++ b/test/casting.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, it, expect } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { CastingEngine, type CastingConfig, @@ -16,7 +17,7 @@ import { const castingRoots: string[] = []; function castingDir(): string { - const root = mkdtempSync(join(process.cwd(), '.casting-registry-test-')); + const root = mkdtempSync(join(tmpdir(), 'casting-registry-test-')); const dir = join(root, 'casting'); mkdirSync(dir); castingRoots.push(root); diff --git a/test/cli-packaging-smoke.test.ts b/test/cli-packaging-smoke.test.ts index bab1f4ab4..b9c0da213 100644 --- a/test/cli-packaging-smoke.test.ts +++ b/test/cli-packaging-smoke.test.ts @@ -22,6 +22,12 @@ const NO_COLOR_ENV = { FORCE_COLOR: '0', npm_config_loglevel: process.env['npm_config_loglevel'] ?? 'warn', }; +delete NO_COLOR_ENV['npm_config_local_prefix']; +delete NO_COLOR_ENV['npm_config_workspace']; +delete NO_COLOR_ENV['npm_config_workspaces']; +delete NO_COLOR_ENV['NPM_CONFIG_LOCAL_PREFIX']; +delete NO_COLOR_ENV['NPM_CONFIG_WORKSPACE']; +delete NO_COLOR_ENV['NPM_CONFIG_WORKSPACES']; const MISSING_MODULE_RE = /MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|Cannot find module|Cannot find package/i; const EXIT_COMMAND_TIMEOUT_MS = process.platform === 'win32' ? 10_000 : 2_000; diff --git a/test/cli.test.ts b/test/cli.test.ts index 04bbfbf45..38a98ab6d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -4,9 +4,10 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdir, rm, writeFile, readFile } from 'fs/promises'; +import { mkdir, mkdtemp, rm, writeFile, readFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; +import { tmpdir } from 'os'; import { parseVersion, @@ -310,14 +311,11 @@ describe('getInstallInstructions', () => { // Copilot Install — installFromCopilot // ============================================================================ -const INSTALL_TEST_ROOT = join(process.cwd(), 'test-fixtures', 'copilot-install-test'); +let INSTALL_TEST_ROOT: string; describe('installFromCopilot', () => { beforeEach(async () => { - if (existsSync(INSTALL_TEST_ROOT)) { - await rm(INSTALL_TEST_ROOT, { recursive: true, force: true }); - } - await mkdir(INSTALL_TEST_ROOT, { recursive: true }); + INSTALL_TEST_ROOT = await mkdtemp(join(tmpdir(), 'squad-copilot-install-')); }); afterEach(async () => { diff --git a/test/cli/cast.test.ts b/test/cli/cast.test.ts index b9a4e6298..b0101a7da 100644 --- a/test/cli/cast.test.ts +++ b/test/cli/cast.test.ts @@ -13,8 +13,10 @@ import { mkdir, rm, writeFile, readFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; import { randomBytes } from 'crypto'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; -const TEST_ROOT = join(process.cwd(), `.test-cast-${randomBytes(4).toString('hex')}`); +const TEST_ROOT = join(tmpdir(), `.test-cast-${randomBytes(4).toString('hex')}`); /** * Charter using ## Identity sections, as expected by parseCharterMetadata(). @@ -49,6 +51,7 @@ describe('squad cast', () => { await rm(TEST_ROOT, { recursive: true, force: true }); } await mkdir(TEST_ROOT, { recursive: true }); + execFileSync('git', ['init', '--quiet'], { cwd: TEST_ROOT }); }); afterEach(async () => { @@ -73,7 +76,7 @@ describe('squad cast', () => { const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); expect(output).toContain('TestAgent'); expect(output).toContain('Session Cast'); - }); + }, 15_000); it('does not look in double-nested .squad/.squad/agents/ path', async () => { await scaffold(TEST_ROOT); diff --git a/test/cli/legacy-casting-upgrade.test.ts b/test/cli/legacy-casting-upgrade.test.ts index bc20a4444..dd7de934d 100644 --- a/test/cli/legacy-casting-upgrade.test.ts +++ b/test/cli/legacy-casting-upgrade.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { runInit } from '@bradygaster/squad-cli/core/init'; import { runUpgrade } from '@bradygaster/squad-cli/core/upgrade'; @@ -35,8 +36,8 @@ function version(value: string): void { describe('upgrade preflights legacy casting before managed writes', () => { beforeEach(async () => { - root = mkdtempSync(join(process.cwd(), '.test-legacy-upgrade-')); - home = mkdtempSync(join(process.cwd(), '.test-legacy-home-')); + root = mkdtempSync(join(tmpdir(), 'squad-legacy-upgrade-')); + home = mkdtempSync(join(tmpdir(), 'squad-legacy-home-')); execFileSync('git', ['init', '--quiet'], { cwd: root }); process.env.SQUAD_HOME_DIR_OVERRIDE = home; await runInit(root); diff --git a/test/cli/state-mcp-lifecycle.test.ts b/test/cli/state-mcp-lifecycle.test.ts index 80d7e66c0..ffe157d54 100644 --- a/test/cli/state-mcp-lifecycle.test.ts +++ b/test/cli/state-mcp-lifecycle.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { randomBytes } from 'node:crypto'; const ROOT = process.cwd(); -const TMP = path.join(ROOT, `.test-state-mcp-lifecycle-${randomBytes(4).toString('hex')}`); +const TMP = path.join(tmpdir(), `.test-state-mcp-lifecycle-${randomBytes(4).toString('hex')}`); const CLI_ENTRY = path.join(ROOT, 'packages', 'squad-cli', 'dist', 'cli-entry.js'); const activeChildren = new Set(); diff --git a/test/init.test.ts b/test/init.test.ts index 472632ce2..52a04ca30 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -5,23 +5,20 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdir, rm, readFile, writeFile } from 'fs/promises'; +import { mkdir, mkdtemp, rm, readFile, writeFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; +import { tmpdir } from 'node:os'; import { initSquad, MANIFEST_SKILL_NAMES } from '@bradygaster/squad-sdk/config'; import { onboardAgent, addAgentToConfig } from '@bradygaster/squad-sdk/agents'; import type { InitOptions, InitAgentSpec } from '@bradygaster/squad-sdk/config'; import type { OnboardOptions } from '@bradygaster/squad-sdk/agents'; -const TEST_ROOT = join(process.cwd(), 'test-fixtures', 'init-test'); +let TEST_ROOT: string; describe('Squad Initialization', () => { beforeEach(async () => { - // Clean up test directory - if (existsSync(TEST_ROOT)) { - await rm(TEST_ROOT, { recursive: true, force: true }); - } - await mkdir(TEST_ROOT, { recursive: true }); + TEST_ROOT = await mkdtemp(join(tmpdir(), 'squad-sdk-init-test-')); }); afterEach(async () => { diff --git a/test/memory-governance.test.ts b/test/memory-governance.test.ts index a5c0b15ea..e0897e36a 100644 --- a/test/memory-governance.test.ts +++ b/test/memory-governance.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as os from 'node:os'; import { randomUUID } from 'node:crypto'; import { FSStorageProvider } from '../packages/squad-sdk/src/storage/fs-storage-provider.js'; import { @@ -15,7 +16,7 @@ import { ensureMemoryGovernanceUpgradeDefaults } from '../packages/squad-cli/src const roots: string[] = []; function testRoot(prefix: string): string { - const root = path.join(process.cwd(), `.test-${prefix}-${randomUUID()}`); + const root = path.join(os.tmpdir(), `.test-${prefix}-${randomUUID()}`); roots.push(root); fs.mkdirSync(path.join(root, '.squad'), { recursive: true }); return root; diff --git a/test/memory-providers.test.ts b/test/memory-providers.test.ts index 717626469..60ac574eb 100644 --- a/test/memory-providers.test.ts +++ b/test/memory-providers.test.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as os from 'node:os'; import { randomUUID } from 'node:crypto'; import { FSStorageProvider } from '../packages/squad-sdk/src/storage/fs-storage-provider.js'; import { @@ -28,7 +29,7 @@ import { const roots: string[] = []; function testRoot(prefix: string): string { - const root = path.join(process.cwd(), `.test-${prefix}-${randomUUID()}`); + const root = path.join(os.tmpdir(), `.test-${prefix}-${randomUUID()}`); roots.push(root); fs.mkdirSync(path.join(root, '.squad'), { recursive: true }); return root; diff --git a/test/presets.test.ts b/test/presets.test.ts index e1a05f182..52b8b9e50 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -14,7 +14,7 @@ import { import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { spawn } from 'node:child_process'; -import { hostname } from 'node:os'; +import { hostname, tmpdir } from 'node:os'; import { resolveSquadHome, ensureSquadHome, resolvePresetsDir } from '@bradygaster/squad-sdk/resolution'; import { listPresets, @@ -37,7 +37,7 @@ import { } from '../packages/squad-sdk/src/presets/scaffold.js'; import { applyPreset as applyPresetSource } from '../packages/squad-sdk/src/presets/index.js'; -const TMP = join(process.cwd(), `.test-presets-${randomBytes(4).toString('hex')}`); +const TMP = join(tmpdir(), `.test-presets-${randomBytes(4).toString('hex')}`); function scaffold(...dirs: string[]): void { for (const d of dirs) { diff --git a/test/setup/tracked-worktree-guard.ts b/test/setup/tracked-worktree-guard.ts new file mode 100644 index 000000000..1a1701399 --- /dev/null +++ b/test/setup/tracked-worktree-guard.ts @@ -0,0 +1,57 @@ +import { execFileSync } from 'node:child_process'; + +export interface TrackedWorktreeSnapshot { + head: string; + diff: Buffer; + files: string[]; +} + +function git(cwd: string, args: string[]): Buffer { + return execFileSync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024 }); +} + +export function snapshotTrackedWorktree(cwd: string): TrackedWorktreeSnapshot { + return { + head: git(cwd, ['rev-parse', 'HEAD']).toString('utf8').trim(), + diff: git(cwd, ['diff', '--no-ext-diff', '--binary', '--ignore-cr-at-eol', 'HEAD', '--']), + files: git(cwd, ['diff', '--no-ext-diff', '--name-only', '-z', '--ignore-cr-at-eol', 'HEAD', '--']) + .toString('utf8') + .split('\0') + .filter(Boolean), + }; +} + +function repositoryRoot(cwd: string): string | undefined { + try { + return git(cwd, ['rev-parse', '--show-toplevel']).toString('utf8').trim(); + } catch (error) { + const failure = error as { status?: number; stderr?: Buffer }; + if (failure.status === 128 && failure.stderr?.toString('utf8').includes('not a git repository')) { + return undefined; + } + throw error; + } +} + +export function assertTrackedWorktreeUnchanged(cwd: string, before: TrackedWorktreeSnapshot): void { + const after = snapshotTrackedWorktree(cwd); + if (before.head === after.head && before.diff.equals(after.diff)) return; + + const files = [...new Set([...before.files, ...after.files])].sort(); + const headChanged = before.head !== after.head ? '\n HEAD changed during the test run.' : ''; + throw new Error( + `Tests changed tracked files in the working tree:${headChanged}\n${files.map(file => ` ${file}`).join('\n')}`, + ); +} + +export function createTrackedWorktreeGuard(cwd: string): (() => void) | undefined { + const root = repositoryRoot(cwd); + if (!root) return; + + const before = snapshotTrackedWorktree(root); + return () => assertTrackedWorktreeUnchanged(root, before); +} + +export function setup(): (() => void) | undefined { + return createTrackedWorktreeGuard(process.cwd()); +} diff --git a/test/speed-gates.test.ts b/test/speed-gates.test.ts index 557ec9240..6921836d6 100644 --- a/test/speed-gates.test.ts +++ b/test/speed-gates.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { resolve } from 'node:path'; import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { TerminalHarness } from './acceptance/harness.js'; import { parseInput } from '@bradygaster/squad-cli/shell/router'; import { isInitNoColor } from '@bradygaster/squad-cli/core/init'; @@ -75,7 +76,7 @@ describe('Speed: squad init ceremony', () => { }); it('init ceremony in non-TTY completes under 5 seconds', async () => { - const tmpDir = resolve(process.cwd(), 'test-fixtures', '_speed-test-init-' + Date.now()); + const tmpDir = resolve(tmpdir(), '_speed-test-init-' + Date.now()); mkdirSync(tmpDir, { recursive: true }); let harness: TerminalHarness | null = null; diff --git a/test/tracked-worktree-guard.test.ts b/test/tracked-worktree-guard.test.ts new file mode 100644 index 000000000..eb8a0a643 --- /dev/null +++ b/test/tracked-worktree-guard.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + assertTrackedWorktreeUnchanged, + createTrackedWorktreeGuard, + snapshotTrackedWorktree, +} from './setup/tracked-worktree-guard.js'; + +let root: string; + +describe('tracked working-tree guard', () => { + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'squad-worktree-guard-')); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 'Squad Test'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }); + writeFileSync(join(root, 'tracked.txt'), 'before\n'); + execFileSync('git', ['add', 'tracked.txt'], { cwd: root }); + execFileSync('git', ['commit', '--quiet', '-m', 'baseline'], { cwd: root }); + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it('allows an unchanged tracked tree', () => { + const before = snapshotTrackedWorktree(root); + expect(() => assertTrackedWorktreeUnchanged(root, before)).not.toThrow(); + }); + + it('names a tracked file changed during the test run', () => { + const before = snapshotTrackedWorktree(root); + writeFileSync(join(root, 'tracked.txt'), 'after\n'); + expect(() => assertTrackedWorktreeUnchanged(root, before)) + .toThrow(/tracked\.txt/); + }); + + it('ignores line-ending-only changes', () => { + const before = snapshotTrackedWorktree(root); + writeFileSync(join(root, 'tracked.txt'), 'before\r\n'); + expect(() => assertTrackedWorktreeUnchanged(root, before)).not.toThrow(); + }); + + it('skips temporary working directories outside a git checkout', () => { + const directory = mkdtempSync(join(tmpdir(), 'squad-no-git-')); + try { + expect(createTrackedWorktreeGuard(directory)).toBeUndefined(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 766cd25f4..dfbf604d5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ }, test: { include: ['test/**/*.test.ts'], + globalSetup: ['test/setup/tracked-worktree-guard.ts'], // Redirect every profile-level directory Squad knows about into a temp dir before any // test loads, so no test can read or write the developer's real user profile. setupFiles: ['test/setup/profile-guard.ts'],