11import { describe , it , expect } from "vitest" ;
2- import { spawn } from "node:child_process" ;
2+ import { spawn , type ChildProcessWithoutNullStreams } from "node:child_process" ;
33import * as path from "node:path" ;
44import { fileURLToPath } from "node:url" ;
55
66const __filename = fileURLToPath ( import . meta. url ) ;
77const __dirname = path . dirname ( __filename ) ;
8+ const binPath = path . join ( __dirname , "../../../bin/run.js" ) ;
89
910/**
1011 * Integration tests for `ably interactive` running as a single process (no bash
@@ -16,68 +17,69 @@ const __dirname = path.dirname(__filename);
1617 * behaviour and terminal-state/EIO assertions live in
1718 * test/tty/commands/interactive-sigint.test.ts (run with `pnpm test:tty`).
1819 */
19- describe ( "Interactive Mode - in-process integration" , ( ) => {
20- const binPath = path . join ( __dirname , "../../../bin/run.js" ) ;
2120
22- it ( "starts and exits cleanly via `exit`" , { timeout : 30000 } , async ( ) => {
23- const proc = spawn ( "node" , [ binPath , "interactive" ] , {
24- stdio : "pipe" ,
25- env : { ...process . env , ABLY_SUPPRESS_WELCOME : "1" } ,
26- } ) ;
21+ interface InteractiveProc {
22+ proc : ChildProcessWithoutNullStreams ;
23+ /** Resolves once `substr` appears in combined stdout+stderr, else rejects. */
24+ waitFor : ( substr : string , timeoutMs : number ) => Promise < void > ;
25+ getOutput : ( ) => string ;
26+ hasExited : ( ) => boolean ;
27+ }
28+
29+ function startInteractive (
30+ extraEnv : Record < string , string > = { } ,
31+ ) : InteractiveProc {
32+ const proc = spawn ( "node" , [ binPath , "interactive" ] , {
33+ stdio : "pipe" ,
34+ env : { ...process . env , ABLY_SUPPRESS_WELCOME : "1" , ...extraEnv } ,
35+ } ) ;
2736
28- let output = "" ;
29- proc . stdout . on ( "data" , ( d ) => ( output += d . toString ( ) ) ) ;
37+ let output = "" ;
38+ let exited = false ;
39+ proc . stdout . on ( "data" , ( d ) => ( output += d . toString ( ) ) ) ;
40+ proc . stderr . on ( "data" , ( d ) => ( output += d . toString ( ) ) ) ;
41+ proc . on ( "exit" , ( ) => ( exited = true ) ) ;
3042
31- await new Promise < void > ( ( resolve ) => {
43+ const waitFor = ( substr : string , timeoutMs : number ) =>
44+ new Promise < void > ( ( resolve , reject ) => {
45+ const start = Date . now ( ) ;
3246 const check = setInterval ( ( ) => {
33- if ( output . includes ( "ably>" ) ) {
47+ if ( output . includes ( substr ) ) {
3448 clearInterval ( check ) ;
3549 resolve ( ) ;
50+ } else if ( exited ) {
51+ clearInterval ( check ) ;
52+ reject ( new Error ( `process exited before "${ substr } "` ) ) ;
53+ } else if ( Date . now ( ) - start > timeoutMs ) {
54+ clearInterval ( check ) ;
55+ reject ( new Error ( `timeout waiting for "${ substr } "` ) ) ;
3656 }
3757 } , 100 ) ;
3858 } ) ;
3959
60+ return { proc, waitFor, getOutput : ( ) => output , hasExited : ( ) => exited } ;
61+ }
62+
63+ describe ( "Interactive Mode - in-process integration" , ( ) => {
64+ it ( "starts and exits cleanly via `exit`" , { timeout : 30000 } , async ( ) => {
65+ const { proc, waitFor, getOutput } = startInteractive ( ) ;
66+
67+ await waitFor ( "ably>" , 8000 ) ;
4068 proc . stdin . write ( "exit\n" ) ;
4169
4270 const exitCode = await new Promise < number > ( ( resolve ) => {
4371 proc . on ( "exit" , ( code ) => resolve ( code ?? 0 ) ) ;
4472 } ) ;
4573
4674 expect ( exitCode ) . toBe ( 0 ) ;
47- expect ( output ) . toContain ( "Goodbye!" ) ;
75+ expect ( getOutput ( ) ) . toContain ( "Goodbye!" ) ;
4876 } ) ;
4977
5078 it (
5179 "interrupts a running command via SIGINT and stays alive in the same process" ,
5280 { timeout : 30000 } ,
5381 async ( ) => {
54- const proc = spawn ( "node" , [ binPath , "interactive" ] , {
55- stdio : "pipe" ,
56- env : { ...process . env , ABLY_SUPPRESS_WELCOME : "1" } ,
57- } ) ;
58-
59- let output = "" ;
60- let exited = false ;
61- proc . stdout . on ( "data" , ( d ) => ( output += d . toString ( ) ) ) ;
62- proc . stderr . on ( "data" , ( d ) => ( output += d . toString ( ) ) ) ;
63- proc . on ( "exit" , ( ) => ( exited = true ) ) ;
64-
65- const waitFor = ( substr : string , timeoutMs : number ) =>
66- new Promise < void > ( ( resolve , reject ) => {
67- const start = Date . now ( ) ;
68- const check = setInterval ( ( ) => {
69- if ( output . includes ( substr ) ) {
70- clearInterval ( check ) ;
71- resolve ( ) ;
72- } else if ( exited ) {
73- clearInterval ( check ) ;
74- reject ( new Error ( `process exited before "${ substr } "` ) ) ;
75- } else if ( Date . now ( ) - start > timeoutMs ) {
76- clearInterval ( check ) ;
77- reject ( new Error ( `timeout waiting for "${ substr } "` ) ) ;
78- }
79- } , 100 ) ;
80- } ) ;
82+ const { proc, waitFor, getOutput, hasExited } = startInteractive ( ) ;
8183
8284 await waitFor ( "ably>" , 8000 ) ;
8385
@@ -88,43 +90,63 @@ describe("Interactive Mode - in-process integration", () => {
8890
8991 // The process must NOT exit; it should re-prompt and still run commands.
9092 await new Promise ( ( r ) => setTimeout ( r , 500 ) ) ;
91- expect ( exited ) . toBe ( false ) ;
93+ expect ( hasExited ( ) ) . toBe ( false ) ;
9294
9395 proc . stdin . write ( "version\n" ) ;
9496 await waitFor ( "Version:" , 6000 ) ;
9597
9698 proc . stdin . write ( "exit\n" ) ;
9799 await new Promise < void > ( ( resolve ) => proc . on ( "exit" , ( ) => resolve ( ) ) ) ;
98100
99- expect ( output ) . not . toMatch ( / s e t R a w M o d e E I O | T e r m i n a l s t a t e c o r r u p t e d / ) ;
101+ expect ( getOutput ( ) ) . not . toMatch (
102+ / s e t R a w M o d e E I O | T e r m i n a l s t a t e c o r r u p t e d / ,
103+ ) ;
100104 } ,
101105 ) ;
102106
103107 it (
104- "emits exit code 42 on `exit` under ABLY_WRAPPER_MODE (host restart-loop contract )" ,
108+ "treats a 0x03 byte at the prompt as Ctrl+C and stays alive (non-TTY data handler )" ,
105109 { timeout : 30000 } ,
106110 async ( ) => {
107- const proc = spawn ( "node" , [ binPath , "interactive" ] , {
108- stdio : "pipe" ,
109- env : {
110- ...process . env ,
111- ABLY_SUPPRESS_WELCOME : "1" ,
112- ABLY_WRAPPER_MODE : "1" ,
113- } ,
114- } ) ;
111+ // In non-TTY mode readline does NOT turn a 0x03 (ETX) byte into a SIGINT,
112+ // so the stdin data handler in interactive.ts does it. At an idle prompt
113+ // it emits SIGINT to readline, which prints `^C` and a hint to type
114+ // `exit` (it does NOT kill the shell). Note: this only covers the
115+ // at-prompt branch — the running-command branch cannot be exercised over
116+ // piped stdin because readline pauses the stream during command
117+ // execution, so a 0x03 byte never reaches the handler then. Mid-command
118+ // interruption is covered by the real-SIGINT test above (the path a TTY
119+ // and the node-pty-backed web CLI actually use).
120+ const { proc, waitFor, getOutput, hasExited } = startInteractive ( ) ;
115121
116- let output = "" ;
117- proc . stdout . on ( "data" , ( d ) => ( output += d . toString ( ) ) ) ;
122+ await waitFor ( "ably>" , 8000 ) ;
123+
124+ // Deliver Ctrl+C as the ETX byte over stdin rather than as an OS signal.
125+ proc . stdin . write ( Buffer . from ( [ 0x03 ] ) ) ;
126+ await waitFor ( "Signal received" , 6000 ) ;
127+ expect ( hasExited ( ) ) . toBe ( false ) ;
118128
119- await new Promise < void > ( ( resolve ) => {
120- const check = setInterval ( ( ) => {
121- if ( output . includes ( "ably>" ) ) {
122- clearInterval ( check ) ;
123- resolve ( ) ;
124- }
125- } , 100 ) ;
129+ // The shell is still usable.
130+ proc . stdin . write ( "exit\n" ) ;
131+ const exitCode = await new Promise < number > ( ( resolve ) => {
132+ proc . on ( "exit" , ( code ) => resolve ( code ?? 0 ) ) ;
126133 } ) ;
127134
135+ expect ( exitCode ) . toBe ( 0 ) ;
136+ expect ( getOutput ( ) ) . toContain ( "Goodbye!" ) ;
137+ expect ( getOutput ( ) ) . not . toMatch (
138+ / s e t R a w M o d e E I O | T e r m i n a l s t a t e c o r r u p t e d / ,
139+ ) ;
140+ } ,
141+ ) ;
142+
143+ it (
144+ "emits exit code 42 on `exit` under ABLY_WRAPPER_MODE (host restart-loop contract)" ,
145+ { timeout : 30000 } ,
146+ async ( ) => {
147+ const { proc, waitFor } = startInteractive ( { ABLY_WRAPPER_MODE : "1" } ) ;
148+
149+ await waitFor ( "ably>" , 8000 ) ;
128150 proc . stdin . write ( "exit\n" ) ;
129151
130152 const exitCode = await new Promise < number > ( ( resolve ) => {
0 commit comments