fix(#371): invoke installed bin via shell for Windows .cmd shims in release-tarball-smoke (#375)

* fix(#371): invoke installed bin via shell for Windows .cmd shims in release-tarball-smoke

`node <gsd-tools.cmd>` cannot execute a Windows batch shim as a JS script, so
runSmoke returned bin_not_callable for every check on Windows. Route .cmd/.bat
shims through shell:true (required by Node >=18.20/20.12) and keep the POSIX
node-invocation path unchanged. Add an exported binInvocation seam plus a
platform-agnostic regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(#371): surface bin-invocation failure details in release-tarball-smoke

The smoke harness captured stderr/stdout in `details` but never printed
them, so Windows bin_not_callable failures gave no actionable cause in CI.
Log the resolved bin, invocation descriptor, exit status/signal/error, and
captured stderr/stdout on spawn-derived failures so the real error is visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(#371): include smoke result details in install-test assertion messages

The node --test TAP runner swallows in-test console.error, so Windows
bin_not_callable failures gave no cause. Embed code + details (incl. captured
stderr/stdout) into the assertion messages, which DO reach the CI log, so the
real Windows failure is diagnosable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#371): resolve installed bin from prefix root on Windows

npm install -g --prefix X writes bin shims to X\ (the prefix root) on
Windows, not X\node_modules\.bin\. The smoke harness only searched
node_modules\.bin on win32, so the installed gsd-tools/installer bin was
never found and runSmoke returned bin_not_callable before invoking anything.
Search the prefix root first (then node_modules/.bin as fallback), report the
searched candidates on miss, and drop the TAP-swallowed console.error probes.
The .cmd-via-shell binInvocation fix remains for actually running the shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-26 22:46:09 -04:00
committed by GitHub
parent 4fbb61c915
commit 33ebd1de19
3 changed files with 145 additions and 37 deletions

View File

@@ -0,0 +1,57 @@
'use strict';
/**
* Unit tests for the binInvocation helper in scripts/release-tarball-smoke.cjs.
*
* Asserts ONLY on the returned descriptor — no process spawning.
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { binInvocation } = require('../scripts/release-tarball-smoke.cjs');
describe('binInvocation', () => {
it('returns shell:true and command=binPath (not execPath) for a .cmd path', () => {
const bin = 'C:\\prefix\\node_modules\\.bin\\gsd-tools.cmd';
const result = binInvocation(bin, ['--help']);
assert.strictEqual(result.command, bin,
'command must be the .cmd path itself, not process.execPath');
assert.strictEqual(result.shell, true,
'shell must be true for .cmd shims (Node CVE-2024-27980 mitigation)');
// The bin path must NOT appear in args as if it were a node script argument
assert.ok(
!result.args.includes(bin),
'the .cmd path must not be pushed into args as a node-script positional',
);
});
it('returns shell:true and command=binPath for a .bat path', () => {
const bin = 'C:\\prefix\\node_modules\\.bin\\gsd-tools.bat';
const result = binInvocation(bin, ['--help']);
assert.strictEqual(result.shell, true, 'shell must be true for .bat shims');
assert.strictEqual(result.command, bin, 'command must be the .bat path');
});
it('returns command===process.execPath and args[0]===binPath and shell falsy for a POSIX path', () => {
const bin = '/tmp/prefix/bin/gsd-tools';
const result = binInvocation(bin, ['--help']);
assert.strictEqual(result.command, process.execPath,
'POSIX bin must be invoked via node (process.execPath)');
assert.strictEqual(result.args[0], bin,
'POSIX bin path must be args[0] (the node script argument)');
assert.ok(!result.shell,
'shell must be falsy for POSIX shebang bins');
});
it('quotes a .cmd path containing a space so the shell receives one token', () => {
const bin = 'C:\\Users\\a b\\node_modules\\.bin\\gsd-tools.cmd';
const result = binInvocation(bin, ['--help']);
assert.strictEqual(result.shell, true, 'shell must be true');
assert.strictEqual(result.command, `"${bin}"`, 'spaced .cmd path must be wrapped in double-quotes as a single shell token');
});
});

View File

@@ -15,6 +15,9 @@ const path = require('node:path');
const { cleanup, createTempDir, runNpm, isolatedNpmEnv } = require('./helpers.cjs');
const { SMOKE, runSmoke } = require('../scripts/release-tarball-smoke.cjs');
const smokeMsg = (label, result) =>
`${label}: code=${result.code} details=${JSON.stringify(result.details)}`;
const PKG_PATH = path.join(__dirname, '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
@@ -74,8 +77,8 @@ describe('release-tarball-smoke', () => {
npmEnv: isolatedNpmEnv(),
});
assert.equal(result.code, SMOKE.OK);
assert.equal(result.details.version, pkg.version);
assert.equal(result.code, SMOKE.OK, smokeMsg('A', result));
assert.equal(result.details.version, pkg.version, smokeMsg('A', result));
});
// ── Test B — version mismatch detected ────────────────────────────────────
@@ -88,7 +91,7 @@ describe('release-tarball-smoke', () => {
npmEnv: isolatedNpmEnv(),
});
assert.equal(result.code, SMOKE.VERSION_MISMATCH);
assert.equal(result.code, SMOKE.VERSION_MISMATCH, smokeMsg('B', result));
});
// ── Test C — happy lifecycle ───────────────────────────────────────────────
@@ -106,7 +109,7 @@ describe('release-tarball-smoke', () => {
npmEnv: isolatedNpmEnv(),
});
assert.equal(result.code, SMOKE.OK);
assert.equal(result.code, SMOKE.OK, smokeMsg('C', result));
// Each non-init command must be in lifecycleResolved with both paths populated
const resolved = result.details.lifecycleResolved;
@@ -145,9 +148,9 @@ describe('release-tarball-smoke', () => {
npmEnv: isolatedNpmEnv(),
});
assert.equal(result.code, SMOKE.COMMAND_FILE_MISSING);
assert.equal(result.details.command, 'nonexistent-phase-xyz');
assert.ok(typeof result.details.path === 'string' && result.details.path.length > 0);
assert.equal(result.code, SMOKE.COMMAND_FILE_MISSING, smokeMsg('D', result));
assert.equal(result.details.command, 'nonexistent-phase-xyz', smokeMsg('D', result));
assert.ok(typeof result.details.path === 'string' && result.details.path.length > 0, smokeMsg('D', result));
});
// ── Test E — workflow-body checks run (informational) ─────────────────────
@@ -167,11 +170,11 @@ describe('release-tarball-smoke', () => {
// Structural: the scan ran and populated the counters
assert.ok(
Number.isInteger(result.details.workflowsScanned) && result.details.workflowsScanned >= 1,
`expected workflowsScanned >= 1, got ${result.details.workflowsScanned}`,
smokeMsg('E', result),
);
assert.ok(
Number.isInteger(result.details.colonLeakCount),
`expected colonLeakCount to be an integer, got ${result.details.colonLeakCount}`,
smokeMsg('E', result),
);
});
});