blob: 689f5f496e9e7c5086e059f13949159096fcc067 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
bashToolTurnShellGuidance,
buildPtyShellSpawnPlan,
buildShellSpawnPlan,
detectShell,
resolveShellPlan,
resolveTurnShellPlan,
ShellPreferenceError,
throwIfShellSetupFailed,
validateShellPreference,
} from '../shell-detect.js';
const winEnv = (over: Record<string, string> = {}) => ({
Path: 'C:\\Windows\\System32;C:\\Users\\u\\bin',
ProgramFiles: 'C:\\Program Files',
SystemRoot: 'C:\\Windows',
...over,
});
const existsIn =
(...paths: string[]) =>
(p: string) =>
paths.includes(p);
describe('detectShell', () => {
test('on win32 picks pwsh from PATH ahead of everything else', () => {
const plan = detectShell({
platform: 'win32',
env: winEnv(),
fileExists: existsIn(
'C:\\Users\\u\\bin\\pwsh.exe',
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
),
});
assert.equal(plan.kind, 'pwsh');
assert.equal(plan.exe, 'C:\\Users\\u\\bin\\pwsh.exe');
});
test('on win32 finds pwsh at its default install location when not on PATH', () => {
const plan = detectShell({
platform: 'win32',
env: winEnv(),
fileExists: existsIn('C:\\Program Files\\PowerShell\\7\\pwsh.exe'),
});
assert.equal(plan.kind, 'pwsh');
assert.equal(plan.exe, 'C:\\Program Files\\PowerShell\\7\\pwsh.exe');
});
test('on win32 falls back to Windows PowerShell 5.1 when pwsh is absent', () => {
const plan = detectShell({
platform: 'win32',
env: winEnv(),
fileExists: existsIn('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'),
});
assert.equal(plan.kind, 'powershell');
assert.equal(plan.exe, 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe');
});
test('on win32 falls back to cmd.exe when no PowerShell exists', () => {
const plan = detectShell({ platform: 'win32', env: winEnv(), fileExists: () => false });
assert.deepEqual(plan, { kind: 'cmd', displayName: 'cmd.exe' });
});
test('on POSIX platforms keeps the system default shell', () => {
const plan = detectShell({ platform: 'darwin', env: {}, fileExists: () => false });
assert.deepEqual(plan, { kind: 'posix', displayName: '/bin/sh' });
});
});
describe('resolveShellPlan', () => {
const executable = 'C:\\Program Files\\Git\\bin\\bash.exe';
test('keeps automatic detection unchanged when no override is selected', () => {
const plan = resolveShellPlan(
{ preference: 'auto', executable },
{
platform: 'win32',
env: winEnv(),
fileExists: existsIn('C:\\Program Files\\PowerShell\\7\\pwsh.exe'),
},
);
assert.equal(plan.kind, 'pwsh');
});
test('resolves an existing absolute bash.exe on the Windows Host', () => {
assert.deepEqual(
resolveShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: existsIn(executable) },
),
{ kind: 'git-bash', displayName: 'Git Bash', exe: executable },
);
});
test('recognizes the legacy System32 WSL shim as a distinct Bash plan', () => {
const executable = 'C:\\Windows\\System32\\bash.exe';
assert.deepEqual(
resolveShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', env: winEnv(), fileExists: existsIn(executable) },
),
{ kind: 'legacy-wsl-bash', displayName: 'Legacy WSL Bash', exe: executable },
);
});
test('fails closed for another platform, a relative path, or a missing executable', () => {
for (const run of [
() =>
resolveShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'linux', fileExists: existsIn(executable) },
),
() =>
resolveShellPlan(
{ preference: 'git_bash', executable: '.\\bash.exe' },
{ platform: 'win32', fileExists: () => true },
),
() =>
resolveShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: () => false },
),
]) {
assert.throws(run, ShellPreferenceError);
}
});
test('probes GNU Bash identity before accepting the preference', async () => {
const settings = { preference: 'git_bash' as const, executable };
const plan = await validateShellPreference(settings, {
platform: 'win32',
fileExists: existsIn(executable),
probeVersion: async () => 'GNU bash, version 5.2.37(1)-release',
});
assert.equal(plan.kind, 'git-bash');
await assert.rejects(
validateShellPreference(settings, {
platform: 'win32',
fileExists: existsIn(executable),
probeVersion: async () => 'not a shell',
}),
(error: unknown) => error instanceof ShellPreferenceError && error.code === 'not_bash',
);
});
});
describe('resolveTurnShellPlan', () => {
const executable = 'C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe';
test('resolves a healthy explicit preference into a ready plan', () => {
const shell = resolveTurnShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: existsIn(executable) },
);
assert.deepEqual(shell, {
plan: { kind: 'git-bash', displayName: 'Git Bash', exe: executable },
});
assert.equal(shell.setupError, undefined);
});
test('captures a moved or uninstalled executable instead of throwing at turn admission', () => {
const shell = resolveTurnShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: () => false },
);
assert.ok(shell.setupError instanceof ShellPreferenceError);
assert.equal(shell.setupError.code, 'executable_missing');
// The fallback plan exists only so guidance surfaces still have a shape;
// execution never reaches it (throwIfShellSetupFailed gates the boundary).
assert.equal(shell.plan, resolveTurnShellPlan({ preference: 'auto', executable }).plan);
});
test('keeps automatic detection error-free', () => {
const shell = resolveTurnShellPlan(
{ preference: 'auto', executable: '' },
{ platform: 'darwin', env: {}, fileExists: () => false },
);
assert.equal(shell.plan.kind, 'posix');
assert.equal(shell.setupError, undefined);
});
test('fails closed at the Bash boundary without falling back to another shell', () => {
const broken = resolveTurnShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: () => false },
);
assert.throws(() => throwIfShellSetupFailed(broken), ShellPreferenceError);
const healthy = resolveTurnShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: existsIn(executable) },
);
throwIfShellSetupFailed(healthy);
});
test('declares the outage to the model instead of naming a shell that cannot run', () => {
const broken = resolveTurnShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: () => false },
);
const guidance = bashToolTurnShellGuidance(broken);
assert.match(guidance, /unavailable this turn/);
assert.match(guidance, /not found/i);
assert.doesNotMatch(guidance, /write POSIX shell syntax/);
const healthy = resolveTurnShellPlan(
{ preference: 'git_bash', executable },
{ platform: 'win32', fileExists: existsIn(executable) },
);
assert.match(bashToolTurnShellGuidance(healthy), /write POSIX shell syntax/);
});
});
describe('buildShellSpawnPlan', () => {
test('spawns an explicit Git Bash executable with POSIX command syntax', () => {
assert.deepEqual(
buildShellSpawnPlan(
{
kind: 'git-bash',
displayName: 'Git Bash',
exe: 'C:\\Program Files\\Git\\bin\\bash.exe',
},
'printf "%s\\n" ready',
),
{
file: 'C:\\Program Files\\Git\\bin\\bash.exe',
args: ['-c', 'printf "%s\\n" ready'],
useShellOption: false,
},
);
});
test('feeds the legacy WSL shim through stdin instead of quoting a command argument', () => {
assert.deepEqual(
buildShellSpawnPlan(
{
kind: 'legacy-wsl-bash',
displayName: 'Legacy WSL Bash',
exe: 'C:\\Windows\\System32\\bash.exe',
},
'printf "%s\\n" ready',
),
{
file: 'C:\\Windows\\System32\\bash.exe',
args: ['-s'],
useShellOption: false,
stdin: 'printf "%s\\n" ready\n',
},
);
});
test('never falls back to the platform shell for a malformed explicit Bash plan', () => {
assert.throws(
() => buildShellSpawnPlan({ kind: 'git-bash', displayName: 'Git Bash' }, 'echo unsafe'),
/missing its executable/,
);
assert.throws(
() =>
buildPtyShellSpawnPlan(
{ kind: 'legacy-wsl-bash', displayName: 'Legacy WSL Bash' },
'echo unsafe',
),
/missing its executable/,
);
});
test('spawns PowerShell explicitly and preserves the command in an isolated script block', () => {
const command = "using namespace System.Text\nWrite-Output '$HOME'";
const spawnPlan = buildShellSpawnPlan(
{ kind: 'pwsh', displayName: 'PowerShell 7 (pwsh)', exe: 'C:\\Users\\u\\bin\\pwsh.exe' },
command,
{ INHERITED: 'kept', __maka_runtime_powershell_command: 'caller value' },
);
assert.equal(spawnPlan.file, 'C:\\Users\\u\\bin\\pwsh.exe');
assert.equal(spawnPlan.useShellOption, false);
assert.deepEqual(spawnPlan.args.slice(0, 4), [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-Command',
]);
const script = spawnPlan.args[4] ?? '';
assert.match(script, /\[Console\]::OutputEncoding = \$__makaUtf8/u);
assert.equal(spawnPlan.env?.INHERITED, 'kept');
assert.ok(spawnPlan.env?.__MAKA_RUNTIME_POWERSHELL_COMMAND?.startsWith(`${command}\n`));
assert.equal(spawnPlan.env?.__maka_runtime_powershell_command, undefined);
assert.match(script, /SetEnvironmentVariable\('__MAKA_RUNTIME_POWERSHELL_COMMAND', \$null\)/u);
assert.doesNotMatch(
script,
/Write-Output/u,
'user syntax is not interpolated into the wrapper',
);
});
test('appends an exit-code wrapper so native command exit codes survive -Command', () => {
// pwsh -Command maps the process exit code to 0/1 from $? — a native
// command exiting 42 comes back as 1. The wrapper re-raises $LASTEXITCODE,
// and only when the final statement actually failed, so a recovered
// failure (native exit 3, then a succeeding cmdlet) still exits 0.
const spawnPlan = buildShellSpawnPlan(
{ kind: 'pwsh', displayName: 'PowerShell 7 (pwsh)', exe: 'C:\\pf\\pwsh.exe' },
'npm test',
);
assert.equal(
spawnPlan.env?.__MAKA_RUNTIME_POWERSHELL_COMMAND,
'npm test\n' +
'$__makaOk = $?\n' +
'if (-not $__makaOk) { if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } else { exit 1 } }',
);
assert.equal(
spawnPlan.args[4],
'$__makaUtf8 = [System.Text.UTF8Encoding]::new($false)\n' +
'[Console]::OutputEncoding = $__makaUtf8\n' +
'$OutputEncoding = $__makaUtf8\n' +
"$__makaCommandText = [Environment]::GetEnvironmentVariable('__MAKA_RUNTIME_POWERSHELL_COMMAND')\n" +
"[Environment]::SetEnvironmentVariable('__MAKA_RUNTIME_POWERSHELL_COMMAND', $null)\n" +
'$__makaCommand = [ScriptBlock]::Create($__makaCommandText)\n' +
'. $__makaCommand',
);
});
test('keeps shell:true for the system default shells (posix and cmd)', () => {
for (const plan of [
{ kind: 'posix', displayName: '/bin/sh' } as const,
{ kind: 'cmd', displayName: 'cmd.exe' } as const,
]) {
assert.deepEqual(buildShellSpawnPlan(plan, 'echo hi'), {
file: 'echo hi',
args: [],
useShellOption: true,
});
}
});
});
describe('buildPtyShellSpawnPlan', () => {
test('uses explicit argv for POSIX, cmd, and PowerShell PTYs', () => {
assert.deepEqual(
buildPtyShellSpawnPlan({ kind: 'posix', displayName: '/bin/sh' }, 'printf ready'),
{ file: '/bin/sh', args: ['-c', 'printf ready'] },
);
assert.deepEqual(
buildPtyShellSpawnPlan({ kind: 'cmd', displayName: 'cmd.exe' }, 'echo ready', {
ComSpec: 'C:\\Windows\\System32\\cmd.exe',
}),
{
file: 'C:\\Windows\\System32\\cmd.exe',
args: ['/d', '/s', '/c', 'echo ready'],
},
);
const powershell = buildPtyShellSpawnPlan(
{ kind: 'pwsh', displayName: 'PowerShell 7 (pwsh)', exe: 'C:\\pf\\pwsh.exe' },
'Write-Output ready',
{ INHERITED: 'kept' },
);
assert.equal(powershell.file, 'C:\\pf\\pwsh.exe');
assert.deepEqual(powershell.args.slice(0, 3), ['-NoLogo', '-NoProfile', '-Command']);
assert.doesNotMatch(powershell.args.join(' '), /-NonInteractive/);
assert.match(
powershell.args[3] ?? '',
/\$OutputEncoding = \$__makaUtf8\n\$__makaCommandText = \[Environment\]::GetEnvironmentVariable/u,
);
assert.equal(powershell.env?.INHERITED, 'kept');
assert.ok(
powershell.env?.__MAKA_RUNTIME_POWERSHELL_COMMAND?.startsWith('Write-Output ready\n'),
);
assert.deepEqual(
buildPtyShellSpawnPlan(
{
kind: 'git-bash',
displayName: 'Git Bash',
exe: 'C:\\Program Files\\Git\\bin\\bash.exe',
},
'exec bash -l',
),
{
file: 'C:\\Program Files\\Git\\bin\\bash.exe',
args: ['-c', 'exec bash -l'],
},
);
assert.deepEqual(
buildPtyShellSpawnPlan(
{
kind: 'legacy-wsl-bash',
displayName: 'Legacy WSL Bash',
exe: 'C:\\Windows\\System32\\bash.exe',
},
'exec bash -l',
),
{
file: 'C:\\Windows\\System32\\bash.exe',
args: ['-c', 'exec bash -l'],
},
);
});
});