From 7a2eceac3626ccb8f4e8968ab4a28292e6af6d81 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 23 Feb 2022 18:09:05 -0500 Subject: [PATCH 01/17] initial markdown summary utils --- packages/core/src/summary.ts | 139 +++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/core/src/summary.ts diff --git a/packages/core/src/summary.ts b/packages/core/src/summary.ts new file mode 100644 index 0000000000..d788a78d76 --- /dev/null +++ b/packages/core/src/summary.ts @@ -0,0 +1,139 @@ +import * as fs from 'fs' +import {promisify} from 'util' + +const exists = promisify(fs.exists) +const appendFile = promisify(fs.appendFile) + +export class MarkdownSummary { + static ENV_VAR = 'GITHUB_STEP_SUMMARY' + private buffer: string + + constructor() { + this.buffer = '' + } + + /** + * Finds the summary file path from the environment, rejects if not found + * + * @returns step summary file path + */ + private async filePath(): Promise { + const filePath = process.env[MarkdownSummary.ENV_VAR] + if (!filePath) { + throw new Error( + `Unable to find environment variable for ${MarkdownSummary.ENV_VAR}` + ) + } + if (!(await exists(filePath))) { + throw new Error(`Missing summary file at path: ${filePath}`) + } + + return filePath + } + + /** + * Writes any text in the buffer to the summary file + * + * @returns {MarkdownSummary} markdown summary instance + */ + async write(): Promise { + const filePath = await this.filePath() + await appendFile(filePath, this.buffer, {encoding: 'utf8'}) + this.clear() + + return this + } + + /** + * Clears the summary buffer without writing to summary file + * + * @returns {MarkdownSummary} markdown summary instance + */ + clear(): MarkdownSummary { + this.buffer = '' + return this + } + + /** + * Adds a newline to the summary + * + * @returns {MarkdownSummary} markdown summary instance + */ + addNewline(): MarkdownSummary { + this.buffer += '\n' + return this + } + + /** + * Adds text to the summary + * @param {string} text content to add + * @param {boolean} [newline=false] whether or not to add a newline + * @returns {MarkdownSummary} markdown summary instance + */ + addText(text: string, newline = false): MarkdownSummary { + this.buffer += text + return newline ? this.addNewline() : this + } + + /** + * Adds a markdown codeblock to the summary + * + * @param {string} code content to render within fenced code block + * @param {string} [language=''] optional language to syntax highlight code + * + * @returns {MarkdownSummary} markdown summary instance + */ + addCodeBlock(code: string, language = ''): MarkdownSummary { + this.buffer += `\`\`\`${language}\n${code}\n\`\`\`\n` + return this + } + + /** + * Adds an HTML list to the summary + * + * @param {string[]} items list of items to render + * @param {boolean} [ordered=false] if the rendered list should be ordered or not (default: false) + * + * @returns {MarkdownSummary} markdown summary instance + */ + addList(items: string[], ordered = false): MarkdownSummary { + const listType = `${ordered ? 'o' : 'u'}l` + const listElems = items.map(e => `
  • ${e}
  • \n`).join() + this.buffer += `<${listType}>\n${listElems}\n` + return this + } + + /** + * Adds an HTML list to the summary + * + * @param {{[key: string]: any}[]} rows list of data rows + * @param {string[]} headers list of keys to use as headers + * + * @returns {MarkdownSummary} markdown summary instance + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addTable(rows: {[key: string]: any}[], headers: string[]): MarkdownSummary { + const headerElems = headers.map(h => `${h}\n`).join() + const rowElems = rows + .map(row => { + const data = headers.map(h => `${row[h]}\n`).join() + return `${data}\n` + }) + .join() + this.buffer += `\n${headerElems}\n${rowElems}
    \n` + return this + } + + /** + * Adds a collapsable HTML details element to the summary + * + * @param {string} label text for the closed state + * @param {string} content collapsable content + * + * @returns {MarkdownSummary} markdown summary instance + */ + addDetails(label: string, content: string): MarkdownSummary { + this.buffer += `
    ${label}\n\n${content}
    \n` + return this + } +} From d496b07cc0579f1117d0c2917dab019f8c58e5f1 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 23 Feb 2022 18:15:26 -0500 Subject: [PATCH 02/17] addText -> add, newline by default --- packages/core/src/summary.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/summary.ts b/packages/core/src/summary.ts index d788a78d76..470655a0f2 100644 --- a/packages/core/src/summary.ts +++ b/packages/core/src/summary.ts @@ -67,10 +67,10 @@ export class MarkdownSummary { /** * Adds text to the summary * @param {string} text content to add - * @param {boolean} [newline=false] whether or not to add a newline + * @param {boolean} [newline=true] whether or not to add a newline * @returns {MarkdownSummary} markdown summary instance */ - addText(text: string, newline = false): MarkdownSummary { + add(text: string, newline = true): MarkdownSummary { this.buffer += text return newline ? this.addNewline() : this } From 518ef1b79e7c67384bf4d2472c1fee8da210e460 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Tue, 1 Mar 2022 20:36:04 -0500 Subject: [PATCH 03/17] html element wrapper method for md summary --- packages/core/src/summary.ts | 148 +++++++++++++++++++++++++---------- 1 file changed, 106 insertions(+), 42 deletions(-) diff --git a/packages/core/src/summary.ts b/packages/core/src/summary.ts index 470655a0f2..82858748ef 100644 --- a/packages/core/src/summary.ts +++ b/packages/core/src/summary.ts @@ -1,8 +1,27 @@ -import * as fs from 'fs' -import {promisify} from 'util' +import {constants, promises} from 'fs' +const {access, appendFile, writeFile} = promises -const exists = promisify(fs.exists) -const appendFile = promisify(fs.appendFile) +export interface TableCell { + /** + * Cell content + */ + data: string + /** + * Render cell as header + * (optional) default: false + */ + header?: boolean + /** + * Number of columns the cell extends + * (optional) default: '1' + */ + colspan?: string + /** + * Number of rows the cell extends + * (optional) default: '1' + */ + rowspan?: string +} export class MarkdownSummary { static ENV_VAR = 'GITHUB_STEP_SUMMARY' @@ -24,24 +43,56 @@ export class MarkdownSummary { `Unable to find environment variable for ${MarkdownSummary.ENV_VAR}` ) } - if (!(await exists(filePath))) { - throw new Error(`Missing summary file at path: ${filePath}`) + + try { + await access(filePath, constants.R_OK | constants.W_OK) + } catch { + throw new Error(`Unable to access summary file: ${filePath}`) } return filePath } /** - * Writes any text in the buffer to the summary file + * Wraps content in an html tag, adding any HTML attributes + * + * @param tag HTML tag to wrap + * @param content content within the tag + * @param attrs key value list of html attributes to add + */ + private wrap( + tag: string, + content: string, + attrs: {[key: string]: string} = {} + ): string { + const htmlAttrs = Object.entries(attrs) + .map(([key, value]) => `${key}="${value}"`) + .join(' ') + + return `<${tag}${htmlAttrs && htmlAttrs.padStart(1)}>${content}` + } + + /** + * Writes text in the buffer to the summary buffer file, will append by default + * + * @param {boolean} [overwrite=false] (optional) replace existing content in summary file with buffer contents, default: false * * @returns {MarkdownSummary} markdown summary instance */ - async write(): Promise { + async write(overwrite = false): Promise { const filePath = await this.filePath() - await appendFile(filePath, this.buffer, {encoding: 'utf8'}) - this.clear() + const writeFunc = overwrite ? writeFile : appendFile + await writeFunc(filePath, this.buffer, {encoding: 'utf8'}) + return this.clearBuffer() + } - return this + /** + * If the summary buffer is empty + * + * @returns {boolen} true if the buffer is empty + */ + isEmptyBuffer(): boolean { + return this.buffer.length === 0 } /** @@ -49,13 +100,13 @@ export class MarkdownSummary { * * @returns {MarkdownSummary} markdown summary instance */ - clear(): MarkdownSummary { + clearBuffer(): MarkdownSummary { this.buffer = '' return this } /** - * Adds a newline to the summary + * Adds a newline to the summary buffer * * @returns {MarkdownSummary} markdown summary instance */ @@ -65,31 +116,35 @@ export class MarkdownSummary { } /** - * Adds text to the summary + * Adds raw text to the summary buffer + * * @param {string} text content to add - * @param {boolean} [newline=true] whether or not to add a newline + * * @returns {MarkdownSummary} markdown summary instance */ - add(text: string, newline = true): MarkdownSummary { + add(text: string): MarkdownSummary { this.buffer += text - return newline ? this.addNewline() : this + return this } /** - * Adds a markdown codeblock to the summary + * Adds an HTML codeblock to the summary buffer * * @param {string} code content to render within fenced code block - * @param {string} [language=''] optional language to syntax highlight code + * @param {string} lang (optional) language to syntax highlight code * * @returns {MarkdownSummary} markdown summary instance */ - addCodeBlock(code: string, language = ''): MarkdownSummary { - this.buffer += `\`\`\`${language}\n${code}\n\`\`\`\n` - return this + addCodeBlock(code: string, lang?: string): MarkdownSummary { + const attrs = { + ...(lang && {lang}) + } + const element = this.wrap('pre', this.wrap('code', code), attrs) + return this.add(element).addNewline() } /** - * Adds an HTML list to the summary + * Adds an HTML list to the summary buffer * * @param {string[]} items list of items to render * @param {boolean} [ordered=false] if the rendered list should be ordered or not (default: false) @@ -97,35 +152,44 @@ export class MarkdownSummary { * @returns {MarkdownSummary} markdown summary instance */ addList(items: string[], ordered = false): MarkdownSummary { - const listType = `${ordered ? 'o' : 'u'}l` - const listElems = items.map(e => `
  • ${e}
  • \n`).join() - this.buffer += `<${listType}>\n${listElems}\n` - return this + const tag = ordered ? 'ol' : 'ul' + const listItems = items.map(item => this.wrap('li', item)).join('') + const element = this.wrap(tag, listItems) + return this.add(element).addNewline() } /** - * Adds an HTML list to the summary + * Adds an HTML table to the summary buffer * - * @param {{[key: string]: any}[]} rows list of data rows - * @param {string[]} headers list of keys to use as headers + * @param {TableCell[]} rows table rows * * @returns {MarkdownSummary} markdown summary instance */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - addTable(rows: {[key: string]: any}[], headers: string[]): MarkdownSummary { - const headerElems = headers.map(h => `${h}\n`).join() - const rowElems = rows + addTable(rows: TableCell[][]): MarkdownSummary { + const tableBody = rows .map(row => { - const data = headers.map(h => `${row[h]}\n`).join() - return `${data}\n` + const cells = row + .map(({header, data, colspan, rowspan}) => { + const tag = header ? 'th' : 'td' + const attrs = { + ...(colspan && {colspan}), + ...(rowspan && {rowspan}) + } + + return this.wrap(tag, data, attrs) + }) + .join('') + + return this.wrap('tr', cells) }) - .join() - this.buffer += `\n${headerElems}\n${rowElems}
    \n` - return this + .join('') + + const element = this.wrap('table', tableBody) + return this.add(element).addNewline() } /** - * Adds a collapsable HTML details element to the summary + * Adds a collapsable HTML details element to the summary buffer * * @param {string} label text for the closed state * @param {string} content collapsable content @@ -133,7 +197,7 @@ export class MarkdownSummary { * @returns {MarkdownSummary} markdown summary instance */ addDetails(label: string, content: string): MarkdownSummary { - this.buffer += `
    ${label}\n\n${content}
    \n` - return this + const element = this.wrap('details', this.wrap('summary', label) + content) + return this.add(element).addNewline() } } From ac58d176bab458d9a3fe6fb0c339c87ea0bbbdb5 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Tue, 1 Mar 2022 20:55:43 -0500 Subject: [PATCH 04/17] '\n' -> os.EOL --- packages/core/src/summary.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/src/summary.ts b/packages/core/src/summary.ts index 82858748ef..b37b961a41 100644 --- a/packages/core/src/summary.ts +++ b/packages/core/src/summary.ts @@ -1,3 +1,4 @@ +import {EOL} from 'os' import {constants, promises} from 'fs' const {access, appendFile, writeFile} = promises @@ -106,25 +107,24 @@ export class MarkdownSummary { } /** - * Adds a newline to the summary buffer + * Adds raw text to the summary buffer + * + * @param {string} text content to add * * @returns {MarkdownSummary} markdown summary instance */ - addNewline(): MarkdownSummary { - this.buffer += '\n' + add(text: string): MarkdownSummary { + this.buffer += text return this } /** - * Adds raw text to the summary buffer - * - * @param {string} text content to add + * Adds the operating system-specific end-of-line marker to the buffer * * @returns {MarkdownSummary} markdown summary instance */ - add(text: string): MarkdownSummary { - this.buffer += text - return this + addEOL(): MarkdownSummary { + return this.add(EOL) } /** @@ -140,7 +140,7 @@ export class MarkdownSummary { ...(lang && {lang}) } const element = this.wrap('pre', this.wrap('code', code), attrs) - return this.add(element).addNewline() + return this.add(element).addEOL() } /** @@ -155,7 +155,7 @@ export class MarkdownSummary { const tag = ordered ? 'ol' : 'ul' const listItems = items.map(item => this.wrap('li', item)).join('') const element = this.wrap(tag, listItems) - return this.add(element).addNewline() + return this.add(element).addEOL() } /** @@ -185,7 +185,7 @@ export class MarkdownSummary { .join('') const element = this.wrap('table', tableBody) - return this.add(element).addNewline() + return this.add(element).addEOL() } /** @@ -198,6 +198,6 @@ export class MarkdownSummary { */ addDetails(label: string, content: string): MarkdownSummary { const element = this.wrap('details', this.wrap('summary', label) + content) - return this.add(element).addNewline() + return this.add(element).addEOL() } } From c42d30607b7e250eee9412b024cc6f2089a5fd76 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Tue, 1 Mar 2022 21:14:58 -0500 Subject: [PATCH 05/17] add more summary elements, clean up jsdoc --- packages/core/src/summary.ts | 89 ++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/packages/core/src/summary.ts b/packages/core/src/summary.ts index b37b961a41..afbd308823 100644 --- a/packages/core/src/summary.ts +++ b/packages/core/src/summary.ts @@ -26,10 +26,10 @@ export interface TableCell { export class MarkdownSummary { static ENV_VAR = 'GITHUB_STEP_SUMMARY' - private buffer: string + private _buffer: string constructor() { - this.buffer = '' + this._buffer = '' } /** @@ -55,16 +55,18 @@ export class MarkdownSummary { } /** - * Wraps content in an html tag, adding any HTML attributes + * Wraps content in an HTML tag, adding any HTML attributes * - * @param tag HTML tag to wrap - * @param content content within the tag - * @param attrs key value list of html attributes to add + * @param {string} tag HTML tag to wrap + * @param {string} content content within the tag + * @param {[attribute: string]: string} attrs key-value list of HTML attributes to add + * + * @returns {string} content wrapped in HTML element */ private wrap( tag: string, content: string, - attrs: {[key: string]: string} = {} + attrs: {[attribute: string]: string} = {} ): string { const htmlAttrs = Object.entries(attrs) .map(([key, value]) => `${key}="${value}"`) @@ -78,13 +80,22 @@ export class MarkdownSummary { * * @param {boolean} [overwrite=false] (optional) replace existing content in summary file with buffer contents, default: false * - * @returns {MarkdownSummary} markdown summary instance + * @returns {Promise} markdown summary instance */ async write(overwrite = false): Promise { const filePath = await this.filePath() const writeFunc = overwrite ? writeFile : appendFile - await writeFunc(filePath, this.buffer, {encoding: 'utf8'}) - return this.clearBuffer() + await writeFunc(filePath, this._buffer, {encoding: 'utf8'}) + return this.emptyBuffer() + } + + /** + * Returns the current summary buffer as a string + * + * @returns {string} string of summary buffer + */ + stringify(): string { + return this._buffer } /** @@ -93,7 +104,7 @@ export class MarkdownSummary { * @returns {boolen} true if the buffer is empty */ isEmptyBuffer(): boolean { - return this.buffer.length === 0 + return this._buffer.length === 0 } /** @@ -101,8 +112,8 @@ export class MarkdownSummary { * * @returns {MarkdownSummary} markdown summary instance */ - clearBuffer(): MarkdownSummary { - this.buffer = '' + emptyBuffer(): MarkdownSummary { + this._buffer = '' return this } @@ -114,7 +125,7 @@ export class MarkdownSummary { * @returns {MarkdownSummary} markdown summary instance */ add(text: string): MarkdownSummary { - this.buffer += text + this._buffer += text return this } @@ -200,4 +211,54 @@ export class MarkdownSummary { const element = this.wrap('details', this.wrap('summary', label) + content) return this.add(element).addEOL() } + + /** + * Adds an HTML image tag to the summary buffer + * + * @param {string} src path to the image you to embed + * @param {string} alt text description of the image + * + * @returns {MarkdownSummary} markdown summary instance + */ + addImage(src: string, alt: string): MarkdownSummary { + const element = this.wrap('img', '', {src, alt}) + return this.add(element).addEOL() + } + + /** + * Adds an HTML section heading element + * + * @param {string} text path to the image you to embed + * @param {number} [level=1] (optional) the heading level, default: 1 + * + * @returns {MarkdownSummary} markdown summary instance + */ + addHeading(text: string, n = 1): MarkdownSummary { + const tag = [1, 2, 3, 4, 5, 6].includes(n) ? `h${n}` : 'h1' + const element = this.wrap(tag, text) + return this.add(element).addEOL() + } + + /** + * Adds an HTML thematic break (
    ) to the summary buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addSeparator(): MarkdownSummary { + const element = this.wrap('hr', '') + return this.add(element).addEOL() + } + + /** + * Adds an HTML blockquote to the summary buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addQuote(text: string, cite?: string): MarkdownSummary { + const attrs = { + ...(cite && {cite}) + } + const element = this.wrap('blockquote', text, attrs) + return this.add(element).addEOL() + } } From 7d95d2cec91a02511f6dfec20ea3efe448521977 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Tue, 1 Mar 2022 21:16:35 -0500 Subject: [PATCH 06/17] summary.ts -> markdown-summary.ts --- packages/core/src/{summary.ts => markdown-summary.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/core/src/{summary.ts => markdown-summary.ts} (100%) diff --git a/packages/core/src/summary.ts b/packages/core/src/markdown-summary.ts similarity index 100% rename from packages/core/src/summary.ts rename to packages/core/src/markdown-summary.ts From 0fc0befe2413381d6cbf075fc980744e28b70895 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Tue, 1 Mar 2022 21:32:26 -0500 Subject: [PATCH 07/17] export markdownSummary singleton from core --- packages/core/src/core.ts | 5 +++++ packages/core/src/markdown-summary.ts | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index fafe3e6cf9..8a9170ca71 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -359,3 +359,8 @@ export function getState(name: string): string { export async function getIDToken(aud?: string): Promise { return await OidcClient.getIDToken(aud) } + +/** + * Markdown summary exports + */ +export {markdownSummary} from './markdown-summary' diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index afbd308823..66d528417c 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -2,7 +2,7 @@ import {EOL} from 'os' import {constants, promises} from 'fs' const {access, appendFile, writeFile} = promises -export interface TableCell { +export interface SummaryTableCell { /** * Cell content */ @@ -24,7 +24,7 @@ export interface TableCell { rowspan?: string } -export class MarkdownSummary { +class MarkdownSummary { static ENV_VAR = 'GITHUB_STEP_SUMMARY' private _buffer: string @@ -41,7 +41,7 @@ export class MarkdownSummary { const filePath = process.env[MarkdownSummary.ENV_VAR] if (!filePath) { throw new Error( - `Unable to find environment variable for ${MarkdownSummary.ENV_VAR}` + `Unable to find environment variable for $${MarkdownSummary.ENV_VAR}` ) } @@ -172,11 +172,11 @@ export class MarkdownSummary { /** * Adds an HTML table to the summary buffer * - * @param {TableCell[]} rows table rows + * @param {SummaryTableCell[]} rows table rows * * @returns {MarkdownSummary} markdown summary instance */ - addTable(rows: TableCell[][]): MarkdownSummary { + addTable(rows: SummaryTableCell[][]): MarkdownSummary { const tableBody = rows .map(row => { const cells = row @@ -262,3 +262,6 @@ export class MarkdownSummary { return this.add(element).addEOL() } } + +// singleton export +export const markdownSummary = new MarkdownSummary() From 70a01b86d399440ee381b7de16e90c281f423b3f Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 2 Mar 2022 00:57:46 -0500 Subject: [PATCH 08/17] summary: self closing tags, additional img attrs & minor fixes --- packages/core/src/markdown-summary.ts | 87 +++++++++++++++++++++------ 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index 66d528417c..461fef0ba6 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -2,6 +2,9 @@ import {EOL} from 'os' import {constants, promises} from 'fs' const {access, appendFile, writeFile} = promises +export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' + +export type SummaryTableRow = (SummaryTableCell | string)[] export interface SummaryTableCell { /** * Cell content @@ -24,8 +27,20 @@ export interface SummaryTableCell { rowspan?: string } +export interface SummaryImageOptions { + /** + * The width of the image in pixels. Must be an integer without a unit. + * (optional) + */ + width?: string + /** + * The height of the image in pixels. Must be an integer without a unit. + * (optional) + */ + height?: string +} + class MarkdownSummary { - static ENV_VAR = 'GITHUB_STEP_SUMMARY' private _buffer: string constructor() { @@ -38,10 +53,10 @@ class MarkdownSummary { * @returns step summary file path */ private async filePath(): Promise { - const filePath = process.env[MarkdownSummary.ENV_VAR] + const filePath = process.env[SUMMARY_ENV_VAR] if (!filePath) { throw new Error( - `Unable to find environment variable for $${MarkdownSummary.ENV_VAR}` + `Unable to find environment variable for $${SUMMARY_ENV_VAR}` ) } @@ -58,21 +73,25 @@ class MarkdownSummary { * Wraps content in an HTML tag, adding any HTML attributes * * @param {string} tag HTML tag to wrap - * @param {string} content content within the tag + * @param {string | null} content content within the tag * @param {[attribute: string]: string} attrs key-value list of HTML attributes to add * * @returns {string} content wrapped in HTML element */ private wrap( tag: string, - content: string, + content: string | null, attrs: {[attribute: string]: string} = {} ): string { const htmlAttrs = Object.entries(attrs) - .map(([key, value]) => `${key}="${value}"`) - .join(' ') + .map(([key, value]) => ` ${key}="${value}"`) + .join('') + + if (!content) { + return `<${tag}${htmlAttrs}>` + } - return `<${tag}${htmlAttrs && htmlAttrs.padStart(1)}>${content}` + return `<${tag}${htmlAttrs}>${content}` } /** @@ -176,11 +195,16 @@ class MarkdownSummary { * * @returns {MarkdownSummary} markdown summary instance */ - addTable(rows: SummaryTableCell[][]): MarkdownSummary { + addTable(rows: SummaryTableRow[]): MarkdownSummary { const tableBody = rows .map(row => { const cells = row - .map(({header, data, colspan, rowspan}) => { + .map(cell => { + if (typeof cell === 'string') { + return this.wrap('td', cell) + } + + const {header, data, colspan, rowspan} = cell const tag = header ? 'th' : 'td' const attrs = { ...(colspan && {colspan}), @@ -217,25 +241,39 @@ class MarkdownSummary { * * @param {string} src path to the image you to embed * @param {string} alt text description of the image + * @param {SummaryImageOptions} options addition image attributes * * @returns {MarkdownSummary} markdown summary instance */ - addImage(src: string, alt: string): MarkdownSummary { - const element = this.wrap('img', '', {src, alt}) + addImage( + src: string, + alt: string, + options?: SummaryImageOptions + ): MarkdownSummary { + const {width, height} = options || {} + const attrs = { + ...(width && {width}), + ...(height && {height}) + } + + const element = this.wrap('img', null, {src, alt, ...attrs}) return this.add(element).addEOL() } /** * Adds an HTML section heading element * - * @param {string} text path to the image you to embed - * @param {number} [level=1] (optional) the heading level, default: 1 + * @param {string} text heading text + * @param {number | string} [level=1] (optional) the heading level, default: 1 * * @returns {MarkdownSummary} markdown summary instance */ - addHeading(text: string, n = 1): MarkdownSummary { - const tag = [1, 2, 3, 4, 5, 6].includes(n) ? `h${n}` : 'h1' - const element = this.wrap(tag, text) + addHeading(text: string, level?: number | string): MarkdownSummary { + const tag = `h${level}` + const allowedTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag) + ? tag + : 'h1' + const element = this.wrap(allowedTag, text) return this.add(element).addEOL() } @@ -245,13 +283,26 @@ class MarkdownSummary { * @returns {MarkdownSummary} markdown summary instance */ addSeparator(): MarkdownSummary { - const element = this.wrap('hr', '') + const element = this.wrap('hr', null) + return this.add(element).addEOL() + } + + /** + * Adds an HTML line break (
    ) to the summary buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addBreak(): MarkdownSummary { + const element = this.wrap('br', null) return this.add(element).addEOL() } /** * Adds an HTML blockquote to the summary buffer * + * @param {string} text quote text + * @param {string} cite (optional) citation url + * * @returns {MarkdownSummary} markdown summary instance */ addQuote(text: string, cite?: string): MarkdownSummary { From ab2b23c50da95cff77b272036cc941b04bc380a3 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 2 Mar 2022 00:58:18 -0500 Subject: [PATCH 09/17] summary: add tests --- .../core/__tests__/markdown-summary.test.ts | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 packages/core/__tests__/markdown-summary.test.ts diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts new file mode 100644 index 0000000000..efb317f0e9 --- /dev/null +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -0,0 +1,240 @@ +import * as fs from 'fs' +import * as os from 'os' +import path from 'path' +import {markdownSummary, SUMMARY_ENV_VAR} from '../src/markdown-summary' + +const testFilePath = path.join(__dirname, 'test', 'test-summary.md') + +async function assertSummary(expected: string): Promise { + const file = await fs.promises.readFile(testFilePath, {encoding: 'utf8'}) + expect(file).toEqual(expected) +} + +const fixtures = { + text: 'hello world 🌎', + code: `func fork() { + for { + go fork() + } +}`, + list: ['foo', 'bar', 'baz', '💣'], + table: [ + [ + { + data: 'foo', + header: true + }, + { + data: 'bar', + header: true + }, + { + data: 'baz', + header: true + }, + { + data: 'tall', + rowspan: '3' + } + ], + ['one', 'two', 'three'], + [ + { + data: 'wide', + colspan: '3' + } + ] + ], + details: { + label: 'open me', + content: '🎉 surprise' + }, + img: { + src: 'https://github.com/actions.png', + alt: 'actions logo', + options: { + width: '32', + height: '32' + } + }, + quote: { + text: 'Where the world builds software', + cite: 'https://github.com/about' + } +} + +describe('@actions/core/src/markdown-summary', () => { + beforeAll(() => { + process.env[SUMMARY_ENV_VAR] = testFilePath + }) + + beforeEach(async () => { + await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) + }) + + afterAll(async () => { + await fs.promises.unlink(testFilePath) + }) + + it('appends text to summary file', async () => { + await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'}) + await markdownSummary.add(fixtures.text).write() + await assertSummary(`# ${fixtures.text}`) + }) + + it('overwrites text to summary file', async () => { + await fs.promises.writeFile(testFilePath, 'overwrite', {encoding: 'utf8'}) + await markdownSummary.add(fixtures.text).write(true) + await assertSummary(fixtures.text) + }) + + it('chains appends text to summary file', async () => { + await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) + await markdownSummary + .add(fixtures.text) + .add(fixtures.text) + .add(fixtures.text) + .write() + await assertSummary([fixtures.text, fixtures.text, fixtures.text].join('')) + }) + + it('empties buffer after write', async () => { + await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) + await markdownSummary.add(fixtures.text).write() + await assertSummary(fixtures.text) + expect(markdownSummary.isEmptyBuffer()).toBe(true) + }) + + it('returns summary buffer as string', () => { + markdownSummary.add(fixtures.text) + expect(markdownSummary.stringify()).toEqual(fixtures.text) + }) + + it('return correct values for isEmptyBuffer', () => { + markdownSummary.add(fixtures.text) + expect(markdownSummary.isEmptyBuffer()).toBe(false) + + markdownSummary.emptyBuffer() + expect(markdownSummary.isEmptyBuffer()).toBe(true) + }) + + it('adds EOL', async () => { + await markdownSummary + .add(fixtures.text) + .addEOL() + .write() + await assertSummary(fixtures.text + os.EOL) + }) + + it('adds a code block without language', async () => { + await markdownSummary.addCodeBlock(fixtures.code).write() + const expected = `
    func fork() {\n  for {\n    go fork()\n  }\n}
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds a code block with a language', async () => { + await markdownSummary.addCodeBlock(fixtures.code, 'go').write() + const expected = `
    func fork() {\n  for {\n    go fork()\n  }\n}
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds an unordered list', async () => { + await markdownSummary.addList(fixtures.list).write() + const expected = `
    • foo
    • bar
    • baz
    • 💣
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds an ordered list', async () => { + await markdownSummary.addList(fixtures.list, true).write() + const expected = `
    1. foo
    2. bar
    3. baz
    4. 💣
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds a table', async () => { + await markdownSummary.addTable(fixtures.table).write() + const expected = `
    foobarbaztall
    onetwothree
    wide
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds a details element', async () => { + await markdownSummary + .addDetails(fixtures.details.label, fixtures.details.content) + .write() + const expected = `
    open me🎉 surprise
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds an image with alt text', async () => { + await markdownSummary.addImage(fixtures.img.src, fixtures.img.alt).write() + const expected = `actions logo${os.EOL}` + await assertSummary(expected) + }) + + it('adds an image with custom dimensions', async () => { + await markdownSummary + .addImage(fixtures.img.src, fixtures.img.alt, fixtures.img.options) + .write() + const expected = `actions logo${os.EOL}` + await assertSummary(expected) + }) + + it('adds an image with custom dimensions', async () => { + await markdownSummary + .addImage(fixtures.img.src, fixtures.img.alt, fixtures.img.options) + .write() + const expected = `actions logo${os.EOL}` + await assertSummary(expected) + }) + + it('adds headings h1...h6', async () => { + for (const i of [1, 2, 3, 4, 5, 6]) { + markdownSummary.addHeading('heading', i) + } + await markdownSummary.write() + const expected = `

    heading

    ${os.EOL}

    heading

    ${os.EOL}

    heading

    ${os.EOL}

    heading

    ${os.EOL}
    heading
    ${os.EOL}
    heading
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds h1 if heading level not specified', async () => { + await markdownSummary.addHeading('heading').write() + const expected = `

    heading

    ${os.EOL}` + await assertSummary(expected) + }) + + it('uses h1 if heading level is garbage or out of range', async () => { + await markdownSummary + .addHeading('heading', 'foobar') + .addHeading('heading', 1337) + .addHeading('heading', -1) + .addHeading('heading', Infinity) + .write() + const expected = `

    heading

    ${os.EOL}

    heading

    ${os.EOL}

    heading

    ${os.EOL}

    heading

    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds a separator', async () => { + await markdownSummary.addSeparator().write() + const expected = `
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds a break', async () => { + await markdownSummary.addBreak().write() + const expected = `
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds a quote', async () => { + await markdownSummary.addQuote(fixtures.quote.text).write() + const expected = `
    Where the world builds software
    ${os.EOL}` + await assertSummary(expected) + }) + + it('adds a quote with citation', async () => { + await markdownSummary + .addQuote(fixtures.quote.text, fixtures.quote.cite) + .write() + const expected = `
    Where the world builds software
    ${os.EOL}` + await assertSummary(expected) + }) +}) From 302a5b31d819896c86b269e0b53de7cf1fa88322 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 2 Mar 2022 12:10:01 -0500 Subject: [PATCH 10/17] summary: add link/anchor element --- packages/core/__tests__/markdown-summary.test.ts | 12 ++++++++++++ packages/core/src/markdown-summary.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts index efb317f0e9..9b6ac03716 100644 --- a/packages/core/__tests__/markdown-summary.test.ts +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -60,6 +60,10 @@ const fixtures = { quote: { text: 'Where the world builds software', cite: 'https://github.com/about' + }, + link: { + text: 'GitHub', + href: 'https://github.com/' } } @@ -237,4 +241,12 @@ describe('@actions/core/src/markdown-summary', () => { const expected = `
    Where the world builds software
    ${os.EOL}` await assertSummary(expected) }) + + it('adds a link with href', async () => { + await markdownSummary + .addLink(fixtures.link.text, fixtures.link.href) + .write() + const expected = `GitHub${os.EOL}` + await assertSummary(expected) + }) }) diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index 461fef0ba6..352767cafd 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -5,6 +5,7 @@ const {access, appendFile, writeFile} = promises export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' export type SummaryTableRow = (SummaryTableCell | string)[] + export interface SummaryTableCell { /** * Cell content @@ -312,6 +313,19 @@ class MarkdownSummary { const element = this.wrap('blockquote', text, attrs) return this.add(element).addEOL() } + + /** + * Adds an HTML anchor tag to the summary buffer + * + * @param {string} text link text/content + * @param {string} href hyperlink + * + * @returns {MarkdownSummary} markdown summary instance + */ + addLink(text: string, href: string): MarkdownSummary { + const element = this.wrap('a', text, {href}) + return this.add(element).addEOL() + } } // singleton export From ec5c955c0ab6204db8bc6a3c4e0a002bf96dae08 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 2 Mar 2022 23:43:51 -0500 Subject: [PATCH 11/17] summary: additional check for max size limit --- .../core/__tests__/markdown-summary.test.ts | 33 ++++++++-- packages/core/src/markdown-summary.ts | 60 ++++++++++++++++--- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts index 9b6ac03716..6b56a98c21 100644 --- a/packages/core/__tests__/markdown-summary.test.ts +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -1,7 +1,11 @@ import * as fs from 'fs' import * as os from 'os' import path from 'path' -import {markdownSummary, SUMMARY_ENV_VAR} from '../src/markdown-summary' +import { + markdownSummary, + SUMMARY_ENV_VAR, + SUMMARY_LIMIT_BYTES +} from '../src/markdown-summary' const testFilePath = path.join(__dirname, 'test', 'test-summary.md') @@ -68,18 +72,37 @@ const fixtures = { } describe('@actions/core/src/markdown-summary', () => { - beforeAll(() => { - process.env[SUMMARY_ENV_VAR] = testFilePath - }) - beforeEach(async () => { + process.env[SUMMARY_ENV_VAR] = testFilePath await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) + markdownSummary.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 = markdownSummary.add(fixtures.text).write() + + await expect(write).rejects.toThrow() + }) + + it('throws if summary file does not exist', async () => { + await fs.promises.unlink(testFilePath) + const write = markdownSummary.add(fixtures.text).write() + + await expect(write).rejects.toThrow() + }) + + it('throws if write will exceed file limit', async () => { + const aaa = 'a'.repeat(SUMMARY_LIMIT_BYTES + 1) + const write = markdownSummary.add(aaa).write() + + await expect(write).rejects.toThrow() + }) + it('appends text to summary file', async () => { await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'}) await markdownSummary.add(fixtures.text).write() diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index 352767cafd..c63b9773a9 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -1,7 +1,8 @@ import {EOL} from 'os' import {constants, promises} from 'fs' -const {access, appendFile, writeFile} = promises +const {access, appendFile, stat, writeFile} = promises +export const SUMMARY_LIMIT_BYTES = 128_000 export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' export type SummaryTableRow = (SummaryTableCell | string)[] @@ -43,31 +44,62 @@ export interface SummaryImageOptions { class MarkdownSummary { private _buffer: string + private _filePath?: string constructor() { this._buffer = '' } /** - * Finds the summary file path from the environment, rejects if not found + * Finds the summary file path from the environment, rejects if env var is not found or file does not exist + * Also checks r/w permissions. * * @returns step summary file path */ private async filePath(): Promise { - const filePath = process.env[SUMMARY_ENV_VAR] - if (!filePath) { + if (this._filePath) { + return this._filePath + } + + const pathFromEnv = process.env[SUMMARY_ENV_VAR] + if (!pathFromEnv) { throw new Error( - `Unable to find environment variable for $${SUMMARY_ENV_VAR}` + `Unable to find environment variable for $${SUMMARY_ENV_VAR}. Check if your runtime environment supports markdown summaries.` ) } try { - await access(filePath, constants.R_OK | constants.W_OK) + await access(pathFromEnv, constants.R_OK | constants.W_OK) } catch { - throw new Error(`Unable to access summary file: ${filePath}`) + throw new Error( + `Unable to access summary file: '${pathFromEnv}'. Check if the file has correct read/write permissions.` + ) + } + + this._filePath = pathFromEnv + return this._filePath + } + + /** + * Checks if the write of the current buffer will exceed the job summary upload limit + * + * @param {boolean} overwrite if the operation is overwrite (otherwise it's append) + * + * @returns {Promise} whether or not the file will exceed the limit + */ + private async willExceedLimit(overwrite: boolean): Promise { + let expectedSize = 0 + if (!overwrite) { + // if appending, we need to check the current size of the summary file + const filePath = await this.filePath() + const {size} = await stat(filePath) + expectedSize += size } - return filePath + const bufferLen = Buffer.byteLength(this._buffer, 'utf8') + expectedSize += bufferLen + + return expectedSize > SUMMARY_LIMIT_BYTES } /** @@ -96,7 +128,8 @@ class MarkdownSummary { } /** - * Writes text in the buffer to the summary buffer file, will append by default + * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. + * Checks if resulting file size > SUMMARY_LIMIT_BYTES, will throw and empty buffer * * @param {boolean} [overwrite=false] (optional) replace existing content in summary file with buffer contents, default: false * @@ -104,6 +137,15 @@ class MarkdownSummary { */ async write(overwrite = false): Promise { const filePath = await this.filePath() + + if (await this.willExceedLimit(overwrite)) { + this.emptyBuffer() + const limitK = SUMMARY_LIMIT_BYTES / 1000 + throw new Error( + `Aborting write to summary file. File size would exceed limit of ${limitK}K.` + ) + } + const writeFunc = overwrite ? writeFile : appendFile await writeFunc(filePath, this._buffer, {encoding: 'utf8'}) return this.emptyBuffer() From d27bf857e640d3621b7dd6a4b0910b01913a1e4b Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 2 Mar 2022 23:49:17 -0500 Subject: [PATCH 12/17] add -> addRaw --- .../core/__tests__/markdown-summary.test.ts | 30 +++++++++++------- packages/core/src/markdown-summary.ts | 31 ++++++++++--------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts index 6b56a98c21..7d7b12e7fa 100644 --- a/packages/core/__tests__/markdown-summary.test.ts +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -84,61 +84,67 @@ describe('@actions/core/src/markdown-summary', () => { it('throws if summary env var is undefined', async () => { process.env[SUMMARY_ENV_VAR] = undefined - const write = markdownSummary.add(fixtures.text).write() + const write = markdownSummary.addRaw(fixtures.text).write() await expect(write).rejects.toThrow() }) it('throws if summary file does not exist', async () => { await fs.promises.unlink(testFilePath) - const write = markdownSummary.add(fixtures.text).write() + const write = markdownSummary.addRaw(fixtures.text).write() await expect(write).rejects.toThrow() }) it('throws if write will exceed file limit', async () => { const aaa = 'a'.repeat(SUMMARY_LIMIT_BYTES + 1) - const write = markdownSummary.add(aaa).write() + const write = markdownSummary.addRaw(aaa).write() await expect(write).rejects.toThrow() }) it('appends text to summary file', async () => { await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'}) - await markdownSummary.add(fixtures.text).write() + await markdownSummary.addRaw(fixtures.text).write() await assertSummary(`# ${fixtures.text}`) }) it('overwrites text to summary file', async () => { await fs.promises.writeFile(testFilePath, 'overwrite', {encoding: 'utf8'}) - await markdownSummary.add(fixtures.text).write(true) + await markdownSummary.addRaw(fixtures.text).write(true) await assertSummary(fixtures.text) }) + it('appends text with EOL to summary file', async () => { + await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'}) + await markdownSummary.addRaw(fixtures.text, true).write() + await assertSummary(`# ${fixtures.text}${os.EOL}`) + }) + it('chains appends text to summary file', async () => { await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) await markdownSummary - .add(fixtures.text) - .add(fixtures.text) - .add(fixtures.text) + .addRaw(fixtures.text) + .addRaw(fixtures.text) + .addRaw(fixtures.text) .write() await assertSummary([fixtures.text, fixtures.text, fixtures.text].join('')) }) it('empties buffer after write', async () => { await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) - await markdownSummary.add(fixtures.text).write() + await markdownSummary.addRaw(fixtures.text).write() await assertSummary(fixtures.text) expect(markdownSummary.isEmptyBuffer()).toBe(true) }) it('returns summary buffer as string', () => { - markdownSummary.add(fixtures.text) + markdownSummary.addRaw(fixtures.text) expect(markdownSummary.stringify()).toEqual(fixtures.text) }) it('return correct values for isEmptyBuffer', () => { - markdownSummary.add(fixtures.text) + markdownSummary.addRaw(fixtures.text) expect(markdownSummary.isEmptyBuffer()).toBe(false) markdownSummary.emptyBuffer() @@ -147,7 +153,7 @@ describe('@actions/core/src/markdown-summary', () => { it('adds EOL', async () => { await markdownSummary - .add(fixtures.text) + .addRaw(fixtures.text) .addEOL() .write() await assertSummary(fixtures.text + os.EOL) diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index c63b9773a9..76e994eb12 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -183,12 +183,13 @@ class MarkdownSummary { * Adds raw text to the summary buffer * * @param {string} text content to add + * @param {boolean} [addEOL=false] (optional) append an EOL to the raw text (default: false) * * @returns {MarkdownSummary} markdown summary instance */ - add(text: string): MarkdownSummary { + addRaw(text: string, addEOL = false): MarkdownSummary { this._buffer += text - return this + return addEOL ? this.addEOL() : this } /** @@ -197,7 +198,7 @@ class MarkdownSummary { * @returns {MarkdownSummary} markdown summary instance */ addEOL(): MarkdownSummary { - return this.add(EOL) + return this.addRaw(EOL) } /** @@ -213,14 +214,14 @@ class MarkdownSummary { ...(lang && {lang}) } const element = this.wrap('pre', this.wrap('code', code), attrs) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** * Adds an HTML list to the summary buffer * * @param {string[]} items list of items to render - * @param {boolean} [ordered=false] if the rendered list should be ordered or not (default: false) + * @param {boolean} [ordered=false] (optional) if the rendered list should be ordered or not (default: false) * * @returns {MarkdownSummary} markdown summary instance */ @@ -228,7 +229,7 @@ class MarkdownSummary { const tag = ordered ? 'ol' : 'ul' const listItems = items.map(item => this.wrap('li', item)).join('') const element = this.wrap(tag, listItems) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -263,7 +264,7 @@ class MarkdownSummary { .join('') const element = this.wrap('table', tableBody) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -276,7 +277,7 @@ class MarkdownSummary { */ addDetails(label: string, content: string): MarkdownSummary { const element = this.wrap('details', this.wrap('summary', label) + content) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -284,7 +285,7 @@ class MarkdownSummary { * * @param {string} src path to the image you to embed * @param {string} alt text description of the image - * @param {SummaryImageOptions} options addition image attributes + * @param {SummaryImageOptions} options (optional) addition image attributes * * @returns {MarkdownSummary} markdown summary instance */ @@ -300,7 +301,7 @@ class MarkdownSummary { } const element = this.wrap('img', null, {src, alt, ...attrs}) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -317,7 +318,7 @@ class MarkdownSummary { ? tag : 'h1' const element = this.wrap(allowedTag, text) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -327,7 +328,7 @@ class MarkdownSummary { */ addSeparator(): MarkdownSummary { const element = this.wrap('hr', null) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -337,7 +338,7 @@ class MarkdownSummary { */ addBreak(): MarkdownSummary { const element = this.wrap('br', null) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -353,7 +354,7 @@ class MarkdownSummary { ...(cite && {cite}) } const element = this.wrap('blockquote', text, attrs) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } /** @@ -366,7 +367,7 @@ class MarkdownSummary { */ addLink(text: string, href: string): MarkdownSummary { const element = this.wrap('a', text, {href}) - return this.add(element).addEOL() + return this.addRaw(element).addEOL() } } From 339dd63bec3d12ee3fbaa71cf8737db02cac1ef0 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 2 Mar 2022 23:56:30 -0500 Subject: [PATCH 13/17] summary: method to clear file and buffer --- packages/core/__tests__/markdown-summary.test.ts | 7 +++++++ packages/core/src/markdown-summary.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts index 7d7b12e7fa..00f4ad6c2b 100644 --- a/packages/core/__tests__/markdown-summary.test.ts +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -151,6 +151,13 @@ describe('@actions/core/src/markdown-summary', () => { expect(markdownSummary.isEmptyBuffer()).toBe(true) }) + it('clears a buffer and summary file', async () => { + await fs.promises.writeFile(testFilePath, 'content', {encoding: 'utf8'}) + await markdownSummary.clear() + await assertSummary('') + expect(markdownSummary.isEmptyBuffer()).toBe(true) + }) + it('adds EOL', async () => { await markdownSummary .addRaw(fixtures.text) diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index 76e994eb12..6a4ae9f98d 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -151,6 +151,15 @@ class MarkdownSummary { return this.emptyBuffer() } + /** + * Clears the summary buffer and wipes the summary file + * + * @returns {MarkdownSummary} markdown summary instance + */ + async clear(): Promise { + return this.emptyBuffer().write(true) + } + /** * Returns the current summary buffer as a string * @@ -170,7 +179,7 @@ class MarkdownSummary { } /** - * Clears the summary buffer without writing to summary file + * Resets the summary buffer without writing to summary file * * @returns {MarkdownSummary} markdown summary instance */ From 6295f5d25bb8417c8705fd29b31ce4f3bb743302 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Thu, 3 Mar 2022 11:46:32 -0500 Subject: [PATCH 14/17] summary: consistent kB usage and doc links --- packages/core/src/markdown-summary.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index 6a4ae9f98d..b2880e9012 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -2,8 +2,11 @@ import {EOL} from 'os' import {constants, promises} from 'fs' const {access, appendFile, stat, writeFile} = promises -export const SUMMARY_LIMIT_BYTES = 128_000 +// The runner & server will also block any upload greater than this size +export const SUMMARY_LIMIT_BYTES = 128 * 1024 export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' +export const SUMMARY_DOCS_URL = + 'https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary' export type SummaryTableRow = (SummaryTableCell | string)[] @@ -140,9 +143,9 @@ class MarkdownSummary { if (await this.willExceedLimit(overwrite)) { this.emptyBuffer() - const limitK = SUMMARY_LIMIT_BYTES / 1000 + const limitK = SUMMARY_LIMIT_BYTES / 1024 throw new Error( - `Aborting write to summary file. File size would exceed limit of ${limitK}K.` + `Aborting write to summary file. File size would exceed limit of ${limitK}k. For more information see: ${SUMMARY_DOCS_URL}` ) } From edee7cde32cf4a82dd0e7ecdcb7920a7e9481527 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Tue, 8 Mar 2022 16:37:20 -0500 Subject: [PATCH 15/17] feedback: add summary write options --- packages/core/__tests__/markdown-summary.test.ts | 2 +- packages/core/src/markdown-summary.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts index 00f4ad6c2b..08f19c91f3 100644 --- a/packages/core/__tests__/markdown-summary.test.ts +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -111,7 +111,7 @@ describe('@actions/core/src/markdown-summary', () => { it('overwrites text to summary file', async () => { await fs.promises.writeFile(testFilePath, 'overwrite', {encoding: 'utf8'}) - await markdownSummary.addRaw(fixtures.text).write(true) + await markdownSummary.addRaw(fixtures.text).write({overwrite: true}) await assertSummary(fixtures.text) }) diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index b2880e9012..5835a37c3d 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -45,6 +45,14 @@ export interface SummaryImageOptions { height?: string } +export interface SummaryWriteOptions { + /** + * Replace all existing content in summary file with buffer contents + * (optional) default: false + */ + overwrite?: boolean +} + class MarkdownSummary { private _buffer: string private _filePath?: string @@ -134,11 +142,12 @@ class MarkdownSummary { * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. * Checks if resulting file size > SUMMARY_LIMIT_BYTES, will throw and empty buffer * - * @param {boolean} [overwrite=false] (optional) replace existing content in summary file with buffer contents, default: false + * @param {SummaryWriteOptions | undefined} options (optional) options for write operation * * @returns {Promise} markdown summary instance */ - async write(overwrite = false): Promise { + async write(options?: SummaryWriteOptions): Promise { + const overwrite = !!options?.overwrite const filePath = await this.filePath() if (await this.willExceedLimit(overwrite)) { @@ -160,7 +169,7 @@ class MarkdownSummary { * @returns {MarkdownSummary} markdown summary instance */ async clear(): Promise { - return this.emptyBuffer().write(true) + return this.emptyBuffer().write({overwrite: true}) } /** From ed87cc6ce3d572bac352475bd0accf6e938fba07 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 20 Apr 2022 19:55:54 +0000 Subject: [PATCH 16/17] summary: increase limit to 1MiB --- packages/core/src/markdown-summary.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index 5835a37c3d..eebc88122f 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -3,7 +3,7 @@ import {constants, promises} from 'fs' const {access, appendFile, stat, writeFile} = promises // The runner & server will also block any upload greater than this size -export const SUMMARY_LIMIT_BYTES = 128 * 1024 +export const SUMMARY_LIMIT_BYTES = 1024 * 1024 // 1MiB export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' export const SUMMARY_DOCS_URL = 'https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary' @@ -154,7 +154,7 @@ class MarkdownSummary { this.emptyBuffer() const limitK = SUMMARY_LIMIT_BYTES / 1024 throw new Error( - `Aborting write to summary file. File size would exceed limit of ${limitK}k. For more information see: ${SUMMARY_DOCS_URL}` + `Aborting write to summary file. File size would exceed limit of ${limitK}KiB. For more information see: ${SUMMARY_DOCS_URL}` ) } From eef3e92175f5af99e5cbc5c09691a98ff2574e5c Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Wed, 20 Apr 2022 20:10:56 +0000 Subject: [PATCH 17/17] summary: remove limit validation in client --- .../core/__tests__/markdown-summary.test.ts | 13 +------ packages/core/src/markdown-summary.ts | 38 +------------------ 2 files changed, 3 insertions(+), 48 deletions(-) diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts index 08f19c91f3..d9b8ee5c47 100644 --- a/packages/core/__tests__/markdown-summary.test.ts +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -1,11 +1,7 @@ import * as fs from 'fs' import * as os from 'os' import path from 'path' -import { - markdownSummary, - SUMMARY_ENV_VAR, - SUMMARY_LIMIT_BYTES -} from '../src/markdown-summary' +import {markdownSummary, SUMMARY_ENV_VAR} from '../src/markdown-summary' const testFilePath = path.join(__dirname, 'test', 'test-summary.md') @@ -96,13 +92,6 @@ describe('@actions/core/src/markdown-summary', () => { await expect(write).rejects.toThrow() }) - it('throws if write will exceed file limit', async () => { - const aaa = 'a'.repeat(SUMMARY_LIMIT_BYTES + 1) - const write = markdownSummary.addRaw(aaa).write() - - await expect(write).rejects.toThrow() - }) - it('appends text to summary file', async () => { await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'}) await markdownSummary.addRaw(fixtures.text).write() diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts index eebc88122f..97d2d3cac1 100644 --- a/packages/core/src/markdown-summary.ts +++ b/packages/core/src/markdown-summary.ts @@ -1,9 +1,7 @@ import {EOL} from 'os' import {constants, promises} from 'fs' -const {access, appendFile, stat, writeFile} = promises +const {access, appendFile, writeFile} = promises -// The runner & server will also block any upload greater than this size -export const SUMMARY_LIMIT_BYTES = 1024 * 1024 // 1MiB export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' export const SUMMARY_DOCS_URL = 'https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary' @@ -91,28 +89,6 @@ class MarkdownSummary { return this._filePath } - /** - * Checks if the write of the current buffer will exceed the job summary upload limit - * - * @param {boolean} overwrite if the operation is overwrite (otherwise it's append) - * - * @returns {Promise} whether or not the file will exceed the limit - */ - private async willExceedLimit(overwrite: boolean): Promise { - let expectedSize = 0 - if (!overwrite) { - // if appending, we need to check the current size of the summary file - const filePath = await this.filePath() - const {size} = await stat(filePath) - expectedSize += size - } - - const bufferLen = Buffer.byteLength(this._buffer, 'utf8') - expectedSize += bufferLen - - return expectedSize > SUMMARY_LIMIT_BYTES - } - /** * Wraps content in an HTML tag, adding any HTML attributes * @@ -140,24 +116,14 @@ class MarkdownSummary { /** * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. - * Checks if resulting file size > SUMMARY_LIMIT_BYTES, will throw and empty buffer * - * @param {SummaryWriteOptions | undefined} options (optional) options for write operation + * @param {SummaryWriteOptions} [options] (optional) options for write operation * * @returns {Promise} markdown summary instance */ async write(options?: SummaryWriteOptions): Promise { const overwrite = !!options?.overwrite const filePath = await this.filePath() - - if (await this.willExceedLimit(overwrite)) { - this.emptyBuffer() - const limitK = SUMMARY_LIMIT_BYTES / 1024 - throw new Error( - `Aborting write to summary file. File size would exceed limit of ${limitK}KiB. For more information see: ${SUMMARY_DOCS_URL}` - ) - } - const writeFunc = overwrite ? writeFile : appendFile await writeFunc(filePath, this._buffer, {encoding: 'utf8'}) return this.emptyBuffer()