diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts new file mode 100644 index 0000000000..d9b8ee5c47 --- /dev/null +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -0,0 +1,277 @@ +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' + }, + link: { + text: 'GitHub', + href: 'https://github.com/' + } +} + +describe('@actions/core/src/markdown-summary', () => { + 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.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.addRaw(fixtures.text).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() + await assertSummary(`# ${fixtures.text}`) + }) + + it('overwrites text to summary file', async () => { + await fs.promises.writeFile(testFilePath, 'overwrite', {encoding: 'utf8'}) + await markdownSummary.addRaw(fixtures.text).write({overwrite: 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 + .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.addRaw(fixtures.text).write() + await assertSummary(fixtures.text) + expect(markdownSummary.isEmptyBuffer()).toBe(true) + }) + + it('returns summary buffer as string', () => { + markdownSummary.addRaw(fixtures.text) + expect(markdownSummary.stringify()).toEqual(fixtures.text) + }) + + it('return correct values for isEmptyBuffer', () => { + markdownSummary.addRaw(fixtures.text) + expect(markdownSummary.isEmptyBuffer()).toBe(false) + + markdownSummary.emptyBuffer() + 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) + .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 = `${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) + }) + + 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/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 new file mode 100644 index 0000000000..97d2d3cac1 --- /dev/null +++ b/packages/core/src/markdown-summary.ts @@ -0,0 +1,362 @@ +import {EOL} from 'os' +import {constants, promises} from 'fs' +const {access, appendFile, writeFile} = promises + +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)[] + +export interface SummaryTableCell { + /** + * 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 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 +} + +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 + + constructor() { + this._buffer = '' + } + + /** + * 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 { + 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}. Check if your runtime environment supports markdown summaries.` + ) + } + + try { + await access(pathFromEnv, constants.R_OK | constants.W_OK) + } catch { + throw new Error( + `Unable to access summary file: '${pathFromEnv}'. Check if the file has correct read/write permissions.` + ) + } + + this._filePath = pathFromEnv + return this._filePath + } + + /** + * Wraps content in an HTML tag, adding any HTML attributes + * + * @param {string} tag HTML tag to wrap + * @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 | null, + attrs: {[attribute: string]: string} = {} + ): string { + const htmlAttrs = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${value}"`) + .join('') + + if (!content) { + return `<${tag}${htmlAttrs}>` + } + + return `<${tag}${htmlAttrs}>${content}` + } + + /** + * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. + * + * @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() + const writeFunc = overwrite ? writeFile : appendFile + await writeFunc(filePath, this._buffer, {encoding: 'utf8'}) + return this.emptyBuffer() + } + + /** + * Clears the summary buffer and wipes the summary file + * + * @returns {MarkdownSummary} markdown summary instance + */ + async clear(): Promise { + return this.emptyBuffer().write({overwrite: true}) + } + + /** + * Returns the current summary buffer as a string + * + * @returns {string} string of summary buffer + */ + stringify(): string { + return this._buffer + } + + /** + * If the summary buffer is empty + * + * @returns {boolen} true if the buffer is empty + */ + isEmptyBuffer(): boolean { + return this._buffer.length === 0 + } + + /** + * Resets the summary buffer without writing to summary file + * + * @returns {MarkdownSummary} markdown summary instance + */ + emptyBuffer(): MarkdownSummary { + this._buffer = '' + return this + } + + /** + * 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 + */ + addRaw(text: string, addEOL = false): MarkdownSummary { + this._buffer += text + return addEOL ? this.addEOL() : this + } + + /** + * Adds the operating system-specific end-of-line marker to the buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addEOL(): MarkdownSummary { + return this.addRaw(EOL) + } + + /** + * Adds an HTML codeblock to the summary buffer + * + * @param {string} code content to render within fenced code block + * @param {string} lang (optional) language to syntax highlight code + * + * @returns {MarkdownSummary} markdown summary instance + */ + addCodeBlock(code: string, lang?: string): MarkdownSummary { + const attrs = { + ...(lang && {lang}) + } + const element = this.wrap('pre', this.wrap('code', code), attrs) + 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] (optional) if the rendered list should be ordered or not (default: false) + * + * @returns {MarkdownSummary} markdown summary instance + */ + addList(items: string[], ordered = false): MarkdownSummary { + const tag = ordered ? 'ol' : 'ul' + const listItems = items.map(item => this.wrap('li', item)).join('') + const element = this.wrap(tag, listItems) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML table to the summary buffer + * + * @param {SummaryTableCell[]} rows table rows + * + * @returns {MarkdownSummary} markdown summary instance + */ + addTable(rows: SummaryTableRow[]): MarkdownSummary { + const tableBody = rows + .map(row => { + const cells = row + .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}), + ...(rowspan && {rowspan}) + } + + return this.wrap(tag, data, attrs) + }) + .join('') + + return this.wrap('tr', cells) + }) + .join('') + + const element = this.wrap('table', tableBody) + return this.addRaw(element).addEOL() + } + + /** + * Adds a collapsable HTML details element to the summary buffer + * + * @param {string} label text for the closed state + * @param {string} content collapsable content + * + * @returns {MarkdownSummary} markdown summary instance + */ + addDetails(label: string, content: string): MarkdownSummary { + const element = this.wrap('details', this.wrap('summary', label) + content) + return this.addRaw(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 + * @param {SummaryImageOptions} options (optional) addition image attributes + * + * @returns {MarkdownSummary} markdown summary instance + */ + 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.addRaw(element).addEOL() + } + + /** + * Adds an HTML section heading element + * + * @param {string} text heading text + * @param {number | string} [level=1] (optional) the heading level, default: 1 + * + * @returns {MarkdownSummary} markdown summary instance + */ + 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.addRaw(element).addEOL() + } + + /** + * Adds an HTML thematic break (
) to the summary buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addSeparator(): MarkdownSummary { + const element = this.wrap('hr', null) + return this.addRaw(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.addRaw(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 { + const attrs = { + ...(cite && {cite}) + } + const element = this.wrap('blockquote', text, attrs) + return this.addRaw(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.addRaw(element).addEOL() + } +} + +// singleton export +export const markdownSummary = new MarkdownSummary()