11// Covers SSH target parsing and tunnel startup preflight behavior.
2- import { describe , expect , it , vi } from "vitest" ;
2+ import { EventEmitter } from "node:events" ;
3+ import net from "node:net" ;
4+ import { afterEach , describe , expect , it , vi } from "vitest" ;
35
46const mocks = vi . hoisted ( ( ) => ( {
57 ensurePortAvailable : vi . fn < ( port : number , host ?: string ) => Promise < void > > ( ) ,
8+ spawn : vi . fn ( ) ,
69} ) ) ;
710
811vi . mock ( "./ports.js" , async ( importOriginal ) => ( {
912 ...( await importOriginal < typeof import ( "./ports.js" ) > ( ) ) ,
1013 ensurePortAvailable : mocks . ensurePortAvailable ,
1114} ) ) ;
1215
16+ vi . mock ( "node:child_process" , async ( importOriginal ) => ( {
17+ ...( await importOriginal < typeof import ( "node:child_process" ) > ( ) ) ,
18+ spawn : mocks . spawn ,
19+ } ) ) ;
20+
21+ import { PortInUseError } from "./ports.js" ;
1322import { parseSshTarget , startSshPortForward } from "./ssh-tunnel.js" ;
1423
1524describe ( "parseSshTarget" , ( ) => {
@@ -42,6 +51,51 @@ describe("parseSshTarget", () => {
4251} ) ;
4352
4453describe ( "startSshPortForward" , ( ) => {
54+ const openServers : net . Server [ ] = [ ] ;
55+
56+ afterEach ( async ( ) => {
57+ while ( openServers . length > 0 ) {
58+ const server = openServers . pop ( ) ;
59+ await new Promise < void > ( ( resolve ) => {
60+ server ?. close ( ( ) => resolve ( ) ) ;
61+ } ) ;
62+ }
63+ mocks . ensurePortAvailable . mockReset ( ) ;
64+ mocks . spawn . mockReset ( ) ;
65+ } ) ;
66+
67+ // Fake ssh child that, when spawned, parses the -L forward spec and starts a
68+ // real IPv4-loopback listener on the chosen local port so waitForLocalListener
69+ // resolves without launching a real ssh process.
70+ function spawnFakeSshListening ( ) {
71+ mocks . spawn . mockImplementation ( ( _cmd : string , args : string [ ] ) => {
72+ const forwardSpec = args [ args . indexOf ( "-L" ) + 1 ] ?? "" ;
73+ const localPort = Number ( forwardSpec . split ( ":" ) [ 0 ] ) ;
74+ const server = net . createServer ( ) ;
75+ server . on ( "error" , ( ) => { } ) ;
76+ openServers . push ( server ) ;
77+ server . listen ( localPort , "127.0.0.1" ) ;
78+
79+ const child = new EventEmitter ( ) as EventEmitter & {
80+ killed : boolean ;
81+ pid : number ;
82+ stderr : EventEmitter & { setEncoding : ( enc : string ) => void } ;
83+ kill : ( signal ?: string ) => boolean ;
84+ } ;
85+ child . killed = false ;
86+ child . pid = 4242 ;
87+ const stderr = new EventEmitter ( ) as EventEmitter & { setEncoding : ( enc : string ) => void } ;
88+ stderr . setEncoding = ( ) => { } ;
89+ child . stderr = stderr ;
90+ child . kill = ( signal ?: string ) => {
91+ child . killed = true ;
92+ queueMicrotask ( ( ) => child . emit ( "exit" , 0 , signal ?? null ) ) ;
93+ return true ;
94+ } ;
95+ return child ;
96+ } ) ;
97+ }
98+
4599 it ( "scopes the preferred-port preflight to the IPv4 loopback interface" , async ( ) => {
46100 const sentinel = new Error ( "stop before spawning ssh" ) ;
47101 mocks . ensurePortAvailable . mockRejectedValueOnce ( sentinel ) ;
@@ -57,4 +111,45 @@ describe("startSshPortForward", () => {
57111
58112 expect ( mocks . ensurePortAvailable ) . toHaveBeenCalledWith ( 43210 , "127.0.0.1" ) ;
59113 } ) ;
114+
115+ it ( "falls back to an ephemeral port when the preferred port is in use" , async ( ) => {
116+ // ensurePortAvailable raises the domain PortInUseError (no errno `code`),
117+ // which the catch must treat as "busy" and route to pickEphemeralPort.
118+ // Reserve a real port so pickEphemeralPort (listen(0)) cannot hand the same
119+ // number back and make the assertion flaky.
120+ const occupied = net . createServer ( ) ;
121+ await new Promise < void > ( ( resolve , reject ) => {
122+ occupied . once ( "error" , reject ) ;
123+ occupied . listen ( 0 , "127.0.0.1" , ( ) => {
124+ occupied . off ( "error" , reject ) ;
125+ resolve ( ) ;
126+ } ) ;
127+ } ) ;
128+ openServers . push ( occupied ) ;
129+ const addr = occupied . address ( ) ;
130+ if ( ! addr || typeof addr === "string" ) {
131+ throw new Error ( "failed to reserve preferred port" ) ;
132+ }
133+ const preferredPort = addr . port ;
134+
135+ mocks . ensurePortAvailable . mockRejectedValueOnce ( new PortInUseError ( preferredPort ) ) ;
136+ spawnFakeSshListening ( ) ;
137+
138+ const tunnel = await startSshPortForward ( {
139+ 140+ localPortPreferred : preferredPort ,
141+ remotePort : 18789 ,
142+ timeoutMs : 1000 ,
143+ } ) ;
144+
145+ expect ( tunnel . localPort ) . not . toBe ( preferredPort ) ;
146+ expect ( tunnel . localPort ) . toBeGreaterThan ( 0 ) ;
147+ expect ( mocks . spawn ) . toHaveBeenCalledWith (
148+ "/usr/bin/ssh" ,
149+ expect . arrayContaining ( [ "-L" , `${ tunnel . localPort } :127.0.0.1:18789` ] ) ,
150+ expect . anything ( ) ,
151+ ) ;
152+
153+ await tunnel . stop ( ) ;
154+ } ) ;
60155} ) ;
0 commit comments