Files
orca/src/main/ssh/ssh-relay-build-toolchain.test.ts
T
Brennan Benson 86a993aec4 fix(ssh): connect to Linux hosts that cannot compile node-pty (#10776)
* fix(ssh): connect to Linux hosts that cannot compile node-pty

node-pty ships no Linux prebuilt at any architecture, so it is compiled on
the remote. On a host without a C/C++ toolchain that build fails, and because
both native deps install in one npm command it also took down
@parcel/watcher — which does have a working Linux prebuilt — and failed the
whole connection. Every Linux image without build tools was unusable.

node-pty only backs remote terminals; files, git, and the editor do not need
it, and a missing native dep is already non-fatal further down the deploy. So
when the existing toolchain probe confirms the compiler is missing, reinstall
without node-pty instead of aborting. The manifest has to drop it too — npm
reconciles every dependency in package.json, not just the ones named on the
command line, so naming only @parcel/watcher still rebuilds node-pty.

If that reinstall also fails the actionable build-tools error is rethrown, so
a host broken for some other reason still reports the toolchain gap.

The relay's PTY error now names the fix rather than saying only that node-pty
is unavailable.

Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git,
nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is
absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and
spawning a terminal reports the install hint.

* fix(ssh): keep the node-pty skip path honest about platform and watcher

The PTY unavailable message named build tools unconditionally, but only Linux
compiles node-pty — the deploy-side skip is gated on linux and the toolchain
probe returns null on Windows. A Windows or macOS remote, where node-pty ships
prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's
own platform.

The skip path returned before the install probe, so a @parcel/watcher that
installs but cannot require() (glibc below the floor) connected with dead file
watching and nothing logged. Probe before returning and warn; no rebuild, since
node-pty provably cannot compile on that host, and never fatal.

Also log the pty-less reinstall's own failure and attach it as cause — the
rethrown toolchain message is built from the original npm error, so an
unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now
keeps the caller's resetDeps as well, so a repair reconnect still clears every
dep the probe found broken.

Tests: the skip-success fixture queued a chmod/probe/rebuild sequence
production never runs, and the surplus slots were absorbed by launchRelay's
readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12
execs production performs, and pins that no rebuild is issued. Adds the missing
negative case: a gyp-shaped failure on a host whose probe reports a complete
toolchain must still hard-fail rather than silently degrade.

* fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path
2026-07-26 15:29:13 -07:00

128 lines
5.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildToolchainProbeCommand,
parseBuildToolchainProbe,
formatMissingToolchainError,
formatSkippedNodePtyWarning,
shouldProbeBuildToolchainAfterNativeDepsFailure
} from './ssh-relay-build-toolchain'
describe('buildToolchainProbeCommand', () => {
it('probes make and a C++ compiler and detects the package manager', () => {
const cmd = buildToolchainProbeCommand()
expect(cmd).toContain('command -v "$t"')
expect(cmd).toMatch(/\bmake\b/)
expect(cmd).toMatch(/g\+\+/)
expect(cmd).toContain('apt-get')
// Single POSIX-sh line (runs under `/bin/sh -c`), no embedded single quotes
// that shellEscape would have to wrap.
expect(cmd).not.toContain('\n')
expect(cmd).not.toContain("'")
})
})
describe('parseBuildToolchainProbe', () => {
it('flags a missing toolchain when make and the C++ compiler are absent', () => {
const status = parseBuildToolchainProbe('PKG apt-get\n')
expect(status.toolchainMissing).toBe(true)
expect(status.present).toEqual([])
expect(status.packageManager).toBe('apt-get')
})
it('treats a full toolchain as present', () => {
const status = parseBuildToolchainProbe('HAVE make\nHAVE gcc\nHAVE g++\nHAVE python3\nPKG dnf')
expect(status.toolchainMissing).toBe(false)
expect(status.present).toContain('make')
expect(status.present).toContain('g++')
expect(status.packageManager).toBe('dnf')
})
it('still flags missing when make is present but no C++ compiler is', () => {
const status = parseBuildToolchainProbe('HAVE make\nHAVE gcc\nHAVE python3')
expect(status.toolchainMissing).toBe(true)
expect(status.packageManager).toBeNull()
})
it('accepts clang++ as the C++ compiler', () => {
const status = parseBuildToolchainProbe('HAVE make\nHAVE clang\nHAVE clang++\nHAVE python3')
expect(status.toolchainMissing).toBe(false)
})
it('ignores shell noise around the markers', () => {
const status = parseBuildToolchainProbe(
'Welcome to Acme\nHAVE make\nHAVE g++\nHAVE python3\nMOTD line\nPKG apk'
)
expect(status.toolchainMissing).toBe(false)
expect(status.packageManager).toBe('apk')
})
})
describe('shouldProbeBuildToolchainAfterNativeDepsFailure', () => {
it('matches node-gyp missing build-tool output', () => {
expect(
shouldProbeBuildToolchainAfterNativeDepsFailure('gyp ERR! stack Error: not found: make')
).toBe(true)
expect(
shouldProbeBuildToolchainAfterNativeDepsFailure(
'node-gyp ERR! Could not find any Python installation'
)
).toBe(true)
})
it('does not match unrelated npm failures', () => {
expect(shouldProbeBuildToolchainAfterNativeDepsFailure('npm ERR! network ETIMEDOUT')).toBe(
false
)
expect(
shouldProbeBuildToolchainAfterNativeDepsFailure('npm ERR! E404 Not Found node-pty')
).toBe(false)
})
})
describe('formatMissingToolchainError', () => {
it('lists the missing tools and a package-manager-specific install command', () => {
const status = parseBuildToolchainProbe('PKG apt-get')
const msg = formatMissingToolchainError(status, 'gyp ERR! not found: make')
expect(msg).toContain('make')
expect(msg).toContain('a C++ compiler (g++ or clang++)')
expect(msg).toContain('python3')
expect(msg).toContain('sudo apt-get install -y build-essential python3')
// Tailored hint replaces the generic distro list.
expect(msg).not.toContain('Fedora/RHEL:')
// Original error retained for triage.
expect(msg).toContain('gyp ERR! not found: make')
})
it('falls back to a multi-distro list when no package manager was detected', () => {
const status = parseBuildToolchainProbe('')
const msg = formatMissingToolchainError(status, 'exit 1')
expect(msg).toContain('Debian/Ubuntu:')
expect(msg).toContain('Fedora/RHEL:')
expect(msg).toContain('Arch:')
expect(msg).toContain('Alpine:')
})
it('only lists the genuinely missing pieces', () => {
const status = parseBuildToolchainProbe('HAVE make\nHAVE python3\nPKG pacman')
const msg = formatMissingToolchainError(status, 'err')
expect(msg).toContain('a C++ compiler (g++ or clang++)')
expect(msg).not.toMatch(/\(make,/)
expect(msg).toContain('sudo pacman -S --needed base-devel python')
})
})
describe('formatSkippedNodePtyWarning', () => {
it('quotes the tailored install command when a package manager was detected', () => {
const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe('PKG dnf'))
expect(warning).toContain('skipping node-pty')
expect(warning).toContain('sudo dnf install -y make gcc gcc-c++ python3')
})
it('stays distro-neutral when no package manager was detected', () => {
// The hint list is the cross-distro menu here; quoting its first line would name Debian on any host.
const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe(''))
expect(warning).not.toContain('apt-get')
expect(warning).toContain('install a C/C++ toolchain')
})
})