@@ -12,46 +12,121 @@ describe('parseTunnelUrl', () => {
1212 } )
1313} )
1414
15- describe ( 'DevTunnelProvider' , ( ) => {
16- it ( 'resolves the public URL parsed from spawn stdout' , async ( ) => {
17- const fakeChild = Object . assign ( new EventEmitter ( ) , {
18- stdout : new EventEmitter ( ) , stderr : new EventEmitter ( ) , kill : ( ) => { } ,
19- } )
20- const spawnImpl = ( ( ) => fakeChild ) as unknown as typeof import ( 'node:child_process' ) . spawn
21- const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , tunnelId : 't1' , spawnImpl } )
15+ function fakeChild ( ) : EventEmitter & { stdout : EventEmitter ; stderr : EventEmitter ; kill : ( ) => void } {
16+ return Object . assign ( new EventEmitter ( ) , { stdout : new EventEmitter ( ) , stderr : new EventEmitter ( ) , kill : ( ) => { } } )
17+ }
18+
19+ describe ( 'DevTunnelProvider — no tunnelId (ad-hoc/temporary session)' , ( ) => {
20+ it ( 'hosts directly with -p, no port-create step' , async ( ) => {
21+ const child = fakeChild ( )
22+ const calls : string [ ] [ ] = [ ]
23+ const spawnImpl = ( ( _bin : string , args : string [ ] ) => { calls . push ( args ) ; return child } ) as unknown as typeof import ( 'node:child_process' ) . spawn
24+ const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , spawnImpl } )
2225 const started = p . start ( 3978 )
2326 setTimeout ( ( ) => {
24- fakeChild . stdout . emit ( 'data' , Buffer . from ( 'Connect via browser: https://t1-3978.usw2.devtunnels.ms\n' ) )
27+ child . stdout . emit ( 'data' , Buffer . from ( 'Connect via browser: https://t1-3978.usw2.devtunnels.ms\n' ) )
2528 } , 10 )
2629 const { publicUrl } = await started
2730 expect ( publicUrl ) . toBe ( 'https://t1-3978.usw2.devtunnels.ms' )
31+ expect ( calls ) . toEqual ( [ [ 'host' , '--allow-anonymous' , '-p' , '3978' ] ] )
2832 expect ( p . isRunning ( ) ) . toBe ( true )
2933 await p . stop ( )
3034 expect ( p . isRunning ( ) ) . toBe ( false )
3135 } )
3236
3337 it ( 'rejects when the process exits before a URL is seen' , async ( ) => {
34- const fakeChild = Object . assign ( new EventEmitter ( ) , {
35- stdout : new EventEmitter ( ) , stderr : new EventEmitter ( ) , kill : ( ) => { } ,
36- } )
37- const spawnImpl = ( ( ) => fakeChild ) as unknown as typeof import ( 'node:child_process' ) . spawn
38+ const child = fakeChild ( )
39+ const spawnImpl = ( ( ) => child ) as unknown as typeof import ( 'node:child_process' ) . spawn
3840 const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , spawnImpl } )
3941 const started = p . start ( 3978 )
40- setTimeout ( ( ) => { fakeChild . emit ( 'exit' , 1 ) } , 10 )
42+ setTimeout ( ( ) => { child . emit ( 'exit' , 1 ) } , 10 )
4143 await expect ( started ) . rejects . toThrow ( / d e v t u n n e l e x i t e d / )
4244 } )
4345
4446 it ( 'includes the real stderr text in the rejection instead of a hardcoded guess' , async ( ) => {
45- const fakeChild = Object . assign ( new EventEmitter ( ) , {
46- stdout : new EventEmitter ( ) , stderr : new EventEmitter ( ) , kill : ( ) => { } ,
47- } )
48- const spawnImpl = ( ( ) => fakeChild ) as unknown as typeof import ( 'node:child_process' ) . spawn
49- const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , tunnelId : 't1' , spawnImpl } )
47+ const child = fakeChild ( )
48+ const spawnImpl = ( ( ) => child ) as unknown as typeof import ( 'node:child_process' ) . spawn
49+ const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , spawnImpl } )
5050 const started = p . start ( 3978 )
5151 setTimeout ( ( ) => {
52- fakeChild . stderr . emit ( 'data' , Buffer . from ( 'Error: port 3978 is already forwarded on this tunnel\n' ) )
53- fakeChild . emit ( 'exit' , 1 )
52+ child . stderr . emit ( 'data' , Buffer . from ( 'Error: port 3978 is already forwarded on this tunnel\n' ) )
53+ child . emit ( 'exit' , 1 )
5454 } , 10 )
5555 await expect ( started ) . rejects . toThrow ( / p o r t 3 9 7 8 i s a l r e a d y f o r w a r d e d / )
5656 } )
5757} )
58+
59+ describe ( 'DevTunnelProvider — persistent tunnelId' , ( ) => {
60+ /**
61+ * Real CLI behaviour (confirmed against a live devtunnel run): hosting a
62+ * previously-created (persistent) tunnel with `-p <port>` combined in the
63+ * same `host` invocation fails with "Batch update of ports is not
64+ * supported. Add, update, or delete ports individually instead." The port
65+ * must be registered via a separate `devtunnel port create` call first;
66+ * `host` is then invoked with no `-p` at all.
67+ */
68+ function twoPhaseSpawn ( ) : {
69+ spawnImpl : typeof import ( 'node:child_process' ) . spawn
70+ portCreateChild : ReturnType < typeof fakeChild >
71+ hostChild : ReturnType < typeof fakeChild >
72+ calls : string [ ] [ ]
73+ } {
74+ const portCreateChild = fakeChild ( )
75+ const hostChild = fakeChild ( )
76+ const calls : string [ ] [ ] = [ ]
77+ let call = 0
78+ const spawnImpl = ( ( _bin : string , args : string [ ] ) => {
79+ calls . push ( args )
80+ call += 1
81+ return call === 1 ? portCreateChild : hostChild
82+ } ) as unknown as typeof import ( 'node:child_process' ) . spawn
83+ return { spawnImpl, portCreateChild, hostChild, calls }
84+ }
85+
86+ it ( 'registers the port individually before hosting, then hosts without -p' , async ( ) => {
87+ const { spawnImpl, portCreateChild, hostChild, calls } = twoPhaseSpawn ( )
88+ const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , tunnelId : 't1' , spawnImpl } )
89+ const started = p . start ( 3978 )
90+
91+ await new Promise ( ( r ) => setTimeout ( r , 5 ) )
92+ portCreateChild . emit ( 'exit' , 0 )
93+
94+ await new Promise ( ( r ) => setTimeout ( r , 5 ) )
95+ hostChild . stdout . emit ( 'data' , Buffer . from ( 'Connect via browser: https://t1-3978.usw2.devtunnels.ms\n' ) )
96+
97+ const { publicUrl } = await started
98+ expect ( publicUrl ) . toBe ( 'https://t1-3978.usw2.devtunnels.ms' )
99+ expect ( calls [ 0 ] ) . toEqual ( [ 'port' , 'create' , 't1' , '-p' , '3978' , '--allow-anonymous' ] )
100+ expect ( calls [ 1 ] ) . toEqual ( [ 'host' , 't1' ] )
101+ } )
102+
103+ it ( 'tolerates "port already exists" from a prior partial run and still hosts' , async ( ) => {
104+ const { spawnImpl, portCreateChild, hostChild, calls } = twoPhaseSpawn ( )
105+ const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , tunnelId : 't1' , spawnImpl } )
106+ const started = p . start ( 3978 )
107+
108+ await new Promise ( ( r ) => setTimeout ( r , 5 ) )
109+ portCreateChild . stderr . emit ( 'data' , Buffer . from ( 'Error: a port with this number already exists\n' ) )
110+ portCreateChild . emit ( 'exit' , 1 )
111+
112+ await new Promise ( ( r ) => setTimeout ( r , 5 ) )
113+ hostChild . stdout . emit ( 'data' , Buffer . from ( 'Connect via browser: https://t1-3978.usw2.devtunnels.ms\n' ) )
114+
115+ const { publicUrl } = await started
116+ expect ( publicUrl ) . toBe ( 'https://t1-3978.usw2.devtunnels.ms' )
117+ expect ( calls [ 1 ] ) . toEqual ( [ 'host' , 't1' ] )
118+ } )
119+
120+ it ( 'rejects without ever hosting when port registration genuinely fails' , async ( ) => {
121+ const { spawnImpl, portCreateChild, calls } = twoPhaseSpawn ( )
122+ const p = new DevTunnelProvider ( { binPath : '/fake/devtunnel' , tunnelId : 't1' , spawnImpl } )
123+ const started = p . start ( 3978 )
124+
125+ await new Promise ( ( r ) => setTimeout ( r , 5 ) )
126+ portCreateChild . stderr . emit ( 'data' , Buffer . from ( 'Error: not logged in\n' ) )
127+ portCreateChild . emit ( 'exit' , 1 )
128+
129+ await expect ( started ) . rejects . toThrow ( / p o r t c r e a t e f a i l e d .* n o t l o g g e d i n / s)
130+ expect ( calls . length ) . toBe ( 1 ) // host was never invoked
131+ } )
132+ } )
0 commit comments