@@ -9,8 +9,46 @@ import TransformTTY from 'transform-tty';
99import ora , { oraPromise , spinners } from './index.js' ;
1010
1111const spinnerCharacter = process . platform === 'win32' ? '-' : '⠋' ;
12+ const synchronizedOutputEnable = '\u001B[?2026h' ;
13+ const synchronizedOutputDisable = '\u001B[?2026l' ;
1214const noop = ( ) => { } ;
1315
16+ const getLastSynchronizedOutput = output => {
17+ const lastEnableIndex = output . lastIndexOf ( synchronizedOutputEnable ) ;
18+ if ( lastEnableIndex === - 1 ) {
19+ return output ;
20+ }
21+
22+ const disableIndex = output . indexOf ( synchronizedOutputDisable , lastEnableIndex ) ;
23+ if ( disableIndex === - 1 ) {
24+ return output . slice ( lastEnableIndex + synchronizedOutputEnable . length ) ;
25+ }
26+
27+ return output . slice ( lastEnableIndex + synchronizedOutputEnable . length , disableIndex ) ;
28+ } ;
29+
30+ const stripSynchronizedOutputSequences = content => {
31+ if ( typeof content !== 'string' ) {
32+ return content ;
33+ }
34+
35+ return content . replaceAll ( synchronizedOutputEnable , '' ) . replaceAll ( synchronizedOutputDisable , '' ) ;
36+ } ;
37+
38+ const applySynchronizedOutputFilter = stream => {
39+ const originalWrite = stream . write ;
40+ stream . write = function ( content , encoding , callback ) {
41+ const filteredContent = stripSynchronizedOutputSequences ( content ) ;
42+ if ( filteredContent === '' ) {
43+ return true ;
44+ }
45+
46+ return originalWrite . call ( this , filteredContent , encoding , callback ) ;
47+ } ;
48+
49+ return stream ;
50+ } ;
51+
1452const getPassThroughStream = ( ) => {
1553 const stream = new PassThroughStream ( ) ;
1654 stream . clearLine = noop ;
@@ -75,6 +113,33 @@ test('main', async () => {
75113 assert . match ( result , new RegExp ( `${ spinnerCharacter } foo` ) ) ;
76114} ) ;
77115
116+ test ( 'render uses synchronized output sequences' , async ( ) => {
117+ const stream = getPassThroughStream ( ) ;
118+ stream . isTTY = true ;
119+ const output = getStream ( stream ) ;
120+
121+ const spinner = ora ( {
122+ stream,
123+ text : 'foo' ,
124+ color : false ,
125+ isEnabled : true ,
126+ } ) ;
127+
128+ spinner . render ( ) ;
129+ stream . end ( ) ;
130+
131+ const result = await output ;
132+ assert . ok ( result . includes ( synchronizedOutputEnable ) ) ;
133+ assert . ok ( result . includes ( synchronizedOutputDisable ) ) ;
134+ assert . ok ( result . indexOf ( synchronizedOutputEnable ) < result . indexOf ( synchronizedOutputDisable ) ) ;
135+
136+ const synchronizedOutput = getLastSynchronizedOutput ( result ) ;
137+ const renderedText = stripVTControlCharacters ( synchronizedOutput ) ;
138+ assert . ok ( renderedText . includes ( spinnerCharacter ) ) ;
139+ assert . ok ( renderedText . includes ( 'foo' ) ) ;
140+ assert . strictEqual ( stripSynchronizedOutputSequences ( result ) , synchronizedOutput ) ;
141+ } ) ;
142+
78143test ( '`.id` is not set when created' , ( ) => {
79144 const spinner = ora ( 'foo' ) ;
80145 assert . ok ( ! spinner . isSpinning ) ;
@@ -741,7 +806,7 @@ test('multiline content that exactly fits console height is not truncated', () =
741806 let written = '' ;
742807 const originalWrite = stream . write ;
743808 stream . write = function ( content ) {
744- written = content ;
809+ written += String ( content ) ;
745810 return originalWrite . call ( this , content ) ;
746811 } ;
747812
@@ -755,8 +820,9 @@ test('multiline content that exactly fits console height is not truncated', () =
755820 spinner . start ( ) ;
756821 spinner . render ( ) ;
757822
758- assert . ok ( written . includes ( 'Line 3' ) ) ;
759- assert . ok ( ! written . includes ( '(content truncated to fit terminal)' ) ) ;
823+ const renderedOutput = stripVTControlCharacters ( getLastSynchronizedOutput ( written ) ) ;
824+ assert . ok ( renderedOutput . includes ( 'Line 3' ) ) ;
825+ assert . ok ( ! renderedOutput . includes ( '(content truncated to fit terminal)' ) ) ;
760826
761827 spinner . stop ( ) ;
762828} ) ;
@@ -840,7 +906,7 @@ const currentClearMethod = transFormTTY => {
840906} ;
841907
842908test ( 'new clear method test, basic' , ( ) => {
843- const transformTTY = new TransformTTY ( { crlf : true } ) ;
909+ const transformTTY = applySynchronizedOutputFilter ( new TransformTTY ( { crlf : true } ) ) ;
844910 transformTTY . addSequencer ( ) ;
845911 transformTTY . addSequencer ( null , true ) ;
846912
@@ -849,7 +915,7 @@ test('new clear method test, basic', () => {
849915 it means the `spinner.clear()` method has failed to fully clear output between calls to render.
850916 */
851917
852- const currentClearTTY = new TransformTTY ( { crlf : true } ) ;
918+ const currentClearTTY = applySynchronizedOutputFilter ( new TransformTTY ( { crlf : true } ) ) ;
853919 currentClearTTY . addSequencer ( ) ;
854920
855921 const currentOra = currentClearMethod ( currentClearTTY ) ;
@@ -909,11 +975,11 @@ test('new clear method test, basic', () => {
909975} ) ;
910976
911977test ( 'new clear method test, erases wrapped lines' , ( ) => {
912- const transformTTY = new TransformTTY ( { crlf : true , columns : 40 } ) ;
978+ const transformTTY = applySynchronizedOutputFilter ( new TransformTTY ( { crlf : true , columns : 40 } ) ) ;
913979 transformTTY . addSequencer ( ) ;
914980 transformTTY . addSequencer ( null , true ) ;
915981
916- const currentClearTTY = new TransformTTY ( { crlf : true , columns : 40 } ) ;
982+ const currentClearTTY = applySynchronizedOutputFilter ( new TransformTTY ( { crlf : true , columns : 40 } ) ) ;
917983 currentClearTTY . addSequencer ( ) ;
918984
919985 const currentOra = currentClearMethod ( currentClearTTY ) ;
@@ -1073,11 +1139,11 @@ test('new clear method, stress test', () => {
10731139 s2 . indent = indent ;
10741140 } ;
10751141
1076- const transformTTY = new TransformTTY ( { crlf : true } ) ;
1142+ const transformTTY = applySynchronizedOutputFilter ( new TransformTTY ( { crlf : true } ) ) ;
10771143 transformTTY . addSequencer ( ) ;
10781144 transformTTY . addSequencer ( null , true ) ;
10791145
1080- const currentClearTTY = new TransformTTY ( { crlf : true } ) ;
1146+ const currentClearTTY = applySynchronizedOutputFilter ( new TransformTTY ( { crlf : true } ) ) ;
10811147 currentClearTTY . addSequencer ( ) ;
10821148
10831149 const currentOra = currentClearMethod ( currentClearTTY ) ;
@@ -1354,7 +1420,7 @@ test('multiline text exceeding console height', () => {
13541420 // Override write to capture content
13551421 const originalWrite = stream . write ;
13561422 stream . write = function ( content ) {
1357- writtenContent = content ;
1423+ writtenContent += String ( content ) ;
13581424 return originalWrite . call ( this , content ) ;
13591425 } ;
13601426
@@ -1368,12 +1434,14 @@ test('multiline text exceeding console height', () => {
13681434 spinner . start ( ) ;
13691435 spinner . render ( ) ; // Force a render
13701436
1437+ const renderedOutput = stripVTControlCharacters ( getLastSynchronizedOutput ( writtenContent ) ) ;
1438+
13711439 // When content exceeds viewport, should truncate with message
1372- assert . ok ( writtenContent . includes ( 'Line 1' ) , 'Should include some original content' ) ;
1373- assert . ok ( writtenContent . includes ( '(content truncated to fit terminal)' ) , 'Should show truncation message' ) ;
1440+ assert . ok ( renderedOutput . includes ( 'Line 1' ) , 'Should include some original content' ) ;
1441+ assert . ok ( renderedOutput . includes ( '(content truncated to fit terminal)' ) , 'Should show truncation message' ) ;
13741442
13751443 // Should not include all 10 lines
1376- const lineCount = ( writtenContent . match ( / L i n e \d + / g) || [ ] ) . length ;
1444+ const lineCount = ( renderedOutput . match ( / L i n e \d + / g) || [ ] ) . length ;
13771445 assert . ok ( lineCount < 10 , 'Should truncate some lines' ) ;
13781446 assert . ok ( lineCount <= 5 , 'Should not exceed terminal height' ) ;
13791447
@@ -1392,7 +1460,7 @@ test('multiline text within console height (no truncation)', () => {
13921460 // Override write to capture content
13931461 const originalWrite = stream . write ;
13941462 stream . write = function ( content ) {
1395- writtenContent = content ;
1463+ writtenContent += String ( content ) ;
13961464 return originalWrite . call ( this , content ) ;
13971465 } ;
13981466
@@ -1407,9 +1475,10 @@ test('multiline text within console height (no truncation)', () => {
14071475 spinner . render ( ) ;
14081476
14091477 // When content is within viewport, should not truncate
1410- assert . ok ( writtenContent . includes ( 'Line 1' ) , 'Should include first line' ) ;
1411- assert . ok ( writtenContent . includes ( 'Line 5' ) , 'Should include last line' ) ;
1412- assert . ok ( ! writtenContent . includes ( '(content truncated to fit terminal)' ) , 'Should not show truncation message' ) ;
1478+ const renderedOutput = stripVTControlCharacters ( getLastSynchronizedOutput ( writtenContent ) ) ;
1479+ assert . ok ( renderedOutput . includes ( 'Line 1' ) , 'Should include first line' ) ;
1480+ assert . ok ( renderedOutput . includes ( 'Line 5' ) , 'Should include last line' ) ;
1481+ assert . ok ( ! renderedOutput . includes ( '(content truncated to fit terminal)' ) , 'Should not show truncation message' ) ;
14131482
14141483 spinner . stop ( ) ;
14151484} ) ;
@@ -1426,7 +1495,7 @@ test('multiline text with undefined terminal rows (no truncation)', () => {
14261495 // Override write to capture content
14271496 const originalWrite = stream . write ;
14281497 stream . write = function ( content ) {
1429- writtenContent = content ;
1498+ writtenContent += String ( content ) ;
14301499 return originalWrite . call ( this , content ) ;
14311500 } ;
14321501
@@ -1441,9 +1510,10 @@ test('multiline text with undefined terminal rows (no truncation)', () => {
14411510 spinner . render ( ) ;
14421511
14431512 // When terminal height is unknown, should not truncate (no truncation applied)
1444- assert . ok ( writtenContent . includes ( 'Line 1' ) , 'Should include first line' ) ;
1445- assert . ok ( writtenContent . includes ( 'Line 10' ) , 'Should include last line' ) ;
1446- assert . ok ( ! writtenContent . includes ( '(content truncated to fit terminal)' ) , 'Should not truncate when height is unknown' ) ;
1513+ const renderedOutput = stripVTControlCharacters ( getLastSynchronizedOutput ( writtenContent ) ) ;
1514+ assert . ok ( renderedOutput . includes ( 'Line 1' ) , 'Should include first line' ) ;
1515+ assert . ok ( renderedOutput . includes ( 'Line 10' ) , 'Should include last line' ) ;
1516+ assert . ok ( ! renderedOutput . includes ( '(content truncated to fit terminal)' ) , 'Should not truncate when height is unknown' ) ;
14471517
14481518 spinner . stop ( ) ;
14491519} ) ;
@@ -1458,7 +1528,7 @@ test('multiline text with very small console height (no truncation)', () => {
14581528 let writtenContent = '' ;
14591529 const originalWrite = stream . write ;
14601530 stream . write = function ( content ) {
1461- writtenContent = content ;
1531+ writtenContent += String ( content ) ;
14621532 return originalWrite . call ( this , content ) ;
14631533 } ;
14641534
@@ -1473,8 +1543,9 @@ test('multiline text with very small console height (no truncation)', () => {
14731543 spinner . render ( ) ;
14741544
14751545 // When console is too small (1 row), should not truncate because no room for message
1476- assert . ok ( writtenContent . includes ( 'Line 1' ) , 'Should include content' ) ;
1477- assert . ok ( ! writtenContent . includes ( '(content truncated to fit terminal)' ) , 'Should not truncate when console too small for message' ) ;
1546+ const renderedOutput = stripVTControlCharacters ( getLastSynchronizedOutput ( writtenContent ) ) ;
1547+ assert . ok ( renderedOutput . includes ( 'Line 1' ) , 'Should include content' ) ;
1548+ assert . ok ( ! renderedOutput . includes ( '(content truncated to fit terminal)' ) , 'Should not truncate when console too small for message' ) ;
14781549
14791550 spinner . stop ( ) ;
14801551} ) ;
0 commit comments