{
+ const file = await fs.promises.readFile(testFilePath, {encoding: 'utf8'});
+ return file.replace(/\r\n/g, '\n');
+}
+
+const projectName = 'myFirstProject';
+const defaultOptions = {
+ command: 'preview',
+ stackName: 'staging',
+ options: {},
+} as Config;
+
+describe('summary.ts', () => {
+ beforeEach(async () => {
+ process.env[SUMMARY_ENV_VAR] = testFilePath;
+ await fs.promises.mkdir(testDirectoryPath, {recursive: true});
+ await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'});
+ core.summary.emptyBuffer();
+ })
+
+ afterAll(async () => {
+ await fs.promises.unlink(testFilePath);
+ })
+
+ it('throws if summary env var is undefined', async () => {
+ process.env[SUMMARY_ENV_VAR] = undefined;
+ const write = core.summary.addRaw('123').write();
+ await expect(write).rejects.toThrow();
+ })
+
+ it('throws if summary file does not exist', async () => {
+ await fs.promises.unlink(testFilePath);
+ const write = core.summary.addRaw('123').write();
+ await expect(write).rejects.toThrow();
+ })
+
+ it('should add only heading with empty code block to summary', async () => {
+ const message = '';
+ const expected = `Pulumi ${projectName}/${defaultOptions.stackName} results
\n
\n`;
+
+ await handleSummaryMessage(defaultOptions, projectName, message);
+ const summary = await getSummary()
+
+ expect(summary).toBe(expected);
+ })
+
+ it('should convert ansi control character to plain text and add to summary', async () => {
+ const message = '\x1b[30mblack\x1b[37mwhite';
+ const expected = `Pulumi ${projectName}/${defaultOptions.stackName} results
\nblackwhite
\n`;
+
+ await handleSummaryMessage(defaultOptions, projectName, message);
+ const summary = await getSummary()
+
+ expect(summary).toBe(expected);
+ })
+
+ it('should trim the output when the output is larger than 1 MiB', async () => {
+ const message = 'this is at the begining and should not be in the output' + 'a'.repeat(1_048_576) + 'this is at the end and should be in the output';
+
+ await handleSummaryMessage(defaultOptions, projectName, message);
+ const summary = await getSummary()
+
+ expect(Buffer.byteLength(summary, 'utf8')).toBeLessThan(1_048_576);
+ expect(summary).toContain('this is at the begining and should not be in the output')
+ expect(summary).toContain('The output was too long and trimmed.');
+ expect(summary).not.toContain('this is at the end and should be in the output');
+ expect(summary).not.toContain('The output was too long and trimmed from the front.');
+ })
+
+ it('should trim the output from front when the output is larger than 1 MiB and config is set', async () => {
+ const message = 'this is at the begining and should not be in the output' + 'a'.repeat(1_048_576) + 'this is at the end and should be in the output';
+
+ const options: Config = {
+ ...defaultOptions,
+ alwaysIncludeSummary: true,
+ };
+
+ await handleSummaryMessage(options, projectName, message);
+ const summary = await getSummary()
+
+ expect(Buffer.byteLength(summary, 'utf8')).toBeLessThan(1_048_576);
+ expect(summary).toContain('this is at the end and should be in the output');
+ expect(summary).toContain('The output was too long and trimmed from the front.');
+ expect(summary).not.toContain('this is at the begining and should not be in the output')
+ expect(summary).not.toContain('The output was too long and trimmed.');
+ })
+
+ it('should replace first leading space with non-breaking space character to preserve the formatting', async () => {
+ const message =
+`begin
+ some leading spaces in this line
+`;
+
+ const expected = `Pulumi ${projectName}/${defaultOptions.stackName} results
\nbegin\n some leading spaces in this line\n
\n`;
+
+ await handleSummaryMessage(defaultOptions, projectName, message);
+ const summary = await getSummary()
+
+ expect(summary).toBe(expected);
+ })
+
+})
diff --git a/src/libs/__tests__/utils.test.ts b/src/libs/__tests__/utils.test.ts
index e3353477..bcae36a7 100644
--- a/src/libs/__tests__/utils.test.ts
+++ b/src/libs/__tests__/utils.test.ts
@@ -1,4 +1,4 @@
-import { parseSemicolorToArray } from '../utils';
+import { parseSemicolorToArray, stripAnsiControlCodes } from '../utils';
describe('utils.ts', () => {
it('should parse semicolon to array', () => {
@@ -9,3 +9,12 @@ describe('utils.ts', () => {
expect(inputArray[1]).toBe('world');
});
});
+
+describe('utils.ts', () => {
+ it('should parse semicolon to array', () => {
+
+ const output = stripAnsiControlCodes('\x1b[30mblack\x1b[37mwhite');
+
+ expect(output).toBe('blackwhite');
+ });
+});
diff --git a/src/libs/pr.ts b/src/libs/pr.ts
index fc09cc33..a2a64e6c 100644
--- a/src/libs/pr.ts
+++ b/src/libs/pr.ts
@@ -1,47 +1,41 @@
import * as core from '@actions/core';
import { context, getOctokit } from '@actions/github';
-import AnsiToHtml from 'ansi-to-html';
import dedent from 'dedent';
import invariant from 'ts-invariant';
import { Config } from '../config';
+import { stripAnsiControlCodes } from './utils';
-function ansiToHtml(
+function trimOutputByCharacters(
message: string,
maxLength: number,
alwaysIncludeSummary: boolean,
): [string, boolean] {
/**
- * Converts an ansi string to html by for example removing color escape characters.
- * message: ansi string to convert
- * maxLength: Maximum number of characters of final message incl. HTML tags
+ * Trim message to maxLength
+ * message: string to trim
+ * maxLength: Maximum number of characters of final message
* alwaysIncludeSummary: if true, trim message from front (if trimming is needed), otherwise from end
*
- * return message as html and information if message was trimmed because of length
+ * return message and information if message was trimmed
*/
- const convert = new AnsiToHtml();
let trimmed = false;
- let htmlBody: string = convert.toHtml(message);
+ // Check if message exceeds max characters
+ if (message.length > maxLength) {
- // Check if htmlBody exceeds max characters
- if (htmlBody.length > maxLength) {
-
- // trim input message by number of exceeded characters from front or back as configured
- const dif: number = htmlBody.length - maxLength;
+ // Trim input message by number of exceeded characters from front or back as configured
+ const dif: number = message.length - maxLength;
if (alwaysIncludeSummary) {
- message = message.substring(dif, htmlBody.length);
+ message = message.substring(dif, message.length);
} else {
message = message.substring(0, message.length - dif);
}
trimmed = true;
-
- // convert trimmed message to html
- htmlBody = convert.toHtml(message);
}
- return [htmlBody, trimmed];
+ return [message, trimmed];
}
function extractViewLiveLink(output: string) {
@@ -72,14 +66,17 @@ export async function handlePullRequestMessage(
alwaysIncludeSummary,
} = config;
- // GitHub limits comment characters to 65535, use lower max to keep buffer for variable values
+ // Remove ANSI symbols from output because they are not supported in GitHub PR message
+ output = stripAnsiControlCodes(output);
+
+ // GitHub limits PR comment characters to 65_535, use lower max to keep buffer for variable values
const MAX_CHARACTER_COMMENT = 64_000;
const heading = `#### :tropical_drink: \`${command}\` on ${projectName}/${stackName}`;
const summary = 'Pulumi report';
- const [htmlBody, trimmed]: [string, boolean] = ansiToHtml(output, MAX_CHARACTER_COMMENT, alwaysIncludeSummary);
+ const [message, trimmed]: [string, boolean] = trimOutputByCharacters(output, MAX_CHARACTER_COMMENT, alwaysIncludeSummary);
const viewLiveLink = extractViewLiveLink(output);
@@ -94,7 +91,7 @@ export async function handlePullRequestMessage(
: ''
}
- ${htmlBody}
+ ${message}
${trimmed && !alwaysIncludeSummary
? ':warning: **Warn**: The output was too long and trimmed.'
diff --git a/src/libs/summary.ts b/src/libs/summary.ts
new file mode 100644
index 00000000..3ce77e3c
--- /dev/null
+++ b/src/libs/summary.ts
@@ -0,0 +1,74 @@
+import * as core from '@actions/core';
+import { Config } from '../config';
+import { stripAnsiControlCodes } from './utils';
+
+function trimOutputByBytes(
+ message: string,
+ maxSize: number,
+ alwaysIncludeSummary: boolean,
+): [string, boolean] {
+ /**
+ * Trim message to maxSize in bytes
+ * message: string to trim
+ * maxSize: Maximum number of bytes of final message
+ * alwaysIncludeSummary: if true, trim message from front (if trimming is needed), otherwise from end
+ *
+ * return message and information if message was trimmed
+ */
+ let trimmed = false;
+
+ const messageSize = Buffer.byteLength(message, 'utf8');
+
+ // Check if message exceeds max size
+ if (messageSize > maxSize) {
+
+ // Trim input message by number of exceeded bytes from front or back as configured
+ const dif: number = messageSize - maxSize;
+
+ if (alwaysIncludeSummary) {
+ message = Buffer.from(message).subarray(dif, messageSize).toString()
+ } else {
+ message = Buffer.from(message).subarray(0, messageSize - dif).toString()
+ }
+
+ trimmed = true;
+ }
+
+ return [message, trimmed];
+}
+
+export async function handleSummaryMessage(
+ config: Config,
+ projectName: string,
+ output: string,
+): Promise {
+ const {
+ stackName,
+ alwaysIncludeSummary,
+ } = config;
+
+ // Remove ANSI symbols from output because they are not supported in GitHub step Summary
+ output = stripAnsiControlCodes(output);
+
+ // Replace the first leading space in each line with a non-breaking space character to preserve the formatting
+ const regex_space = RegExp(`^[ ]`, 'gm');
+ output = output.replace(regex_space, ' ');
+
+ // GitHub limits step Summary to 1 MiB (1_048_576 bytes), use lower max to keep buffer for variable values
+ const MAX_SUMMARY_SIZE_BYTES = 1_000_000;
+
+ const [message, trimmed]: [string, boolean] = trimOutputByBytes(output, MAX_SUMMARY_SIZE_BYTES, alwaysIncludeSummary);
+
+ let heading = `Pulumi ${projectName}/${stackName} results`;
+
+ if (trimmed && alwaysIncludeSummary) {
+ heading += ' :warning: **Warn**: The output was too long and trimmed from the front.';
+ } else if (trimmed && !alwaysIncludeSummary) {
+ heading += ' :warning: **Warn**: The output was too long and trimmed.';
+ }
+
+ await core.summary
+ .addHeading(heading)
+ .addCodeBlock(message, "diff")
+ .write();
+}
diff --git a/src/libs/utils.ts b/src/libs/utils.ts
index a150cdbf..ee23c234 100644
--- a/src/libs/utils.ts
+++ b/src/libs/utils.ts
@@ -13,3 +13,8 @@ export function parseSemicolorToArray(input?: string[]): undefined | string[] {
[],
);
}
+
+export function stripAnsiControlCodes(text: string): string {
+ const regex_ansi = RegExp(`\x1B(?:[@-Z\\-_]|[[0-?]*[ -/]*[@-~])`, 'g');
+ return text.replace(regex_ansi, '');
+}
diff --git a/src/main.ts b/src/main.ts
index 3b44db1b..15c8cacb 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -19,6 +19,7 @@ import {
import { environmentVariables } from './libs/envs';
import { handlePullRequestMessage } from './libs/pr';
import * as pulumiCli from './libs/pulumi-cli';
+import { handleSummaryMessage } from './libs/summary';
import { login } from './login';
const main = async () => {
@@ -160,10 +161,7 @@ const runAction = async (config: Config): Promise => {
}
if (config.commentOnSummary) {
- await core.summary
- .addHeading(`Pulumi ${config.stackName} results`)
- .addCodeBlock(stdout, "diff")
- .write();
+ handleSummaryMessage(config, projectName, stdout)
}
}