diff --git a/package.json b/package.json index 26b3128..fcef448 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,6 @@ "ext": "^1.7.0", "fast-glob": "^3.3.3", "fs-extra": "^10.1.0", - "is-interactive": "^1", - "is-unicode-supported": "^0.1", "js-yaml": "^4.1.0", "log": "^6.3.1", "log-node": "^8.0.3", diff --git a/src/cli/Output.js b/src/cli/Output.js index d7b025a..c62554a 100644 --- a/src/cli/Output.js +++ b/src/cli/Output.js @@ -5,7 +5,6 @@ const symbols = require('./symbols'); const fs = require('fs'); const { stripVTControlCharacters: stripAnsi } = require('node:util'); const path = require('path'); -const isInteractiveTerminal = require('is-interactive'); const { PassThrough } = require('stream'); /** @@ -29,7 +28,9 @@ class Output { this.stdout = disableIO ? new PassThrough() : process.stdout; this.stderr = disableIO ? new PassThrough() : process.stderr; this.logsFileStream = disableIO ? new PassThrough() : this.openLogsFile(); - if (!disableIO && isInteractiveTerminal()) { + const isInteractive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI; + + if (!disableIO && isInteractive) { this.interactiveStdout = process.stdout; this.interactiveStderr = process.stderr; this.interactiveStdin = process.stdin; @@ -37,7 +38,7 @@ class Output { // We want to apply it only in non-test environment so we check this only if // disableIO is not explicitly set to true - if (!isInteractiveTerminal() && disableIO === false) { + if (!isInteractive && disableIO === false) { // We also want to enable verbose by default for non-interactive environments this.verboseMode = true; } diff --git a/src/cli/Progresses.js b/src/cli/Progresses.js index 9e7c25e..c95bff3 100644 --- a/src/cli/Progresses.js +++ b/src/cli/Progresses.js @@ -9,7 +9,7 @@ const cliCursor = require('cli-cursor'); const { stderrCliColors: colors } = require('./colors'); const symbols = require('./symbols'); -const isUnicodeSupported = require('is-unicode-supported'); +const isUnicodeSupported = require('./is-unicode-supported'); const { stripVTControlCharacters: stripAnsi } = require('node:util'); const { createRegistry, hasOwn } = require('../utils/safe-object'); diff --git a/src/cli/is-unicode-supported.js b/src/cli/is-unicode-supported.js new file mode 100644 index 0000000..11efe64 --- /dev/null +++ b/src/cli/is-unicode-supported.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = () => { + if (process.platform !== 'win32') { + return true; + } + + return ( + Boolean(process.env.CI) || + Boolean(process.env.WT_SESSION) || + process.env.TERM_PROGRAM === 'vscode' || + process.env.TERM === 'xterm-256color' || + process.env.TERM === 'alacritty' + ); +}; diff --git a/src/cli/symbols.js b/src/cli/symbols.js index 588ef13..6d58cf5 100644 --- a/src/cli/symbols.js +++ b/src/cli/symbols.js @@ -1,6 +1,6 @@ 'use strict'; -const isUnicodeSupported = require('is-unicode-supported'); +const isUnicodeSupported = require('./is-unicode-supported'); const main = { success: '✔', diff --git a/test/unit/src/cli/Output.test.js b/test/unit/src/cli/Output.test.js index ee22060..c7dcb31 100644 --- a/test/unit/src/cli/Output.test.js +++ b/test/unit/src/cli/Output.test.js @@ -2,10 +2,28 @@ const expect = require('chai').expect; const proxyquire = require('proxyquire'); +const sinon = require('sinon'); +const { PassThrough } = require('stream'); const Output = require('../../../../src/cli/Output'); const colors = require('../../../../src/cli/colors'); const readStream = require('../../read-stream'); +const setProperty = (object, property, value) => { + Object.defineProperty(object, property, { + configurable: true, + writable: true, + value, + }); +}; + +const restoreProperty = (object, property, descriptor) => { + if (descriptor) { + Object.defineProperty(object, property, descriptor); + } else { + delete object[property]; + } +}; + describe('test/unit/lib/cli/Output.test.js', () => { /** @type {Output} */ let output; @@ -144,4 +162,100 @@ describe('test/unit/lib/cli/Output.test.js', () => { 'Error: TypeError: An error\n at Context.' ); }); + + describe('interactivity detection', () => { + let originalStdinIsTTYDescriptor; + let originalStdoutIsTTYDescriptor; + let originalCI; + let hadCI; + let openLogsFileStub; + + beforeEach(() => { + originalStdinIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + originalStdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + hadCI = Object.prototype.hasOwnProperty.call(process.env, 'CI'); + originalCI = process.env.CI; + openLogsFileStub = sinon.stub(Output.prototype, 'openLogsFile').returns(new PassThrough()); + }); + + afterEach(() => { + openLogsFileStub.restore(); + restoreProperty(process.stdin, 'isTTY', originalStdinIsTTYDescriptor); + restoreProperty(process.stdout, 'isTTY', originalStdoutIsTTYDescriptor); + if (hadCI) { + process.env.CI = originalCI; + } else { + delete process.env.CI; + } + }); + + const createOutput = ({ stdinIsTTY, stdoutIsTTY, ci } = {}) => { + setProperty(process.stdin, 'isTTY', stdinIsTTY); + setProperty(process.stdout, 'isTTY', stdoutIsTTY); + if (ci === undefined) { + delete process.env.CI; + } else { + process.env.CI = ci; + } + + return new Output(false); + }; + + it('enables interactive streams when stdin and stdout are TTY outside CI', () => { + const localOutput = createOutput({ stdinIsTTY: true, stdoutIsTTY: true }); + + expect(localOutput.interactiveStdout).to.equal(process.stdout); + expect(localOutput.interactiveStderr).to.equal(process.stderr); + expect(localOutput.interactiveStdin).to.equal(process.stdin); + expect(localOutput.verboseMode).to.equal(false); + }); + + it('disables interactivity when stdin is not TTY', () => { + const localOutput = createOutput({ stdinIsTTY: false, stdoutIsTTY: true }); + + expect(localOutput.interactiveStdout).to.equal(undefined); + expect(localOutput.interactiveStderr).to.equal(undefined); + expect(localOutput.interactiveStdin).to.equal(undefined); + expect(localOutput.verboseMode).to.equal(true); + }); + + it('disables interactivity when stdout is not TTY', () => { + const localOutput = createOutput({ stdinIsTTY: true, stdoutIsTTY: false }); + + expect(localOutput.interactiveStdout).to.equal(undefined); + expect(localOutput.interactiveStderr).to.equal(undefined); + expect(localOutput.interactiveStdin).to.equal(undefined); + expect(localOutput.verboseMode).to.equal(true); + }); + + it('disables interactivity when CI is truthy', () => { + const localOutput = createOutput({ stdinIsTTY: true, stdoutIsTTY: true, ci: '1' }); + + expect(localOutput.interactiveStdout).to.equal(undefined); + expect(localOutput.interactiveStderr).to.equal(undefined); + expect(localOutput.interactiveStdin).to.equal(undefined); + expect(localOutput.verboseMode).to.equal(true); + }); + + it('matches Serverless by allowing interactivity when CI is empty', () => { + const localOutput = createOutput({ stdinIsTTY: true, stdoutIsTTY: true, ci: '' }); + + expect(localOutput.interactiveStdout).to.equal(process.stdout); + expect(localOutput.interactiveStdin).to.equal(process.stdin); + expect(localOutput.verboseMode).to.equal(false); + }); + + it('does not enable interactive streams when IO is disabled', () => { + setProperty(process.stdin, 'isTTY', true); + setProperty(process.stdout, 'isTTY', true); + delete process.env.CI; + + const localOutput = new Output(false, true); + + expect(localOutput.interactiveStdout).to.equal(undefined); + expect(localOutput.interactiveStderr).to.equal(undefined); + expect(localOutput.interactiveStdin).to.equal(undefined); + expect(localOutput.verboseMode).to.equal(false); + }); + }); }); diff --git a/test/unit/src/cli/Progresses.test.js b/test/unit/src/cli/Progresses.test.js index 245296e..aa47471 100644 --- a/test/unit/src/cli/Progresses.test.js +++ b/test/unit/src/cli/Progresses.test.js @@ -1,11 +1,17 @@ 'use strict'; const expect = require('chai').expect; +const proxyquire = require('proxyquire'); const sinon = require('sinon'); const Progresses = require('../../../../src/cli/Progresses'); describe('test/unit/src/cli/Progresses.test.js', () => { + const loadProgresses = (isUnicodeSupported) => + proxyquire.noCallThru().load('../../../../src/cli/Progresses', { + './is-unicode-supported': () => isUnicodeSupported, + }); + const createProgresses = (columns = 5, rows = 3) => { const progresses = Object.create(Progresses.prototype); progresses.output = { @@ -51,4 +57,41 @@ describe('test/unit/src/cli/Progresses.test.js', () => { expect(progresses.exists('service')).to.deep.include({ status: 'success', text: 'done' }); expect(progresses.exists('constructor')).to.equal(undefined); }); + + it('uses dots spinner when unicode is supported', () => { + const LocalProgresses = loadProgresses(true); + const bindSigintStub = sinon.stub(LocalProgresses.prototype, 'bindSigint'); + + try { + const progresses = new LocalProgresses({}); + + expect(progresses.options.spinner.frames).to.deep.equal([ + '⠋', + '⠙', + '⠹', + '⠸', + '⠼', + '⠴', + '⠦', + '⠧', + '⠇', + '⠏', + ]); + } finally { + bindSigintStub.restore(); + } + }); + + it('uses dashes spinner when unicode is not supported', () => { + const LocalProgresses = loadProgresses(false); + const bindSigintStub = sinon.stub(LocalProgresses.prototype, 'bindSigint'); + + try { + const progresses = new LocalProgresses({}); + + expect(progresses.options.spinner.frames).to.deep.equal(['-', '_']); + } finally { + bindSigintStub.restore(); + } + }); }); diff --git a/test/unit/src/cli/is-unicode-supported.test.js b/test/unit/src/cli/is-unicode-supported.test.js new file mode 100644 index 0000000..3f03057 --- /dev/null +++ b/test/unit/src/cli/is-unicode-supported.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const expect = require('chai').expect; + +const isUnicodeSupported = require('../../../../src/cli/is-unicode-supported'); + +describe('test/unit/src/cli/is-unicode-supported.test.js', () => { + let originalPlatformDescriptor; + let originalEnv; + + const setPlatform = (platform) => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + }; + + beforeEach(() => { + originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + originalEnv = { + CI: process.env.CI, + WT_SESSION: process.env.WT_SESSION, + TERM_PROGRAM: process.env.TERM_PROGRAM, + TERM: process.env.TERM, + }; + + delete process.env.CI; + delete process.env.WT_SESSION; + delete process.env.TERM_PROGRAM; + delete process.env.TERM; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('supports unicode outside Windows', () => { + setPlatform('darwin'); + + expect(isUnicodeSupported()).to.equal(true); + }); + + it('does not support unicode on unknown Windows terminals', () => { + setPlatform('win32'); + + expect(isUnicodeSupported()).to.equal(false); + }); + + it('supports unicode on Windows in CI', () => { + setPlatform('win32'); + process.env.CI = '1'; + + expect(isUnicodeSupported()).to.equal(true); + }); + + it('supports unicode in Windows Terminal', () => { + setPlatform('win32'); + process.env.WT_SESSION = 'session-id'; + + expect(isUnicodeSupported()).to.equal(true); + }); + + it('supports unicode in VS Code terminal on Windows', () => { + setPlatform('win32'); + process.env.TERM_PROGRAM = 'vscode'; + + expect(isUnicodeSupported()).to.equal(true); + }); + + it('supports unicode in xterm-compatible Windows terminals', () => { + setPlatform('win32'); + process.env.TERM = 'xterm-256color'; + + expect(isUnicodeSupported()).to.equal(true); + }); + + it('supports unicode in Alacritty on Windows', () => { + setPlatform('win32'); + process.env.TERM = 'alacritty'; + + expect(isUnicodeSupported()).to.equal(true); + }); +}); diff --git a/test/unit/src/cli/symbols.test.js b/test/unit/src/cli/symbols.test.js new file mode 100644 index 0000000..7ef92e8 --- /dev/null +++ b/test/unit/src/cli/symbols.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const expect = require('chai').expect; +const proxyquire = require('proxyquire'); + +describe('test/unit/src/cli/symbols.test.js', () => { + const loadSymbols = (isUnicodeSupported) => + proxyquire.noCallThru().load('../../../../src/cli/symbols', { + './is-unicode-supported': () => isUnicodeSupported, + }); + + it('exports unicode symbols when unicode is supported', () => { + expect(loadSymbols(true)).to.deep.equal({ + success: '✔', + error: '✖', + separator: '›', + }); + }); + + it('exports fallback symbols when unicode is not supported', () => { + expect(loadSymbols(false)).to.deep.equal({ + success: '√', + error: '×', + separator: '>', + }); + }); +}); diff --git a/test/unit/src/utils/colors.test.js b/test/unit/src/utils/colors.test.js index 8096414..688e048 100644 --- a/test/unit/src/utils/colors.test.js +++ b/test/unit/src/utils/colors.test.js @@ -27,6 +27,15 @@ describe('test/unit/src/utils/colors.test.js', () => { ).to.equal(0); }); + it('disables colors for dumb terminals', () => { + expect( + getColorLevel({ + stream: { isTTY: true, getColorDepth: () => 24 }, + env: { TERM: 'dumb' }, + }) + ).to.equal(0); + }); + it('treats COLORTERM=truecolor case-insensitively', () => { expect( getColorLevel({ diff --git a/test/unit/src/utils/serverless-utils/log-reporters/node.test.js b/test/unit/src/utils/serverless-utils/log-reporters/node.test.js index 2f68bf0..e219dbf 100644 --- a/test/unit/src/utils/serverless-utils/log-reporters/node.test.js +++ b/test/unit/src/utils/serverless-utils/log-reporters/node.test.js @@ -104,6 +104,76 @@ describe('test/unit/src/utils/serverless-utils/log-reporters/node.test.js', () = expect(uniGlobalState.logIsInteractive).to.equal('1'); }); + it('registers the progress reporter when stdin and stdout are TTY outside CI', () => { + const uniGlobalState = {}; + + const { progressReporter, result } = loadModule(uniGlobalState, { + stdin: { isTTY: true }, + stdout: { isTTY: true, write: sinon.stub() }, + env: {}, + }); + + expect(progressReporter).to.have.been.calledOnceWithExactly({ logLevelIndex: 2 }); + expect(uniGlobalState.logIsInteractive).to.equal(true); + expect(result).to.deep.equal({ logLevelIndex: 2, isInteractive: true }); + }); + + it('matches Serverless by treating empty CI as interactive', () => { + const uniGlobalState = {}; + + const { progressReporter, result } = loadModule(uniGlobalState, { + stdin: { isTTY: true }, + stdout: { isTTY: true, write: sinon.stub() }, + env: { CI: '' }, + }); + + expect(progressReporter).to.have.been.calledOnceWithExactly({ logLevelIndex: 2 }); + expect(uniGlobalState.logIsInteractive).to.equal(true); + expect(result).to.deep.equal({ logLevelIndex: 2, isInteractive: true }); + }); + + it('does not register the progress reporter when stdin is not TTY', () => { + const uniGlobalState = {}; + + const { progressReporter, result } = loadModule(uniGlobalState, { + stdin: { isTTY: false }, + stdout: { isTTY: true, write: sinon.stub() }, + env: {}, + }); + + expect(progressReporter.called).to.equal(false); + expect(uniGlobalState.logIsInteractive).to.equal(undefined); + expect(result).to.deep.equal({ logLevelIndex: 2, isInteractive: undefined }); + }); + + it('does not register the progress reporter when stdout is not TTY', () => { + const uniGlobalState = {}; + + const { progressReporter, result } = loadModule(uniGlobalState, { + stdin: { isTTY: true }, + stdout: { isTTY: false, write: sinon.stub() }, + env: {}, + }); + + expect(progressReporter.called).to.equal(false); + expect(uniGlobalState.logIsInteractive).to.equal(undefined); + expect(result).to.deep.equal({ logLevelIndex: 2, isInteractive: undefined }); + }); + + it('does not register the progress reporter when CI is truthy', () => { + const uniGlobalState = {}; + + const { progressReporter, result } = loadModule(uniGlobalState, { + stdin: { isTTY: true }, + stdout: { isTTY: true, write: sinon.stub() }, + env: { CI: '1' }, + }); + + expect(progressReporter.called).to.equal(false); + expect(uniGlobalState.logIsInteractive).to.equal(undefined); + expect(result).to.deep.equal({ logLevelIndex: 2, isInteractive: undefined }); + }); + it('writes text-mode output events through joinTextTokens', () => { const handlers = new Map(); const outputEmitter = {