* fix(#3275): resolve reviewer lane binaries via shared pathext scan * fix(#3275): pin changeset fragment to pr 3445 * fix(#3275): stage p4 resolver fixture in pathext casing --------- Co-authored-by: sim <sim@local>
This commit is contained in:
268
tests/review-lane-windows-spawn-resolution.test.cjs
Normal file
268
tests/review-lane-windows-spawn-resolution.test.cjs
Normal file
@@ -0,0 +1,268 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #3275 — reviewer lanes fail with spawn ENOENT on Windows.
|
||||
*
|
||||
* Root cause: `routeReviewLane`'s `deps.spawn` (gsd-core/bin/gsd-tools.cjs) mediate
|
||||
* Windows `.cmd`/`.bat` shims through cmd.exe only when the binary name ALREADY
|
||||
* carries the extension, but lane descriptors declare BARE names ('codex', 'kimi',
|
||||
* 'agy') and nothing resolved them to the on-disk extensioned form — while
|
||||
* `deps.hasBinary` scanned PATH WITH PATHEXT, so probes reported the lane
|
||||
* available for a spawn that could never start.
|
||||
*
|
||||
* The fix gives both seams ONE shared resolver, `resolveSpawnBinary`, exported
|
||||
* from gsd-tools.cjs. This file exercises:
|
||||
* - the resolver itself, portably, against staged fake-bin dirs (P1–P8);
|
||||
* - the real CLI end-to-end on Windows: staged `codex.CMD` (primary invocation
|
||||
* path, file-arg) and `kimi.CMD` (capability-probe path) invoked through
|
||||
* `gsd-tools review-lane invoke` with the staged dir first on PATH (W1–W2).
|
||||
*
|
||||
* The E2E cases stage an extensionless POSIX sh shim NEXT to each `.CMD` on
|
||||
* purpose: an `['', ...PATHEXT]` resolver resolves to it and re-opens the exact
|
||||
* ENOENT (field-reported on Windows 11), so its presence pins the ordering.
|
||||
*
|
||||
* Matrix: .gsd/bug/fix-3275-reviewer-lanes-enoent-windows/50-test-matrix.md
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runGsdTools, createTempDir, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const { resolveSpawnBinary } = require('../gsd-core/bin/gsd-tools.cjs');
|
||||
|
||||
const WIN32 = { PATH: '', PATHEXT: '.COM;.EXE;.BAT;.CMD' };
|
||||
const POSIX = { PATH: '' };
|
||||
|
||||
/** Stage a fake bin dir: entries are written verbatim (name → content). */
|
||||
function stageBin(dir, entries) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(entries)) {
|
||||
fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveSpawnBinary (#3275)', () => {
|
||||
test('is exported as a function', () => {
|
||||
assert.equal(typeof resolveSpawnBinary, 'function');
|
||||
});
|
||||
|
||||
test('P1: win32 resolves the .CMD shim, never the extensionless POSIX sh shim beside it', () => {
|
||||
const dir = createTempDir('gsd-3275-p1-');
|
||||
try {
|
||||
stageBin(dir, {
|
||||
codex: '#!/bin/sh\nexec node "$0".js "$@"\n',
|
||||
'codex.CMD': '@echo off\r\nexit /b 0\r\n',
|
||||
});
|
||||
const resolved = resolveSpawnBinary('codex', 'win32', { ...WIN32, PATH: dir });
|
||||
assert.equal(resolved, path.join(dir, 'codex.CMD'));
|
||||
// The resolution must carry the extension the cmd.exe mediation gate keys on.
|
||||
assert.match(path.basename(resolved || ''), /\.(cmd|bat)$/i);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('P2: win32 returns null when only the extensionless sh shim exists', () => {
|
||||
const dir = createTempDir('gsd-3275-p2-');
|
||||
try {
|
||||
stageBin(dir, { codex: '#!/bin/sh\nexit 0\n' });
|
||||
assert.equal(resolveSpawnBinary('codex', 'win32', { ...WIN32, PATH: dir }), null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('P3: win32 honors PATHEXT ORDER, not just membership', () => {
|
||||
const dir = createTempDir('gsd-3275-p3-');
|
||||
try {
|
||||
stageBin(dir, { 'gemini.CMD': '@echo off\r\n', 'gemini.EXE': 'exe' });
|
||||
const resolved = resolveSpawnBinary('gemini', 'win32', { PATH: dir, PATHEXT: '.CMD;.EXE' });
|
||||
assert.equal(resolved, path.join(dir, 'gemini.CMD'));
|
||||
const flipped = resolveSpawnBinary('gemini', 'win32', { PATH: dir, PATHEXT: '.EXE;.CMD' });
|
||||
assert.equal(flipped, path.join(dir, 'gemini.EXE'));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('P4: win32 scans PATH dirs in order across multiple entries', () => {
|
||||
const early = createTempDir('gsd-3275-p4a-');
|
||||
const late = createTempDir('gsd-3275-p4b-');
|
||||
try {
|
||||
// Staged in PATHEXT casing: the resolver probes `name + ext` verbatim, and a
|
||||
// case-SENSITIVE CI filesystem (Linux ext4) must find it exactly as a
|
||||
// case-insensitive Windows one would.
|
||||
stageBin(late, { 'tool.EXE': 'exe' });
|
||||
const resolved = resolveSpawnBinary('tool', 'win32', {
|
||||
PATH: [early, late].join(path.delimiter),
|
||||
PATHEXT: '.EXE;.CMD',
|
||||
});
|
||||
assert.equal(resolved, path.join(late, 'tool.EXE'));
|
||||
} finally {
|
||||
cleanup(early);
|
||||
cleanup(late);
|
||||
}
|
||||
});
|
||||
|
||||
test('P5: win32 returns null when the name exists nowhere on PATH', () => {
|
||||
const dir = createTempDir('gsd-3275-p5-');
|
||||
try {
|
||||
stageBin(dir, {});
|
||||
assert.equal(
|
||||
resolveSpawnBinary('definitely-not-installed-3275', 'win32', { ...WIN32, PATH: dir }),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('P6: a path-like name bypasses the PATH scan — existing file passes through, missing is null', () => {
|
||||
const dir = createTempDir('gsd-3275-p6-');
|
||||
try {
|
||||
const file = path.join(dir, 'tool.CMD');
|
||||
fs.writeFileSync(file, '@echo off\r\n');
|
||||
assert.equal(resolveSpawnBinary(file, 'win32', WIN32), file);
|
||||
assert.equal(resolveSpawnBinary(path.join(dir, 'absent.CMD'), 'win32', WIN32), null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('P7: POSIX existence semantics — bare name found in a PATH dir, null when absent', () => {
|
||||
const dir = createTempDir('gsd-3275-p7-');
|
||||
try {
|
||||
stageBin(dir, { codex: '#!/bin/sh\nexit 0\n' });
|
||||
assert.equal(resolveSpawnBinary('codex', 'darwin', { ...POSIX, PATH: dir }), path.join(dir, 'codex'));
|
||||
assert.equal(resolveSpawnBinary('codex', 'linux', POSIX), null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('P8: an empty name resolves to null on every platform', () => {
|
||||
assert.equal(resolveSpawnBinary('', 'win32', WIN32), null);
|
||||
assert.equal(resolveSpawnBinary('', 'darwin', POSIX), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Windows-gated end-to-end: the REAL CLI seam (no injected deps), staged
|
||||
// shims first on PATH, mirroring how `npm install -g` lays down CLIs.
|
||||
// Pre-fix, W1 stubs with `[spawn error: ENOENT]` and W2 fails its
|
||||
// capability probe — the two symptoms the issue reports.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
describe('review-lane invoke on Windows (#3275)', () => {
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
/** PATH env key as spelled on this process (Windows env keys are case-folded). */
|
||||
function withStagedPath(binDir) {
|
||||
const pathKey =
|
||||
Object.keys(process.env).find((k) => k.toLowerCase() === 'path') || 'PATH';
|
||||
return {
|
||||
[pathKey]: [binDir, process.env[pathKey]].filter(Boolean).join(path.delimiter),
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the real `gsd-tools review-lane invoke` for one slug and parse its JSON result. */
|
||||
function invokeLane({ slug, binDir, runDir, repoRoot }) {
|
||||
const r = runGsdTools(
|
||||
[
|
||||
'review-lane', 'invoke',
|
||||
'--selected', slug,
|
||||
'--slug', slug,
|
||||
'--run-dir', runDir,
|
||||
'--repo-root', repoRoot,
|
||||
'--explicit',
|
||||
],
|
||||
repoRoot,
|
||||
withStagedPath(binDir),
|
||||
);
|
||||
assert.equal(r.exitCode, 0, `invoke exited ${r.exitCode}: ${r.error}`);
|
||||
return { result: JSON.parse(r.output), stdout: r.output };
|
||||
}
|
||||
|
||||
test('W1: codex lane runs to completion through a staged codex.CMD shim (primary invocation path)', { skip: !isWin }, () => {
|
||||
const binDir = createTempDir('gsd-3275-w1-bin-');
|
||||
const runDir = createTempDir('gsd-3275-w1-run-');
|
||||
const repoRoot = createTempDir('gsd-3275-w1-repo-');
|
||||
try {
|
||||
// The pitfall, live: the extensionless sh shim sits beside codex.CMD exactly
|
||||
// as npm lays it down. A bare-name-first resolver picks it and re-ENOENTs.
|
||||
stageBin(binDir, {
|
||||
codex: '#!/bin/sh\nexit 1\n',
|
||||
'codex.CMD': [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'set "OUT="',
|
||||
':parse',
|
||||
'if "%~1"=="" goto write',
|
||||
'if /I "%~1"=="-o" set "OUT=%~2"',
|
||||
'shift',
|
||||
'goto parse',
|
||||
':write',
|
||||
'> "%OUT%" echo fake codex review from the #3275 Windows shim',
|
||||
'exit /b 0',
|
||||
].join('\r\n'),
|
||||
});
|
||||
const { result } = invokeLane({ slug: 'codex', binDir, runDir, repoRoot });
|
||||
|
||||
assert.equal(result.ok, true, `lane failed: ${result.reason} ${result.detail}`);
|
||||
assert.equal(result.stubbed, false, `review was stubbed: ${JSON.stringify(result)}`);
|
||||
|
||||
const review = fs.readFileSync(path.join(runDir, 'gsd-review-codex.md'), 'utf8');
|
||||
assert.ok(
|
||||
review.includes('#3275 Windows shim'),
|
||||
`review file should carry the shim's output, got: ${review}`,
|
||||
);
|
||||
const errSide = fs.readFileSync(path.join(runDir, 'gsd-review-codex.err'), 'utf8');
|
||||
assert.ok(!errSide.includes('[spawn error: ENOENT]'), `sidecar carries ENOENT: ${errSide}`);
|
||||
} finally {
|
||||
cleanup(binDir);
|
||||
cleanup(runDir);
|
||||
cleanup(repoRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test('W2: kimi-code lane passes its capability probe and runs through a staged kimi.CMD shim', { skip: !isWin }, () => {
|
||||
const binDir = createTempDir('gsd-3275-w2-bin-');
|
||||
const runDir = createTempDir('gsd-3275-w2-run-');
|
||||
const repoRoot = createTempDir('gsd-3275-w2-repo-');
|
||||
try {
|
||||
stageBin(binDir, {
|
||||
kimi: '#!/bin/sh\nexit 1\n',
|
||||
'kimi.CMD': [
|
||||
'@echo off',
|
||||
'if /I "%~1"=="--help" (',
|
||||
' echo Usage: kimi [-m MODEL] [-p PROMPT] --output-format FORMAT',
|
||||
' exit /b 0',
|
||||
')',
|
||||
'echo fake kimi-code review from the #3275 Windows shim',
|
||||
'exit /b 0',
|
||||
].join('\r\n'),
|
||||
});
|
||||
const { result } = invokeLane({ slug: 'kimi-code', binDir, runDir, repoRoot });
|
||||
|
||||
assert.equal(result.ok, true, `lane failed: ${result.reason} ${result.detail}`);
|
||||
assert.equal(result.stubbed, false, `review was stubbed: ${JSON.stringify(result)}`);
|
||||
|
||||
const review = fs.readFileSync(path.join(runDir, 'gsd-review-kimi-code.md'), 'utf8');
|
||||
assert.ok(
|
||||
review.includes('#3275 Windows shim'),
|
||||
`review file should carry the shim's output, got: ${review}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(binDir);
|
||||
cleanup(runDir);
|
||||
cleanup(repoRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test('E2E wiring is aimed at the real CLI seam', () => {
|
||||
// Guards the test itself: if TOOLS_PATH or the review-lane verb ever moves,
|
||||
// W1/W2 would silently skip their assertions behind the win32 gate.
|
||||
assert.ok(fs.existsSync(TOOLS_PATH), `gsd-tools.cjs not found at ${TOOLS_PATH}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user