From 7314e2ea9a374c401be977e652b2136940c88bbe Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Tue, 21 Apr 2026 10:55:25 +0200 Subject: [PATCH] Remove deprecated `prettyoutput` package And use an internal solution instead. Also add test to ensure the result is the same (before/after). --- package.json | 1 - src/Context.js | 17 ++-- src/utils/format-output.js | 154 ++++++++++++++++++++++++++++++++++ test/unit/src/Context.test.js | 81 ++++++++++++++++++ 4 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/utils/format-output.js create mode 100644 test/unit/src/Context.test.js diff --git a/package.json b/package.json index e0df6d4..9d9acc6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "node-fetch": "^2.6.7", "p-limit": "^3.1.0", "path2": "^0.1.0", - "prettyoutput": "^1.2.0", "promise-queue": "^2.2.5", "ramda": "^0.28.0", "semver": "^7.3.7", diff --git a/src/Context.js b/src/Context.js index e59a148..9ce316f 100644 --- a/src/Context.js +++ b/src/Context.js @@ -1,7 +1,6 @@ 'use strict'; const path = require('path'); -const prettyoutput = require('prettyoutput'); const isPlainObject = require('type/plain-object/is'); const utils = require('./utils'); const packageJson = require('../package.json'); @@ -12,6 +11,7 @@ const readline = require('readline'); const Progresses = require('./cli/Progresses'); const colors = require('./cli/colors'); const ServerlessError = require('./serverless-error'); +const formatOutput = require('./utils/format-output'); class Context { constructor(config) { @@ -72,21 +72,14 @@ class Context { } renderOutputs(outputs) { - if (typeof outputs !== 'object' || Object.keys(outputs).length === 0) { + if (!outputs || typeof outputs !== 'object' || Object.keys(outputs).length === 0) { return; } this.output.writeText( - `\n${prettyoutput(outputs, { - alignKeyValues: false, - colors: { - keys: 'gray', - dash: 'gray', - number: 'white', - true: 'white', - false: 'white', - }, - })}`.trimEnd() + `\n${formatOutput(outputs)}` + // Output.writeText already appends a trailing newline per line. + .trimEnd() ); } diff --git a/src/utils/format-output.js b/src/utils/format-output.js new file mode 100644 index 0000000..953c15b --- /dev/null +++ b/src/utils/format-output.js @@ -0,0 +1,154 @@ +'use strict'; + +const colors = require('../cli/colors'); + +const DEFAULT_OPTIONS = { + indentation: ' ', + maxDepth: 3, + colors: { + keys: 'gray', + dash: 'gray', + number: 'white', + string: null, + true: 'white', + false: 'white', + null: 'gray', + undefined: 'gray', + }, +}; + +const colorHandlers = { + gray: colors.gray, + white: colors.foreground, + red: colors.red, +}; + +const colorize = (value, colorName) => { + const text = String(value); + if (!colorName || !colorHandlers[colorName]) { + return text; + } + return colorHandlers[colorName](text); +}; + +const isEmptyArray = (value) => Array.isArray(value) && value.length === 0; +const isSingleLineString = (value) => typeof value === 'string' && !value.includes('\n'); + +const isSerializable = (value) => { + return ( + typeof value === 'boolean' || + typeof value === 'number' || + value === null || + value === undefined || + value instanceof Date || + isSingleLineString(value) || + isEmptyArray(value) + ); +}; + +const getValueColor = (value, outputColors) => { + if (typeof value === 'string') return outputColors.string; + if (value === true) return outputColors.true; + if (value === false) return outputColors.false; + if (value === null) return outputColors.null; + if (value === undefined) return outputColors.undefined; + if (typeof value === 'number') return outputColors.number; + return null; +}; + +const renderSerializable = (value, options, indentation = '') => { + if (Array.isArray(value)) { + return `${indentation}(empty array)\n`; + } + + return `${indentation}${colorize(value, getValueColor(value, options.colors))}\n`; +}; + +const renderMultilineString = (value, options, indentation) => { + const nestedIndentation = `${indentation}${options.indentation}`; + const lines = value.split('\n').map((line) => `${nestedIndentation}${line}`); + return `${indentation}"""\n${lines.join('\n')}\n${indentation}"""\n`; +}; + +const renderObjectKey = (key, options, indentation) => { + return colorize(`${indentation}${key}: `, options.colors.keys); +}; + +const renderDash = (options, indentation) => { + return colorize(`${indentation}- `, options.colors.dash); +}; + +const renderValue = (value, options, indentation, depth) => { + if (depth > options.maxDepth) { + return `${indentation}(max depth reached)\n`; + } + + if (isSerializable(value)) { + return renderSerializable(value, options, indentation); + } + + if (typeof value === 'string') { + return renderMultilineString(value, options, indentation); + } + + if (Array.isArray(value)) { + return value + .map((item) => { + if (isSerializable(item)) { + return `${renderDash(options, indentation)}${renderSerializable(item, options, '')}`; + } + + if (depth + 1 > options.maxDepth) { + return `${renderDash(options, indentation)}(max depth reached)\n`; + } + + return `${renderDash(options, indentation)}\n${renderValue( + item, + options, + `${indentation}${options.indentation}`, + depth + 1 + )}`; + }) + .join(''); + } + + return Object.getOwnPropertyNames(value) + .map((key) => { + const childValue = value[key]; + if (isSerializable(childValue)) { + return `${renderObjectKey(key, options, indentation)}${renderSerializable( + childValue, + options, + '' + )}`; + } + + if (depth + 1 > options.maxDepth) { + return `${renderObjectKey(key, options, indentation)}(max depth reached)\n`; + } + + return `${renderObjectKey(key, options, indentation)}\n${renderValue( + childValue, + options, + `${indentation}${options.indentation}`, + depth + 1 + )}`; + }) + .join(''); +}; + +module.exports = (value, options = {}) => { + return renderValue( + value, + { + ...DEFAULT_OPTIONS, + ...options, + colors: { + ...DEFAULT_OPTIONS.colors, + ...(options.colors || {}), + }, + }, + '', + 0 + ); +}; diff --git a/test/unit/src/Context.test.js b/test/unit/src/Context.test.js new file mode 100644 index 0000000..939b6ce --- /dev/null +++ b/test/unit/src/Context.test.js @@ -0,0 +1,81 @@ +'use strict'; + +const expect = require('chai').expect; +const stripAnsi = require('strip-ansi'); +const Context = require('../../../src/Context'); +const readStream = require('../read-stream'); + +describe('test/unit/src/Context.test.js', () => { + const contextConfig = { + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }; + + it('does not render empty or invalid outputs', async () => { + const context = new Context(contextConfig); + + context.renderOutputs(null); + context.renderOutputs({}); + + expect(await readStream(context.output.stdout)).to.equal(''); + }); + + it('renders nested outputs in the expected CLI format', async () => { + const context = new Context(contextConfig); + + context.renderOutputs({ + service: { + url: 'https://example.com', + enabled: true, + }, + values: [1, 'text', { nested: false }], + emptyArr: [], + emptyObject: {}, + description: 'line1\nline2', + }); + + expect(stripAnsi(await readStream(context.output.stdout))).to.equal( + [ + '', + 'service: ', + ' url: https://example.com', + ' enabled: true', + 'values: ', + ' - 1', + ' - text', + ' - ', + ' nested: false', + 'emptyArr: (empty array)', + 'emptyObject: ', + 'description: ', + ' """', + ' line1', + ' line2', + ' """', + '', + ].join('\n') + ); + }); + + it('caps nested rendering depth consistently', async () => { + const context = new Context(contextConfig); + + context.renderOutputs({ + a: { + b: { + c: { + d: { + e: 1, + }, + }, + }, + }, + }); + + expect(stripAnsi(await readStream(context.output.stdout))).to.equal( + ['', 'a: ', ' b: ', ' c: ', ' d: (max depth reached)', ''].join('\n') + ); + }); +});