test: run the Hermes hook tests with a resolved Python 3 on Windows

- windows-test-env.js gains resolvePython: it probes each candidate with
  `-c "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)"`, so the
  Store stub is rejected.
- hermes-hooks.test.js launches its four hook spawns through the resolved
  interpreter, falling back to python3 when none is found so a machine
  without Python still fails loudly instead of skipping.

hermes-hooks goes from 0/14 to 14/14 on Windows 11; the other users of the
helper are unchanged. Product code is untouched: Hermes runs these hooks
itself and the installer writes no interpreter.
This commit is contained in:
montjeffrey committed 2026-10-11 14:21:25 -04:00
1 parent e9efbc2626
commit 72f2105cf6
2 files changed
+36 -5

No files matched your search

+9 -4
View File
@@ -16,10 +16,15 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { resolvePython } = require('./lib/helpers/windows-test-env');
const repoRoot = path.resolve(__dirname, '..');
const GUARD = path.join(repoRoot, 'hooks', 'hermes', 'config-protection.py');
const LOGGER = path.join(repoRoot, 'hooks', 'hermes', 'check-console-log.py');
// A working Python 3, not the Windows Store `python3` stub; with none found the
// spawns use python3 as before and fail loudly rather than skip.
const PYTHON = resolvePython() || { command: 'python3', prefix: [] };
const runPython = (script, options) => spawnSync(PYTHON.command, [...PYTHON.prefix, script], options);
function test(name, fn) {
try {
@@ -35,7 +40,7 @@ function test(name, fn) {
/** Run a hook with a payload; return {rc, stdout, stderr} and assert clean spawn. */
function runHook(script, payload) {
const res = spawnSync('python3', [script], {
const res = runPython(script, {
input: JSON.stringify(payload),
encoding: 'utf8',
timeout: 15000,
@@ -181,7 +186,7 @@ run('allows non-editing tools (read/search)', () => {
// ─── config-protection: fail-open + diagnostics ────────────────────────────
run('malformed payload: allows AND reports on stderr', () => {
const res = spawnSync('python3', [GUARD], { input: 'not-json{{', encoding: 'utf8', timeout: 15000 });
const res = runPython(GUARD, { input: 'not-json{{', encoding: 'utf8', timeout: 15000 });
assert.strictEqual(res.status, 0, 'must exit 0 (fail-open)');
assert.strictEqual(res.stdout.trim(), '', 'no decision emitted');
assert.ok(res.stderr.includes('unparseable payload'), 'diagnostic required');
@@ -250,13 +255,13 @@ run('hermes-hooks module installs via the real installer and the landed guard bl
const t = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-guard-'));
const target = path.join(t, 'ESLINT.CONFIG.MJS');
fs.writeFileSync(target, 'export default {}\n');
const g = spawnSync('python3', [landed], {
const g = runPython(landed, {
input: JSON.stringify({ hook_event_name: 'pre_tool_call', tool_name: 'patch', tool_input: { path: target, old_string: 'a', new_string: 'b' } }),
encoding: 'utf8', timeout: 15000,
});
assert.ok((g.stdout || '').includes('"decision": "block"'), 'landed guard must block');
fs.unlinkSync(target);
const bootstrap = spawnSync('python3', [landed], {
const bootstrap = runPython(landed, {
input: JSON.stringify({ hook_event_name: 'pre_tool_call', tool_name: 'patch', tool_input: { path: target } }),
encoding: 'utf8', timeout: 15000,
});
+27 -1
View File
@@ -8,6 +8,7 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
// On Windows, spawning a bare `claude` resolves only .com/.exe on PATH, never
// .cmd. A natively installed claude.exe therefore wins over a test's fake
@@ -35,4 +36,29 @@ function toRelativeTarPath(cwd, archivePath) {
return path.relative(cwd, archivePath).split(path.sep).join('/');
}
module.exports = { withoutShadowingClaude, toRelativeTarPath };
// On Windows, `python3` is usually the Microsoft Store alias stub, which exits
// 9009 instead of running Python; python.org installs provide `python` and the
// `py` launcher instead.
const PYTHON_CANDIDATES = Object.freeze({
win32: [['python'], ['py', '-3'], ['python3']],
default: [['python3'], ['python']]
});
const PYTHON3_CHECK = 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)';
function runsPython3(command, args) {
return spawnSync(command, args, { encoding: 'utf8', timeout: 15000 }).status === 0;
}
/**
* The first candidate that actually runs Python 3, as `{ command, prefix }`
* (spawn `command` with `[...prefix, script]`), or null when none does.
*/
function resolvePython({ platform = process.platform, probe = runsPython3 } = {}) {
const candidates = platform === 'win32' ? PYTHON_CANDIDATES.win32 : PYTHON_CANDIDATES.default;
for (const [command, ...prefix] of candidates) {
if (probe(command, [...prefix, '-c', PYTHON3_CHECK])) return { command, prefix };
}
return null;
}
module.exports = { withoutShadowingClaude, toRelativeTarPath, resolvePython };