diff --git a/test/helpers/bash-script.ts b/test/helpers/bash-script.ts new file mode 100644 index 000000000..8de5c2455 --- /dev/null +++ b/test/helpers/bash-script.ts @@ -0,0 +1,60 @@ +/** + * Run an assembled bash script from a TEMP FILE — never via `bash -c `. + * + * Why: the setup harnesses slice functions out of `setup` and join them into + * one script. On Windows, bash is an MSYS2 program; when its parent is a + * non-MSYS process (bun), msys-2.0.dll's build_argv() runs every argument + * containing any of `?*["'(){}` through globify()/glob(). glob() copies the + * pattern into a fixed `Char patbuf[8192]` and silently stops after + * 8192 - MB_CUR_MAX (8186 characters under C.UTF-8); GLOB_NOCHECK then hands + * the truncated text to bash as the argument. Observed on windows-free-tests + * when the alias harness grew from 6.7 KB to 15.7 KB: the `-c` script was cut + * inside a single-quoted token on line 178 ("unexpected EOF while looking for + * matching `'"). A short, glob-character-free file path never enters + * globify, and bash reads the file's bytes directly, so quoting and encoding + * are never re-parsed by any argument layer. The same ~8186-char ceiling + * applies to any MSYS tool (sh, sed, awk, grep) spawned from bun on Windows + * with a long single argument. + * + * `timeout` is always set (test/spawnsync-timeout-tripwire.test.ts). + */ +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface BashScriptResult { + status: number; + stdout: string; + stderr: string; + signal: NodeJS.Signals | null; +} + +export interface BashScriptOptions { + timeout?: number; + env?: NodeJS.ProcessEnv; + cwd?: string; +} + +export function runBashScript(script: string, opts: BashScriptOptions = {}): BashScriptResult { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bash-script-')); + const file = path.join(dir, 'script.sh'); + // LF only: a stray CR would reach bash as part of a token. + fs.writeFileSync(file, script.replace(/\r\n/g, '\n')); + try { + const r = spawnSync('bash', [file], { + encoding: 'utf-8', + timeout: opts.timeout ?? 60_000, + ...(opts.env ? { env: opts.env } : {}), + ...(opts.cwd ? { cwd: opts.cwd } : {}), + }); + // A spawn failure (bash missing) or a timeout kill has no bash stderr of + // its own; surface the cause instead of a bare status -1. + const stderr = (r.stderr ?? '') + (r.error ? `\n[spawn] ${r.error.message}` : ''); + return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr, signal: r.signal ?? null }; + } finally { + // Best-effort: an AV scanner or indexer still holding script.sh on + // Windows must never turn a good result into an unlink error. + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort temp cleanup */ } + } +} diff --git a/test/setup-alias-name-uniqueness.test.ts b/test/setup-alias-name-uniqueness.test.ts index 589d0aa3c..d3c46468f 100644 --- a/test/setup-alias-name-uniqueness.test.ts +++ b/test/setup-alias-name-uniqueness.test.ts @@ -16,7 +16,7 @@ * corrupted the generated SKILL.md — the source files must stay byte-intact. */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -74,7 +74,7 @@ beforeAll(() => { installOnce, installOnce, ].join('\n'); - const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 }); + const result = runBashScript(script, { timeout: 60_000 }); if (result.status !== 0) { throw new Error(`alias install failed: ${result.stderr}\n${result.stdout}`); } @@ -168,7 +168,7 @@ describe('alias installs are rewritten copies (#2511, #2201)', () => { extractFn('link_claude_root_skill_alias'), `link_claude_root_skill_alias "${ROOT}" "${legacyDir}"`, ].join('\n'); - const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); + const result = runBashScript(script, { timeout: 30_000 }); expect(result.status).toBe(0); const aliasSkill = path.join(aliasDir, 'SKILL.md'); diff --git a/test/setup-bun-cmd-and-pipe-bugs.test.ts b/test/setup-bun-cmd-and-pipe-bugs.test.ts index 21a73f69b..a33bc0997 100644 --- a/test/setup-bun-cmd-and-pipe-bugs.test.ts +++ b/test/setup-bun-cmd-and-pipe-bugs.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -9,7 +9,7 @@ const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); // Run a bash snippet, return {stdout, stderr, status}. function runBash(script: string): { stdout: string; stderr: string; status: number } { - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); + const r = runBashScript(script, { timeout: 30_000 }); return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? -1 }; } diff --git a/test/setup-claude-skill-assets.test.ts b/test/setup-claude-skill-assets.test.ts index c563b6ef5..9e02355dc 100644 --- a/test/setup-claude-skill-assets.test.ts +++ b/test/setup-claude-skill-assets.test.ts @@ -21,7 +21,7 @@ * down to uselessness. */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -82,7 +82,7 @@ beforeAll(() => { extractFn('link_claude_skill_dirs'), `link_claude_skill_dirs "${ROOT}" "${installDir}"`, ].join('\n'); - const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 }); + const result = runBashScript(script, { timeout: 60_000 }); if (result.status !== 0) { throw new Error(`installer functions failed: ${result.stderr}\n${result.stdout}`); } diff --git a/test/setup-cleanup-orphans.test.ts b/test/setup-cleanup-orphans.test.ts index 1874caba2..13f165888 100644 --- a/test/setup-cleanup-orphans.test.ts +++ b/test/setup-cleanup-orphans.test.ts @@ -8,7 +8,7 @@ * skills must stay. */ import { describe, test, expect } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -72,10 +72,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink extractFn('cleanup_old_claude_symlinks'), `cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`, ].join('\n'); - const result = spawnSync('bash', ['-c', script], { - encoding: 'utf-8', - timeout: 5000, - }); + const result = runBashScript(script, { timeout: 5000 }); const names = fs.existsSync(skills) ? fs.readdirSync(skills).sort() : []; diff --git a/test/setup-conductor-worktree.test.ts b/test/setup-conductor-worktree.test.ts index 2ff48eefa..64b32d245 100644 --- a/test/setup-conductor-worktree.test.ts +++ b/test/setup-conductor-worktree.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; @@ -85,7 +86,7 @@ describe('setup: Conductor worktree guard', () => { echo "LINKED" fi `; - const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); + const result = runBashScript(script, { timeout: 30_000 }); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe('SKIP'); // No child symlink leaked. @@ -120,7 +121,7 @@ describe('setup: Conductor worktree guard', () => { echo "LINKED" fi `; - const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); + const result = runBashScript(script, { timeout: 30_000 }); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe('LINKED'); expect(fs.lstatSync(dest).isSymbolicLink()).toBe(true); @@ -159,7 +160,7 @@ describe('setup: Conductor worktree guard', () => { echo "LINKED" fi `; - const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); + const result = runBashScript(script, { timeout: 30_000 }); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe('LINKED'); expect(fs.readlinkSync(dest)).toBe(source); @@ -191,7 +192,7 @@ describe('setup: Conductor worktree guard', () => { fi echo "skip=$_SKIP_CLAUDE_REGISTER" `; - const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); + const result = runBashScript(script, { timeout: 30_000 }); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe('skip=0'); } finally { diff --git a/test/setup-emoji-font.test.ts b/test/setup-emoji-font.test.ts index 7e8668c2d..b38201155 100644 --- a/test/setup-emoji-font.test.ts +++ b/test/setup-emoji-font.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; @@ -137,11 +137,7 @@ describe.skipIf(process.platform === 'win32')('setup: ensure_emoji_font behavior 'echo "INSTALLED=$EMOJI_FONT_INSTALLED"', ].join('\n'); - const result = spawnSync('bash', ['-c', script], { - encoding: 'utf-8', - timeout: 10000, - env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, - }); + const result = runBashScript(script, { timeout: 10000, env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }); const out = result.stdout ?? ''; return { exit: Number((out.match(/EXIT=(\d+)/) ?? [])[1] ?? -1), diff --git a/test/setup-link-ownership.test.ts b/test/setup-link-ownership.test.ts index 01b57310b..ed8f5c465 100644 --- a/test/setup-link-ownership.test.ts +++ b/test/setup-link-ownership.test.ts @@ -9,7 +9,7 @@ * test/setup-cleanup-orphans.test.ts. */ import { describe, test, expect } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -53,10 +53,7 @@ function mkTree(): { tmp: string; skills: string; payload: string } { return { tmp, skills, payload }; } function bash(lines: string[], tmp: string) { - const r = spawnSync('bash', ['-c', lines.join('\n')], { - encoding: 'utf-8', timeout: 10_000, - env: { PATH: process.env.PATH ?? '', HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') }, - }); + const r = runBashScript(lines.join('\n'), { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') } }); // An extracted function calling a helper this harness forgot to extract must // fail loudly, not degrade into "foreign, skipped". if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`); diff --git a/test/setup-playwright-best-effort.test.ts b/test/setup-playwright-best-effort.test.ts index ccb98d0eb..bb5829f3d 100644 --- a/test/setup-playwright-best-effort.test.ts +++ b/test/setup-playwright-best-effort.test.ts @@ -17,6 +17,7 @@ */ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -403,7 +404,7 @@ function runEmojiStep(reason: string, fontOk: boolean): { stdout: string; stderr emoji, 'echo "REACHED_END=1"', ].join('\n'); - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 }); + const r = runBashScript(script, { timeout: 10_000 }); if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`); return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; } @@ -450,7 +451,7 @@ function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing', prelud tail, 'echo "REACHED_END=1"', ].join('\n'); - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 }); + const r = runBashScript(script, { timeout: 10_000 }); if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`); const argv = fs.existsSync(argvFile) ? fs.readFileSync(argvFile, 'utf-8') : ''; return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, argv }; @@ -582,7 +583,7 @@ function runLinker(opts: { `link_claude_skill_dirs "${payload}" "${skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"', ].join('\n'); - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } }); + const r = runBashScript(script, { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } }); if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`); return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '', skills, payload, tmp }; } @@ -628,12 +629,12 @@ describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership ma fs.writeFileSync(path.join(r.skills, 'review', 'SKILL.md'), '---\nname: review\n---\n# mine, hand-written\n'); fs.mkdirSync(path.join(r.skills, 'my-own')); fs.writeFileSync(path.join(r.skills, 'my-own', 'SKILL.md'), '---\nname: my-own\n---\n'); - const flip = spawnSync('bash', ['-c', [ + const flip = runBashScript([ 'set -e', 'IS_WINDOWS=1', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_old_claude_symlinks'), `cleanup_old_claude_symlinks "${r.payload}" "${r.skills}"`, - ].join('\n')], { encoding: 'utf-8', timeout: 10_000 }); + ].join('\n'), { timeout: 10_000 }); expect(flip.status).toBe(0); expect(flip.stdout).toContain('cleaned up old entries:'); expect(flip.stdout).toContain('qa'); diff --git a/test/setup-windows-fallback.test.ts b/test/setup-windows-fallback.test.ts index d1b895056..2d9a77007 100644 --- a/test/setup-windows-fallback.test.ts +++ b/test/setup-windows-fallback.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; @@ -95,10 +95,7 @@ describe.skipIf(process.platform === 'win32')('setup: _link_or_copy helper — b const helper = extractHelper(); // IS_WINDOWS must exist as a shell-readable var before sourcing. const script = `IS_WINDOWS=${isWindows}\n${helper}\n_link_or_copy "${src}" "${dst}"\n`; - const result = spawnSync('bash', ['-c', script], { - encoding: 'utf-8', - timeout: 5000, - }); + const result = runBashScript(script, { timeout: 5000 }); const lst = fs.lstatSync(dst, { throwIfNoEntry: false }); return { ok: result.status === 0, diff --git a/test/setup-windows-rerun-refresh.test.ts b/test/setup-windows-rerun-refresh.test.ts index 6c60ad47c..b68ad23a1 100644 --- a/test/setup-windows-rerun-refresh.test.ts +++ b/test/setup-windows-rerun-refresh.test.ts @@ -16,7 +16,7 @@ * factory/opencode can't silently regress. */ import { describe, test, expect } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -165,7 +165,7 @@ function runInstaller( ...fns.map(extractFn), invocation, ].join('\n'); - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 }); + const r = runBashScript(script, { timeout: 15_000 }); return { status: r.status, stdout: r.stdout, stderr: r.stderr }; } diff --git a/test/user-render-out-dir-install.test.ts b/test/user-render-out-dir-install.test.ts index 0295c5925..e982f9cd6 100644 --- a/test/user-render-out-dir-install.test.ts +++ b/test/user-render-out-dir-install.test.ts @@ -14,6 +14,7 @@ */ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; +import { runBashScript } from './helpers/bash-script'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -86,7 +87,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => { extractFn(src, '_swap_in_render'), `_swap_in_render "${live}" "${fresh}"`, ].join('\n'); - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 }); + const r = runBashScript(script, { timeout: 15_000 }); expect(r.status).toBe(0); // Live dir now serves the fresh render at the SAME path (links into // it stay valid), tmp and .old are gone. @@ -126,7 +127,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => { ' echo "render failed — previous render left in place" >&2', 'fi', ].join('\n'); - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 }); + const r = runBashScript(script, { timeout: 15_000 }); expect(r.status).toBe(0); expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('previous-render\n'); // The installed symlink still resolves — the skill set did not vanish. @@ -191,7 +192,7 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => { extractFn(SETUP_SRC, 'link_claude_skill_dirs'), `link_claude_skill_dirs "${src}" "${skills}"`, ].join('\n'); - const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 }); + const r = runBashScript(script, { timeout: 15_000 }); expect(r.status).toBe(0); expect(fs.readFileSync(path.join(skills, 'alpha', 'SKILL.md'), 'utf-8')).toContain('rendered-alpha');