diff --git a/.env.example b/.env.example index 124f68f..0714769 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,58 @@ # Do not commit your actual .env file to Git! This may contain secrets or other # private information. +# Enable/disable step debug logging (default: `false`). For local debugging, it +# may be useful to set it to `true`. +ACTIONS_STEP_DEBUG=true + # GitHub Actions inputs should follow `INPUT_` format (case-insensitive). INPUT_milliseconds=2400 -# Enable/disable step debug logging. Normally this is false by default, but for -# the purpose of debugging, it is set to true here. -ACTIONS_STEP_DEBUG=true \ No newline at end of file +# GitHub Actions default environment variables. These are set for every run of a +# workflow and can be used in your actions. Setting the value here will override +# any value set by the local-action tool. +# https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + +# CI="true" +# GITHUB_ACTION="" +# GITHUB_ACTION_PATH="" +# GITHUB_ACTION_REPOSITORY="" +# GITHUB_ACTIONS="" +# GITHUB_ACTOR="mona" +# GITHUB_ACTOR_ID="123456789" +# GITHUB_API_URL="" +# GITHUB_BASE_REF="" +# GITHUB_ENV="" +# GITHUB_EVENT_NAME="" +# GITHUB_EVENT_PATH="" +# GITHUB_GRAPHQL_URL="" +# GITHUB_HEAD_REF="" +# GITHUB_JOB="" +# GITHUB_OUTPUT="" +# GITHUB_PATH="" +# GITHUB_REF="" +# GITHUB_REF_NAME="" +# GITHUB_REF_PROTECTED="" +# GITHUB_REF_TYPE="" +# GITHUB_REPOSITORY="" +# GITHUB_REPOSITORY_ID="" +# GITHUB_REPOSITORY_OWNER="" +# GITHUB_REPOSITORY_OWNER_ID="" +# GITHUB_RETENTION_DAYS="" +# GITHUB_RUN_ATTEMPT="" +# GITHUB_RUN_ID="" +# GITHUB_RUN_NUMBER="" +# GITHUB_SERVER_URL="" +# GITHUB_SHA="" +# GITHUB_STEP_SUMMARY="" +# GITHUB_TRIGGERING_ACTOR="" +# GITHUB_WORKFLOW="" +# GITHUB_WORKFLOW_REF="" +# GITHUB_WORKFLOW_SHA="" +# GITHUB_WORKSPACE="" +# RUNNER_ARCH="" +# RUNNER_DEBUG="" +# RUNNER_NAME="" +# RUNNER_OS="" +# RUNNER_TEMP="" +# RUNNER_TOOL_CACHE="" \ No newline at end of file diff --git a/README.md b/README.md index 09858ad..0c1f03e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ actions can be run directly on your workstation. > > This tool currently only supports JavaScript and TypeScript actions! -## v1.0.0 Changes +## v1 Changes With the release of `v1.0.0`, there was a need to switch from [`ts-node`](https://www.npmjs.com/package/ts-node) to @@ -209,30 +209,11 @@ $ local-action run /path/to/typescript-action src/index.ts .env ================================================================================ ``` -## Debugging in VS Code - -This package can also be used with VS Code's built-in debugging tools. You just -need to add a `launch.json` to the project containing your local action. The -following can be used as an example. - -```json -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Debug Local Action", - "type": "node", - "request": "launch", - "runtimeExecutable": "local-action", - "cwd": "${workspaceRoot}", - "args": [".", "src/index.ts", ".env"], - "console": "integratedTerminal", - "skipFiles": ["/**", "node_modules/**"] - } - ] -} -``` +## Features + +The following list links to documentation on how to use various features of the +`local-action` tool. -In the `args` section, make sure to specify the appropriate path, entrypoint, -and dotenv file path. After that, you can add breakpoints to your action code -and start debugging! +- [Supported Functionality](./docs//supported-functionality.md) +- [Debugging in VS Code](./docs/debugging-in-vscode.md) +- [Create a Job Summary](./docs/create-a-step-summary.md) diff --git a/__fixtures__/javascript/failure/src/main.js b/__fixtures__/javascript/failure/src/main.js index b5fc56b..8243490 100644 --- a/__fixtures__/javascript/failure/src/main.js +++ b/__fixtures__/javascript/failure/src/main.js @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable @typescript-eslint/require-await */ /* eslint-disable import/no-commonjs */ const core = require('@actions/core') async function run() { + core.summary.addRaw('JavaScript Action Failed!') + await core.summary.write() + core.setFailed('JavaScript Action Failed!') } diff --git a/__fixtures__/javascript/no-import/src/main.js b/__fixtures__/javascript/no-import/src/main.js index f161a3f..f80e0d2 100644 --- a/__fixtures__/javascript/no-import/src/main.js +++ b/__fixtures__/javascript/no-import/src/main.js @@ -1,8 +1,7 @@ -/* eslint-disable @typescript-eslint/require-await */ /* eslint-disable import/no-commonjs */ async function run() { - return + return Promise.resolve() } module.exports = { diff --git a/__fixtures__/javascript/success/.env.fixture b/__fixtures__/javascript/success/.env.fixture index 1a3d037..f1b3926 100644 --- a/__fixtures__/javascript/success/.env.fixture +++ b/__fixtures__/javascript/success/.env.fixture @@ -5,4 +5,7 @@ INPUT_milliseconds=2400 # Enable/disable step debug logs -ACTIONS_STEP_DEBUG=false \ No newline at end of file +ACTIONS_STEP_DEBUG=false + +# Step summary output location +GITHUB_STEP_SUMMARY='summary.md' \ No newline at end of file diff --git a/__fixtures__/javascript/success/src/main.js b/__fixtures__/javascript/success/src/main.js index 198174b..975d152 100644 --- a/__fixtures__/javascript/success/src/main.js +++ b/__fixtures__/javascript/success/src/main.js @@ -1,12 +1,16 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable @typescript-eslint/require-await */ /* eslint-disable import/no-commonjs */ const core = require('@actions/core') async function run() { const myInput = core.getInput('myInput') + core.setOutput('myOutput', myInput) + + core.summary.addRaw('JavaScript Action Succeeded!') + await core.summary.write() + core.info('JavaScript Action Succeeded!') } diff --git a/__fixtures__/typescript/failure/src/main.ts b/__fixtures__/typescript/failure/src/main.ts index f3432f2..757616a 100644 --- a/__fixtures__/typescript/failure/src/main.ts +++ b/__fixtures__/typescript/failure/src/main.ts @@ -1,7 +1,8 @@ -/* eslint-disable @typescript-eslint/require-await */ - -import { setFailed } from '@actions/core' +import { setFailed, summary } from '@actions/core' export async function run(): Promise { + summary.addRaw('TypeScript Action Failed!') + await summary.write() + setFailed('TypeScript Action Failed!') } diff --git a/__fixtures__/typescript/no-import/src/main.ts b/__fixtures__/typescript/no-import/src/main.ts index 5efd8a9..3e8d603 100644 --- a/__fixtures__/typescript/no-import/src/main.ts +++ b/__fixtures__/typescript/no-import/src/main.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/require-await */ - export async function run(): Promise { - return + return Promise.resolve() } diff --git a/__fixtures__/typescript/success/.env.fixture b/__fixtures__/typescript/success/.env.fixture index 1a3d037..f1b3926 100644 --- a/__fixtures__/typescript/success/.env.fixture +++ b/__fixtures__/typescript/success/.env.fixture @@ -5,4 +5,7 @@ INPUT_milliseconds=2400 # Enable/disable step debug logs -ACTIONS_STEP_DEBUG=false \ No newline at end of file +ACTIONS_STEP_DEBUG=false + +# Step summary output location +GITHUB_STEP_SUMMARY='summary.md' \ No newline at end of file diff --git a/__fixtures__/typescript/success/src/main.ts b/__fixtures__/typescript/success/src/main.ts index 92c0193..4500783 100644 --- a/__fixtures__/typescript/success/src/main.ts +++ b/__fixtures__/typescript/success/src/main.ts @@ -1,9 +1,12 @@ -/* eslint-disable @typescript-eslint/require-await */ - -import { getInput, info, setOutput } from '@actions/core' +import { getInput, info, setOutput, summary } from '@actions/core' export async function run(): Promise { const myInput: string = getInput('myInput') + setOutput('myOutput', myInput) + + summary.addRaw('TypeScript Action Succeeded!') + await summary.write() + info('TypeScript Action Succeeded!') } diff --git a/__mocks__/@actions/core.ts b/__mocks__/@actions/core.ts index 6c99ef3..1a9307b 100644 --- a/__mocks__/@actions/core.ts +++ b/__mocks__/@actions/core.ts @@ -1,19 +1,67 @@ +const addPath = jest.fn() const debug = jest.fn() +const endGroup = jest.fn() const error = jest.fn() +const exportVariable = jest.fn() +const getBooleanInput = jest.fn() +const getIDToken = jest.fn() const getInput = jest.fn() +const getMultilineInput = jest.fn() +const getState = jest.fn() +const group = jest.fn() const info = jest.fn() +const isDebug = jest.fn() +const notice = jest.fn() +const saveState = jest.fn() +const setCommandEcho = jest.fn() const setFailed = jest.fn() const setOutput = jest.fn() const setSecret = jest.fn() +const startGroup = jest.fn() const warning = jest.fn() +const summary = {} +summary['filePath'] = jest.fn().mockReturnValue(summary) +summary['wrap'] = jest.fn().mockReturnValue(summary) +summary['write'] = jest.fn().mockReturnValue(summary) +summary['clear'] = jest.fn().mockReturnValue(summary) +summary['stringify'] = jest.fn().mockReturnValue(summary) +summary['isEmptyBuffer'] = jest.fn().mockReturnValue(summary) +summary['emptyBuffer'] = jest.fn().mockReturnValue(summary) +summary['addRaw'] = jest.fn().mockReturnValue(summary) +summary['addEOL'] = jest.fn().mockReturnValue(summary) +summary['addCodeBlock'] = jest.fn().mockReturnValue(summary) +summary['addList'] = jest.fn().mockReturnValue(summary) +summary['addTable'] = jest.fn().mockReturnValue(summary) +summary['addDetails'] = jest.fn().mockReturnValue(summary) +summary['addImage'] = jest.fn().mockReturnValue(summary) +summary['addHeading'] = jest.fn().mockReturnValue(summary) +summary['addSeparator'] = jest.fn().mockReturnValue(summary) +summary['addBreak'] = jest.fn().mockReturnValue(summary) +summary['addQuote'] = jest.fn().mockReturnValue(summary) +summary['addLink'] = jest.fn().mockReturnValue(summary) + export { + addPath, debug, + endGroup, error, + exportVariable, + getBooleanInput, + getIDToken, getInput, + getMultilineInput, + getState, + group, info, + isDebug, + notice, + saveState, + setCommandEcho, setFailed, setOutput, setSecret, + startGroup, + summary, warning } diff --git a/__tests__/commands/run.test.ts b/__tests__/commands/run.test.ts index 7f33e64..61c8fa3 100644 --- a/__tests__/commands/run.test.ts +++ b/__tests__/commands/run.test.ts @@ -1,11 +1,15 @@ /* eslint-disable import/no-namespace */ -import { setFailed } from '@actions/core' +import { setFailed, summary } from '@actions/core' import { action } from '../../src/commands/run' import { ResetCoreMetadata } from '../../src/stubs/core-stubs' import { EnvMeta, ResetEnvMetadata } from '../../src/stubs/env-stubs' import * as output from '../../src/utils/output' +const summary_writeSpy: jest.SpyInstance = jest + .spyOn(summary, 'write') + .mockImplementation() + let envBackup: { [key: string]: string | undefined } = process.env describe('Command: run', () => { @@ -35,22 +39,30 @@ describe('Command: run', () => { describe('TypeScript', () => { it('Action: success', async () => { + process.env.GITHUB_STEP_SUMMARY = 'summary.md' + EnvMeta.actionFile = `./__fixtures__/typescript/success/action.yml` EnvMeta.actionPath = `./__fixtures__/typescript/success` EnvMeta.dotenvFile = `./__fixtures__/typescript/success/.env.fixture` EnvMeta.entrypoint = `./__fixtures__/typescript/success/src/index.ts` await expect(action()).resolves.toBeUndefined() + + expect(summary_writeSpy).toHaveBeenCalled() expect(setFailed).not.toHaveBeenCalled() }) it('Action: failure', async () => { + delete process.env.GITHUB_STEP_SUMMARY + EnvMeta.actionFile = `./__fixtures__/typescript/failure/action.yml` EnvMeta.actionPath = `./__fixtures__/typescript/failure` EnvMeta.dotenvFile = `./__fixtures__/typescript/failure/.env.fixture` EnvMeta.entrypoint = `./__fixtures__/typescript/failure/src/index.ts` await expect(action()).resolves.toBeUndefined() + + expect(summary_writeSpy).toHaveBeenCalled() expect(setFailed).toHaveBeenCalledWith('TypeScript Action Failed!') }) @@ -61,28 +73,38 @@ describe('Command: run', () => { EnvMeta.entrypoint = `./__fixtures__/typescript/no-import/src/index.ts` await expect(action()).resolves.toBeUndefined() + + expect(summary_writeSpy).not.toHaveBeenCalled() expect(setFailed).not.toHaveBeenCalled() }) }) describe('JavaScript', () => { it('Action: success', async () => { + process.env.GITHUB_STEP_SUMMARY = 'summary.md' + EnvMeta.actionFile = `./__fixtures__/javascript/success/action.yml` EnvMeta.actionPath = `./__fixtures__/javascript/success` EnvMeta.dotenvFile = `./__fixtures__/javascript/success/.env.fixture` EnvMeta.entrypoint = `./__fixtures__/javascript/success/src/index.js` await expect(action()).resolves.toBeUndefined() + + expect(summary_writeSpy).toHaveBeenCalled() expect(setFailed).not.toHaveBeenCalled() }) it('Action: failure', async () => { + delete process.env.GITHUB_STEP_SUMMARY + EnvMeta.actionFile = `./__fixtures__/javascript/failure/action.yml` EnvMeta.actionPath = `./__fixtures__/javascript/failure` EnvMeta.dotenvFile = `./__fixtures__/javascript/failure/.env.fixture` EnvMeta.entrypoint = `./__fixtures__/javascript/failure/src/index.js` await expect(action()).resolves.toBeUndefined() + + expect(summary_writeSpy).toHaveBeenCalled() expect(setFailed).toHaveBeenCalled() }) @@ -93,6 +115,8 @@ describe('Command: run', () => { EnvMeta.entrypoint = `./__fixtures__/javascript/no-import/src/index.js` await expect(action()).resolves.toBeUndefined() + + expect(summary_writeSpy).not.toHaveBeenCalled() expect(setFailed).not.toHaveBeenCalled() }) }) diff --git a/__tests__/stubs/core-stubs.test.ts b/__tests__/stubs/core-stubs.test.ts index 0edd41d..acf45a1 100644 --- a/__tests__/stubs/core-stubs.test.ts +++ b/__tests__/stubs/core-stubs.test.ts @@ -22,10 +22,14 @@ import { group, saveState, getState, - getIDToken + getIDToken, + toWin32Path, + toPlatformPath, + toPosixPath } from '../../src/stubs/core-stubs' import { EnvMeta, ResetEnvMetadata } from '../../src/stubs/env-stubs' import type { CoreMetadata } from '../../src/types' +import path from 'path' let envBackup: { [key: string]: string | undefined } = process.env @@ -36,6 +40,7 @@ const empty: CoreMetadata = { outputs: {}, secrets: [], stepDebug: process.env.ACTIONS_STEP_DEBUG === 'true', + stepSummaryPath: process.env.GITHUB_STEP_SUMMARY ?? '', echo: false, state: {}, colors: { @@ -82,6 +87,7 @@ describe('Core', () => { expect(CoreMeta.outputs).toMatchObject(empty.outputs) expect(CoreMeta.secrets).toMatchObject(empty.secrets) expect(CoreMeta.stepDebug).toEqual(empty.stepDebug) + expect(CoreMeta.stepSummaryPath).toEqual(empty.stepSummaryPath) expect(CoreMeta.echo).toEqual(empty.echo) expect(CoreMeta.state).toMatchObject(empty.state) @@ -91,6 +97,7 @@ describe('Core', () => { CoreMeta.outputs = { 'my-output': 'test' } CoreMeta.secrets = ['secret-value-1234'] CoreMeta.stepDebug = true + CoreMeta.stepSummaryPath = 'test' CoreMeta.echo = true CoreMeta.state = { 'my-state': 'test' } @@ -100,6 +107,7 @@ describe('Core', () => { expect(CoreMeta.outputs).toMatchObject({ 'my-output': 'test' }) expect(CoreMeta.secrets).toMatchObject(['secret-value-1234']) expect(CoreMeta.stepDebug).toEqual(true) + expect(CoreMeta.stepSummaryPath).toEqual('test') expect(CoreMeta.echo).toEqual(true) expect(CoreMeta.state).toMatchObject({ 'my-state': 'test' }) @@ -112,9 +120,24 @@ describe('Core', () => { expect(CoreMeta.outputs).toMatchObject(empty.outputs) expect(CoreMeta.secrets).toMatchObject(empty.secrets) expect(CoreMeta.stepDebug).toEqual(empty.stepDebug) + expect(CoreMeta.stepSummaryPath).toEqual(empty.stepSummaryPath) expect(CoreMeta.echo).toEqual(empty.echo) expect(CoreMeta.state).toMatchObject(empty.state) }) + + it('Defaults stepSummaryPath to an empty string', () => { + delete process.env.GITHUB_STEP_SUMMARY + + ResetCoreMetadata() + expect(CoreMeta.stepSummaryPath).toEqual('') + }) + + it('Sets stepSummaryPath from the environment', () => { + process.env.GITHUB_STEP_SUMMARY = 'summary.md' + + ResetCoreMetadata() + expect(CoreMeta.stepSummaryPath).toEqual('summary.md') + }) }) describe('Core Stubs', () => { @@ -586,5 +609,33 @@ describe('Core', () => { await expect(getIDToken()).rejects.toThrow('Not implemented') }) }) + + describe('toPosixPath()', () => { + it('Returns a POSIX path', () => { + expect(toPosixPath('C:\\Users\\mona\\Desktop')).toEqual( + 'C:/Users/mona/Desktop' + ) + }) + }) + + describe('toWin32Path()', () => { + it('Returns a WIN32 path', () => { + expect(toWin32Path('C:/Users/mona/Desktop')).toEqual( + 'C:\\Users\\mona\\Desktop' + ) + }) + }) + + describe('toPlatformPath()', () => { + it('Returns a platform-specific path', () => { + expect(toPlatformPath('C:/Users/mona/Desktop')).toEqual( + `C:${path.sep}Users${path.sep}mona${path.sep}Desktop` + ) + + expect(toPosixPath('C:\\Users\\mona\\Desktop')).toEqual( + `C:${path.sep}Users${path.sep}mona${path.sep}Desktop` + ) + }) + }) }) }) diff --git a/__tests__/stubs/summary-stubs.test.ts b/__tests__/stubs/summary-stubs.test.ts new file mode 100644 index 0000000..dad8a52 --- /dev/null +++ b/__tests__/stubs/summary-stubs.test.ts @@ -0,0 +1,450 @@ +import fs from 'fs' +import { EOL } from 'os' +import path from 'path' +import { Summary } from '../../src/stubs/summary-stubs' +import { CoreMeta, ResetCoreMetadata } from '../../src/stubs/core-stubs' + +let summary: Summary = new Summary() + +describe('Summary', () => { + beforeAll(() => { + // Prevent output during tests + jest.spyOn(console, 'log').mockImplementation() + }) + + beforeEach(() => { + // Reset the summary instance + summary = new Summary() + + CoreMeta.stepSummaryPath = 'summary.md' + }) + + afterEach(() => { + // Reset all spies + jest.resetAllMocks() + + // Restore environment metadata + ResetCoreMetadata() + }) + + describe('constructor', () => { + it('Creates an instance of the Summary class', () => { + expect(summary).toBeInstanceOf(Summary) + }) + }) + + describe('filePath()', () => { + let fs_accessSyncSpy: jest.SpyInstance + let fs_existsSyncSpy: jest.SpyInstance + let path_resolveSpy: jest.SpyInstance + + beforeEach(() => { + fs_accessSyncSpy = jest.spyOn(fs, 'accessSync').mockImplementation() + fs_existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true) + path_resolveSpy = jest + .spyOn(path, 'resolve') + .mockReturnValueOnce('/path/to/summary.md') + }) + + afterEach(() => { + fs_accessSyncSpy.mockRestore() + fs_existsSyncSpy.mockRestore() + path_resolveSpy.mockRestore() + }) + + it('Resolves the summary file path from the environment', async () => { + await expect(summary.filePath()).resolves.toBe('/path/to/summary.md') + + expect(fs_accessSyncSpy).toHaveBeenCalledWith( + '/path/to/summary.md', + fs.constants.R_OK | fs.constants.W_OK + ) + }) + + it('Returns the already-resolved file path', async () => { + await summary.filePath() + await summary.filePath() + await summary.filePath() + + expect(fs_accessSyncSpy).toHaveBeenCalledTimes(1) + expect(fs_accessSyncSpy).toHaveBeenNthCalledWith( + 1, + '/path/to/summary.md', + fs.constants.R_OK | fs.constants.W_OK + ) + + await expect(summary.filePath()).resolves.toBe('/path/to/summary.md') + }) + + it('Throws an error if the environment variable is not set', async () => { + CoreMeta.stepSummaryPath = '' + + await expect(summary.filePath()).rejects.toThrow( + 'Unable to find environment variable for $GITHUB_STEP_SUMMARY. Check if your runtime environment supports job summaries.' + ) + }) + + it('Throws an error if the file does not exist', async () => { + fs_accessSyncSpy.mockReset().mockImplementation(() => { + throw new Error('File does not exist') + }) + + await expect(summary.filePath()).rejects.toThrow( + `Unable to access summary file: '/path/to/summary.md'. Check if the file has correct read/write permissions.` + ) + }) + }) + + describe('wrap()', () => { + it('Wraps the input string with the provided tag', () => { + expect(summary.wrap('div', 'text')).toBe('
text
') + }) + + it('Creates a self-closing tag when no input string is provided', () => { + expect(summary.wrap('img', null)).toBe('') + }) + + it('Adds HTML attributes', () => { + expect( + summary.wrap('a', 'GitHub.com', { + href: 'https://github.com', + target: '_blank' + }) + ).toBe('GitHub.com') + }) + }) + + describe('write()', () => { + let fs_accessSyncSpy: jest.SpyInstance + let fs_appendFileSyncSpy: jest.SpyInstance + let fs_existsSyncSpy: jest.SpyInstance + let fs_writeFileSyncSpy: jest.SpyInstance + let path_resolveSpy: jest.SpyInstance + + beforeEach(() => { + fs_accessSyncSpy = jest.spyOn(fs, 'accessSync').mockImplementation() + fs_appendFileSyncSpy = jest + .spyOn(fs, 'appendFileSync') + .mockImplementation() + fs_existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true) + fs_writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation() + path_resolveSpy = jest + .spyOn(path, 'resolve') + .mockReturnValueOnce('/path/to/summary.md') + + // Add some text to test with + summary.addRaw('text') + }) + + afterEach(() => { + fs_accessSyncSpy.mockRestore() + fs_appendFileSyncSpy.mockRestore() + fs_existsSyncSpy.mockRestore() + fs_writeFileSyncSpy.mockRestore() + path_resolveSpy.mockRestore() + + // Clear the buffer for the next test + summary.emptyBuffer() + }) + + it('Appends content to the summary file', async () => { + await summary.write() + + expect(fs_existsSyncSpy).toHaveBeenCalledWith('/path/to/summary.md') + expect(fs_writeFileSyncSpy).not.toHaveBeenCalled() + expect(fs_appendFileSyncSpy).toHaveBeenCalledWith( + '/path/to/summary.md', + 'text', + { encoding: 'utf8' } + ) + }) + + it('Creates the summary file if it does not exist', async () => { + fs_existsSyncSpy.mockReset().mockReturnValueOnce(false) + + await summary.write() + + expect(fs_existsSyncSpy).toHaveBeenCalledWith('/path/to/summary.md') + expect(fs_writeFileSyncSpy).toHaveBeenCalledWith( + '/path/to/summary.md', + '', + { encoding: 'utf8' } + ) + expect(fs_appendFileSyncSpy).toHaveBeenCalledWith( + '/path/to/summary.md', + 'text', + { encoding: 'utf8' } + ) + }) + + it('Overwrites the summary file', async () => { + await summary.write({ overwrite: true }) + + expect(fs_existsSyncSpy).toHaveBeenCalledWith('/path/to/summary.md') + expect(fs_writeFileSyncSpy).toHaveBeenCalledWith( + '/path/to/summary.md', + 'text', + { encoding: 'utf8' } + ) + expect(fs_appendFileSyncSpy).not.toHaveBeenCalled() + }) + }) + + describe('clear()', () => { + it('Clears the buffer', async () => { + const summary_emptyBufferSpy = jest.fn().mockReturnValue(summary) + const summary_writeSpy = jest.fn().mockResolvedValue(summary) + + summary.emptyBuffer = summary_emptyBufferSpy + summary.write = summary_writeSpy + + await summary.clear() + + expect(summary_emptyBufferSpy).toHaveBeenCalled() + expect(summary_writeSpy).toHaveBeenCalled() + }) + }) + + describe('stringify()', () => { + it('Returns the buffer', () => { + summary.addRaw('text') + + expect(summary.stringify()).toEqual('text') + }) + }) + + describe('isEmptyBuffer()', () => { + it('Returns true if the buffer is empty', () => { + expect(summary.isEmptyBuffer()).toBe(true) + }) + + it('Returns false if the buffer is not empty', () => { + summary.addRaw('text') + + expect(summary.isEmptyBuffer()).toBe(false) + }) + }) + + describe('emptyBuffer()', () => { + it('Empties the buffer', () => { + summary.addRaw('text') + summary.emptyBuffer() + + expect(summary.isEmptyBuffer()).toBe(true) + expect(summary.stringify()).toEqual('') + }) + }) + + describe('addRaw()', () => { + it('Adds text', () => { + summary.addRaw('text') + + expect(summary.stringify()).toEqual('text') + }) + + it('Adds an EOL', () => { + const summary_addEOLSpy = jest.fn().mockReturnValue(summary) + + summary.addEOL = summary_addEOLSpy + + summary.addRaw('text', true) + + expect(summary.stringify()).toEqual('text') + expect(summary_addEOLSpy).toHaveBeenCalled() + }) + }) + + describe('addEOL()', () => { + it('Adds an EOL', () => { + const summary_addRawSpy = jest.fn().mockReturnValue(summary) + + summary.addRaw = summary_addRawSpy + + summary.addEOL() + + expect(summary_addRawSpy).toHaveBeenCalledWith(EOL) + }) + }) + + describe('addCodeBlock()', () => { + it('Adds a code block', () => { + summary.addCodeBlock('text') + + expect(summary.stringify() === `
text
${EOL}`).toBe( + true + ) + }) + + it('Adds a code block with syntax highlighting', () => { + summary.addCodeBlock('text', 'javascript') + + expect( + summary.stringify() === + `
text
${EOL}` + ).toBe(true) + }) + }) + + describe('addList()', () => { + it('Adds an unordered list', () => { + summary.addList(['text', 'text']) + + expect( + summary.stringify() === `
  • text
  • text
