import { describe, expect, it, vi } from 'vitest' // Pin the passwd login shell to a non-bash value for the pty helpers tests: // the CI runner's own login shell is /bin/bash, which the old hardcoded // fallback would have satisfied by coincidence, so the fallback chain needs // a mock passwd to be genuinely discriminating. No other test reads os.userInfo. vi.mock('node:os', async (importOriginal) => { const os = await importOriginal() return { ...os, userInfo: () => ({ ...os.userInfo(), shell: '/usr/bin/zsh' }), } }) import { existsSync, statSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { resolveSidebarConfig } from '../src/config.ts' import { defaultShell, ensureSpawnHelper, resolveShellExecutable, shellDisplayName, shellSpawnArgs, splitShellArgs, unquotePath, } from '../src/pty-manager.ts' import { SidebarError } from '../src/wire.ts' describe('pty helpers', () => { it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => { // The platform is injected so this chain also runs (and is asserted) on // non-POSIX developer machines. The userInfo mock at the top of the file // pins the passwd login shell to /usr/bin/zsh. expect(defaultShell({ platform: 'linux', env: { SHELL: '/explicit/zsh' } })).toBe('/explicit/zsh') // Surrounding whitespace must not leak into the spawned executable path. expect(defaultShell({ platform: 'linux', env: { SHELL: ' /explicit/zsh ' } })).toBe('/explicit/zsh') expect(defaultShell({ platform: 'linux', env: { SHELL: ' ' } })).toBe('/usr/bin/zsh') expect(defaultShell({ platform: 'linux', env: {} })).toBe('/usr/bin/zsh') }) it('Windows: explicit shell wins, then DSH_SIDEBAR_SHELL, then a probed pwsh.exe, then powershell.exe 5.1', () => { // The explicit shell (the `shell` config field) beats every automatic // source, and whitespace-only values count as unset. expect(defaultShell({ platform: 'win32', explicit: 'C:\\Tools\\pwsh.exe', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } })) .toBe('C:\\Tools\\pwsh.exe') expect(defaultShell({ platform: 'win32', explicit: ' ', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } })) .toBe('pwsh.exe') // PATH is probed entry by entry; the first directory containing pwsh.exe // wins and the returned value is the full resolved path. The candidate is // normalized to forward slashes before comparing so the assertion holds // under BOTH join() implementations: on the ubuntu runners node:path is // POSIX and joins 'C:\\other' + 'pwsh.exe' as 'C:\\other/pwsh.exe'. const fromPath = defaultShell({ platform: 'win32', env: { PATH: 'C:\\tools;C:\\other' }, exists: path => path.replaceAll('\\', '/') === 'C:/other/pwsh.exe', }) expect(fromPath.replaceAll('\\', '/')).toBe('C:/other/pwsh.exe') // PATH misses fall through to the known install directories. On 32-bit // Node, ProgramW6432 is the real 64-bit Program Files and is preferred. const fromProgramW6432 = defaultShell({ platform: 'win32', env: { ProgramW6432: 'C:\\PF64' }, exists: path => path.replaceAll('\\', '/') === 'C:/PF64/PowerShell/7/pwsh.exe', }) expect(fromProgramW6432.replaceAll('\\', '/')).toBe('C:/PF64/PowerShell/7/pwsh.exe') const fromProgramFiles = defaultShell({ platform: 'win32', env: { ProgramFiles: 'C:\\PF', LOCALAPPDATA: 'C:\\Users\\x\\AppData\\Local' }, exists: path => path.replaceAll('\\', '/') === 'C:/PF/PowerShell/7/pwsh.exe', }) expect(fromProgramFiles.replaceAll('\\', '/')).toBe('C:/PF/PowerShell/7/pwsh.exe') // Nothing installed: keep the inbox 5.1 fallback instead of breaking. expect(defaultShell({ platform: 'win32', env: {}, exists: () => false })).toBe('powershell.exe') }) it('Windows: resolves bare custom shells through PATH/PATHEXT before node-pty spawn', () => { const files = new Set([ 'C:/Tools/PowerShell/pwsh.exe', 'C:/Windows/System32/cmd.exe', ]) const options = { platform: 'win32' as const, // Mixed-case Path/PATHEXT pins the case-insensitive environment lookup // that real Windows process.env provides but plain test objects do not. env: { Path: 'C:\\Tools\\PowerShell', PathExt: '.COM;.EXE;.BAT;.CMD', SystemRoot: 'C:\\Windows', }, exists: (path: string) => files.has(path.replaceAll('\\', '/')), } expect(resolveShellExecutable('pwsh', options).replaceAll('\\', '/')) .toBe('C:/Tools/PowerShell/pwsh.exe') expect(resolveShellExecutable('cmd', options).replaceAll('\\', '/')) .toBe('C:/Windows/System32/cmd.exe') }) it('Windows: accepts .exe names and absolute paths, and names a missing configured shell', () => { const options = { platform: 'win32' as const, env: { PATH: 'C:\\Tools' }, exists: (path: string) => path.replaceAll('\\', '/') === 'C:/Tools/pwsh.exe' || path.replaceAll('\\', '/') === 'D:/Portable Shell/custom.exe', } expect(resolveShellExecutable('pwsh.exe', options).replaceAll('\\', '/')) .toBe('C:/Tools/pwsh.exe') expect(resolveShellExecutable('D:\\Portable Shell\\custom.exe', options).replaceAll('\\', '/')) .toBe('D:/Portable Shell/custom.exe') expect(() => resolveShellExecutable('missing-shell', options)) .toThrow('shell executable not found: "missing-shell"') }) it('POSIX: resolves bare names along PATH and names a missing configured shell', () => { const options = { platform: 'linux' as const, env: { PATH: '/usr/local/bin:/usr/bin' }, exists: (path: string) => path === join('/usr/bin', 'zsh'), } expect(resolveShellExecutable('zsh', options)).toBe(join('/usr/bin', 'zsh')) const thrown = (() => { try { resolveShellExecutable('nope', options) return undefined } catch (error) { return error } })() expect(thrown).toBeInstanceOf(SidebarError) expect((thrown as SidebarError).code).toBe('shell-not-found') expect((thrown as SidebarError).message).toBe('shell executable not found: "nope"') expect((thrown as SidebarError).meta?.shell).toBe('nope') }) it('POSIX: checks an absolute path exists and passes it through verbatim', () => { expect(resolveShellExecutable('/explicit/zsh', { platform: 'linux', env: {}, exists: () => true })) .toBe('/explicit/zsh') expect(() => resolveShellExecutable('/missing/zsh', { platform: 'linux', env: {}, exists: () => false })) .toThrow('shell executable not found: "/missing/zsh"') }) it('unquotes the configured shell before resolving (Windows probe included)', () => { const options = { platform: 'win32' as const, env: { PATH: 'C:\\Tools' }, exists: (path: string) => path.replaceAll('\\', '/') === 'C:/Tools/pwsh.exe', } expect(resolveShellExecutable('"pwsh.exe"', options).replaceAll('\\', '/')).toBe('C:/Tools/pwsh.exe') }) it('trims the configured shell and defaults it to auto for old documents', () => { expect(resolveSidebarConfig(undefined).shell).toBe('') expect(resolveSidebarConfig({ shell: ' pwsh.exe ' }).shell).toBe('pwsh.exe') expect(resolveSidebarConfig({ shell: '/bin/zsh', shellArgs: ['--noprofile'] }).shellArgs).toEqual(['--noprofile']) expect(resolveSidebarConfig(undefined).shellArgs).toEqual([]) }) it('derives a short display name for terminal tab titles', () => { expect(shellDisplayName('/bin/zsh')).toBe('zsh') expect(shellDisplayName('/usr/bin/bash')).toBe('bash') expect(shellDisplayName('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')).toBe('powershell') expect(shellDisplayName('pwsh.exe')).toBe('pwsh') expect(shellDisplayName('/weird')).toBe('weird') expect(shellDisplayName('/')).toBe('/') }) it('uses explicit shell args verbatim and keeps platform defaults when none are configured', () => { expect(shellSpawnArgs(['--noprofile', '--no-rc'])).toEqual(['--noprofile', '--no-rc']) expect(shellSpawnArgs([])).toEqual(process.platform === 'win32' ? [] : ['-l']) }) it('restores the spawn-helper executable bit idempotently', () => { // node-pty's spawn-helper is a macOS-only artifact: binding.gyp builds // the executable only for OS=="mac", and no other platform ships one to // restore (linux uses forkpty directly). Skip elsewhere. if (process.platform !== 'darwin') return ensureSpawnHelper() ensureSpawnHelper() const entry = createRequire(import.meta.url).resolve('node-pty') const root = dirname(dirname(entry)) // Prebuilt (tarball) or node-gyp-compiled (build/Release): mirror // ensureSpawnHelper's candidate list — either location is acceptable. const candidates = [ join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'), join(root, 'build', 'Release', 'spawn-helper'), ] const helper = candidates.find(existsSync) expect(helper).toBeTruthy() expect((statSync(helper!).mode & 0o111) !== 0).toBe(true) }) it('unquotes a paired surrounding quote from a configured shell path', () => { expect(unquotePath('"C:\\Program Files\\PowerShell\\7\\pwsh.exe"')) .toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') expect(unquotePath("'/usr/bin/my shell'")).toBe('/usr/bin/my shell') // Unpaired or single characters stay verbatim. expect(unquotePath('"mismatched')).toBe('"mismatched') expect(unquotePath('pwsh.exe')).toBe('pwsh.exe') expect(unquotePath('"')).toBe('"') expect(unquotePath('')).toBe('') }) it('splits shell args with quote-aware grouping', () => { expect(splitShellArgs('-NoLogo -File "C:\\my init\\init.ps1"')) .toEqual(['-NoLogo', '-File', 'C:\\my init\\init.ps1']) // Single quotes group too, and preserve inner double quotes verbatim. expect(splitShellArgs("-c 'echo \"hi\"'")).toEqual(['-c', 'echo "hi"']) expect(splitShellArgs(' -l ')).toEqual(['-l']) expect(splitShellArgs('')).toEqual([]) expect(splitShellArgs(' ')).toEqual([]) }) it('keeps backslashes literal inside quotes and tolerates an unclosed quote', () => { // Backslash is NOT an escape (Windows paths): "C:\a\" ends with a slash. expect(splitShellArgs('"C:\\a\\"')).toEqual(['C:\\a\\']) // An unclosed quote folds the remainder into one token instead of erroring. expect(splitShellArgs('"unclosed quote')).toEqual(['unclosed quote']) // Empty quote pairs produce no empty-string argument. expect(splitShellArgs('"" x')).toEqual(['x']) }) })