From 6084c8602a120fd4eef1e7ae9dbc0df29559e05b Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Thu, 29 Aug 2024 12:00:01 -0400 Subject: [PATCH] add additional tests for last frame rendering scenarios --- src/components/App.tsx | 6 +- src/ink.tsx | 25 ++++---- test/components.tsx | 111 +++++++++++++++++++++++++++++++++- test/helpers/create-stdout.ts | 4 +- 4 files changed, 125 insertions(+), 21 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index bc7c52cd..3beaacd1 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -22,7 +22,7 @@ type Props = { readonly writeToStderr: (data: string) => void; readonly exitOnCtrlC: boolean; readonly onExit: (error?: Error) => void; - readonly debug: boolean; + readonly hasCursor: boolean; }; type State = { @@ -128,13 +128,13 @@ export default class App extends PureComponent { } override componentDidMount() { - if (!this.props.debug) { + if (this.props.hasCursor) { cliCursor.hide(this.props.stdout); } } override componentWillUnmount() { - if (!this.props.debug) { + if (this.props.hasCursor) { cliCursor.show(this.props.stdout); } diff --git a/src/ink.tsx b/src/ink.tsx index eacfc640..e68ac522 100644 --- a/src/ink.tsx +++ b/src/ink.tsx @@ -41,6 +41,9 @@ export default class Ink { // This variable is used only in debug mode to store full static output // so that it's rerendered every time, not just new static parts, like in non-debug mode private fullStaticOutput: string; + // CI environments and piped output don't handle erasing ansi escapes well, + // so it's better to only render last frame of non-static output + private readonly renderLastFrameOnly: boolean; private exitPromise?: Promise; private restoreConsole?: () => void; private readonly unsubscribeResize?: () => void; @@ -67,12 +70,8 @@ export default class Ink { leading: true, trailing: true, }); - - // CI environments and piped output don't handle erasing ansi escapes well, - // so it's better to only render last frame of non-static output - if (options.renderLastFrameOnly === undefined) { - options.renderLastFrameOnly = isInCi || !options.stdout.isTTY; - } + this.renderLastFrameOnly = + options.renderLastFrameOnly ?? (isInCi || !options.stdout.isTTY); // Ignore last render after unmounting a tree to prevent empty output before exit this.isUnmounted = false; @@ -114,7 +113,7 @@ export default class Ink { this.patchConsole(); } - if (!options.renderLastFrameOnly) { + if (!this.renderLastFrameOnly) { options.stdout.on('resize', this.resized); this.unsubscribeResize = () => { @@ -165,7 +164,7 @@ export default class Ink { return; } - if (this.options.renderLastFrameOnly) { + if (this.renderLastFrameOnly) { if (hasStaticOutput) { this.options.stdout.write(staticOutput); } @@ -209,7 +208,7 @@ export default class Ink { writeToStdout={this.writeToStdout} writeToStderr={this.writeToStderr} exitOnCtrlC={this.options.exitOnCtrlC} - debug={this.options.debug} + hasCursor={!this.options.debug && !this.renderLastFrameOnly} onExit={this.unmount} > {node} @@ -229,7 +228,7 @@ export default class Ink { return; } - if (this.options.renderLastFrameOnly) { + if (this.renderLastFrameOnly) { this.options.stdout.write(data); return; } @@ -250,7 +249,7 @@ export default class Ink { return; } - if (this.options.renderLastFrameOnly) { + if (this.renderLastFrameOnly) { this.options.stderr.write(data); return; } @@ -278,7 +277,7 @@ export default class Ink { this.unsubscribeResize(); } - if (this.options.renderLastFrameOnly) { + if (this.renderLastFrameOnly) { this.options.stdout.write(this.lastOutput + '\n'); } else if (!this.options.debug) { this.log.done(); @@ -306,7 +305,7 @@ export default class Ink { } clear(): void { - if (!this.options.renderLastFrameOnly && !this.options.debug) { + if (!this.renderLastFrameOnly && !this.options.debug) { this.log.clear(); } } diff --git a/test/components.tsx b/test/components.tsx index 29690d76..d975af4d 100644 --- a/test/components.tsx +++ b/test/components.tsx @@ -1,8 +1,8 @@ import EventEmitter from 'node:events'; import test from 'ava'; import chalk from 'chalk'; -import React, {Component, useState} from 'react'; -import {spy} from 'sinon'; +import React, {Component, useEffect, useState} from 'react'; +import {spy, type SinonSpy} from 'sinon'; import ansiEscapes from 'ansi-escapes'; import { Box, @@ -645,7 +645,7 @@ test('render only last frame when run in CI', async t => { t.true(output.includes('Counter: 5')); }); -test('render all frames if CI environment variable equals false', async t => { +test('render all frames if CI environment variable equals false and there is an output TTY', async t => { const output = await run('ci', { // eslint-disable-next-line @typescript-eslint/naming-convention env: {CI: 'false'}, @@ -657,6 +657,111 @@ test('render all frames if CI environment variable equals false', async t => { } }); +function TestLastFrame(props: { + readonly continueTest: () => void; + readonly message1: string; + readonly message2: string; +}) { + const [message, setMessage] = useState(props.message1); + useEffect(() => { + setTimeout(() => { + setMessage(props.message2); + props.continueTest(); + }, 1); + }, [message]); + return {message}; +} + +test('render only last frame when run without an output tty', async t => { + // Create without a TTY + const stdout = createStdout(undefined, false); + const message1 = 'Hello'; + const message2 = 'World'; + + let continueTest = () => {}; + const synchronizer = new Promise((resolve, _reject) => { + continueTest = resolve; + }); + + const {unmount} = render( + , + { + stdout, + }, + ); + await synchronizer; + unmount(); + + const writeSpy = stdout.write as SinonSpy; + t.true(writeSpy.calledOnce); + t.true((writeSpy.firstCall.args[0] as string).includes(message2)); +}); + +test('render only last frame when enabled in options', async t => { + const stdout = createStdout(); + const message1 = 'Hello'; + const message2 = 'World'; + + let continueTest = () => {}; + const synchronizer = new Promise((resolve, _reject) => { + continueTest = resolve; + }); + + const {unmount} = render( + , + { + stdout, + renderLastFrameOnly: true, + }, + ); + await synchronizer; + unmount(); + + const writeSpy = stdout.write as SinonSpy; + t.true(writeSpy.calledOnce); + t.true((writeSpy.firstCall.args[0] as string).includes(message2)); +}); + +test('render all frames when enabled in options', async t => { + // Create without a TTY so last frame is the default + const stdout = createStdout(undefined, false); + const message1 = 'Hello'; + const message2 = 'World'; + + let continueTest = () => {}; + const synchronizer = new Promise((resolve, _reject) => { + continueTest = resolve; + }); + + const {unmount} = render( + , + { + stdout, + // Override the default + renderLastFrameOnly: false, + }, + ); + await synchronizer; + unmount(); + + const writeSpy = stdout.write as SinonSpy; + t.true(writeSpy.calledTwice); + t.true((writeSpy.firstCall.args[0] as string).includes(message1)); + t.true((writeSpy.secondCall.args[0] as string).includes(message2)); +}); + test('reset prop when it’s removed from the element', t => { const stdout = createStdout(); diff --git a/test/helpers/create-stdout.ts b/test/helpers/create-stdout.ts index 0028c111..73f7c692 100644 --- a/test/helpers/create-stdout.ts +++ b/test/helpers/create-stdout.ts @@ -6,13 +6,13 @@ type FakeStdout = { get: () => string; } & NodeJS.WriteStream; -const createStdout = (columns?: number): FakeStdout => { +const createStdout = (columns?: number, isTTY?: boolean): FakeStdout => { const stdout = new EventEmitter() as unknown as FakeStdout; stdout.columns = columns ?? 100; const write = spy(); stdout.write = write; - stdout.isTTY = true; + stdout.isTTY = isTTY ?? true; stdout.get = () => write.lastCall.args[0] as string;