Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 5 additions & 12 deletions src/Context.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
);
}

Expand Down
154 changes: 154 additions & 0 deletions src/utils/format-output.js
Original file line number Diff line number Diff line change
@@ -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
);
};
81 changes: 81 additions & 0 deletions test/unit/src/Context.test.js
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
Loading