|
1 | 1 | import { describe, test, expect } from 'bun:test'; |
| 2 | +import { spawnSync } from 'child_process'; |
2 | 3 | import * as fs from 'fs'; |
| 4 | +import * as os from 'os'; |
3 | 5 | import * as path from 'path'; |
4 | 6 |
|
5 | 7 | const ROOT = path.resolve(import.meta.dir, '..'); |
6 | 8 | const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')) as { |
7 | 9 | scripts: Record<string, string>; |
8 | 10 | }; |
9 | | -const BUILD_SCRIPT = fs.readFileSync(path.join(ROOT, 'scripts', 'build.sh'), 'utf-8'); |
| 11 | +const BUILD_SCRIPT_PATH = path.join(ROOT, 'scripts', 'build.sh'); |
| 12 | +const WRITE_VERSION_PATH = path.join(ROOT, 'scripts', 'write-version-files.sh'); |
| 13 | +const BUILD_SCRIPT = fs.readFileSync(BUILD_SCRIPT_PATH, 'utf-8'); |
10 | 14 |
|
11 | 15 | // Strip single-quoted strings so JS code emitted as `echo '{ ... }'` doesn't |
12 | 16 | // trip the shell-brace-group check. Conservative: only `'...'` segments. |
@@ -52,3 +56,85 @@ describe('package.json build scripts — POSIX shell compat (D-1460)', () => { |
52 | 56 | expect(BUILD_SCRIPT).toContain('bash scripts/write-version-files.sh'); |
53 | 57 | }); |
54 | 58 | }); |
| 59 | + |
| 60 | +// ── Issue #1602: Windows build hardening ────────────────────────────────── |
| 61 | +// Static string-matching catches the package.json regression. These tests |
| 62 | +// also exercise the helper scripts so a hand-written bash bug (missing |
| 63 | +// shebang, broken loop, syntax error) is caught at `bun test` time on every |
| 64 | +// platform — no need to fully build the project to find a syntax error. |
| 65 | + |
| 66 | +describe('Windows build hardening (issue #1602)', () => { |
| 67 | + test('scripts/build.sh is bash-syntax-clean (bash -n)', () => { |
| 68 | + const r = spawnSync('bash', ['-n', BUILD_SCRIPT_PATH], { timeout: 5000 }); |
| 69 | + expect(r.status).toBe(0); |
| 70 | + expect((r.stderr ?? '').toString()).toBe(''); |
| 71 | + }); |
| 72 | + |
| 73 | + test('scripts/write-version-files.sh is bash-syntax-clean (bash -n)', () => { |
| 74 | + const r = spawnSync('bash', ['-n', WRITE_VERSION_PATH], { timeout: 5000 }); |
| 75 | + expect(r.status).toBe(0); |
| 76 | + expect((r.stderr ?? '').toString()).toBe(''); |
| 77 | + }); |
| 78 | + |
| 79 | + test('scripts/write-version-files.sh writes one .version file per argument', () => { |
| 80 | + // The build chain calls this with three paths (browse/dist/.version, |
| 81 | + // design/dist/.version, make-pdf/dist/.version). Validate the loop body |
| 82 | + // by invoking with controlled targets in a tmpdir — catches a future |
| 83 | + // refactor that loses the `for/do/done` shape or drops `mkdir -p`. |
| 84 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-version-files-')); |
| 85 | + try { |
| 86 | + const a = path.join(dir, 'a', 'dist', '.version'); |
| 87 | + const b = path.join(dir, 'b', 'dist', '.version'); |
| 88 | + const r = spawnSync('bash', [WRITE_VERSION_PATH, a, b], { |
| 89 | + timeout: 5000, |
| 90 | + env: { ...process.env, PATH: process.env.PATH ?? '' }, |
| 91 | + }); |
| 92 | + expect(r.status).toBe(0); |
| 93 | + expect(fs.existsSync(a)).toBe(true); |
| 94 | + expect(fs.existsSync(b)).toBe(true); |
| 95 | + // Both files share the same git_head value, derived once at the top |
| 96 | + // of the script. |
| 97 | + expect(fs.readFileSync(a, 'utf-8')).toBe(fs.readFileSync(b, 'utf-8')); |
| 98 | + } finally { |
| 99 | + fs.rmSync(dir, { recursive: true, force: true }); |
| 100 | + } |
| 101 | + }); |
| 102 | + |
| 103 | + test('scripts/write-version-files.sh tolerates being run outside a git repo', () => { |
| 104 | + // The script's git rev-parse is wrapped in `if … then : else git_head=""`. |
| 105 | + // Catches a regression where that fallback gets stripped and the script |
| 106 | + // dies under `set -e` when run from a tarball. |
| 107 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-version-no-git-')); |
| 108 | + try { |
| 109 | + const target = path.join(dir, 'dist', '.version'); |
| 110 | + const r = spawnSync('bash', [WRITE_VERSION_PATH, target], { |
| 111 | + timeout: 5000, |
| 112 | + cwd: dir, // outside any git repo |
| 113 | + env: { |
| 114 | + ...process.env, |
| 115 | + PATH: process.env.PATH ?? '', |
| 116 | + GIT_DIR: '/nonexistent', // force git rev-parse to fail |
| 117 | + }, |
| 118 | + }); |
| 119 | + expect(r.status).toBe(0); |
| 120 | + expect(fs.existsSync(target)).toBe(true); |
| 121 | + } finally { |
| 122 | + fs.rmSync(dir, { recursive: true, force: true }); |
| 123 | + } |
| 124 | + }); |
| 125 | + |
| 126 | + test('package.json build entry contains no constructs the Bun Windows shell rejects', () => { |
| 127 | + // Comprehensive guard collecting every known bunsh-incompatible pattern |
| 128 | + // in one place. Catches a future package.json edit that re-introduces |
| 129 | + // any of them without needing a Windows runner. |
| 130 | + const build = PKG.scripts.build ?? ''; |
| 131 | + // Subshells `( ... )` — bunsh rejects when paired with redirection. |
| 132 | + expect(build).not.toMatch(/\([^)]*\)\s*[<>]/); |
| 133 | + // Multiple redirections — `cmd >a 2>b` form. Bunsh rejects ">" "2>" pairs. |
| 134 | + expect(build).not.toMatch(/>\s*\S+\s+2>/); |
| 135 | + // Bash brace groups `{ cmd; }`. |
| 136 | + expect(stripSingleQuoted(build)).not.toMatch(/\{\s+[^}]*;\s*\}/); |
| 137 | + // Process substitution `<(cmd)` and `>(cmd)`. |
| 138 | + expect(build).not.toMatch(/[<>]\([^)]+\)/); |
| 139 | + }); |
| 140 | +}); |
0 commit comments