${EOL}` + ).toBe(true) + }) + + it('Adds an ordered list', () => { + summary.addList(['text', 'text'], true) + + expect( + summary.stringify() === `
  1. text
  2. text
${EOL}` + ).toBe(true) + }) + }) + + describe('addTable()', () => { + it('Adds a table', () => { + summary.addTable([ + [ + { + data: 'text', + header: true, + colspan: '1', + rowspan: '1' + }, + { + data: 'text', + header: true, + colspan: '1', + rowspan: '1' + }, + { + data: 'text', + header: true + } + ], + [ + { + data: 'text' + }, + 'text', + 'text' + ] + ]) + + expect( + summary.stringify() === + `
texttexttext
texttexttext
${EOL}` + ).toBe(true) + }) + }) + + describe('addDetails()', () => { + it('Adds a details element', () => { + summary.addDetails('label', 'text') + + expect( + summary.stringify() === + `
labeltext
${EOL}` + ).toBe(true) + }) + }) + + describe('addImage()', () => { + it('Adds an image', () => { + summary.addImage('src', 'alt') + + expect(summary.stringify() === `alt${EOL}`).toBe( + true + ) + }) + + it('Adds an image with options', () => { + summary.addImage('src', 'alt', { + width: '100', + height: '100' + }) + + expect( + summary.stringify() === + `alt${EOL}` + ).toBe(true) + }) + }) + + describe('addHeading()', () => { + it('Adds a heading (no level)', () => { + summary.addHeading('text') + + expect(summary.stringify() === `

