From 3a98ce83e5860c3f0b5bc8fa760029ed2ecebfa8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 16 May 2026 12:16:20 -0400 Subject: [PATCH] =?UTF-8?q?fix(3597):=20windows=20parity=20batch=20?= =?UTF-8?q?=E2=80=94=20sdk/install/hook/worktree=20clusters=20(21=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three parallel diagnostic agents (G: sdk/install path-sep, H: hook scripts, I: worktree/workspace) characterised the remaining windows-22 failures on 23b52f1a. Patches by class: CRLF in test parsers / file-content reads - bug-2136-sh-hook-version: shebang check split('\n') → split(/\r?\n/) - bug-3542-executor-git-stash-prohibition: strip \r from read content (git rewrites stashed text with CRLF on win autocrlf=true checkout) - workspace: BOM-strip + explicit \r strip in parseCommandFile (BOM at byte 0 defeats /^---/ anchor → fmMatch null → fm.name undefined) Windows path-separator / 8.3-shortname normalization - bug-3491-nested-git-worktree: use fs.realpathSync.native to expand %TEMP% RUNNER~1 → runneradmin; normalize sep before path-equality - prune-orphaned-worktrees: normalize \\→/ before substring includes (git emits forward-slash in --porcelain on Windows even when path.join produced backslashes) - bug-3017-codex-hook-absolute-node: accept POSIX path OR path with drive-letter prefix in hookPath equality assertion - bug-3126-global-skills-base-runtime-path: use path.join for expected /xdg/ values (production calls path.join → \xdg\… on win32) Test under-specified platform / forgot win32 env - bug-2979-hook-absolute-node: pass {platform:'linux'} to buildHookCommand + rewriteLegacyManagedNodeHookCommands so the POSIX-branch tests don't pick up the #3393 GitBash code path - bug-3288-model-catalog + bug-3571-config-manifest: also set USERPROFILE alongside HOME so os.homedir() on win32 redirects to the test fixture instead of the runner's real ~ External-cmd resolution - bug-2647-outer-tarball-sdk-dist: use npm.cmd + {shell:true} on win32 so execFileSync resolves PATHEXT (literal `npm` is ENOENT) ESM loader: tests/runtime-bridge-sync-smoke.test.cjs already migrated to pathToFileURL in 23b52f1a (cluster E). Explicit per-test/describe skip on win32 (with required string reasons to satisfy no-unconditional-win32-skip guard) - feat-3347-graphify-auto-update-hook: 3 describes — harness spawns bash/kill/sleep + the hook itself is bash - feat-3595-fs-fault-injection: move \t and \n filenames into the POSIX-only branch (NTFS forbids 0x00–0x1F in filenames) - bug-2775/2829/3033/3231/3359: POSIX shim under ~/.local/bin with chmod 0o755 — not how Windows install works - bug-3211: single subtest where cp.execSync reassignment isn't picked up on win32 (POSIX coverage via the mock; live windows behavior covered by 3211-D which keeps running) - install-path-detection: parses sh-style export PATH= rc files; Windows has no rc files (registry Path) - worktree-safety-policy: single test using POSIX /repo/wt fixture paths that can't be expressed under win32 path.resolve Validated: plex2 (ubuntu docker) 11224/0 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/bug-2136-sh-hook-version.test.cjs | 2 +- .../bug-2647-outer-tarball-sdk-dist.test.cjs | 12 ++++++---- tests/bug-2775-sdk-shim-path-verify.test.cjs | 6 ++++- .../bug-2829-local-install-sdk-path.test.cjs | 6 ++++- tests/bug-2979-hook-absolute-node.test.cjs | 6 ++--- ...bug-3017-codex-hook-absolute-node.test.cjs | 10 ++++++-- tests/bug-3033-sdk-flag-wired.test.cjs | 6 ++++- ...6-global-skills-base-runtime-path.test.cjs | 4 ++-- tests/bug-3211-windows-sdk-not-found.test.cjs | 6 ++++- ...ug-3231-false-gsd-sdk-ready-linux.test.cjs | 15 +++++++++--- ...g-3288-model-catalog-install-path.test.cjs | 9 +++++++ ...g-3359-stale-gsd-sdk-path-version.test.cjs | 6 ++++- tests/bug-3491-nested-git-worktree.test.cjs | 24 ++++++++++++------- ...42-executor-git-stash-prohibition.test.cjs | 5 +++- ...nfiguration-manifest-install-path.test.cjs | 8 ++++++- ...at-3347-graphify-auto-update-hook.test.cjs | 14 ++++++++--- ...5-fs-fault-injection-atomic-write.test.cjs | 8 +++---- tests/install-path-detection.test.cjs | 6 ++++- tests/prune-orphaned-worktrees.test.cjs | 7 ++++-- tests/workspace.test.cjs | 11 +++++++-- tests/worktree-safety-policy.test.cjs | 6 ++++- 21 files changed, 134 insertions(+), 43 deletions(-) diff --git a/tests/bug-2136-sh-hook-version.test.cjs b/tests/bug-2136-sh-hook-version.test.cjs index cfd6a4db8..bb2fe1e46 100644 --- a/tests/bug-2136-sh-hook-version.test.cjs +++ b/tests/bug-2136-sh-hook-version.test.cjs @@ -113,7 +113,7 @@ describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder // — POSIX guarantees /bin/sh but not /bin/bash, and distros like NixOS // do not ship /bin/bash by default. for (const sh of SH_HOOKS) { - const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split('\n'); + const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split(/\r?\n/); assert.strictEqual( lines[0], '#!/usr/bin/env bash', diff --git a/tests/bug-2647-outer-tarball-sdk-dist.test.cjs b/tests/bug-2647-outer-tarball-sdk-dist.test.cjs index b183067fa..e35deae1f 100644 --- a/tests/bug-2647-outer-tarball-sdk-dist.test.cjs +++ b/tests/bug-2647-outer-tarball-sdk-dist.test.cjs @@ -97,21 +97,25 @@ describe('bug #2647: outer tarball ships sdk/dist so gsd-sdk query works', () => // if sdk/dist/cli.js already exists, use it; otherwise build. const sdkDir = path.join(REPO_ROOT, 'sdk'); const cliJs = path.join(sdkDir, 'dist', 'cli.js'); + // On Windows `npm` is the shell script `npm.cmd`; without {shell:true} + // execFileSync only finds literal-name binaries and errors with ENOENT. + const isWindows = process.platform === 'win32'; + const npmCmd = isWindows ? 'npm.cmd' : 'npm'; if (!fs.existsSync(cliJs)) { // Build requires node_modules; install if missing, then build. const sdkNodeModules = path.join(sdkDir, 'node_modules'); if (!fs.existsSync(sdkNodeModules)) { - execFileSync('npm', ['ci', '--silent'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv }); + execFileSync(npmCmd, ['ci', '--silent'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv, shell: isWindows }); } - execFileSync('npm', ['run', 'build'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv }); + execFileSync(npmCmd, ['run', 'build'], { cwd: sdkDir, stdio: 'pipe', env: npmEnv, shell: isWindows }); } assert.ok(fs.existsSync(cliJs), 'sdk build must produce sdk/dist/cli.js'); try { const out = execFileSync( - 'npm', + npmCmd, ['pack', '--dry-run', '--json', '--ignore-scripts'], - { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: npmEnv }, + { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: npmEnv, shell: isWindows }, ).toString('utf-8'); const manifest = JSON.parse(out); const files = manifest[0].files.map((f) => f.path); diff --git a/tests/bug-2775-sdk-shim-path-verify.test.cjs b/tests/bug-2775-sdk-shim-path-verify.test.cjs index 11bb816f6..8f8adcef7 100644 --- a/tests/bug-2775-sdk-shim-path-verify.test.cjs +++ b/tests/bug-2775-sdk-shim-path-verify.test.cjs @@ -36,6 +36,8 @@ const fs = require('fs'); const path = require('path'); const installModule = require('../bin/install.js'); + +const isWindows = process.platform === 'win32'; const { installSdkIfNeeded } = installModule; const { createTempDir, cleanup } = require('./helpers.cjs'); @@ -71,7 +73,9 @@ function captureConsole(fn) { }; } -describe('bug #2775: installSdkIfNeeded must verify gsd-sdk on PATH before reporting ready', () => { +describe('bug #2775: installSdkIfNeeded must verify gsd-sdk on PATH before reporting ready', + { skip: isWindows ? 'POSIX-only: asserts ~/.local/bin shebang shim with mode 0o755; Windows uses gsd-sdk.cmd + PATHEXT + registry Path' : false }, + () => { let tmpRoot; let sdkDir; let pathDir; diff --git a/tests/bug-2829-local-install-sdk-path.test.cjs b/tests/bug-2829-local-install-sdk-path.test.cjs index 1530d0f8b..d132d391e 100644 --- a/tests/bug-2829-local-install-sdk-path.test.cjs +++ b/tests/bug-2829-local-install-sdk-path.test.cjs @@ -31,6 +31,8 @@ const path = require('path'); const { installSdkIfNeeded } = require('../bin/install.js'); const { createTempDir, cleanup } = require('./helpers.cjs'); +const isWindows = process.platform === 'win32'; + function captureConsole(fn) { const stdout = []; const stderr = []; @@ -58,7 +60,9 @@ function captureConsole(fn) { }; } -describe('bug #2829: local-mode install must materialize gsd-sdk on PATH', () => { +describe('bug #2829: local-mode install must materialize gsd-sdk on PATH', + { skip: isWindows ? 'POSIX-only: asserts ~/.local/bin shebang shim; Windows uses gsd-sdk.cmd + USERPROFILE + PATHEXT' : false }, + () => { let tmpRoot; let sdkDir; let pathDir; diff --git a/tests/bug-2979-hook-absolute-node.test.cjs b/tests/bug-2979-hook-absolute-node.test.cjs index eebb6ad45..53b498d36 100644 --- a/tests/bug-2979-hook-absolute-node.test.cjs +++ b/tests/bug-2979-hook-absolute-node.test.cjs @@ -153,7 +153,7 @@ describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => { describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => { test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => { - const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh'); + const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' }); const parsed = parseHookCommand(cmd); assert.equal(parsed.runner, 'bash'); }); @@ -364,7 +364,7 @@ describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites ba }, }; const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); assert.equal(changed, true); assert.equal( settings.hooks.SessionStart[0].hooks[0].command, @@ -381,7 +381,7 @@ describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites ba }, }; const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); assert.equal(changed, true); assert.equal( settings.hooks.SessionStart[0].hooks[0].command, diff --git a/tests/bug-3017-codex-hook-absolute-node.test.cjs b/tests/bug-3017-codex-hook-absolute-node.test.cjs index 933fbe39c..36227e9e9 100644 --- a/tests/bug-3017-codex-hook-absolute-node.test.cjs +++ b/tests/bug-3017-codex-hook-absolute-node.test.cjs @@ -124,8 +124,14 @@ describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => { // pass — e.g. '/Users/x/notnode/foo'. assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, `parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`); - assert.equal(parsed.hookPath, '/tmp/codex-test/.codex/hooks/gsd-check-update.js', - `hook path equality, got: ${parsed.hookPath}`); + // On Windows, path.resolve prepends the current drive letter ("D:") to + // the POSIX-shaped fixture path. Accept either form. + const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js'; + assert.ok( + parsed.hookPath === expectedHookSuffix || + parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix, + `hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`, + ); }); test('returns null when absoluteRunner is null (caller skips registration)', () => { diff --git a/tests/bug-3033-sdk-flag-wired.test.cjs b/tests/bug-3033-sdk-flag-wired.test.cjs index 276a25699..c628aebbb 100644 --- a/tests/bug-3033-sdk-flag-wired.test.cjs +++ b/tests/bug-3033-sdk-flag-wired.test.cjs @@ -28,6 +28,8 @@ const os = require('os'); const { installSdkIfNeeded } = require('../bin/install.js'); const { createTempDir, cleanup } = require('./helpers.cjs'); +const isWindows = process.platform === 'win32'; + function captureConsole(fn) { const stdout = []; const stderr = []; @@ -55,7 +57,9 @@ function captureConsole(fn) { }; } -describe('bug #3033: --sdk flag (opts.forceSdk) must be wired into installSdkIfNeeded', () => { +describe('bug #3033: --sdk flag (opts.forceSdk) must be wired into installSdkIfNeeded', + { skip: isWindows ? 'POSIX-only: forces shebang gsd-sdk shim into ~/.local/bin and asserts mode 0o755' : false }, + () => { let tmpRoot; let sdkDir; let pathDir; diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs index deba3c344..9a219b739 100644 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ b/tests/bug-3126-global-skills-base-runtime-path.test.cjs @@ -106,14 +106,14 @@ describe('bug #3126: runtime-homes env-var overrides', () => { test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => { withEnv('OPENCODE_CONFIG_DIR', undefined, () => { withEnv('XDG_CONFIG_HOME', '/xdg', () => { - assert.strictEqual(getGlobalConfigDir('opencode'), '/xdg/opencode'); + assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode')); }); }); }); test('kilo uses XDG_CONFIG_HOME when KILO_CONFIG_DIR absent', () => { withEnv('KILO_CONFIG_DIR', undefined, () => { withEnv('XDG_CONFIG_HOME', '/xdg', () => { - assert.strictEqual(getGlobalConfigDir('kilo'), '/xdg/kilo'); + assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo')); }); }); }); diff --git a/tests/bug-3211-windows-sdk-not-found.test.cjs b/tests/bug-3211-windows-sdk-not-found.test.cjs index bcdd965a2..0b09f6ef6 100644 --- a/tests/bug-3211-windows-sdk-not-found.test.cjs +++ b/tests/bug-3211-windows-sdk-not-found.test.cjs @@ -51,6 +51,8 @@ const cp = require('node:child_process'); const ROOT = path.join(__dirname, '..'); const installModule = require(path.join(ROOT, 'bin', 'install.js')); +const isWindows = process.platform === 'win32'; + const { filterNpxFromPath, isLegacyGsdSdkShim, @@ -202,7 +204,9 @@ describe('bug #3211-C: getUserShellWindowsPersistentPath export', () => { } }); - test('when the PowerShell probe is mocked to return a path, strips _npx dirs', () => { + test('when the PowerShell probe is mocked to return a path, strips _npx dirs', + { skip: isWindows ? 'cp.execSync reassignment is not picked up by install.js on Windows; real registry Path is returned. POSIX coverage in mock; live Windows path is covered by 3211-D.' : false }, + () => { // Mock cp.execSync to return a Windows Path with both persistent and _npx dirs. const savedExecSync = cp.execSync; const winPersistentDir = 'C:\\Users\\user\\AppData\\Roaming\\npm'; diff --git a/tests/bug-3231-false-gsd-sdk-ready-linux.test.cjs b/tests/bug-3231-false-gsd-sdk-ready-linux.test.cjs index 4afffb7eb..eb2e7f0dd 100644 --- a/tests/bug-3231-false-gsd-sdk-ready-linux.test.cjs +++ b/tests/bug-3231-false-gsd-sdk-ready-linux.test.cjs @@ -36,6 +36,9 @@ const os = require('node:os'); const path = require('node:path'); const installModule = require('../bin/install.js'); + +const isWindows = process.platform === 'win32'; + const { installSdkIfNeeded, isGsdSdkOnPath, @@ -90,7 +93,9 @@ function makeSdkDir(root) { // --------------------------------------------------------------------------- // Bug 1: transient npx PATH hit + null login-shell PATH → false "GSD SDK ready" // --------------------------------------------------------------------------- -describe('bug #3231: transient npx PATH + null login-shell PATH', () => { +describe('bug #3231: transient npx PATH + null login-shell PATH', + { skip: isWindows ? 'Linux-specific: simulates getUserShellPath()=null + POSIX shebang shim; Windows path is covered by #3211' : false }, + () => { let tmpRoot; let sdkDir; let savedEnv; @@ -205,7 +210,9 @@ describe('bug #3231: transient npx PATH + null login-shell PATH', () => { // --------------------------------------------------------------------------- // Bug 2: stale legacy symlink pointing at gsd-tools.cjs (deprecated binary) // --------------------------------------------------------------------------- -describe('bug #3231: stale legacy symlink to deprecated gsd-tools.cjs', () => { +describe('bug #3231: stale legacy symlink to deprecated gsd-tools.cjs', + { skip: isWindows ? 'POSIX-only: relies on fs.symlinkSync + mode bits + bare shim filename' : false }, + () => { let tmpRoot; let sdkDir; let savedEnv; @@ -353,7 +360,9 @@ describe('bug #3231: stale legacy symlink to deprecated gsd-tools.cjs', () => { // --------------------------------------------------------------------------- // Test 3: clean install with gsd-sdk self-linked into a persistent PATH dir // --------------------------------------------------------------------------- -describe('bug #3231: clean install — gsd-sdk self-linked into persistent PATH dir', () => { +describe('bug #3231: clean install — gsd-sdk self-linked into persistent PATH dir', + { skip: isWindows ? 'POSIX-only: asserts bare gsd-sdk shim in ~/.local/bin' : false }, + () => { let tmpRoot; let sdkDir; let savedEnv; diff --git a/tests/bug-3288-model-catalog-install-path.test.cjs b/tests/bug-3288-model-catalog-install-path.test.cjs index b2077d203..3b1aece35 100644 --- a/tests/bug-3288-model-catalog-install-path.test.cjs +++ b/tests/bug-3288-model-catalog-install-path.test.cjs @@ -82,11 +82,17 @@ function silenceConsole(fn) { describe('bug #3288: model-catalog.cjs install-layout resolution', () => { let tmpRoot; let savedHome; + let savedUserProfile; let savedExplicitConfigDir; beforeEach(() => { tmpRoot = makeTmpDir('gsd-3288-'); savedHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT + // HOME. install() resolves the install destination via os.homedir(), so the + // tests must also redirect USERPROFILE → tmpRoot on win32 to keep the + // installer writing inside the fixture. + savedUserProfile = process.env.USERPROFILE; // Stash and clear explicitConfigDir via env so install() picks up our tmp dir. // Must delete (not just save) so any CI-set value doesn't leak into install() // and target a different directory than tmpRoot (CR finding, PR #3293). @@ -96,6 +102,8 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => { afterEach(() => { process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; if (savedExplicitConfigDir === undefined) { delete process.env.GSD_EXPLICIT_CONFIG_DIR; } else { @@ -179,6 +187,7 @@ module.exports = { catalog }; const claudeDir = path.join(tmpRoot, '.claude'); fs.mkdirSync(claudeDir, { recursive: true }); process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; // Capture process.exit to prevent the test from being killed. const origExit = process.exit; diff --git a/tests/bug-3359-stale-gsd-sdk-path-version.test.cjs b/tests/bug-3359-stale-gsd-sdk-path-version.test.cjs index 4cb57bee7..a1593d91f 100644 --- a/tests/bug-3359-stale-gsd-sdk-path-version.test.cjs +++ b/tests/bug-3359-stale-gsd-sdk-path-version.test.cjs @@ -21,6 +21,8 @@ const cp = require('node:child_process'); const pkg = require('../package.json'); const { createTempDir, cleanup } = require('./helpers.cjs'); +const isWindows = process.platform === 'win32'; + function captureConsole(fn) { const stdout = []; const stderr = []; @@ -48,7 +50,9 @@ function captureConsole(fn) { }; } -describe('bug #3359: installer detects stale gsd-sdk earlier on PATH', () => { +describe('bug #3359: installer detects stale gsd-sdk earlier on PATH', + { skip: isWindows ? 'POSIX-only: stages bare gsd-sdk shebang shims in a PATH dir; Windows uses .cmd + PATHEXT resolution' : false }, + () => { let tmpRoot; let sdkDir; let pathDir; diff --git a/tests/bug-3491-nested-git-worktree.test.cjs b/tests/bug-3491-nested-git-worktree.test.cjs index a7d6cadc9..3ccc5ccd2 100644 --- a/tests/bug-3491-nested-git-worktree.test.cjs +++ b/tests/bug-3491-nested-git-worktree.test.cjs @@ -41,11 +41,19 @@ const WORKFLOW_PATH = path.join( // ─── Helper: create outer git repo with a nested workstream subdir ───────── +// On Windows the runtime emits forward slashes (git's convention) while +// path.join produces backslashes — normalize both sides before any +// equality comparison against the handler's git_worktree_root. +function normalizePath(p) { + return p == null ? p : p.split(path.sep).join('/'); +} + function createOuterRepoWithSubdir(prefix = 'bug-3491-') { const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - // macOS /tmp -> /private/tmp; resolve to the canonical path so comparisons - // against `git rev-parse --show-toplevel` succeed regardless of symlink. - const outerReal = fs.realpathSync(outer); + // macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3 + // short-name (RUNNER~1) and the runtime resolves to the long form. + // realpathSync.native handles both; then normalize separators for compare. + const outerReal = fs.realpathSync.native(outer); execSync('git init', { cwd: outerReal, stdio: 'pipe' }); execSync('git config user.email "test@test.com"', { cwd: outerReal, stdio: 'pipe' }); execSync('git config user.name "Test"', { cwd: outerReal, stdio: 'pipe' }); @@ -80,8 +88,8 @@ test('bug-3491: init new-project reports has_git: true inside parent git worktre // The workflow needs the worktree root and a nesting flag to decide // whether to skip `git init` and emit a friendly warning. assert.strictEqual( - payload.git_worktree_root, - outer, + normalizePath(payload.git_worktree_root), + normalizePath(outer), `expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`, ); assert.strictEqual( @@ -102,7 +110,7 @@ test('bug-3491: init new-project reports has_git: true at worktree root with in_ const payload = JSON.parse(result.output); assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root'); - assert.strictEqual(payload.git_worktree_root, outer); + assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer)); assert.strictEqual( payload.in_nested_subdir, false, @@ -114,7 +122,7 @@ test('bug-3491: init new-project reports has_git: true at worktree root with in_ }); test('bug-3491: init new-project reports has_git: false outside any git worktree', () => { - const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-'))); + const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-'))); try { const result = runGsdTools('init new-project', tmp); assert.ok(result.success, `init new-project failed: ${result.error}`); @@ -139,7 +147,7 @@ test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => { true, 'init ingest-docs must also detect parent worktree (#3491 related path)', ); - assert.strictEqual(payload.git_worktree_root, outer); + assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer)); assert.strictEqual(payload.in_nested_subdir, true); } finally { cleanup(outer); diff --git a/tests/bug-3542-executor-git-stash-prohibition.test.cjs b/tests/bug-3542-executor-git-stash-prohibition.test.cjs index 9406bf262..2ae3129ff 100644 --- a/tests/bug-3542-executor-git-stash-prohibition.test.cjs +++ b/tests/bug-3542-executor-git-stash-prohibition.test.cjs @@ -160,7 +160,10 @@ test('bug-3542: stash pushed in main checkout is visible inside a linked worktre // not just visibility. We pop into a clean working tree on a // different branch, so any applied content is the contamination. execSync('git stash pop -q', { cwd: linkedWorktree, stdio: 'pipe' }); - const popped = fs.readFileSync(path.join(linkedWorktree, 'a.txt'), 'utf-8'); + // On Windows autocrlf=true, git rewrites stashed content with CRLF on + // checkout. Strip \r before content compare — the test pins git's + // shared-stash behavior, not line endings. + const popped = fs.readFileSync(path.join(linkedWorktree, 'a.txt'), 'utf-8').replace(/\r\n/g, '\n'); assert.strictEqual( popped, 'wip in main\n', diff --git a/tests/bug-3571-configuration-manifest-install-path.test.cjs b/tests/bug-3571-configuration-manifest-install-path.test.cjs index 974143a09..c5b9fd2ac 100644 --- a/tests/bug-3571-configuration-manifest-install-path.test.cjs +++ b/tests/bug-3571-configuration-manifest-install-path.test.cjs @@ -45,23 +45,28 @@ function silenceConsole(fn) { describe('bug #3571: configuration generated manifests resolve in install layout', () => { let tmpRoot; let savedHome; + let savedUserProfile; let savedExplicitConfigDir; beforeEach(() => { tmpRoot = makeTmpDir(); savedHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE; install() resolves via it. + savedUserProfile = process.env.USERPROFILE; savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; delete process.env.GSD_EXPLICIT_CONFIG_DIR; }); afterEach(() => { process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; if (savedExplicitConfigDir === undefined) { delete process.env.GSD_EXPLICIT_CONFIG_DIR; } else { process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; } - fs.rmSync(tmpRoot, { recursive: true, force: true }); + fs.rmSync(tmpRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); test('co-located bin/shared manifests let configuration.generated.cjs load without sdk/shared', () => { @@ -93,6 +98,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout test('post-install: install() copies configuration manifests to co-located bin/shared', () => { process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; silenceConsole(() => { install(true, 'codex'); diff --git a/tests/feat-3347-graphify-auto-update-hook.test.cjs b/tests/feat-3347-graphify-auto-update-hook.test.cjs index c4eda8948..dd34a0103 100644 --- a/tests/feat-3347-graphify-auto-update-hook.test.cjs +++ b/tests/feat-3347-graphify-auto-update-hook.test.cjs @@ -36,6 +36,8 @@ const os = require('node:os'); const ROOT = path.join(__dirname, '..'); const HOOK = path.join(ROOT, 'hooks', 'gsd-graphify-update.sh'); +const isWindows = process.platform === 'win32'; + function createTempGitRepo(opts = {}) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3347-')); cp.execFileSync('git', ['init', '-b', opts.defaultBranch || 'main'], { @@ -119,7 +121,9 @@ function cleanup(tmpDir) { fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 }); } -describe('#3347 hook — bail paths (no side effects)', () => { +describe('#3347 hook — bail paths (no side effects)', + { skip: isWindows ? 'POSIX-only: harness spawns bash + kill -0 + sleep; the hook itself is a bash script under test' : false }, + () => { test('non-Bash tool call exits 0 with no status file', (t) => { const tmpDir = createTempGitRepo({ config: { graphify: { enabled: true, auto_update: true } }, @@ -218,7 +222,9 @@ describe('#3347 hook — bail paths (no side effects)', () => { }); }); -describe('#3347 hook — dispatch path (all gates pass)', () => { +describe('#3347 hook — dispatch path (all gates pass)', + { skip: isWindows ? 'POSIX-only: harness spawns bash + kill -0 + sleep; the hook itself is a bash script under test' : false }, + () => { test('writes status file with status=running synchronously before returning', (t) => { const tmpDir = createTempGitRepo({ config: { graphify: { enabled: true, auto_update: true } }, @@ -366,7 +372,9 @@ describe('#3347 hook — dispatch path (all gates pass)', () => { }); }); -describe('#3347 hook — HEAD-advancing command matchers', () => { +describe('#3347 hook — HEAD-advancing command matchers', + { skip: isWindows ? 'POSIX-only: harness spawns bash to invoke the .sh hook under test' : false }, + () => { for (const cmd of [ 'git commit -m fix', 'git merge feature', diff --git a/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs b/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs index 8df869217..cf1f585ef 100644 --- a/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs +++ b/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs @@ -234,12 +234,12 @@ test('platformWriteSync handles paths with spaces, unicode, and newline characte const cases = [ 'has spaces in name.json', 'unicode-日本語-name.json', - 'with tab.json', - // Note: newlines in filenames are POSIX-valid but Windows-illegal. - // The test runs on whatever platform CI picks; we skip the - // newline case on Windows to keep cross-platform CI green. ]; if (process.platform !== 'win32') { + // Tab (0x09) and newline (0x0A) in filenames are POSIX-valid but + // Windows-illegal (NTFS forbids control characters 0x00–0x1F). Append + // both only on POSIX so cross-platform CI stays green. + cases.push('with\ttab.json'); cases.push('with\nnewline.json'); } diff --git a/tests/install-path-detection.test.cjs b/tests/install-path-detection.test.cjs index f98a2ca4e..81b6107e6 100644 --- a/tests/install-path-detection.test.cjs +++ b/tests/install-path-detection.test.cjs @@ -17,6 +17,8 @@ const os = require('os'); const path = require('path'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); + +const isWindows = process.platform === 'win32'; const PROJECTION_PATH = path.join( __dirname, '..', @@ -40,7 +42,9 @@ function cleanup(dir) { fs.rmSync(dir, { recursive: true, force: true }); } -describe('installer HOME-relative PATH detection (#2620)', () => { +describe('installer HOME-relative PATH detection (#2620)', + { skip: isWindows ? 'POSIX-only: parses sh-style "export PATH=" rc files; Windows has no rc files and uses registry Path' : false }, + () => { let installer; let projection; before(() => { diff --git a/tests/prune-orphaned-worktrees.test.cjs b/tests/prune-orphaned-worktrees.test.cjs index bcc14a982..695963daa 100644 --- a/tests/prune-orphaned-worktrees.test.cjs +++ b/tests/prune-orphaned-worktrees.test.cjs @@ -173,7 +173,10 @@ describe('pruneOrphanedWorktrees', () => { // Verify it appears in git worktree list const beforeList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf8' }); - assert.ok(beforeList.includes(worktreeDir), 'worktree should appear in list before deletion'); + // git worktree list --porcelain emits forward slashes on Windows even + // when path.join produced backslashes; normalize both sides for compare. + const normalizeSlashes = (p) => p.replace(/\\/g, '/'); + assert.ok(normalizeSlashes(beforeList).includes(normalizeSlashes(worktreeDir)), 'worktree should appear in list before deletion'); // Manually delete the worktree directory (simulate orphan) fs.rmSync(worktreeDir, { recursive: true, force: true }); @@ -185,7 +188,7 @@ describe('pruneOrphanedWorktrees', () => { // Assert: git worktree list no longer shows the stale entry const afterList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf8' }); assert.ok( - !afterList.includes(worktreeDir), + !normalizeSlashes(afterList).includes(normalizeSlashes(worktreeDir)), 'git worktree list still shows stale entry after prune:\n' + afterList ); }); diff --git a/tests/workspace.test.cjs b/tests/workspace.test.cjs index c41453913..eac9def89 100644 --- a/tests/workspace.test.cjs +++ b/tests/workspace.test.cjs @@ -326,11 +326,18 @@ describe('workspace command files', () => { * substring matching on the file as a whole. */ function parseCommandFile(filePath) { - const raw = fs.readFileSync(filePath, 'utf8'); + // Strip UTF-8 BOM if present (some editors inject on save under Windows); + // a BOM byte at offset 0 defeats the ^--- anchor, making fmMatch null. + const raw = fs.readFileSync(filePath, 'utf8').replace(/^/, ''); const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); assert.ok(fmMatch, `${path.basename(filePath)} must start with a YAML frontmatter block`); const fm = {}; - for (const line of fmMatch[1].split('\n')) { + for (const rawLine of fmMatch[1].split('\n')) { + // Explicit \r strip: split('\n') on CRLF content leaves a trailing + // \r on every line, which the value regex pulls into `kv[2]` and trim + // is enough for most values — but be defensive so future keys with + // exact-string compare don't surprise us. + const line = rawLine.replace(/\r$/, ''); const kv = line.match(/^([a-zA-Z_-]+):\s*(.*)$/); if (!kv) continue; const key = kv[1]; diff --git a/tests/worktree-safety-policy.test.cjs b/tests/worktree-safety-policy.test.cjs index f0d093b3a..6507b74f4 100644 --- a/tests/worktree-safety-policy.test.cjs +++ b/tests/worktree-safety-policy.test.cjs @@ -13,6 +13,8 @@ const { snapshotWorktreeInventory, } = require('../get-shit-done/bin/lib/worktree-safety.cjs'); +const isWindows = process.platform === 'win32'; + describe('worktree-safety policy module', () => { test('resolveWorktreeContext prefers current directory when .planning exists', () => { const context = resolveWorktreeContext('/repo/wt', { @@ -24,7 +26,9 @@ describe('worktree-safety policy module', () => { assert.strictEqual(context.mode, 'current_directory'); }); - test('resolveWorktreeContext maps linked worktree to common-dir parent', () => { + test('resolveWorktreeContext maps linked worktree to common-dir parent', + { skip: isWindows ? 'POSIX-rooted fixture paths cannot be expressed on Windows path.resolve; resolveWorktreeContext uses platform-native path module and would prepend a drive letter to "/repo" inputs. Behaviour is covered indirectly by real-fs worktree tests.' : false }, + () => { const context = resolveWorktreeContext('/repo/wt', { existsSync: () => false, execGit: (args) => {