text

${EOL}`).toBe(true) + }) + + it('Adds a heading (number level)', () => { + summary.addHeading('text', 2) + + expect(summary.stringify() === `

text

${EOL}`).toBe(true) + }) + + it('Adds a heading (string level)', () => { + summary.addHeading('text', '3') + + expect(summary.stringify() === `

text

${EOL}`).toBe(true) + }) + + it('Adds a heading (out of bounds level)', () => { + summary.addHeading('text', -10) + + expect(summary.stringify() === `

text

${EOL}`).toBe(true) + }) + + it('Adds a heading (NaN level)', () => { + summary.addHeading('text', 'text') + + expect(summary.stringify() === `

text

${EOL}`).toBe(true) + }) + }) + + describe('addSeparator()', () => { + it('Adds a horizontal rule', () => { + summary.addSeparator() + + expect(summary.stringify() === `
${EOL}`).toBe(true) + }) + }) + + describe('addBreak()', () => { + it('Adds a line break', () => { + summary.addBreak() + + expect(summary.stringify() === `
${EOL}`).toBe(true) + }) + }) + + describe('addQuote()', () => { + it('Adds a quote', () => { + summary.addQuote('text') + + expect( + summary.stringify() === `
text
${EOL}` + ).toBe(true) + }) + + it('Adds a quote with citation', () => { + summary.addQuote('text', 'citation') + + expect( + summary.stringify() === + `
text
${EOL}` + ).toBe(true) + }) + }) + + describe('addLink()', () => { + it('Adds a link', () => { + summary.addLink('text', 'url') + + expect(summary.stringify() === `text${EOL}`).toBe(true) + }) + }) +}) diff --git a/docs/create-a-job-summary.md b/docs/create-a-job-summary.md new file mode 100644 index 0000000..39f4ab2 --- /dev/null +++ b/docs/create-a-job-summary.md @@ -0,0 +1,32 @@ +# Create a Job Summary + +[Job summaries](https://github.blog/2022-05-09-supercharging-github-actions-with-job-summaries/) +are Markdown files generated by GitHub Actions and displayed on the summary page +of each job. + +![Job summary example](./img/job-summary.png) + +> [!NOTE] +> +> This behavior is slightly different from what happens on GitHub Actions +> runners. If the file referenced by `GITHUB_STEP_SUMMARY` does not exist, the +> workflow run will fail with an error like the following: +> +> `Unable to access summary file: '/path/to/summary'.` +> +> However, GitHub Actions normally creates this file for you, so functionally +> you should not see a difference :blush: + +When testing with `local-action`, you can output the summary for your action to +a file. Just set the `GITHUB_STEP_SUMMARY` environment variable in your +[`.env` file](../.env.example). If the summary file does not exist, it will be +created. + +```ini +GITHUB_STEP_SUMMARY="/path/to/summary.md" +``` + +> [!WARNING] +> +> If your action calls `core.summary.write()` and no value is set for this +> environment variable, the action will fail! diff --git a/docs/debugging-in-vscode.md b/docs/debugging-in-vscode.md new file mode 100644 index 0000000..2844d5c --- /dev/null +++ b/docs/debugging-in-vscode.md @@ -0,0 +1,27 @@ +# Debugging in VS Code + +This package can also be used with VS Code's built-in debugging tools. You just +need to add a `launch.json` to the project containing your local action. The +following can be used as an example. + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Local Action", + "type": "node", + "request": "launch", + "runtimeExecutable": "local-action", + "cwd": "${workspaceRoot}", + "args": [".", "src/index.ts", ".env"], + "console": "integratedTerminal", + "skipFiles": ["/**", "node_modules/**"] + } + ] +} +``` + +In the `args` section, make sure to specify the appropriate path, entrypoint, +and dotenv file path. After that, you can add breakpoints to your action code +and start debugging! diff --git a/docs/img/job-summary.png b/docs/img/job-summary.png new file mode 100644 index 0000000..c71637c Binary files /dev/null and b/docs/img/job-summary.png differ diff --git a/docs/supported-functionality.md b/docs/supported-functionality.md new file mode 100644 index 0000000..de3834e --- /dev/null +++ b/docs/supported-functionality.md @@ -0,0 +1,65 @@ +# Supported Functionality + +The following tables list the functionality of the GitHub Actions libraries and +whether or not they are currently supported by `local-action`. + +> [!NOTE] +> +> [Workflow commands](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions) +> are currently unsupported. Since `local-action` only supports +> JavaScript/TypeScript actions, it is assumed that they are using the +> [GitHub Actions Toolkit](https://github.com/actions/toolkit). + +## [`@actions/core`](https://github.com/actions/toolkit/blob/main/packages/core/README.md) + +| Feature | Supported | Notes | +| --------------------- | ------------------ | ------------------------------- | +| `exportVariable()` | :white_check_mark: | | +| `setSecret()` | :white_check_mark: | | +| `addPath()` | :white_check_mark: | | +| `getInput()` | :white_check_mark: | | +| `getMultilineInput()` | :white_check_mark: | | +| `getBooleanInput()` | :white_check_mark: | | +| `setOutput()` | :white_check_mark: | | +| `setCommandEcho()` | :white_check_mark: | Setting is not currently in use | +| `setFailed()` | :white_check_mark: | | +| `isDebug()` | :white_check_mark: | | +| `debug()` | :white_check_mark: | | +| `error()` | :white_check_mark: | | +| `warning()` | :white_check_mark: | | +| `notice()` | :white_check_mark: | | +| `info()` | :white_check_mark: | | +| `startGroup()` | :white_check_mark: | | +| `endGroup()` | :white_check_mark: | | +| `group()` | :white_check_mark: | | +| `saveState()` | :white_check_mark: | State is not currently in use | +| `getState()` | :white_check_mark: | State is not currently in use | +| `getIDToken()` | :no_entry: | | +| `summary.*` | :white_check_mark: | | +| `toPosixPath()` | :white_check_mark: | | +| `toWin32Path()` | :white_check_mark: | | +| `toPlatformPath()` | :white_check_mark: | | +| `platform.*` | :no_entry: | | + +## Under Investigation + +The following packages are under investigation for how to integrate with +`local-action`. Make sure to check back later! + +- [`@actions/artifact`](https://github.com/actions/toolkit/tree/main/packages/artifact) +- [`@actions/cache`](https://github.com/actions/toolkit/tree/main/packages/cache) +- [`@actions/github`](https://github.com/actions/toolkit/tree/main/packages/github) +- [`@actions/tool-cache`](https://github.com/actions/toolkit/tree/main/packages/tool-cache) + +## No Action Needed + +Currently, there shouldn't be any need to stub the functionality of the +following packages from the GitHub Actions Toolkit; they _should_ function as +expected when run using `local-action`. If you do encounter a scenario where +this doesn't work correctly, please +[open an issue!](https://github.com/github/local-action/issues/new) + +- [`@actions/exec`](https://github.com/actions/toolkit/tree/main/packages/exec) +- [`@actions/glob`](https://github.com/actions/toolkit/tree/main/packages/glob) +- [`@actions/http-client`](https://github.com/actions/toolkit/tree/main/packages/http-client) +- [`@actions/io`](https://github.com/actions/toolkit/tree/main/packages/io) diff --git a/package-lock.json b/package-lock.json index 0f0e96a..1c2d2dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github/local-action", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github/local-action", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@actions/core": "^1.10.1", @@ -23,8 +23,7 @@ "yaml": "^2.3.4" }, "bin": { - "local-action": "bin/local-action", - "tsx": "node_modules/dist/cli.mjs" + "local-action": "bin/local-action" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/package.json b/package.json index 0f67059..598268c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@github/local-action", "description": "Local Debugging for GitHub Actions", - "version": "1.0.0", + "version": "1.1.0", "author": "Nick Alteen ", "private": false, "homepage": "https://github.com/github/local-action", diff --git a/src/commands/run.ts b/src/commands/run.ts index 21abec2..0f68aec 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -24,6 +24,7 @@ import { startGroup, warning } from '../stubs/core-stubs' +import { Summary } from '../stubs/summary-stubs' import { EnvMeta } from '../stubs/env-stubs' import type { Action } from '../types' import { printTitle } from '../utils/output' @@ -81,8 +82,9 @@ export async function action(): Promise { // @todo Load this into EnvMeta directly? What about secrets... config({ path: path.resolve(process.cwd(), EnvMeta.dotenvFile) }) - // Load step debug setting + // Load action settings CoreMeta.stepDebug = process.env.ACTIONS_STEP_DEBUG === 'true' + CoreMeta.stepSummaryPath = process.env.GITHUB_STEP_SUMMARY ?? '' // Read the action.yml file and parse the expected inputs/outputs const actionYaml: Action = YAML.parse( @@ -135,6 +137,7 @@ export async function action(): Promise { setOutput, setSecret, startGroup, + summary: new Summary(), warning } }) diff --git a/src/stubs/core-stubs.ts b/src/stubs/core-stubs.ts index cd7f2eb..e354275 100644 --- a/src/stubs/core-stubs.ts +++ b/src/stubs/core-stubs.ts @@ -5,13 +5,15 @@ import { EnvMeta } from './env-stubs' * Metadata for `@actions/core` */ export const CoreMeta: CoreMetadata = { + echo: false, exitCode: 0, exitMessage: '', outputs: {}, secrets: [], - stepDebug: process.env.ACTIONS_STEP_DEBUG === 'true', - echo: false, state: {}, + stepDebug: process.env.ACTIONS_STEP_DEBUG === 'true', + stepSummaryPath: + /* istanbul ignore next */ process.env.GITHUB_STEP_SUMMARY ?? '', colors: { cyan: /* istanbul ignore next */ (msg: string) => console.log(msg), blue: /* istanbul ignore next */ (msg: string) => console.log(msg), @@ -30,13 +32,14 @@ export const CoreMeta: CoreMetadata = { * @returns void */ export function ResetCoreMetadata(): void { + CoreMeta.echo = false CoreMeta.exitCode = 0 CoreMeta.exitMessage = '' CoreMeta.outputs = {} CoreMeta.secrets = [] - CoreMeta.stepDebug = process.env.ACTIONS_STEP_DEBUG === 'true' - CoreMeta.echo = false CoreMeta.state = {} + CoreMeta.stepDebug = process.env.ACTIONS_STEP_DEBUG === 'true' + CoreMeta.stepSummaryPath = process.env.GITHUB_STEP_SUMMARY ?? '' } //----------------------------------------------------------------------- @@ -509,3 +512,44 @@ export async function getIDToken(aud?: string): Promise { /* istanbul ignore next */ return aud } + +//----------------------------------------------------------------------- +// Path exports +//----------------------------------------------------------------------- + +/** + * Converts the given path to the posix form. On Windows, `\\` will be replaced + * with `/`. + * + * @param pth Path to transform + * @return Posix path + */ +export function toPosixPath(pth: string): string { + return pth.replace(/[\\]/g, '/') +} + +/** + * Converts the given path to the win32 form. On Linux, `/` will be replaced + * with `\\`. + * + * @param pth Path to transform + * @return Win32 path + */ +export function toWin32Path(pth: string): string { + return pth.replace(/[/]/g, '\\') +} + +/** + * Converts the given path to a platform-specific path. It does this by + * replacing instances of `/` and `\` with the platform-specific path separator. + * + * @param pth The path to platformize + * @return The platform-specific path + */ +export function toPlatformPath(pth: string): string { + // Importing with require is necessary to avoid async/await. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require('path') as typeof import('path') + + return pth.replace(/[/\\]/g, path.sep) +} diff --git a/src/stubs/summary-stubs.ts b/src/stubs/summary-stubs.ts new file mode 100644 index 0000000..c80d3ef --- /dev/null +++ b/src/stubs/summary-stubs.ts @@ -0,0 +1,338 @@ +import fs from 'fs' +import { EOL } from 'os' +import path from 'path' +import { CoreMeta } from './core-stubs' +import type { + SummaryImageOptions, + SummaryTableRow, + SummaryWriteOptions +} from '../types' + +/** + * A class for creating and writing job step summaries. + */ +export class Summary { + /** Output buffer to write to the summary file. */ + private _buffer: string + + /** The path to the summary file. */ + private _filePath?: string + + /** + * Initialize with an empty buffer. + */ + constructor() { + this._buffer = '' + } + + /** + * Finds the summary file path from the environment. Rejects if the + * environment variable is not set/empty or the file does not exist. + * + * @returns Step summary file path. + */ + async filePath(): Promise { + // Return the current value, if available. + if (this._filePath) return this._filePath + + // Throw if the path is not set/empty. + if (!CoreMeta.stepSummaryPath) + throw new Error( + 'Unable to find environment variable for $GITHUB_STEP_SUMMARY. Check if your runtime environment supports job summaries.' + ) + + try { + // Resolve the full path to the file. + CoreMeta.stepSummaryPath = path.resolve( + process.cwd(), + CoreMeta.stepSummaryPath + ) + + // If the file does not exist, create it. GitHub Actions runners normally + // create this file automatically. When testing with local-action, we do + // not know if the file already exists. + if (!fs.existsSync(CoreMeta.stepSummaryPath)) + fs.writeFileSync(CoreMeta.stepSummaryPath, '', { encoding: 'utf8' }) + + // Test access to the file (read or write). + fs.accessSync( + CoreMeta.stepSummaryPath, + fs.constants.R_OK | fs.constants.W_OK + ) + } catch (error) { + throw new Error( + `Unable to access summary file: '${CoreMeta.stepSummaryPath}'. Check if the file has correct read/write permissions.` + ) + } + + this._filePath = CoreMeta.stepSummaryPath + return Promise.resolve(this._filePath) + } + + /** + * Wraps content in the provided HTML tag and adds any specified attributes. + * + * @param tag HTML tag to wrap. Example: 'html', 'body', 'div', etc. + * @param content The content to wrap within the tag. + * @param attrs A key-value list of HTML attributes to add. + * @returns Content wrapped in an HTML element. + */ + wrap( + tag: string, + content: string | null, + attrs: { [attribute: string]: string } = {} + ): string { + const htmlAttrs: string = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${value}"`) + .join('') + + return !content + ? `<${tag}${htmlAttrs}>` + : `<${tag}${htmlAttrs}>${content}` + } + + /** + * Writes the buffer to the summary file and empties the buffer. This can + * append (default) or overwrite the file. + * + * @param options Options for the write operation. + * @returns A promise that resolves to the Summary instance for chaining. + */ + async write( + options: SummaryWriteOptions = { overwrite: false } + ): Promise { + // Set the function to call based on the overwrite setting. + const writeFunc = options.overwrite ? fs.writeFileSync : fs.appendFileSync + + // If the file does not exist, create it. GitHub Actions runners normally + // create this file automatically. When testing with local-action, we do not + // know if the file already exists. + const filePath: string = await this.filePath() + + // Call the write function. + writeFunc(filePath, this._buffer, { encoding: 'utf8' }) + + // Empty the buffer. + return this.emptyBuffer() + } + + /** + * Clears the buffer and summary file. + * + * @returns A promise that resolve to the Summary instance for chaining. + */ + async clear(): Promise { + return this.emptyBuffer().write({ overwrite: true }) + } + + /** + * Returns the current buffer as a string. + * + * @returns Current buffer contents. + */ + stringify(): string { + return this._buffer + } + + /** + * Returns `true` the buffer is empty, `false` otherwise. + * + * @returns Whether the buffer is empty. + */ + isEmptyBuffer(): boolean { + return this._buffer.length === 0 + } + + /** + * Resets the buffer without writing to the summary file. + * + * @returns The Summary instance for chaining. + */ + emptyBuffer(): Summary { + this._buffer = '' + return this + } + + /** + * Adds raw text to the buffer. + * + * @param text The content to add. + * @param addEOL Whether to append `EOL` to the raw text (default: `false`). + * + * @returns The Summary instance for chaining. + */ + addRaw(text: string, addEOL: boolean = false): Summary { + this._buffer += text + return addEOL ? this.addEOL() : this + } + + /** + * Adds the operating system-specific `EOL` marker to the buffer. + * + * @returns The Summary instance for chaining. + */ + addEOL(): Summary { + return this.addRaw(EOL) + } + + /** + * Adds a code block (\) to the buffer. + * + * @param code Content to render within the code block. + * @param lang Language to use for syntax highlighting. + * @returns Summary instance for chaining. + */ + addCodeBlock(code: string, lang?: string): Summary { + return this.addRaw( + this.wrap('pre', this.wrap('code', code), lang ? { lang } : {}) + ).addEOL() + } + + /** + * Adds a list (\) element to the buffer. + * + * @param items List of items to render. + * @param ordered Whether the list should be ordered. + * @returns Summary instance for chaining. + */ + addList(items: string[], ordered: boolean = false): Summary { + return this.addRaw( + this.wrap( + ordered ? 'ol' : 'ul', + items.map(item => this.wrap('li', item)).join('') + ) + ).addEOL() + } + + /** + * Adds a table (\) element to the buffer. + * + * @param rows Table rows to render. + * @returns Summary instance for chaining. + */ + addTable(rows: SummaryTableRow[]): Summary { + return this.addRaw( + this.wrap( + 'table', + // The table body consists of a list of rows, each with a list of cells. + rows + .map(row => { + const cells: string = row + .map(cell => { + // Cell is a string, return as-is. + if (typeof cell === 'string') return this.wrap('td', cell) + + // Cell is a SummaryTableCell, extract the data and attributes. + return this.wrap(cell.header ? 'th' : 'td', cell.data, { + ...(cell.colspan ? { colspan: cell.colspan } : {}), + ...(cell.rowspan ? { rowspan: cell.rowspan } : {}) + }) + }) + .join('') + + return this.wrap('tr', cells) + }) + .join('') + ) + ).addEOL() + } + + /** + * Adds a details (\) element to the buffer. + * + * @param label Text for the \ element. + * @param content Text for the \ container. + * @returns Summary instance for chaining. + */ + addDetails(label: string, content: string): Summary { + return this.addRaw( + this.wrap('details', this.wrap('summary', label) + content) + ).addEOL() + } + + /** + * Adds an image (\) element to the buffer. + * + * @param src Path to the image to embed. + * @param alt Text description of the image. + * @param options Additional image attributes. + * @returns Summary instance for chaining. + */ + addImage( + src: string, + alt: string, + options: SummaryImageOptions = {} + ): Summary { + return this.addRaw( + this.wrap('img', null, { + src, + alt, + ...(options.width ? { width: options.width } : {}), + ...(options.height ? { height: options.height } : {}) + }) + ).addEOL() + } + + /** + * Adds a heading (\) element to the buffer. + * + * @param text Heading text to render. + * @param level Heading level. Defaults to `1`. + * @returns Summary instance for chaining. + */ + addHeading(text: string, level: number | string = 1): Summary { + // If level is a string, attempt to parse it as a number. + const levelAsNum = typeof level === 'string' ? parseInt(level) : level + + // If level is less than 1 or greater than 6, default to `h1`. + const tag = + Number.isNaN(levelAsNum) || levelAsNum < 1 || levelAsNum > 6 + ? 'h1' + : `h${level}` + + const element = this.wrap(tag, text) + return this.addRaw(element).addEOL() + } + + /** + * Adds a horizontal rule (\) element to the buffer. + * + * @returns Summary instance for chaining. + */ + addSeparator(): Summary { + return this.addRaw(this.wrap('hr', null)).addEOL() + } + + /** + * Adds a line break (\) to the buffer. + * + * @returns Summary instance for chaining. + */ + addBreak(): Summary { + return this.addRaw(this.wrap('br', null)).addEOL() + } + + /** + * Adds a block quote \ element to the buffer. + * + * @param text Quote text to render. + * @param cite (Optional) Citation URL. + * @returns Summary instance for chaining. + */ + addQuote(text: string, cite?: string): Summary { + return this.addRaw( + this.wrap('blockquote', text, cite ? { cite } : {}) + ).addEOL() + } + + /** + * Adds an anchor (\) element to the buffer. + * + * @param text Text content to render. + * @param href Hyperlink to the target. + * @returns Summary instance for chaining. + */ + addLink(text: string, href: string): Summary { + return this.addRaw(this.wrap('a', text, { href })).addEOL() + } +} diff --git a/src/types.ts b/src/types.ts index ad2458d..a565b95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,9 @@ export type EnvMetadata = { /** Metadata for `@actions/core` */ export type CoreMetadata = { + /** Command echo setting */ + echo: boolean + /** Exit code (0 = success, 1 = failure) */ exitCode: 0 | 1 @@ -54,9 +57,6 @@ export type CoreMetadata = { /** Actions step debug setting */ stepDebug: boolean - /** Command echo setting */ - echo: boolean - /** Current action state */ state: { [key: string]: string } @@ -69,6 +69,14 @@ export type CoreMetadata = { colors: { [key: string]: (message: string) => void } + + /** + * The path to the step summary output file. + * + * This is not part of `@actions/core` but is included here for convenience + * when calling related functions. + */ + stepSummaryPath: string } /** Properties of an `action.yml` */ @@ -184,3 +192,39 @@ export type InputOptions = { */ trimWhitespace?: boolean } + +/** A table cell from a job step summary. */ +export type SummaryTableCell = { + /** Cell content */ + data: string + + /** Optional. Render cell as header. Defaults to `false`. */ + header?: boolean + + /** Optional. Number of columns the cell extends. Defaults to `1`. */ + colspan?: string + + /** Optional. Number of rows the cell extends. Defaults to '1'. */ + rowspan?: string +} + +/** A row for a summary table. */ +export type SummaryTableRow = (SummaryTableCell | string)[] + +/** The formatting options for an image in a job step summary. */ +export type SummaryImageOptions = { + /** Optional. The width of the image in pixels. */ + width?: string + + /** Optional. The height of the image in pixels. */ + height?: string +} + +/** The options for writing a job step summary. */ +export type SummaryWriteOptions = { + /** + * Optional. Replace all existing content in the summary file with the + * contents of the buffer. Defaults to `false`. + */ + overwrite?: boolean +}