From b4ff0922c99a055da3412b94ac67303d042e4e60 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Wed, 30 Aug 2023 09:40:45 +0200 Subject: [PATCH 01/16] Add helpers package --- packages/helpers/LICENSE.md | 9 ++++ packages/helpers/package-lock.json | 72 ++++++++++++++++++++++++++++++ packages/helpers/package.json | 46 +++++++++++++++++++ packages/helpers/src/helpers.ts | 1 + packages/helpers/tsconfig.json | 12 +++++ 5 files changed, 140 insertions(+) create mode 100644 packages/helpers/LICENSE.md create mode 100644 packages/helpers/package-lock.json create mode 100644 packages/helpers/package.json create mode 100644 packages/helpers/src/helpers.ts create mode 100644 packages/helpers/tsconfig.json diff --git a/packages/helpers/LICENSE.md b/packages/helpers/LICENSE.md new file mode 100644 index 0000000000..b6ddf8f7d2 --- /dev/null +++ b/packages/helpers/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright 2023 GitHub + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/helpers/package-lock.json b/packages/helpers/package-lock.json new file mode 100644 index 0000000000..3e83649421 --- /dev/null +++ b/packages/helpers/package-lock.json @@ -0,0 +1,72 @@ +{ + "name": "@actions/helpers", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@actions/helpers", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@actions/core": "^1.10.0", + "@actions/exec": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^20.5.0" + } + }, + "node_modules/@actions/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz", + "integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==", + "dependencies": { + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/http-client": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz", + "integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==", + "dependencies": { + "tunnel": "^0.0.6" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==" + }, + "node_modules/@types/node": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.0.tgz", + "integrity": "sha512-Mgq7eCtoTjT89FqNoTzzXg2XvCi5VMhRV6+I2aYanc6kQCBImeNaAYRs/DyoVqk1YEUJK5gN9VO7HRIdz4Wo3Q==", + "dev": true + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + } + } +} diff --git a/packages/helpers/package.json b/packages/helpers/package.json new file mode 100644 index 0000000000..c4a5448611 --- /dev/null +++ b/packages/helpers/package.json @@ -0,0 +1,46 @@ +{ + "name": "@actions/helpers", + "version": "1.0.0", + "description": "Helpers for creating actions", + "main": "lib/helpers.js", + "types": "lib/helpers.d.ts", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib", + "!.DS_Store" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json", + "test": "echo \"Error: run tests from root\" && exit 1", + "tsc": "tsc -p tsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/actions/toolkit.git", + "directory": "packages/helpers" + }, + "keywords": [ + "github", + "actions", + "helpers" + ], + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/actions/toolkit/issues" + }, + "homepage": "https://github.com/actions/toolkit#readme", + "devDependencies": { + "@types/node": "^20.5.0" + }, + "dependencies": { + "@actions/core": "^1.10.0", + "@actions/exec": "^1.1.1" + } +} diff --git a/packages/helpers/src/helpers.ts b/packages/helpers/src/helpers.ts new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/packages/helpers/src/helpers.ts @@ -0,0 +1 @@ + diff --git a/packages/helpers/tsconfig.json b/packages/helpers/tsconfig.json new file mode 100644 index 0000000000..3bb20cf8eb --- /dev/null +++ b/packages/helpers/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./lib", + "declaration": true, + "rootDir": "./src" + }, + "include": [ + "./src" + ] +} \ No newline at end of file From 2a75b5e2b38ca403a41eaee8017c9abe9c874c4c Mon Sep 17 00:00:00 2001 From: Dusan Trickovic Date: Fri, 1 Sep 2023 12:42:38 +0200 Subject: [PATCH 02/16] Add initial logic for command wrapper and some basic unit tests --- .../__tests__/exec-command-wrapper.test.ts | 53 ++++++++ packages/helpers/src/exec-command-wrapper.ts | 123 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 packages/helpers/__tests__/exec-command-wrapper.test.ts create mode 100644 packages/helpers/src/exec-command-wrapper.ts diff --git a/packages/helpers/__tests__/exec-command-wrapper.test.ts b/packages/helpers/__tests__/exec-command-wrapper.test.ts new file mode 100644 index 0000000000..91d9d69d53 --- /dev/null +++ b/packages/helpers/__tests__/exec-command-wrapper.test.ts @@ -0,0 +1,53 @@ +import {Command} from '../src/exec-command-wrapper' +import * as io from '@actions/io' + +const IS_LINUX = process.platform === 'linux' + +describe('Command', () => { + it('creates a command object', async () => { + if (IS_LINUX) { + const toolpath = await io.which('echo', true) + const command = new Command(`"${toolpath}"`, ['hello']) + expect(command).toBeDefined() + expect(command).toBeInstanceOf(Command) + } + }) + + it('runs a command with non-zero exit code', async () => { + if (IS_LINUX) { + const nonExistentDir = 'non-existent-dir' + const toolpath = await io.which('ls', true) + const args = ['-l', nonExistentDir] + const command = new Command(`"${toolpath}"`, args) + + let failed = false + + await command.execute().catch(err => { + failed = true + expect(err.message).toContain( + `The process '${toolpath}' failed with exit code ` + ) + }) + + expect(failed).toBe(true) + } + }) + + it('runs a command with zero exit code', async () => { + if (IS_LINUX) { + const toolpath = await io.which('echo', true) + const command = new Command(`"${toolpath}"`, ['hello']) + const result = await command.execute() + expect(result).toEqual('hello') + } + }) + + it('runs a command with empty output', async () => { + if (IS_LINUX) { + const toolpath = await io.which('echo', true) + const command = new Command(`"${toolpath}"`, ['']) + const result = await command.execute() + expect(result).toEqual('') + } + }) +}) diff --git a/packages/helpers/src/exec-command-wrapper.ts b/packages/helpers/src/exec-command-wrapper.ts new file mode 100644 index 0000000000..51b8206f07 --- /dev/null +++ b/packages/helpers/src/exec-command-wrapper.ts @@ -0,0 +1,123 @@ +import * as core from '@actions/core' +import * as exec from '@actions/exec' + +export class Command { + private readonly commandText: string + private readonly args: string[] + private readonly options: exec.ExecOptions | undefined + + private failOnError = false + private throwOnError = false + + private failOnEmptyOutput = false + private throwOnEmptyOutput = false + + constructor( + commandText: string, + args: string[], + options: exec.ExecOptions | undefined = undefined + ) { + this.commandText = commandText + this.args = args + this.options = options + } + + get failOn(): {error: () => Command; empty: () => Command} { + return { + error: this.setFailOnError, + empty: this.setFailOnEmptyOutput + } + } + + get throwOn(): {error: () => Command; empty: () => Command} { + return { + error: this.setThrowOnError, + empty: this.setThrowOnEmptyOutput + } + } + + private setFailOnError = (): Command => { + this.failOnError = true + return this + } + + private setThrowOnError = (): Command => { + this.throwOnError = true + return this + } + + private setFailOnEmptyOutput = (): Command => { + this.failOnEmptyOutput = true + return this + } + + private setThrowOnEmptyOutput = (): Command => { + this.throwOnEmptyOutput = true + return this + } + + private setFailedOnNonZeroExitCode( + command: string, + exitCode: number, + error: string + ): void { + if (exitCode !== 0) { + error = !error.trim() + ? `The '${command}' command failed with exit code: ${exitCode}` + : error + core.setFailed(error) + } + return + } + + private throwErrorOnNonZeroExitCode( + command: string, + exitCode: number, + error: string + ): void { + if (exitCode !== 0) { + error = !error.trim() + ? `The '${command}' command failed with exit code: ${exitCode}` + : error + throw new Error(error) + } + return + } + + async execute(): Promise { + const {stdout, stderr, exitCode} = await exec.getExecOutput( + this.commandText, + this.args, + this.options + ) + + if (this.failOnError) { + this.setFailedOnNonZeroExitCode(this.commandText, exitCode, stderr) + return stdout.trim() + } + + if (this.throwOnError) { + this.throwErrorOnNonZeroExitCode(this.commandText, exitCode, stderr) + return stdout.trim() + } + + if (this.failOnEmptyOutput && !stdout.trim()) { + core.setFailed( + `The '${this.commandText}' command failed with empty output` + ) + return stdout.trim() + } + + if (this.throwOnEmptyOutput && !stdout.trim()) { + throw new Error( + `The '${this.commandText}' command failed with empty output` + ) + } + + return stdout.trim() + } +} + +// new Command('echo', ['hello', 'world']) +// .failOn.error() +// .execute() From 72a2612e0c42ff7fcc72f06fafe16979f8858541 Mon Sep 17 00:00:00 2001 From: Dusan Trickovic Date: Tue, 19 Sep 2023 12:00:52 +0200 Subject: [PATCH 03/16] Implement new logic for the command wrapper and add tests --- .../__tests__/exec-command-wrapper.test.ts | 94 +++++++---- packages/helpers/src/exec-command-wrapper.ts | 154 +++++++----------- 2 files changed, 117 insertions(+), 131 deletions(-) diff --git a/packages/helpers/__tests__/exec-command-wrapper.test.ts b/packages/helpers/__tests__/exec-command-wrapper.test.ts index 91d9d69d53..04401a3fba 100644 --- a/packages/helpers/__tests__/exec-command-wrapper.test.ts +++ b/packages/helpers/__tests__/exec-command-wrapper.test.ts @@ -1,53 +1,83 @@ -import {Command} from '../src/exec-command-wrapper' +import CommandHelper from '../src/exec-command-wrapper' import * as io from '@actions/io' -const IS_LINUX = process.platform === 'linux' +const IS_WINDOWS = process.platform === 'win32' describe('Command', () => { it('creates a command object', async () => { - if (IS_LINUX) { - const toolpath = await io.which('echo', true) - const command = new Command(`"${toolpath}"`, ['hello']) - expect(command).toBeDefined() - expect(command).toBeInstanceOf(Command) + let toolpath: string + let args: string[] + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + args = ['/c', 'echo', 'hello'] + } else { + toolpath = await io.which('echo', true) + args = ['hello'] } + const command = new CommandHelper(`"${toolpath}"`, args) + expect(command).toBeDefined() + expect(command).toBeInstanceOf(CommandHelper) }) it('runs a command with non-zero exit code', async () => { - if (IS_LINUX) { - const nonExistentDir = 'non-existent-dir' - const toolpath = await io.which('ls', true) - const args = ['-l', nonExistentDir] - const command = new Command(`"${toolpath}"`, args) - - let failed = false - - await command.execute().catch(err => { - failed = true - expect(err.message).toContain( - `The process '${toolpath}' failed with exit code ` - ) - }) - - expect(failed).toBe(true) + let toolpath: string + let args: string[] + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + args = ['/c', 'dir', 'non-existent-dir'] + } else { + toolpath = await io.which('ls', true) + args = ['-l', 'non-existent-dir'] + } + const command = new CommandHelper(`"${toolpath}"`, args, undefined, { + throwOnEmptyOutput: true + }) + try { + const result = await command.execute() + expect(result.exitCode).not.toEqual(0) + } catch (err) { + expect(err.message).toContain( + `The process '${toolpath}' failed with exit code ` + ) } }) it('runs a command with zero exit code', async () => { - if (IS_LINUX) { - const toolpath = await io.which('echo', true) - const command = new Command(`"${toolpath}"`, ['hello']) - const result = await command.execute() - expect(result).toEqual('hello') + let toolpath: string + let args: string[] + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + args = ['/c', 'echo', 'hello'] + } else { + toolpath = await io.which('echo', true) + args = ['hello'] } + const command = new CommandHelper(`"${toolpath}"`, args) + const result = await command.execute() + + expect(result.stdout).toContain('hello') + expect(result.exitCode).toEqual(0) }) it('runs a command with empty output', async () => { - if (IS_LINUX) { - const toolpath = await io.which('echo', true) - const command = new Command(`"${toolpath}"`, ['']) + let toolpath: string + let args: string[] + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + args = ['/c', 'echo.'] + } else { + toolpath = await io.which('echo', true) + args = [''] + } + + const command = new CommandHelper(`"${toolpath}"`, args, undefined, { + throwOnEmptyOutput: true + }) + try { const result = await command.execute() - expect(result).toEqual('') + expect(result.stdout).toBe('') + } catch (err) { + expect(err.message).toContain('Command produced empty output.') } }) }) diff --git a/packages/helpers/src/exec-command-wrapper.ts b/packages/helpers/src/exec-command-wrapper.ts index 51b8206f07..3882a20858 100644 --- a/packages/helpers/src/exec-command-wrapper.ts +++ b/packages/helpers/src/exec-command-wrapper.ts @@ -1,123 +1,79 @@ -import * as core from '@actions/core' import * as exec from '@actions/exec' +import * as core from '@actions/core' -export class Command { - private readonly commandText: string - private readonly args: string[] - private readonly options: exec.ExecOptions | undefined - - private failOnError = false - private throwOnError = false +export default class CommandHelper { + private commandText: string + private args: string[] + private options: exec.ExecOptions | undefined - private failOnEmptyOutput = false - private throwOnEmptyOutput = false + private throwOnError: boolean + private throwOnEmptyOutput: boolean + private failOnError: boolean + private failOnEmptyOutput: boolean constructor( commandText: string, - args: string[], - options: exec.ExecOptions | undefined = undefined + args: string[] = [], + options: exec.ExecOptions | undefined = {}, + config: { + throwOnError?: boolean + throwOnEmptyOutput?: boolean + failOnError?: boolean + failOnEmptyOutput?: boolean + } = {} ) { this.commandText = commandText this.args = args this.options = options + this.throwOnError = config.throwOnError ?? false + this.throwOnEmptyOutput = config.throwOnEmptyOutput ?? false + this.failOnError = config.failOnError ?? false + this.failOnEmptyOutput = config.failOnEmptyOutput ?? false } - get failOn(): {error: () => Command; empty: () => Command} { - return { - error: this.setFailOnError, - empty: this.setFailOnEmptyOutput - } - } - - get throwOn(): {error: () => Command; empty: () => Command} { - return { - error: this.setThrowOnError, - empty: this.setThrowOnEmptyOutput - } - } + async execute(): Promise { + try { + const output = await exec.getExecOutput( + this.commandText, + this.args, + this.options + ) - private setFailOnError = (): Command => { - this.failOnError = true - return this - } + if (this.throwOnError && output.stderr) { + this.onError(output.stderr).throw() + } - private setThrowOnError = (): Command => { - this.throwOnError = true - return this - } + if (this.throwOnEmptyOutput && output.stdout.trim() === '') { + this.onError('Command produced empty output.').throw() + } - private setFailOnEmptyOutput = (): Command => { - this.failOnEmptyOutput = true - return this - } + if (this.failOnError && output.stderr) { + this.onError(output.stderr).fail() + } - private setThrowOnEmptyOutput = (): Command => { - this.throwOnEmptyOutput = true - return this - } + if (this.failOnEmptyOutput && output.stdout.trim() === '') { + this.onError('Command produced empty output.').fail() + } - private setFailedOnNonZeroExitCode( - command: string, - exitCode: number, - error: string - ): void { - if (exitCode !== 0) { - error = !error.trim() - ? `The '${command}' command failed with exit code: ${exitCode}` - : error - core.setFailed(error) + return output + } catch (error) { + throw new Error((error as Error).message) } - return } - private throwErrorOnNonZeroExitCode( - command: string, - exitCode: number, - error: string - ): void { - if (exitCode !== 0) { - error = !error.trim() - ? `The '${command}' command failed with exit code: ${exitCode}` - : error - throw new Error(error) - } - return - } - - async execute(): Promise { - const {stdout, stderr, exitCode} = await exec.getExecOutput( - this.commandText, - this.args, - this.options - ) - - if (this.failOnError) { - this.setFailedOnNonZeroExitCode(this.commandText, exitCode, stderr) - return stdout.trim() - } - - if (this.throwOnError) { - this.throwErrorOnNonZeroExitCode(this.commandText, exitCode, stderr) - return stdout.trim() - } + private onError(errorMessage: string): { + throw: () => never + fail: () => void + } { + core.error(`Error occurred: ${errorMessage}`) - if (this.failOnEmptyOutput && !stdout.trim()) { - core.setFailed( - `The '${this.commandText}' command failed with empty output` - ) - return stdout.trim() - } - - if (this.throwOnEmptyOutput && !stdout.trim()) { - throw new Error( - `The '${this.commandText}' command failed with empty output` - ) + return { + throw: () => { + throw new Error(errorMessage) + }, + fail: () => { + core.setFailed(errorMessage) + } } - - return stdout.trim() } } - -// new Command('echo', ['hello', 'world']) -// .failOn.error() -// .execute() From 31cd25530e98888bcbc205a5687566f2dcae78e0 Mon Sep 17 00:00:00 2001 From: Dusan Trickovic Date: Tue, 19 Sep 2023 12:28:23 +0200 Subject: [PATCH 04/16] Refactor the code inside the constructor --- packages/helpers/src/exec-command-wrapper.ts | 33 +++++++------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/helpers/src/exec-command-wrapper.ts b/packages/helpers/src/exec-command-wrapper.ts index 3882a20858..317bf5965b 100644 --- a/packages/helpers/src/exec-command-wrapper.ts +++ b/packages/helpers/src/exec-command-wrapper.ts @@ -2,20 +2,11 @@ import * as exec from '@actions/exec' import * as core from '@actions/core' export default class CommandHelper { - private commandText: string - private args: string[] - private options: exec.ExecOptions | undefined - - private throwOnError: boolean - private throwOnEmptyOutput: boolean - private failOnError: boolean - private failOnEmptyOutput: boolean - constructor( - commandText: string, - args: string[] = [], - options: exec.ExecOptions | undefined = {}, - config: { + private commandText: string, + private args: string[] = [], + private options: exec.ExecOptions | undefined = {}, + private config: { throwOnError?: boolean throwOnEmptyOutput?: boolean failOnError?: boolean @@ -25,10 +16,10 @@ export default class CommandHelper { this.commandText = commandText this.args = args this.options = options - this.throwOnError = config.throwOnError ?? false - this.throwOnEmptyOutput = config.throwOnEmptyOutput ?? false - this.failOnError = config.failOnError ?? false - this.failOnEmptyOutput = config.failOnEmptyOutput ?? false + this.config.throwOnError = config.throwOnError ?? false + this.config.throwOnEmptyOutput = config.throwOnEmptyOutput ?? false + this.config.failOnError = config.failOnError ?? false + this.config.failOnEmptyOutput = config.failOnEmptyOutput ?? false } async execute(): Promise { @@ -39,19 +30,19 @@ export default class CommandHelper { this.options ) - if (this.throwOnError && output.stderr) { + if (this.config.throwOnError && output.stderr) { this.onError(output.stderr).throw() } - if (this.throwOnEmptyOutput && output.stdout.trim() === '') { + if (this.config.throwOnEmptyOutput && output.stdout.trim() === '') { this.onError('Command produced empty output.').throw() } - if (this.failOnError && output.stderr) { + if (this.config.failOnError && output.stderr) { this.onError(output.stderr).fail() } - if (this.failOnEmptyOutput && output.stdout.trim() === '') { + if (this.config.failOnEmptyOutput && output.stdout.trim() === '') { this.onError('Command produced empty output.').fail() } From 45c24094535a9855a4a44c8fe87b658b49cfee6d Mon Sep 17 00:00:00 2001 From: Dusan Trickovic Date: Tue, 19 Sep 2023 12:36:22 +0200 Subject: [PATCH 05/16] Change output message --- packages/helpers/__tests__/exec-command-wrapper.test.ts | 2 +- packages/helpers/src/exec-command-wrapper.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/helpers/__tests__/exec-command-wrapper.test.ts b/packages/helpers/__tests__/exec-command-wrapper.test.ts index 04401a3fba..ffbf208dda 100644 --- a/packages/helpers/__tests__/exec-command-wrapper.test.ts +++ b/packages/helpers/__tests__/exec-command-wrapper.test.ts @@ -77,7 +77,7 @@ describe('Command', () => { const result = await command.execute() expect(result.stdout).toBe('') } catch (err) { - expect(err.message).toContain('Command produced empty output.') + expect(err.message).toContain('The command produced an empty output.') } }) }) diff --git a/packages/helpers/src/exec-command-wrapper.ts b/packages/helpers/src/exec-command-wrapper.ts index 317bf5965b..b0dd3ccf8a 100644 --- a/packages/helpers/src/exec-command-wrapper.ts +++ b/packages/helpers/src/exec-command-wrapper.ts @@ -35,7 +35,7 @@ export default class CommandHelper { } if (this.config.throwOnEmptyOutput && output.stdout.trim() === '') { - this.onError('Command produced empty output.').throw() + this.onError(`The command produced an empty output.`).throw() } if (this.config.failOnError && output.stderr) { @@ -43,7 +43,7 @@ export default class CommandHelper { } if (this.config.failOnEmptyOutput && output.stdout.trim() === '') { - this.onError('Command produced empty output.').fail() + this.onError(`The command produced an empty output.`).fail() } return output @@ -56,8 +56,6 @@ export default class CommandHelper { throw: () => never fail: () => void } { - core.error(`Error occurred: ${errorMessage}`) - return { throw: () => { throw new Error(errorMessage) From c00940a8206418c1e188e223c490c649aa6ea932 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Fri, 29 Sep 2023 16:17:46 +0200 Subject: [PATCH 06/16] Add middleware for command-runner --- .../helpers/__tests__/command-runner.test.ts | 202 ++++++++ ....test.ts => exec-command-wrapper.test1.ts} | 0 .../src/command-runner/command-runner.ts | 157 ++++++ packages/helpers/src/command-runner/core.ts | 125 +++++ packages/helpers/src/command-runner/index.ts | 1 + .../helpers/src/command-runner/middleware.ts | 474 ++++++++++++++++++ packages/helpers/src/command-runner/test.ts | 18 + packages/helpers/src/command-runner/types.ts | 44 ++ packages/helpers/src/helpers.ts | 2 +- 9 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 packages/helpers/__tests__/command-runner.test.ts rename packages/helpers/__tests__/{exec-command-wrapper.test.ts => exec-command-wrapper.test1.ts} (100%) create mode 100644 packages/helpers/src/command-runner/command-runner.ts create mode 100644 packages/helpers/src/command-runner/core.ts create mode 100644 packages/helpers/src/command-runner/index.ts create mode 100644 packages/helpers/src/command-runner/middleware.ts create mode 100644 packages/helpers/src/command-runner/test.ts create mode 100644 packages/helpers/src/command-runner/types.ts diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/helpers/__tests__/command-runner.test.ts new file mode 100644 index 0000000000..7dd2873242 --- /dev/null +++ b/packages/helpers/__tests__/command-runner.test.ts @@ -0,0 +1,202 @@ +import * as exec from '@actions/exec' +import {CommandRunner, commandPipeline} from '../src/helpers' + +describe('command-runner', () => { + describe('commandPipeline', () => { + it('creates a command object', async () => { + const command = commandPipeline('echo') + expect(command).toBeDefined() + expect(command).toBeInstanceOf(CommandRunner) + }) + }) + + describe('CommandRunner', () => { + const execSpy = jest.spyOn(exec, 'getExecOutput') + + afterEach(() => { + jest.resetAllMocks() + }) + + it('runs basic commands', async () => { + execSpy.mockImplementation(async () => + Promise.resolve({ + stdout: 'hello', + stderr: '', + exitCode: 0 + }) + ) + + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + command.run() + + expect(execSpy).toHaveBeenCalledTimes(1) + expect(execSpy).toHaveBeenCalledWith('echo', ['hello', 'world'], { + silent: true + }) + }) + + it('overrides args with addArgs and withArgs', async () => { + execSpy.mockImplementation(async () => + Promise.resolve({ + stdout: 'hello', + stderr: '', + exitCode: 0 + }) + ) + + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + + await command.withArgs('bye').run() + + expect(execSpy).toHaveBeenCalledWith('echo', ['bye'], { + silent: true + }) + + execSpy.mockClear() + + await command.addArgs('and stuff').run() + + expect(execSpy).toHaveBeenCalledWith( + 'echo', + ['hello', 'world', 'and stuff'], + { + silent: true + } + ) + }) + + it('allows to use middlewares', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + + const middleware = jest.fn() + + await command.use(middleware).run() + + expect(middleware).toHaveBeenCalledTimes(1) + + expect(middleware).toHaveBeenCalledWith( + expect.objectContaining({ + commandLine: 'echo', + args: ['hello', 'world'], + options: { + silent: true + }, + stdout: 'hello', + stderr: '', + exitCode: 0, + execerr: null, + state: null + }), + expect.any(Function) + ) + }) + + describe('CommandRunner.prototype.on', () => { + it('passes control to next middleware if nothing has matched', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const willBeCalled = jest.fn() + const willNotBeCalled = jest.fn() + await commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + .on('no-stdout', willNotBeCalled) + .use(willBeCalled) + .run() + + expect(willNotBeCalled).not.toHaveBeenCalled() + expect(willBeCalled).toHaveBeenCalledTimes(1) + }) + + it('runs a middleware if event matches', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const middleware = jest.fn() + + await commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + .on('ok', middleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + }) + + it('runs a middleware if event matches with negation', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const middleware = jest.fn() + await commandPipeline('echo', ['hello', 'world'], { + silent: true + }) + .on('!no-stdout', middleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + }) + + it('runs a middleware on multiple events', async () => { + execSpy.mockImplementation(async () => { + return { + stdout: 'hello', + stderr: '', + exitCode: 0 + } + }) + + const middleware = jest.fn() + const command = commandPipeline('echo', ['hello', 'world'], { + silent: true + }).on(['!no-stdout', 'ok'], middleware) + + await command.run() + + expect(middleware).toHaveBeenCalledTimes(1) + + execSpy.mockImplementation(async () => { + return { + stdout: '', + stderr: '', + exitCode: 1 + } + }) + + await command.run() + + expect(middleware).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/packages/helpers/__tests__/exec-command-wrapper.test.ts b/packages/helpers/__tests__/exec-command-wrapper.test1.ts similarity index 100% rename from packages/helpers/__tests__/exec-command-wrapper.test.ts rename to packages/helpers/__tests__/exec-command-wrapper.test1.ts diff --git a/packages/helpers/src/command-runner/command-runner.ts b/packages/helpers/src/command-runner/command-runner.ts new file mode 100644 index 0000000000..fd7cbbe27f --- /dev/null +++ b/packages/helpers/src/command-runner/command-runner.ts @@ -0,0 +1,157 @@ +import * as exec from '@actions/exec' +import {CommandRunnerBase} from './core' +import { + ErrorMatcher, + ExitCodeMatcher, + OutputMatcher, + failAction, + matchEvent, + matchExitCode, + matchOutput, + matchSpecificError, + produceLog, + throwError +} from './middleware' +import { + CommandRunnerActionType, + CommandRunnerEventTypeExtended, + CommandRunnerMiddleware +} from './types' + +const commandRunnerActions = { + throw: throwError, + fail: failAction, + log: produceLog +} as const + +export class CommandRunner extends CommandRunnerBase { + on( + event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchEvent(event, middleware as CommandRunnerMiddleware[])) + + return this + } + + onEmptyOutput( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + this.on('no-stdout', action, message) + + return this + } + + onExecutionError( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use( + matchSpecificError( + ({type}) => type === 'execerr', + middleware as CommandRunnerMiddleware[] + ) + ) + + return this + } + + onStdError( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use( + matchSpecificError( + ({type}) => type === 'stderr', + middleware as CommandRunnerMiddleware[] + ) + ) + + return this + } + + onError( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + return this.on(['execerr', 'stderr'], action, message) + } + + onSpecificError( + matcher: ErrorMatcher, + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use( + matchSpecificError(matcher, middleware as CommandRunnerMiddleware[]) + ) + + return this + } + + onSuccess( + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + return this.on('ok', action, message) + } + + onExitCode( + matcher: ExitCodeMatcher, + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchExitCode(matcher, middleware as CommandRunnerMiddleware[])) + + return this + } + + onOutput( + matcher: OutputMatcher, + action: CommandRunnerActionType | CommandRunnerMiddleware, + message?: string + ): this { + const middleware = + typeof action === 'string' + ? [commandRunnerActions[action](message)] + : [action] + + this.use(matchOutput(matcher, middleware as CommandRunnerMiddleware[])) + + return this + } +} + +export const commandPipeline = ( + commandLine: string, + args: string[] = [], + options: Record = {} +): CommandRunner => + new CommandRunner(commandLine, args, options, exec.getExecOutput) diff --git a/packages/helpers/src/command-runner/core.ts b/packages/helpers/src/command-runner/core.ts new file mode 100644 index 0000000000..5efa7e7cc4 --- /dev/null +++ b/packages/helpers/src/command-runner/core.ts @@ -0,0 +1,125 @@ +import * as exec from '@actions/exec' +import { + CommandRunnerContext, + CommandRunnerMiddleware, + CommandRunnerMiddlewarePromisified +} from './types' + +export const promisifyCommandRunnerMiddleware = + ( + middleware: CommandRunnerMiddleware + ): CommandRunnerMiddlewarePromisified => + async (ctx, next) => { + return Promise.resolve(middleware(ctx, next)) + } + +export const composeCommandRunnerMiddleware = + (middleware: CommandRunnerMiddlewarePromisified[]) => + async (context: CommandRunnerContext, nextGlobal: () => Promise) => { + let index = 0 + + const nextLocal = async (): Promise => { + if (index < middleware.length) { + const currentMiddleware = middleware[index++] + if (middleware === undefined) { + return + } + + await currentMiddleware(context, nextLocal) + } + + await nextGlobal() + } + + await nextLocal() + } + +export class CommandRunnerBase { + private middleware: CommandRunnerMiddlewarePromisified[] = [] + private tmpArgs: string[] = [] + + constructor( + private commandLine: string, + private args: string[] = [], + private options: exec.ExecOptions = {}, + private executor: typeof exec.getExecOutput = exec.getExecOutput + ) {} + + /** + * Adds additional arguments to the command + * for the one time execution. + */ + addArgs(...args: string[]): this { + this.tmpArgs = [...this.args, ...args] + return this + } + + /** Overrides command arguments for one time execution */ + withArgs(...args: string[]): this { + this.tmpArgs = args + return this + } + + /** Retrieves args for one-time execution and clears them afterwards */ + private getTmpArgs(): string[] | null { + if (this.tmpArgs.length === 0) return null + + const args = this.tmpArgs + + this.tmpArgs = [] + + return args + } + + use(middleware: CommandRunnerMiddleware): this { + this.middleware.push( + promisifyCommandRunnerMiddleware( + middleware as CommandRunnerMiddleware + ) + ) + return this + } + + async run( + /* overrides command for this specific execution if not undefined */ + commandLine?: string, + + /* overrides args for this specific execution if not undefined */ + args?: string[], + + /* overrides options for this specific execution if not undefined */ + options?: exec.ExecOptions + ): Promise> { + const tmpArgs = this.getTmpArgs() + + const context: CommandRunnerContext = { + commandLine: commandLine ?? this.commandLine, + args: args ?? tmpArgs ?? this.args, + options: options ?? this.options, + stdout: null, + stderr: null, + execerr: null, + exitCode: null, + state: null + } + + try { + const {stdout, stderr, exitCode} = await this.executor( + context.commandLine, + context.args, + context.options + ) + + context.stdout = stdout + context.stderr = stderr + context.exitCode = exitCode + } catch (error) { + context.execerr = error as Error + } + + const next = async (): Promise => Promise.resolve() + await composeCommandRunnerMiddleware(this.middleware)(context, next) + + return context + } +} diff --git a/packages/helpers/src/command-runner/index.ts b/packages/helpers/src/command-runner/index.ts new file mode 100644 index 0000000000..24e10dc294 --- /dev/null +++ b/packages/helpers/src/command-runner/index.ts @@ -0,0 +1 @@ +export {commandPipeline, CommandRunner} from './command-runner' diff --git a/packages/helpers/src/command-runner/middleware.ts b/packages/helpers/src/command-runner/middleware.ts new file mode 100644 index 0000000000..2ba844968b --- /dev/null +++ b/packages/helpers/src/command-runner/middleware.ts @@ -0,0 +1,474 @@ +import * as core from '@actions/core' +import { + CommandRunnerContext, + CommandRunnerEventType, + CommandRunnerEventTypeExtended, + CommandRunnerMiddleware, + CommandRunnerMiddlewarePromisified +} from './types' +import { + composeCommandRunnerMiddleware, + promisifyCommandRunnerMiddleware +} from './core' + +const getEventTypesFromContext = ( + ctx: CommandRunnerContext +): CommandRunnerEventType[] => { + const eventTypes = new Set() + + if (ctx.execerr) { + eventTypes.add('execerr') + } + + if (ctx.stderr || ctx.exitCode !== 0) { + eventTypes.add('stderr') + } + + if (ctx.stdout !== null && !ctx.stdout.trim()) { + eventTypes.add('no-stdout') + } + + if (!ctx.stderr && !ctx.execerr && ctx.stdout !== null && !ctx.exitCode) { + eventTypes.add('ok') + } + + return [...eventTypes] +} + +type CommandRunnerAction = ( + message?: + | string + | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) +) => CommandRunnerMiddlewarePromisified + +/** + * Basic middleware + */ + +/** + * Fails Github Action with the given message or with a default one depending on execution conditions. + */ +export const failAction: CommandRunnerAction = message => async ctx => { + const events = getEventTypesFromContext(ctx) + + if (message !== undefined) { + core.setFailed(typeof message === 'string' ? message : message(ctx, events)) + return + } + + if (events.includes('execerr')) { + core.setFailed( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + + return + } + + if (events.includes('stderr')) { + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + + return + } + + if (events.includes('no-stdout')) { + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + + return + } + + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) +} + +/** + * Throws an error with the given message or with a default one depending on execution conditions. + */ +export const throwError: CommandRunnerAction = message => async ctx => { + const events = getEventTypesFromContext(ctx) + + if (message !== undefined) { + throw new Error( + typeof message === 'string' ? message : message(ctx, events) + ) + } + + if (events.includes('execerr')) { + throw new Error( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + } + + if (events.includes('stderr')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + } + + if (events.includes('no-stdout')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + } + + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) +} + +/** + * Logs a message with the given message or with a default one depending on execution conditions. + */ +export const produceLog: CommandRunnerAction = message => async (ctx, next) => { + const events = getEventTypesFromContext(ctx) + + if (message !== undefined) { + // core.info(typeof message === 'string' ? message : message(ctx, [])) + const messageText = + typeof message === 'string' ? message : message(ctx, events) + + if (events.includes('execerr')) { + core.error(messageText) + next() + return + } + + if (events.includes('stderr')) { + core.error(messageText) + next() + return + } + + if (events.includes('no-stdout')) { + core.warning(messageText) + next() + return + } + + if (events.includes('ok')) { + core.notice(messageText) + next() + return + } + + core.info(messageText) + next() + return + } + + if (events.includes('execerr')) { + core.error( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + next() + return + } + + if (events.includes('stderr')) { + core.error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + next() + return + } + + if (events.includes('no-stdout')) { + core.warning( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + next() + return + } + + if (events.includes('ok')) { + core.notice( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + next() + return + } + + core.info( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + next() +} + +/** + * Filtering middleware + */ + +/** Calls next middleware */ +export const passThrough: () => CommandRunnerMiddlewarePromisified = + () => async (_, next) => + next() + +/** + * Either calls next middleware or not depending on the result of the given condition. + */ +export const filter: ( + shouldPass: + | boolean + | ((ctx: CommandRunnerContext) => boolean | Promise) +) => CommandRunnerMiddlewarePromisified = shouldPass => async (ctx, next) => { + if (typeof shouldPass === 'function') { + if (await shouldPass(ctx)) { + next() + return + } + } +} + +/** + * Will call passed middleware if matching event has occured. + * Will call the next middleware otherwise. + */ +export const matchEvent = ( + eventType: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + const expectedEventsPositiveArray = ( + Array.isArray(eventType) ? eventType : [eventType] + ).filter(e => !e.startsWith('!')) as CommandRunnerEventType[] + + const expectedEventsNegativeArray = ( + Array.isArray(eventType) ? eventType : [eventType] + ) + .filter(e => e.startsWith('!')) + .map(e => e.slice(1)) as CommandRunnerEventType[] + + const expectedEventsPositive = new Set(expectedEventsPositiveArray) + const expectedEventsNegative = new Set(expectedEventsNegativeArray) + + return async (ctx, next) => { + const currentEvents = getEventTypesFromContext(ctx) + let shouldRun = false + + if ( + expectedEventsPositive.size && + currentEvents.some(e => expectedEventsPositive.has(e)) + ) { + shouldRun = true + } + + if ( + expectedEventsNegative.size && + currentEvents.every(e => !expectedEventsNegative.has(e)) + ) { + shouldRun = true + } + + if (shouldRun) { + composedMiddleware(ctx, next) + return + } + + next() + } +} + +export type OutputMatcher = RegExp | string | ((output: string) => boolean) + +/** + * Will call passed middleware if matching event has occured. + * Will call the next middleware otherwise. + */ +export const matchOutput = ( + matcher: OutputMatcher, + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + return async (ctx, next) => { + const output = ctx.stdout + + if (output === null) { + next() + return + } + + if (typeof matcher === 'function' && !matcher(output)) { + next() + return + } + + if (typeof matcher === 'string' && output !== matcher) { + next() + return + } + + if (matcher instanceof RegExp && !matcher.test(output)) { + next() + return + } + + composedMiddleware(ctx, next) + } +} + +export type ExitCodeMatcher = string | number + +const lte = + (a: number) => + (b: number): boolean => + b <= a +const gte = + (a: number) => + (b: number): boolean => + b >= a +const lt = + (a: number) => + (b: number): boolean => + b < a +const gt = + (a: number) => + (b: number): boolean => + b > a +const eq = + (a: number) => + (b: number): boolean => + b === a + +const matchers = { + '>=': gte, + '>': gt, + '<=': lte, + '<': lt, + '=': eq +} as const + +const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') + +const parseExitCodeMatcher = ( + code: ExitCodeMatcher +): [keyof typeof matchers, number] => { + if (typeof code === 'number') { + return ['=', code] + } + + code = removeWhitespaces(code) + + // just shortcuts for the most common cases + if (code.startsWith('=')) return ['=', Number(code.slice(1))] + if (code === '>0') return ['>', 0] + if (code === '<1') return ['<', 1] + + const match = code.match(/^([><]=?)(\d+)$/) + + if (match === null) { + throw new Error(`Invalid exit code matcher: ${code}`) + } + + const [, operator, number] = match + return [operator as keyof typeof matchers, parseInt(number)] +} + +const matcherToMatcherFn = ( + matcher: ExitCodeMatcher +): ((exitCode: number) => boolean) => { + const [operator, number] = parseExitCodeMatcher(matcher) + return matchers[operator](number) +} + +/** + * Will call passed middleware if matching exit code was returned. + * Will call the next middleware otherwise. + */ +export const matchExitCode = ( + code: ExitCodeMatcher, + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + const matcher = matcherToMatcherFn(code) + + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + return async (ctx, next) => { + // if exit code is undefined, NaN will not match anything + if (matcher(ctx.exitCode ?? NaN)) { + composedMiddleware(ctx, next) + return + } + + next() + } +} + +export type ErrorMatcher = + | RegExp + | string + | ((error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + }) => boolean) + +export const matchSpecificError = ( + matcher: ErrorMatcher, + middleware?: CommandRunnerMiddleware[] +): CommandRunnerMiddlewarePromisified => { + if (!middleware?.length) { + middleware = [passThrough()] + } + + const composedMiddleware = composeCommandRunnerMiddleware( + middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + ) + + return async (ctx, next) => { + if (ctx.execerr === null && ctx.stderr === null) { + next() + return + } + + const error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + } = { + type: ctx.execerr ? 'execerr' : 'stderr', + error: ctx.execerr ? ctx.execerr : null, + message: ctx.execerr ? ctx.execerr.message : ctx.stderr ?? '' + } + + if (typeof matcher === 'function' && !matcher(error)) { + next() + return + } + + if (typeof matcher === 'string' && error.message !== matcher) { + next() + return + } + + if (matcher instanceof RegExp && !matcher.test(error.message)) { + next() + return + } + + composedMiddleware(ctx, next) + } +} diff --git a/packages/helpers/src/command-runner/test.ts b/packages/helpers/src/command-runner/test.ts new file mode 100644 index 0000000000..49a7c028ff --- /dev/null +++ b/packages/helpers/src/command-runner/test.ts @@ -0,0 +1,18 @@ +import {CommandRunner} from './command-runner' +import * as io from '@actions/io' + +;(async () => { + const toolpath = await io.which('cmd', true) + const args = ['/c', 'echo'] + + const echo = new CommandRunner('echo') + + echo + .on('exec-error', 'log') + .use(async (ctx, next) => { + console.log('success') + next() + }) + .addArgs('hello') + .run() +})() diff --git a/packages/helpers/src/command-runner/types.ts b/packages/helpers/src/command-runner/types.ts new file mode 100644 index 0000000000..478d695121 --- /dev/null +++ b/packages/helpers/src/command-runner/types.ts @@ -0,0 +1,44 @@ +import * as exec from '@actions/exec' + +/* CommandRunner core */ + +export interface CommandRunnerContext { + /* Inputs with which command was executed */ + commandLine: string + args: string[] + options: exec.ExecOptions + + /* Results of the execution */ + execerr: Error | null + stderr: string | null + stdout: string | null + exitCode: number | null + + /* Arbitrary state that can be change during middleware execution if needed */ + state: S | null +} + +/* Middlewares as used internally in CommandRunner */ +export type CommandRunnerMiddlewarePromisified = ( + ctx: CommandRunnerContext, + next: () => Promise +) => Promise + +/* Middlewares as used by the user */ +export type CommandRunnerMiddleware = ( + ctx: CommandRunnerContext, + next: () => Promise +) => void | Promise + +/* Command runner events handling and command runner actions */ + +/* Command runner default actions types on which preset middleware exists */ +export type CommandRunnerActionType = 'throw' | 'fail' | 'log' + +/* Command runner event types as used internally passed to middleware for the user */ +export type CommandRunnerEventType = 'execerr' | 'stderr' | 'no-stdout' | 'ok' + +/* Command runner event types as used by the user for filtering results */ +export type CommandRunnerEventTypeExtended = + | CommandRunnerEventType + | `!${CommandRunnerEventType}` diff --git a/packages/helpers/src/helpers.ts b/packages/helpers/src/helpers.ts index 8b13789179..981c07b582 100644 --- a/packages/helpers/src/helpers.ts +++ b/packages/helpers/src/helpers.ts @@ -1 +1 @@ - +export * from './command-runner' From b4df76861f701eff83ad866c3a33a4cf1065b3b8 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Wed, 4 Oct 2023 07:57:29 +0200 Subject: [PATCH 07/16] Remove redundant featurs, overhaul API --- .../helpers/__tests__/command-runner.test.ts | 146 ++++++++---------- .../src/command-runner/command-runner.ts | 14 +- packages/helpers/src/command-runner/core.ts | 73 ++++----- packages/helpers/src/command-runner/index.ts | 2 +- .../helpers/src/command-runner/middleware.ts | 18 ++- packages/helpers/src/command-runner/test.ts | 18 --- packages/helpers/src/command-runner/types.ts | 12 +- 7 files changed, 123 insertions(+), 160 deletions(-) delete mode 100644 packages/helpers/src/command-runner/test.ts diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/helpers/__tests__/command-runner.test.ts index 7dd2873242..cbd6ba6a75 100644 --- a/packages/helpers/__tests__/command-runner.test.ts +++ b/packages/helpers/__tests__/command-runner.test.ts @@ -1,84 +1,68 @@ import * as exec from '@actions/exec' -import {CommandRunner, commandPipeline} from '../src/helpers' +import {CommandRunner, createCommandRunner} from '../src/helpers' describe('command-runner', () => { - describe('commandPipeline', () => { + describe('createCommandRunner', () => { it('creates a command object', async () => { - const command = commandPipeline('echo') + const command = createCommandRunner('echo') expect(command).toBeDefined() expect(command).toBeInstanceOf(CommandRunner) }) }) describe('CommandRunner', () => { - const execSpy = jest.spyOn(exec, 'getExecOutput') + const execSpy = jest.spyOn(exec, 'exec') afterEach(() => { jest.resetAllMocks() }) it('runs basic commands', async () => { - execSpy.mockImplementation(async () => - Promise.resolve({ - stdout: 'hello', - stderr: '', - exitCode: 0 - }) - ) + execSpy.mockImplementation(async () => 0) - const command = commandPipeline('echo', ['hello', 'world'], { + const command = createCommandRunner('echo', ['hello', 'world'], { silent: true }) command.run() expect(execSpy).toHaveBeenCalledTimes(1) - expect(execSpy).toHaveBeenCalledWith('echo', ['hello', 'world'], { - silent: true - }) - }) - - it('overrides args with addArgs and withArgs', async () => { - execSpy.mockImplementation(async () => - Promise.resolve({ - stdout: 'hello', - stderr: '', - exitCode: 0 - }) - ) - - const command = commandPipeline('echo', ['hello', 'world'], { - silent: true - }) - - await command.withArgs('bye').run() - - expect(execSpy).toHaveBeenCalledWith('echo', ['bye'], { - silent: true - }) - - execSpy.mockClear() - - await command.addArgs('and stuff').run() - expect(execSpy).toHaveBeenCalledWith( 'echo', - ['hello', 'world', 'and stuff'], - { - silent: true - } + ['hello', 'world'], + expect.objectContaining({ + silent: true, + ignoreReturnCode: true + }) ) }) + const createExecMock = (output: { + stdout: string + stderr: string + exitCode: number + }): typeof exec.exec => { + const stdoutBuffer = Buffer.from(output.stdout, 'utf8') + const stderrBuffer = Buffer.from(output.stderr, 'utf8') + + return async ( + commandLine?: string, + args?: string[], + options?: exec.ExecOptions + ) => { + options?.listeners?.stdout?.(stdoutBuffer) + options?.listeners?.stderr?.(stderrBuffer) + + await new Promise(resolve => setTimeout(resolve, 5)) + return output.exitCode + } + } + it('allows to use middlewares', async () => { - execSpy.mockImplementation(async () => { - return { - stdout: 'hello', - stderr: '', - exitCode: 0 - } - }) + execSpy.mockImplementation( + createExecMock({stdout: 'hello', stderr: '', exitCode: 0}) + ) - const command = commandPipeline('echo', ['hello', 'world'], { + const command = createCommandRunner('echo', ['hello', 'world'], { silent: true }) @@ -92,9 +76,9 @@ describe('command-runner', () => { expect.objectContaining({ commandLine: 'echo', args: ['hello', 'world'], - options: { + options: expect.objectContaining({ silent: true - }, + }), stdout: 'hello', stderr: '', exitCode: 0, @@ -107,20 +91,20 @@ describe('command-runner', () => { describe('CommandRunner.prototype.on', () => { it('passes control to next middleware if nothing has matched', async () => { - execSpy.mockImplementation(async () => { - return { + execSpy.mockImplementation( + createExecMock({ stdout: 'hello', stderr: '', exitCode: 0 - } - }) + }) + ) const willBeCalled = jest.fn() const willNotBeCalled = jest.fn() - await commandPipeline('echo', ['hello', 'world'], { + await createCommandRunner('echo', ['hello', 'world'], { silent: true }) - .on('no-stdout', willNotBeCalled) + .on('!stdout', willNotBeCalled) .use(willBeCalled) .run() @@ -129,17 +113,13 @@ describe('command-runner', () => { }) it('runs a middleware if event matches', async () => { - execSpy.mockImplementation(async () => { - return { - stdout: 'hello', - stderr: '', - exitCode: 0 - } - }) + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 0}) + ) const middleware = jest.fn() - await commandPipeline('echo', ['hello', 'world'], { + await createCommandRunner('echo', ['hello', 'world'], { silent: true }) .on('ok', middleware) @@ -149,35 +129,30 @@ describe('command-runner', () => { }) it('runs a middleware if event matches with negation', async () => { - execSpy.mockImplementation(async () => { - return { - stdout: 'hello', - stderr: '', - exitCode: 0 - } - }) + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 1}) + ) const middleware = jest.fn() - await commandPipeline('echo', ['hello', 'world'], { + await createCommandRunner('echo', ['hello', 'world'], { silent: true }) - .on('!no-stdout', middleware) + .on('!stdout', middleware) .run() expect(middleware).toHaveBeenCalledTimes(1) }) it('runs a middleware on multiple events', async () => { - execSpy.mockImplementation(async () => { - return { - stdout: 'hello', - stderr: '', - exitCode: 0 - } - }) + execSpy.mockImplementation( + createExecMock({stdout: 'foo', stderr: '', exitCode: 1}) + ) + /* execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 1}) + ) const middleware = jest.fn() - const command = commandPipeline('echo', ['hello', 'world'], { + const command = createCommandRunner('echo', ['hello', 'world'], { silent: true }).on(['!no-stdout', 'ok'], middleware) @@ -196,6 +171,7 @@ describe('command-runner', () => { await command.run() expect(middleware).toHaveBeenCalledTimes(1) + */ }) }) }) diff --git a/packages/helpers/src/command-runner/command-runner.ts b/packages/helpers/src/command-runner/command-runner.ts index fd7cbbe27f..d671d8c7e3 100644 --- a/packages/helpers/src/command-runner/command-runner.ts +++ b/packages/helpers/src/command-runner/command-runner.ts @@ -15,7 +15,8 @@ import { import { CommandRunnerActionType, CommandRunnerEventTypeExtended, - CommandRunnerMiddleware + CommandRunnerMiddleware, + CommandRunnerOptions } from './types' const commandRunnerActions = { @@ -36,7 +37,6 @@ export class CommandRunner extends CommandRunnerBase { : [action] this.use(matchEvent(event, middleware as CommandRunnerMiddleware[])) - return this } @@ -44,8 +44,7 @@ export class CommandRunner extends CommandRunnerBase { action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { - this.on('no-stdout', action, message) - + this.onOutput(stdout => stdout?.trim() === '', action, message) return this } @@ -149,9 +148,8 @@ export class CommandRunner extends CommandRunnerBase { } } -export const commandPipeline = ( +export const createCommandRunner = ( commandLine: string, args: string[] = [], - options: Record = {} -): CommandRunner => - new CommandRunner(commandLine, args, options, exec.getExecOutput) + options: CommandRunnerOptions = {} +): CommandRunner => new CommandRunner(commandLine, args, options, exec.exec) diff --git a/packages/helpers/src/command-runner/core.ts b/packages/helpers/src/command-runner/core.ts index 5efa7e7cc4..894fa05bf6 100644 --- a/packages/helpers/src/command-runner/core.ts +++ b/packages/helpers/src/command-runner/core.ts @@ -1,8 +1,10 @@ import * as exec from '@actions/exec' +import {StringDecoder} from 'string_decoder' import { CommandRunnerContext, CommandRunnerMiddleware, - CommandRunnerMiddlewarePromisified + CommandRunnerMiddlewarePromisified, + CommandRunnerOptions } from './types' export const promisifyCommandRunnerMiddleware = @@ -36,41 +38,14 @@ export const composeCommandRunnerMiddleware = export class CommandRunnerBase { private middleware: CommandRunnerMiddlewarePromisified[] = [] - private tmpArgs: string[] = [] constructor( private commandLine: string, private args: string[] = [], - private options: exec.ExecOptions = {}, - private executor: typeof exec.getExecOutput = exec.getExecOutput + private options: CommandRunnerOptions, + private executor: typeof exec.exec = exec.exec ) {} - /** - * Adds additional arguments to the command - * for the one time execution. - */ - addArgs(...args: string[]): this { - this.tmpArgs = [...this.args, ...args] - return this - } - - /** Overrides command arguments for one time execution */ - withArgs(...args: string[]): this { - this.tmpArgs = args - return this - } - - /** Retrieves args for one-time execution and clears them afterwards */ - private getTmpArgs(): string[] | null { - if (this.tmpArgs.length === 0) return null - - const args = this.tmpArgs - - this.tmpArgs = [] - - return args - } - use(middleware: CommandRunnerMiddleware): this { this.middleware.push( promisifyCommandRunnerMiddleware( @@ -88,14 +63,17 @@ export class CommandRunnerBase { args?: string[], /* overrides options for this specific execution if not undefined */ - options?: exec.ExecOptions + options?: CommandRunnerOptions ): Promise> { - const tmpArgs = this.getTmpArgs() + const requiredOptions: exec.ExecOptions = { + ignoreReturnCode: true, + failOnStdErr: false + } const context: CommandRunnerContext = { commandLine: commandLine ?? this.commandLine, - args: args ?? tmpArgs ?? this.args, - options: options ?? this.options, + args: args ?? this.args, + options: {...(options ?? this.options), ...requiredOptions}, stdout: null, stderr: null, execerr: null, @@ -104,15 +82,30 @@ export class CommandRunnerBase { } try { - const {stdout, stderr, exitCode} = await this.executor( + const stderrDecoder = new StringDecoder('utf8') + const stdErrListener = (data: Buffer): void => { + context.stderr = (context.stderr ?? '') + stderrDecoder.write(data) + options?.listeners?.stderr?.(data) + } + + const stdoutDecoder = new StringDecoder('utf8') + const stdOutListener = (data: Buffer): void => { + context.stdout = (context.stdout ?? '') + stdoutDecoder.write(data) + options?.listeners?.stdout?.(data) + } + + context.exitCode = await this.executor( context.commandLine, context.args, - context.options + { + ...context.options, + listeners: { + ...options?.listeners, + stdout: stdOutListener, + stderr: stdErrListener + } + } ) - - context.stdout = stdout - context.stderr = stderr - context.exitCode = exitCode } catch (error) { context.execerr = error as Error } diff --git a/packages/helpers/src/command-runner/index.ts b/packages/helpers/src/command-runner/index.ts index 24e10dc294..83f9a612b5 100644 --- a/packages/helpers/src/command-runner/index.ts +++ b/packages/helpers/src/command-runner/index.ts @@ -1 +1 @@ -export {commandPipeline, CommandRunner} from './command-runner' +export {createCommandRunner, CommandRunner} from './command-runner' diff --git a/packages/helpers/src/command-runner/middleware.ts b/packages/helpers/src/command-runner/middleware.ts index 2ba844968b..87b27bd018 100644 --- a/packages/helpers/src/command-runner/middleware.ts +++ b/packages/helpers/src/command-runner/middleware.ts @@ -20,12 +20,16 @@ const getEventTypesFromContext = ( eventTypes.add('execerr') } - if (ctx.stderr || ctx.exitCode !== 0) { + if (ctx.stderr) { eventTypes.add('stderr') } - if (ctx.stdout !== null && !ctx.stdout.trim()) { - eventTypes.add('no-stdout') + if (ctx.exitCode) { + eventTypes.add('exitcode') + } + + if (ctx.stdout) { + eventTypes.add('stdout') } if (!ctx.stderr && !ctx.execerr && ctx.stdout !== null && !ctx.exitCode) { @@ -72,7 +76,7 @@ export const failAction: CommandRunnerAction = message => async ctx => { return } - if (events.includes('no-stdout')) { + if (!events.includes('stdout')) { core.setFailed( `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` ) @@ -109,7 +113,7 @@ export const throwError: CommandRunnerAction = message => async ctx => { ) } - if (events.includes('no-stdout')) { + if (!events.includes('stdout')) { throw new Error( `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` ) @@ -143,7 +147,7 @@ export const produceLog: CommandRunnerAction = message => async (ctx, next) => { return } - if (events.includes('no-stdout')) { + if (!events.includes('stdout')) { core.warning(messageText) next() return @@ -176,7 +180,7 @@ export const produceLog: CommandRunnerAction = message => async (ctx, next) => { return } - if (events.includes('no-stdout')) { + if (!events.includes('stdout')) { core.warning( `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` ) diff --git a/packages/helpers/src/command-runner/test.ts b/packages/helpers/src/command-runner/test.ts deleted file mode 100644 index 49a7c028ff..0000000000 --- a/packages/helpers/src/command-runner/test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {CommandRunner} from './command-runner' -import * as io from '@actions/io' - -;(async () => { - const toolpath = await io.which('cmd', true) - const args = ['/c', 'echo'] - - const echo = new CommandRunner('echo') - - echo - .on('exec-error', 'log') - .use(async (ctx, next) => { - console.log('success') - next() - }) - .addArgs('hello') - .run() -})() diff --git a/packages/helpers/src/command-runner/types.ts b/packages/helpers/src/command-runner/types.ts index 478d695121..54e7fad793 100644 --- a/packages/helpers/src/command-runner/types.ts +++ b/packages/helpers/src/command-runner/types.ts @@ -36,9 +36,19 @@ export type CommandRunnerMiddleware = ( export type CommandRunnerActionType = 'throw' | 'fail' | 'log' /* Command runner event types as used internally passed to middleware for the user */ -export type CommandRunnerEventType = 'execerr' | 'stderr' | 'no-stdout' | 'ok' +export type CommandRunnerEventType = + | 'execerr' + | 'stderr' + | 'stdout' + | 'exitcode' + | 'ok' /* Command runner event types as used by the user for filtering results */ export type CommandRunnerEventTypeExtended = | CommandRunnerEventType | `!${CommandRunnerEventType}` + +export type CommandRunnerOptions = Omit< + exec.ExecOptions, + 'failOnStdErr' | 'ignoreReturnCode' +> From 36629a3962343189e7d67e487793f04120b69902 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 05:54:13 +0200 Subject: [PATCH 08/16] Remove state from context --- .../helpers/__tests__/command-runner.test.ts | 13 ++--- .../src/command-runner/command-runner.ts | 48 +++++++------------ packages/helpers/src/command-runner/core.ts | 21 +++----- packages/helpers/src/command-runner/types.ts | 9 ++-- 4 files changed, 33 insertions(+), 58 deletions(-) diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/helpers/__tests__/command-runner.test.ts index cbd6ba6a75..d4ec674666 100644 --- a/packages/helpers/__tests__/command-runner.test.ts +++ b/packages/helpers/__tests__/command-runner.test.ts @@ -74,16 +74,13 @@ describe('command-runner', () => { expect(middleware).toHaveBeenCalledWith( expect.objectContaining({ - commandLine: 'echo', args: ['hello', 'world'], - options: expect.objectContaining({ - silent: true - }), - stdout: 'hello', - stderr: '', - exitCode: 0, + commandLine: 'echo', execerr: null, - state: null + exitCode: 0, + options: {failOnStdErr: false, ignoreReturnCode: true, silent: true}, + stderr: '', + stdout: 'hello' }), expect.any(Function) ) diff --git a/packages/helpers/src/command-runner/command-runner.ts b/packages/helpers/src/command-runner/command-runner.ts index d671d8c7e3..ac2120c434 100644 --- a/packages/helpers/src/command-runner/command-runner.ts +++ b/packages/helpers/src/command-runner/command-runner.ts @@ -25,10 +25,10 @@ const commandRunnerActions = { log: produceLog } as const -export class CommandRunner extends CommandRunnerBase { +export class CommandRunner extends CommandRunnerBase { on( event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { const middleware = @@ -36,12 +36,12 @@ export class CommandRunner extends CommandRunnerBase { ? [commandRunnerActions[action](message)] : [action] - this.use(matchEvent(event, middleware as CommandRunnerMiddleware[])) + this.use(matchEvent(event, middleware)) return this } onEmptyOutput( - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { this.onOutput(stdout => stdout?.trim() === '', action, message) @@ -49,7 +49,7 @@ export class CommandRunner extends CommandRunnerBase { } onExecutionError( - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { const middleware = @@ -57,18 +57,13 @@ export class CommandRunner extends CommandRunnerBase { ? [commandRunnerActions[action](message)] : [action] - this.use( - matchSpecificError( - ({type}) => type === 'execerr', - middleware as CommandRunnerMiddleware[] - ) - ) + this.use(matchSpecificError(({type}) => type === 'execerr', middleware)) return this } onStdError( - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { const middleware = @@ -76,18 +71,13 @@ export class CommandRunner extends CommandRunnerBase { ? [commandRunnerActions[action](message)] : [action] - this.use( - matchSpecificError( - ({type}) => type === 'stderr', - middleware as CommandRunnerMiddleware[] - ) - ) + this.use(matchSpecificError(({type}) => type === 'stderr', middleware)) return this } onError( - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { return this.on(['execerr', 'stderr'], action, message) @@ -95,7 +85,7 @@ export class CommandRunner extends CommandRunnerBase { onSpecificError( matcher: ErrorMatcher, - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { const middleware = @@ -103,15 +93,13 @@ export class CommandRunner extends CommandRunnerBase { ? [commandRunnerActions[action](message)] : [action] - this.use( - matchSpecificError(matcher, middleware as CommandRunnerMiddleware[]) - ) + this.use(matchSpecificError(matcher, middleware)) return this } onSuccess( - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { return this.on('ok', action, message) @@ -119,7 +107,7 @@ export class CommandRunner extends CommandRunnerBase { onExitCode( matcher: ExitCodeMatcher, - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { const middleware = @@ -127,14 +115,14 @@ export class CommandRunner extends CommandRunnerBase { ? [commandRunnerActions[action](message)] : [action] - this.use(matchExitCode(matcher, middleware as CommandRunnerMiddleware[])) + this.use(matchExitCode(matcher, middleware)) return this } onOutput( matcher: OutputMatcher, - action: CommandRunnerActionType | CommandRunnerMiddleware, + action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string ): this { const middleware = @@ -142,14 +130,14 @@ export class CommandRunner extends CommandRunnerBase { ? [commandRunnerActions[action](message)] : [action] - this.use(matchOutput(matcher, middleware as CommandRunnerMiddleware[])) + this.use(matchOutput(matcher, middleware)) return this } } -export const createCommandRunner = ( +export const createCommandRunner = ( commandLine: string, args: string[] = [], options: CommandRunnerOptions = {} -): CommandRunner => new CommandRunner(commandLine, args, options, exec.exec) +): CommandRunner => new CommandRunner(commandLine, args, options, exec.exec) diff --git a/packages/helpers/src/command-runner/core.ts b/packages/helpers/src/command-runner/core.ts index 894fa05bf6..b93dd26d8f 100644 --- a/packages/helpers/src/command-runner/core.ts +++ b/packages/helpers/src/command-runner/core.ts @@ -8,9 +8,7 @@ import { } from './types' export const promisifyCommandRunnerMiddleware = - ( - middleware: CommandRunnerMiddleware - ): CommandRunnerMiddlewarePromisified => + (middleware: CommandRunnerMiddleware): CommandRunnerMiddlewarePromisified => async (ctx, next) => { return Promise.resolve(middleware(ctx, next)) } @@ -36,7 +34,7 @@ export const composeCommandRunnerMiddleware = await nextLocal() } -export class CommandRunnerBase { +export class CommandRunnerBase { private middleware: CommandRunnerMiddlewarePromisified[] = [] constructor( @@ -46,12 +44,8 @@ export class CommandRunnerBase { private executor: typeof exec.exec = exec.exec ) {} - use(middleware: CommandRunnerMiddleware): this { - this.middleware.push( - promisifyCommandRunnerMiddleware( - middleware as CommandRunnerMiddleware - ) - ) + use(middleware: CommandRunnerMiddleware): this { + this.middleware.push(promisifyCommandRunnerMiddleware(middleware)) return this } @@ -64,21 +58,20 @@ export class CommandRunnerBase { /* overrides options for this specific execution if not undefined */ options?: CommandRunnerOptions - ): Promise> { + ): Promise { const requiredOptions: exec.ExecOptions = { ignoreReturnCode: true, failOnStdErr: false } - const context: CommandRunnerContext = { + const context: CommandRunnerContext = { commandLine: commandLine ?? this.commandLine, args: args ?? this.args, options: {...(options ?? this.options), ...requiredOptions}, stdout: null, stderr: null, execerr: null, - exitCode: null, - state: null + exitCode: null } try { diff --git a/packages/helpers/src/command-runner/types.ts b/packages/helpers/src/command-runner/types.ts index 54e7fad793..12e0eb7f37 100644 --- a/packages/helpers/src/command-runner/types.ts +++ b/packages/helpers/src/command-runner/types.ts @@ -2,7 +2,7 @@ import * as exec from '@actions/exec' /* CommandRunner core */ -export interface CommandRunnerContext { +export interface CommandRunnerContext { /* Inputs with which command was executed */ commandLine: string args: string[] @@ -13,9 +13,6 @@ export interface CommandRunnerContext { stderr: string | null stdout: string | null exitCode: number | null - - /* Arbitrary state that can be change during middleware execution if needed */ - state: S | null } /* Middlewares as used internally in CommandRunner */ @@ -25,8 +22,8 @@ export type CommandRunnerMiddlewarePromisified = ( ) => Promise /* Middlewares as used by the user */ -export type CommandRunnerMiddleware = ( - ctx: CommandRunnerContext, +export type CommandRunnerMiddleware = ( + ctx: CommandRunnerContext, next: () => Promise ) => void | Promise From b45c9d38015225ba9e5844ecf97ae1f2d4bfacb7 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 06:58:22 +0200 Subject: [PATCH 09/16] Refactor base middlewares --- .../src/command-runner/command-runner.ts | 8 +- packages/helpers/src/command-runner/core.ts | 63 ++++---- .../helpers/src/command-runner/middleware.ts | 147 ++++-------------- packages/helpers/src/command-runner/types.ts | 32 +++- packages/helpers/src/command-runner/utils.ts | 52 +++++++ 5 files changed, 147 insertions(+), 155 deletions(-) create mode 100644 packages/helpers/src/command-runner/utils.ts diff --git a/packages/helpers/src/command-runner/command-runner.ts b/packages/helpers/src/command-runner/command-runner.ts index ac2120c434..a4622d4aac 100644 --- a/packages/helpers/src/command-runner/command-runner.ts +++ b/packages/helpers/src/command-runner/command-runner.ts @@ -1,9 +1,6 @@ import * as exec from '@actions/exec' import {CommandRunnerBase} from './core' import { - ErrorMatcher, - ExitCodeMatcher, - OutputMatcher, failAction, matchEvent, matchExitCode, @@ -16,7 +13,10 @@ import { CommandRunnerActionType, CommandRunnerEventTypeExtended, CommandRunnerMiddleware, - CommandRunnerOptions + CommandRunnerOptions, + ErrorMatcher, + ExitCodeMatcher, + OutputMatcher } from './types' const commandRunnerActions = { diff --git a/packages/helpers/src/command-runner/core.ts b/packages/helpers/src/command-runner/core.ts index b93dd26d8f..631a80bc9e 100644 --- a/packages/helpers/src/command-runner/core.ts +++ b/packages/helpers/src/command-runner/core.ts @@ -3,39 +3,12 @@ import {StringDecoder} from 'string_decoder' import { CommandRunnerContext, CommandRunnerMiddleware, - CommandRunnerMiddlewarePromisified, CommandRunnerOptions } from './types' - -export const promisifyCommandRunnerMiddleware = - (middleware: CommandRunnerMiddleware): CommandRunnerMiddlewarePromisified => - async (ctx, next) => { - return Promise.resolve(middleware(ctx, next)) - } - -export const composeCommandRunnerMiddleware = - (middleware: CommandRunnerMiddlewarePromisified[]) => - async (context: CommandRunnerContext, nextGlobal: () => Promise) => { - let index = 0 - - const nextLocal = async (): Promise => { - if (index < middleware.length) { - const currentMiddleware = middleware[index++] - if (middleware === undefined) { - return - } - - await currentMiddleware(context, nextLocal) - } - - await nextGlobal() - } - - await nextLocal() - } +import {PromisifiedFn, promisifyFn} from './utils' export class CommandRunnerBase { - private middleware: CommandRunnerMiddlewarePromisified[] = [] + private middleware: PromisifiedFn[] = [] constructor( private commandLine: string, @@ -45,7 +18,7 @@ export class CommandRunnerBase { ) {} use(middleware: CommandRunnerMiddleware): this { - this.middleware.push(promisifyCommandRunnerMiddleware(middleware)) + this.middleware.push(promisifyFn(middleware)) return this } @@ -104,8 +77,36 @@ export class CommandRunnerBase { } const next = async (): Promise => Promise.resolve() - await composeCommandRunnerMiddleware(this.middleware)(context, next) + await composeMiddleware(this.middleware)(context, next) return context } } + +export function composeMiddleware( + middleware: PromisifiedFn[] +): PromisifiedFn { + middleware = middleware.map(mw => promisifyFn(mw)) + + return async ( + context: CommandRunnerContext, + nextGlobal: () => Promise + ) => { + let index = 0 + + const nextLocal = async (): Promise => { + if (index < middleware.length) { + const currentMiddleware = middleware[index++] + if (middleware === undefined) { + return + } + + await currentMiddleware(context, nextLocal) + } + + await nextGlobal() + } + + await nextLocal() + } +} diff --git a/packages/helpers/src/command-runner/middleware.ts b/packages/helpers/src/command-runner/middleware.ts index 87b27bd018..aa8632ba1d 100644 --- a/packages/helpers/src/command-runner/middleware.ts +++ b/packages/helpers/src/command-runner/middleware.ts @@ -1,15 +1,16 @@ import * as core from '@actions/core' import { + CommandRunnerAction, CommandRunnerContext, CommandRunnerEventType, CommandRunnerEventTypeExtended, CommandRunnerMiddleware, - CommandRunnerMiddlewarePromisified + ErrorMatcher, + ExitCodeMatcher, + OutputMatcher } from './types' -import { - composeCommandRunnerMiddleware, - promisifyCommandRunnerMiddleware -} from './core' +import {composeMiddleware} from './core' +import {gte, gt, lte, lt, eq, PromisifiedFn} from './utils' const getEventTypesFromContext = ( ctx: CommandRunnerContext @@ -39,16 +40,15 @@ const getEventTypesFromContext = ( return [...eventTypes] } -type CommandRunnerAction = ( - message?: - | string - | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) -) => CommandRunnerMiddlewarePromisified - /** * Basic middleware */ +/** Calls next middleware */ +export const passThrough: () => PromisifiedFn = + () => async (_, next) => + next() + /** * Fails Github Action with the given message or with a default one depending on execution conditions. */ @@ -202,31 +202,6 @@ export const produceLog: CommandRunnerAction = message => async (ctx, next) => { next() } -/** - * Filtering middleware - */ - -/** Calls next middleware */ -export const passThrough: () => CommandRunnerMiddlewarePromisified = - () => async (_, next) => - next() - -/** - * Either calls next middleware or not depending on the result of the given condition. - */ -export const filter: ( - shouldPass: - | boolean - | ((ctx: CommandRunnerContext) => boolean | Promise) -) => CommandRunnerMiddlewarePromisified = shouldPass => async (ctx, next) => { - if (typeof shouldPass === 'function') { - if (await shouldPass(ctx)) { - next() - return - } - } -} - /** * Will call passed middleware if matching event has occured. * Will call the next middleware otherwise. @@ -234,13 +209,9 @@ export const filter: ( export const matchEvent = ( eventType: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], middleware?: CommandRunnerMiddleware[] -): CommandRunnerMiddlewarePromisified => { - if (!middleware?.length) { - middleware = [passThrough()] - } - - const composedMiddleware = composeCommandRunnerMiddleware( - middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) +): PromisifiedFn => { + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware ) const expectedEventsPositiveArray = ( @@ -283,8 +254,6 @@ export const matchEvent = ( } } -export type OutputMatcher = RegExp | string | ((output: string) => boolean) - /** * Will call passed middleware if matching event has occured. * Will call the next middleware otherwise. @@ -292,13 +261,9 @@ export type OutputMatcher = RegExp | string | ((output: string) => boolean) export const matchOutput = ( matcher: OutputMatcher, middleware?: CommandRunnerMiddleware[] -): CommandRunnerMiddlewarePromisified => { - if (!middleware?.length) { - middleware = [passThrough()] - } - - const composedMiddleware = composeCommandRunnerMiddleware( - middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) +): PromisifiedFn => { + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware ) return async (ctx, next) => { @@ -328,30 +293,9 @@ export const matchOutput = ( } } -export type ExitCodeMatcher = string | number - -const lte = - (a: number) => - (b: number): boolean => - b <= a -const gte = - (a: number) => - (b: number): boolean => - b >= a -const lt = - (a: number) => - (b: number): boolean => - b < a -const gt = - (a: number) => - (b: number): boolean => - b > a -const eq = - (a: number) => - (b: number): boolean => - b === a - -const matchers = { +const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') + +const MATCHERS = { '>=': gte, '>': gt, '<=': lte, @@ -359,11 +303,9 @@ const matchers = { '=': eq } as const -const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') - const parseExitCodeMatcher = ( code: ExitCodeMatcher -): [keyof typeof matchers, number] => { +): [keyof typeof MATCHERS, number] => { if (typeof code === 'number') { return ['=', code] } @@ -382,14 +324,7 @@ const parseExitCodeMatcher = ( } const [, operator, number] = match - return [operator as keyof typeof matchers, parseInt(number)] -} - -const matcherToMatcherFn = ( - matcher: ExitCodeMatcher -): ((exitCode: number) => boolean) => { - const [operator, number] = parseExitCodeMatcher(matcher) - return matchers[operator](number) + return [operator as keyof typeof MATCHERS, parseInt(number)] } /** @@ -399,20 +334,17 @@ const matcherToMatcherFn = ( export const matchExitCode = ( code: ExitCodeMatcher, middleware?: CommandRunnerMiddleware[] -): CommandRunnerMiddlewarePromisified => { - const matcher = matcherToMatcherFn(code) - - if (!middleware?.length) { - middleware = [passThrough()] - } +): PromisifiedFn => { + const [operator, number] = parseExitCodeMatcher(code) + const matcherFn = MATCHERS[operator](number) - const composedMiddleware = composeCommandRunnerMiddleware( - middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware ) return async (ctx, next) => { // if exit code is undefined, NaN will not match anything - if (matcher(ctx.exitCode ?? NaN)) { + if (matcherFn(ctx.exitCode ?? NaN)) { composedMiddleware(ctx, next) return } @@ -421,25 +353,14 @@ export const matchExitCode = ( } } -export type ErrorMatcher = - | RegExp - | string - | ((error: { - type: 'stderr' | 'execerr' - error: Error | null - message: string - }) => boolean) - export const matchSpecificError = ( matcher: ErrorMatcher, - middleware?: CommandRunnerMiddleware[] -): CommandRunnerMiddlewarePromisified => { - if (!middleware?.length) { - middleware = [passThrough()] - } - - const composedMiddleware = composeCommandRunnerMiddleware( - middleware.map(mw => promisifyCommandRunnerMiddleware(mw)) + middleware?: + | CommandRunnerMiddleware[] + | PromisifiedFn[] +): PromisifiedFn => { + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware ) return async (ctx, next) => { diff --git a/packages/helpers/src/command-runner/types.ts b/packages/helpers/src/command-runner/types.ts index 12e0eb7f37..bb4a987d0c 100644 --- a/packages/helpers/src/command-runner/types.ts +++ b/packages/helpers/src/command-runner/types.ts @@ -1,4 +1,5 @@ import * as exec from '@actions/exec' +import {PromisifiedFn} from './utils' /* CommandRunner core */ @@ -15,20 +16,22 @@ export interface CommandRunnerContext { exitCode: number | null } -/* Middlewares as used internally in CommandRunner */ -export type CommandRunnerMiddlewarePromisified = ( - ctx: CommandRunnerContext, - next: () => Promise -) => Promise - /* Middlewares as used by the user */ -export type CommandRunnerMiddleware = ( +type _CommandRunnerMiddleware = ( ctx: CommandRunnerContext, next: () => Promise ) => void | Promise +export type CommandRunnerMiddleware = PromisifiedFn<_CommandRunnerMiddleware> + /* Command runner events handling and command runner actions */ +export type CommandRunnerAction = ( + message?: + | string + | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) +) => PromisifiedFn + /* Command runner default actions types on which preset middleware exists */ export type CommandRunnerActionType = 'throw' | 'fail' | 'log' @@ -49,3 +52,18 @@ export type CommandRunnerOptions = Omit< exec.ExecOptions, 'failOnStdErr' | 'ignoreReturnCode' > + +/* Matchers */ + +export type OutputMatcher = RegExp | string | ((output: string) => boolean) + +export type ExitCodeMatcher = string | number + +export type ErrorMatcher = + | RegExp + | string + | ((error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + }) => boolean) diff --git a/packages/helpers/src/command-runner/utils.ts b/packages/helpers/src/command-runner/utils.ts new file mode 100644 index 0000000000..279f5c4955 --- /dev/null +++ b/packages/helpers/src/command-runner/utils.ts @@ -0,0 +1,52 @@ +/** + * Promises + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type PromisifiedFn any> = ( + ...args: Parameters +) => ReturnType extends Promise + ? ReturnType + : Promise> + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const promisifyFn = any>( + fn: T +): PromisifiedFn => { + const result = async (...args: Parameters): Promise => { + return new Promise((resolve, reject) => { + try { + resolve(fn(...args)) + } catch (error) { + reject(error) + } + }) + } + + return result as PromisifiedFn +} + +/** + * Comparators + */ + +export const lte = + (a: number) => + (b: number): boolean => + b <= a +export const gte = + (a: number) => + (b: number): boolean => + b >= a +export const lt = + (a: number) => + (b: number): boolean => + b < a +export const gt = + (a: number) => + (b: number): boolean => + b > a +export const eq = + (a: number) => + (b: number): boolean => + b === a From 1581d81abad72a5c9135879d6b8f5062c119b553 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 07:30:25 +0200 Subject: [PATCH 10/16] Allow to specify command with builder pattern --- .../helpers/__tests__/command-runner.test.ts | 91 ++++++++++++------- .../src/command-runner/command-runner.ts | 2 +- packages/helpers/src/command-runner/core.ts | 35 ++++++- 3 files changed, 94 insertions(+), 34 deletions(-) diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/helpers/__tests__/command-runner.test.ts index d4ec674666..e7f7097334 100644 --- a/packages/helpers/__tests__/command-runner.test.ts +++ b/packages/helpers/__tests__/command-runner.test.ts @@ -36,6 +36,66 @@ describe('command-runner', () => { ) }) + it('throws error if command is not specified', async () => { + const command = createCommandRunner() + await expect(command.run()).rejects.toThrow('Command was not specified') + }) + + it('will have exec error if it occured', async () => { + execSpy.mockImplementation(async () => { + throw new Error('test') + }) + + const command = createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + const context = await command.run() + + expect(context.execerr).toBeDefined() + expect(context.execerr?.message).toBe('test') + }) + + it('allows to set command, args and options', async () => { + execSpy.mockImplementation(async () => 0) + + createCommandRunner() + .setCommand('echo') + .setArgs(['hello', 'world']) + .setOptions({silent: true}) + .run() + + expect(execSpy).toHaveBeenCalledTimes(1) + expect(execSpy).toHaveBeenCalledWith( + 'echo', + ['hello', 'world'], + expect.objectContaining({ + silent: true, + ignoreReturnCode: true + }) + ) + }) + + it('allows to modify command, args and options', async () => { + execSpy.mockImplementation(async () => 0) + + createCommandRunner('echo', ['hello', 'world'], {silent: true}) + .setCommand(commandLine => `${commandLine} hello world`) + .setArgs(() => []) + .setOptions(options => ({...options, env: {test: 'test'}})) + .run() + + expect(execSpy).toHaveBeenCalledTimes(1) + expect(execSpy).toHaveBeenCalledWith( + 'echo hello world', + [], + expect.objectContaining({ + silent: true, + ignoreReturnCode: true, + env: {test: 'test'} + }) + ) + }) + const createExecMock = (output: { stdout: string stderr: string @@ -139,37 +199,6 @@ describe('command-runner', () => { expect(middleware).toHaveBeenCalledTimes(1) }) - - it('runs a middleware on multiple events', async () => { - execSpy.mockImplementation( - createExecMock({stdout: 'foo', stderr: '', exitCode: 1}) - ) - /* execSpy.mockImplementation( - createExecMock({stdout: '', stderr: '', exitCode: 1}) - ) - - const middleware = jest.fn() - const command = createCommandRunner('echo', ['hello', 'world'], { - silent: true - }).on(['!no-stdout', 'ok'], middleware) - - await command.run() - - expect(middleware).toHaveBeenCalledTimes(1) - - execSpy.mockImplementation(async () => { - return { - stdout: '', - stderr: '', - exitCode: 1 - } - }) - - await command.run() - - expect(middleware).toHaveBeenCalledTimes(1) - */ - }) }) }) }) diff --git a/packages/helpers/src/command-runner/command-runner.ts b/packages/helpers/src/command-runner/command-runner.ts index a4622d4aac..6b2f625a9b 100644 --- a/packages/helpers/src/command-runner/command-runner.ts +++ b/packages/helpers/src/command-runner/command-runner.ts @@ -137,7 +137,7 @@ export class CommandRunner extends CommandRunnerBase { } export const createCommandRunner = ( - commandLine: string, + commandLine = '', args: string[] = [], options: CommandRunnerOptions = {} ): CommandRunner => new CommandRunner(commandLine, args, options, exec.exec) diff --git a/packages/helpers/src/command-runner/core.ts b/packages/helpers/src/command-runner/core.ts index 631a80bc9e..7989693a5c 100644 --- a/packages/helpers/src/command-runner/core.ts +++ b/packages/helpers/src/command-runner/core.ts @@ -11,12 +11,39 @@ export class CommandRunnerBase { private middleware: PromisifiedFn[] = [] constructor( - private commandLine: string, + private commandLine = '', private args: string[] = [], private options: CommandRunnerOptions, private executor: typeof exec.exec = exec.exec ) {} + setCommand(commandLine: string | ((commandLine: string) => string)): this { + this.commandLine = + typeof commandLine === 'function' + ? commandLine(this.commandLine) + : commandLine + + return this + } + + setArgs(args: string[] | ((args: string[]) => string[])): this { + this.args = + typeof args === 'function' ? args(this.args) : [...this.args, ...args] + + return this + } + + setOptions( + options: + | CommandRunnerOptions + | ((options: CommandRunnerOptions) => CommandRunnerOptions) + ): this { + this.options = + typeof options === 'function' ? options(this.options) : options + + return this + } + use(middleware: CommandRunnerMiddleware): this { this.middleware.push(promisifyFn(middleware)) return this @@ -47,6 +74,10 @@ export class CommandRunnerBase { exitCode: null } + if (!context.commandLine) { + throw new Error('Command was not specified') + } + try { const stderrDecoder = new StringDecoder('utf8') const stdErrListener = (data: Buffer): void => { @@ -84,7 +115,7 @@ export class CommandRunnerBase { } export function composeMiddleware( - middleware: PromisifiedFn[] + middleware: CommandRunnerMiddleware[] ): PromisifiedFn { middleware = middleware.map(mw => promisifyFn(mw)) From 0d75606364cd6b806dbd354b23d98fd9c81ac947 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 09:21:26 +0200 Subject: [PATCH 11/16] Add additional tests --- .../helpers/__tests__/command-runner.test.ts | 224 ++++++++++++++++++ .../helpers/src/command-runner/middleware.ts | 50 ++-- 2 files changed, 250 insertions(+), 24 deletions(-) diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/helpers/__tests__/command-runner.test.ts index e7f7097334..255a591648 100644 --- a/packages/helpers/__tests__/command-runner.test.ts +++ b/packages/helpers/__tests__/command-runner.test.ts @@ -1,4 +1,5 @@ import * as exec from '@actions/exec' +import * as core from '@actions/core' import {CommandRunner, createCommandRunner} from '../src/helpers' describe('command-runner', () => { @@ -12,6 +13,7 @@ describe('command-runner', () => { describe('CommandRunner', () => { const execSpy = jest.spyOn(exec, 'exec') + const failSpy = jest.spyOn(core, 'setFailed') afterEach(() => { jest.resetAllMocks() @@ -199,6 +201,228 @@ describe('command-runner', () => { expect(middleware).toHaveBeenCalledTimes(1) }) + + it('fails if event matches', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 1}) + ) + + failSpy.mockImplementation(() => {}) + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .on('!stdout', 'fail') + .run() + + expect(failSpy).toHaveBeenCalledWith( + `The command "echo" finished with exit code 1 and produced an empty output.` + ) + }) + + it('throws error if event matches', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 1}) + ) + + const cmdPromise = createCommandRunner('echo', ['hello', 'world']) + .onExitCode('> 0', 'throw') + .run() + + await expect(cmdPromise).rejects.toThrow( + 'The command "echo" finished with exit code 1 and produced an empty output.' + ) + }) + + it('logs if event matches', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 1}) + ) + + const logSpy = jest.spyOn(core, 'error') + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .on('!ok', 'log') + .run() + + expect(logSpy).toHaveBeenCalledWith( + 'The command "echo" finished with exit code 1 and produced an error: test' + ) + }) + }) + + describe('default handlers', () => { + test('onEmptyOutput', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world']) + .onError(notCalledMiddleware) + .onEmptyOutput(middleware) + .onError(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onExecutionError', async () => { + execSpy.mockImplementation(() => { + throw new Error('test') + }) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onExecutionError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onStdError', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onStdError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onError', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + execSpy.mockImplementation(() => { + throw new Error('test') + }) + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onError(middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(2) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onSpecificError', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: 'test', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onSpecificError(/test/, middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onSuccess', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onError(notCalledMiddleware) + .onSuccess(middleware) + .onError(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onExitcode', async () => { + execSpy.mockImplementation( + createExecMock({stdout: '', stderr: '', exitCode: 2}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onSuccess(notCalledMiddleware) + .onExitCode('> 0', middleware) + .onSuccess(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) + + test('onOutput', async () => { + execSpy.mockImplementation( + createExecMock({stdout: 'test', stderr: '', exitCode: 0}) + ) + + const notCalledMiddleware = jest.fn() + const middleware = jest.fn() + + await createCommandRunner('echo', ['hello', 'world'], { + silent: true + }) + .onError(notCalledMiddleware) + .onOutput(/test/, middleware) + .onError(notCalledMiddleware) + .run() + + expect(middleware).toHaveBeenCalledTimes(1) + expect(notCalledMiddleware).not.toHaveBeenCalled() + }) }) }) }) diff --git a/packages/helpers/src/command-runner/middleware.ts b/packages/helpers/src/command-runner/middleware.ts index aa8632ba1d..8814c2e68e 100644 --- a/packages/helpers/src/command-runner/middleware.ts +++ b/packages/helpers/src/command-runner/middleware.ts @@ -92,36 +92,38 @@ export const failAction: CommandRunnerAction = message => async ctx => { /** * Throws an error with the given message or with a default one depending on execution conditions. */ -export const throwError: CommandRunnerAction = message => async ctx => { - const events = getEventTypesFromContext(ctx) +export const throwError: CommandRunnerAction = message => { + return async ctx => { + const events = getEventTypesFromContext(ctx) + + if (message !== undefined) { + throw new Error( + typeof message === 'string' ? message : message(ctx, events) + ) + } - if (message !== undefined) { - throw new Error( - typeof message === 'string' ? message : message(ctx, events) - ) - } + if (events.includes('execerr')) { + throw new Error( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + } - if (events.includes('execerr')) { - throw new Error( - `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` - ) - } + if (events.includes('stderr')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + } - if (events.includes('stderr')) { - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` - ) - } + if (!events.includes('stdout')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + } - if (!events.includes('stdout')) { throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` ) } - - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) } /** @@ -345,7 +347,7 @@ export const matchExitCode = ( return async (ctx, next) => { // if exit code is undefined, NaN will not match anything if (matcherFn(ctx.exitCode ?? NaN)) { - composedMiddleware(ctx, next) + await composedMiddleware(ctx, next) return } From b0e0efadbaaa0352ee312f4d1fe2890e9d083fed Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 11:18:54 +0200 Subject: [PATCH 12/16] Add no-mock testing --- .../helpers/__tests__/command-runner.test.ts | 79 +++++++++++++++++++ packages/helpers/src/command-runner/core.ts | 3 + .../helpers/src/command-runner/middleware.ts | 6 +- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/helpers/__tests__/command-runner.test.ts index 255a591648..2941bf50be 100644 --- a/packages/helpers/__tests__/command-runner.test.ts +++ b/packages/helpers/__tests__/command-runner.test.ts @@ -1,5 +1,6 @@ import * as exec from '@actions/exec' import * as core from '@actions/core' +import * as io from '@actions/io' import {CommandRunner, createCommandRunner} from '../src/helpers' describe('command-runner', () => { @@ -425,4 +426,82 @@ describe('command-runner', () => { }) }) }) + + const IS_WINDOWS = process.platform === 'win32' + + describe('no-mock testing', () => { + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('creates a command object', async () => { + let toolpath: string + let args: string[] + if (IS_WINDOWS) { + toolpath = await io.which('cmd', true) + args = ['/c', 'echo', 'hello'] + } else { + toolpath = await io.which('echo', true) + args = ['hello'] + } + const command = createCommandRunner(`"${toolpath}"`, args) + expect(command).toBeDefined() + expect(command).toBeInstanceOf(CommandRunner) + }) + + it('runs a command with non-zero exit code', async () => { + const runner = createCommandRunner() + + runner.setOptions({ + silent: true + }) + + if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'dir']) + } else { + runner.setCommand(await io.which('ls', true)) + runner.setArgs(['-l']) + } + + runner.setArgs((args: string[]) => [...args, 'non-existent-dir']) + + const cmdPromise = runner.onError('throw').run() + + await expect(cmdPromise).rejects.toThrow() + }) + + it('runs a command with zero exit code', async () => { + const runner = createCommandRunner() + + if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'echo']) + } else { + runner.setCommand(await io.which('echo', true)) + } + + runner.setArgs((args: string[]) => [...args, 'hello']) + + const result = await runner.run() + + expect(result.stdout).toContain('hello') + expect(result.exitCode).toEqual(0) + }) + + it('runs a command with empty output', async () => { + const runner = createCommandRunner() + + if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'echo.']) + } else { + runner.setCommand(await io.which('echo', true)) + } + + const cmdPromise = runner.onEmptyOutput('throw').run() + + await expect(cmdPromise).rejects.toThrow() + }) + }) }) diff --git a/packages/helpers/src/command-runner/core.ts b/packages/helpers/src/command-runner/core.ts index 7989693a5c..0e4a8390c0 100644 --- a/packages/helpers/src/command-runner/core.ts +++ b/packages/helpers/src/command-runner/core.ts @@ -103,6 +103,9 @@ export class CommandRunnerBase { } } ) + + context.stdout = (context.stdout ?? '') + stdoutDecoder.end() + context.stderr = (context.stderr ?? '') + stderrDecoder.end() } catch (error) { context.execerr = error as Error } diff --git a/packages/helpers/src/command-runner/middleware.ts b/packages/helpers/src/command-runner/middleware.ts index 8814c2e68e..3707e3e722 100644 --- a/packages/helpers/src/command-runner/middleware.ts +++ b/packages/helpers/src/command-runner/middleware.ts @@ -248,7 +248,7 @@ export const matchEvent = ( } if (shouldRun) { - composedMiddleware(ctx, next) + await composedMiddleware(ctx, next) return } @@ -291,7 +291,7 @@ export const matchOutput = ( return } - composedMiddleware(ctx, next) + await composedMiddleware(ctx, next) } } @@ -396,6 +396,6 @@ export const matchSpecificError = ( return } - composedMiddleware(ctx, next) + await composedMiddleware(ctx, next) } } From d15c09b8367c86239a92edfa6770b1ca58065158 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 11:31:48 +0200 Subject: [PATCH 13/16] Move command runner to @actions/exec --- .../__tests__/command-runner.test.ts | 4 +- packages/exec/package-lock.json | 95 +++++++++++++++++++ packages/exec/package.json | 1 + .../src/command-runner/command-runner.ts | 2 +- .../src/command-runner/core.ts | 2 +- .../src/command-runner/index.ts | 0 .../src/command-runner/middleware.ts | 0 .../src/command-runner/types.ts | 2 +- .../src/command-runner/utils.ts | 0 9 files changed, 101 insertions(+), 5 deletions(-) rename packages/{helpers => exec}/__tests__/command-runner.test.ts (99%) rename packages/{helpers => exec}/src/command-runner/command-runner.ts (98%) rename packages/{helpers => exec}/src/command-runner/core.ts (99%) rename packages/{helpers => exec}/src/command-runner/index.ts (100%) rename packages/{helpers => exec}/src/command-runner/middleware.ts (100%) rename packages/{helpers => exec}/src/command-runner/types.ts (97%) rename packages/{helpers => exec}/src/command-runner/utils.ts (100%) diff --git a/packages/helpers/__tests__/command-runner.test.ts b/packages/exec/__tests__/command-runner.test.ts similarity index 99% rename from packages/helpers/__tests__/command-runner.test.ts rename to packages/exec/__tests__/command-runner.test.ts index 2941bf50be..1a30ab018e 100644 --- a/packages/helpers/__tests__/command-runner.test.ts +++ b/packages/exec/__tests__/command-runner.test.ts @@ -1,7 +1,7 @@ -import * as exec from '@actions/exec' +import * as exec from '../src/exec' import * as core from '@actions/core' import * as io from '@actions/io' -import {CommandRunner, createCommandRunner} from '../src/helpers' +import {CommandRunner, createCommandRunner} from '../src/command-runner' describe('command-runner', () => { describe('createCommandRunner', () => { diff --git a/packages/exec/package-lock.json b/packages/exec/package-lock.json index 3832178444..c909c7dafb 100644 --- a/packages/exec/package-lock.json +++ b/packages/exec/package-lock.json @@ -9,20 +9,115 @@ "version": "1.1.1", "license": "MIT", "dependencies": { + "@actions/core": "^1.10.1", "@actions/io": "^1.0.1" } }, + "node_modules/@actions/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.1.tgz", + "integrity": "sha512-3lBR9EDAY+iYIpTnTIXmWcNbX3T2kCkAEQGIQx4NVQ0575nk2k3GRZDTPQG+vVtS2izSLmINlxXf0uLtnrTP+g==", + "dependencies": { + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.0.tgz", + "integrity": "sha512-q+epW0trjVUUHboliPb4UF9g2msf+w61b32tAkFEwL/IwP0DQWgbCMM0Hbe3e3WXSKz5VcUXbzJQgy8Hkra/Lg==", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, "node_modules/@actions/io": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.0.1.tgz", "integrity": "sha512-rhq+tfZukbtaus7xyUtwKfuiCRXd1hWSfmJNEpFgBQJ4woqPEpsBw04awicjwz9tyG2/MVhAEMfVn664Cri5zA==" + }, + "node_modules/@fastify/busboy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", + "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==", + "engines": { + "node": ">=14" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.26.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.3.tgz", + "integrity": "sha512-H7n2zmKEWgOllKkIUkLvFmsJQj062lSm3uA4EYApG8gLuiOM0/go9bIoC3HVaSnfg4xunowDE2i9p8drkXuvDw==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } } }, "dependencies": { + "@actions/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.1.tgz", + "integrity": "sha512-3lBR9EDAY+iYIpTnTIXmWcNbX3T2kCkAEQGIQx4NVQ0575nk2k3GRZDTPQG+vVtS2izSLmINlxXf0uLtnrTP+g==", + "requires": { + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" + } + }, + "@actions/http-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.0.tgz", + "integrity": "sha512-q+epW0trjVUUHboliPb4UF9g2msf+w61b32tAkFEwL/IwP0DQWgbCMM0Hbe3e3WXSKz5VcUXbzJQgy8Hkra/Lg==", + "requires": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, "@actions/io": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.0.1.tgz", "integrity": "sha512-rhq+tfZukbtaus7xyUtwKfuiCRXd1hWSfmJNEpFgBQJ4woqPEpsBw04awicjwz9tyG2/MVhAEMfVn664Cri5zA==" + }, + "@fastify/busboy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", + "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==" + }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" + }, + "undici": { + "version": "5.26.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.3.tgz", + "integrity": "sha512-H7n2zmKEWgOllKkIUkLvFmsJQj062lSm3uA4EYApG8gLuiOM0/go9bIoC3HVaSnfg4xunowDE2i9p8drkXuvDw==", + "requires": { + "@fastify/busboy": "^2.0.0" + } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" } } } diff --git a/packages/exec/package.json b/packages/exec/package.json index bc4d77a23c..9fc136ca35 100644 --- a/packages/exec/package.json +++ b/packages/exec/package.json @@ -36,6 +36,7 @@ "url": "https://github.com/actions/toolkit/issues" }, "dependencies": { + "@actions/core": "^1.10.1", "@actions/io": "^1.0.1" } } diff --git a/packages/helpers/src/command-runner/command-runner.ts b/packages/exec/src/command-runner/command-runner.ts similarity index 98% rename from packages/helpers/src/command-runner/command-runner.ts rename to packages/exec/src/command-runner/command-runner.ts index 6b2f625a9b..f2f0967ccb 100644 --- a/packages/helpers/src/command-runner/command-runner.ts +++ b/packages/exec/src/command-runner/command-runner.ts @@ -1,4 +1,4 @@ -import * as exec from '@actions/exec' +import * as exec from '../exec' import {CommandRunnerBase} from './core' import { failAction, diff --git a/packages/helpers/src/command-runner/core.ts b/packages/exec/src/command-runner/core.ts similarity index 99% rename from packages/helpers/src/command-runner/core.ts rename to packages/exec/src/command-runner/core.ts index 0e4a8390c0..e6917418da 100644 --- a/packages/helpers/src/command-runner/core.ts +++ b/packages/exec/src/command-runner/core.ts @@ -1,4 +1,4 @@ -import * as exec from '@actions/exec' +import * as exec from '../exec' import {StringDecoder} from 'string_decoder' import { CommandRunnerContext, diff --git a/packages/helpers/src/command-runner/index.ts b/packages/exec/src/command-runner/index.ts similarity index 100% rename from packages/helpers/src/command-runner/index.ts rename to packages/exec/src/command-runner/index.ts diff --git a/packages/helpers/src/command-runner/middleware.ts b/packages/exec/src/command-runner/middleware.ts similarity index 100% rename from packages/helpers/src/command-runner/middleware.ts rename to packages/exec/src/command-runner/middleware.ts diff --git a/packages/helpers/src/command-runner/types.ts b/packages/exec/src/command-runner/types.ts similarity index 97% rename from packages/helpers/src/command-runner/types.ts rename to packages/exec/src/command-runner/types.ts index bb4a987d0c..7f4659aa87 100644 --- a/packages/helpers/src/command-runner/types.ts +++ b/packages/exec/src/command-runner/types.ts @@ -1,4 +1,4 @@ -import * as exec from '@actions/exec' +import * as exec from '../exec' import {PromisifiedFn} from './utils' /* CommandRunner core */ diff --git a/packages/helpers/src/command-runner/utils.ts b/packages/exec/src/command-runner/utils.ts similarity index 100% rename from packages/helpers/src/command-runner/utils.ts rename to packages/exec/src/command-runner/utils.ts From 61f54e28ca112d124ba18bc34ea8b5a266d07600 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 11:32:17 +0200 Subject: [PATCH 14/16] Remove helpers package --- packages/helpers/LICENSE.md | 9 -- .../__tests__/exec-command-wrapper.test1.ts | 83 ------------------- packages/helpers/package-lock.json | 72 ---------------- packages/helpers/package.json | 46 ---------- packages/helpers/src/exec-command-wrapper.ts | 68 --------------- packages/helpers/src/helpers.ts | 1 - packages/helpers/tsconfig.json | 12 --- 7 files changed, 291 deletions(-) delete mode 100644 packages/helpers/LICENSE.md delete mode 100644 packages/helpers/__tests__/exec-command-wrapper.test1.ts delete mode 100644 packages/helpers/package-lock.json delete mode 100644 packages/helpers/package.json delete mode 100644 packages/helpers/src/exec-command-wrapper.ts delete mode 100644 packages/helpers/src/helpers.ts delete mode 100644 packages/helpers/tsconfig.json diff --git a/packages/helpers/LICENSE.md b/packages/helpers/LICENSE.md deleted file mode 100644 index b6ddf8f7d2..0000000000 --- a/packages/helpers/LICENSE.md +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright 2023 GitHub - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/helpers/__tests__/exec-command-wrapper.test1.ts b/packages/helpers/__tests__/exec-command-wrapper.test1.ts deleted file mode 100644 index ffbf208dda..0000000000 --- a/packages/helpers/__tests__/exec-command-wrapper.test1.ts +++ /dev/null @@ -1,83 +0,0 @@ -import CommandHelper from '../src/exec-command-wrapper' -import * as io from '@actions/io' - -const IS_WINDOWS = process.platform === 'win32' - -describe('Command', () => { - it('creates a command object', async () => { - let toolpath: string - let args: string[] - if (IS_WINDOWS) { - toolpath = await io.which('cmd', true) - args = ['/c', 'echo', 'hello'] - } else { - toolpath = await io.which('echo', true) - args = ['hello'] - } - const command = new CommandHelper(`"${toolpath}"`, args) - expect(command).toBeDefined() - expect(command).toBeInstanceOf(CommandHelper) - }) - - it('runs a command with non-zero exit code', async () => { - let toolpath: string - let args: string[] - if (IS_WINDOWS) { - toolpath = await io.which('cmd', true) - args = ['/c', 'dir', 'non-existent-dir'] - } else { - toolpath = await io.which('ls', true) - args = ['-l', 'non-existent-dir'] - } - const command = new CommandHelper(`"${toolpath}"`, args, undefined, { - throwOnEmptyOutput: true - }) - try { - const result = await command.execute() - expect(result.exitCode).not.toEqual(0) - } catch (err) { - expect(err.message).toContain( - `The process '${toolpath}' failed with exit code ` - ) - } - }) - - it('runs a command with zero exit code', async () => { - let toolpath: string - let args: string[] - if (IS_WINDOWS) { - toolpath = await io.which('cmd', true) - args = ['/c', 'echo', 'hello'] - } else { - toolpath = await io.which('echo', true) - args = ['hello'] - } - const command = new CommandHelper(`"${toolpath}"`, args) - const result = await command.execute() - - expect(result.stdout).toContain('hello') - expect(result.exitCode).toEqual(0) - }) - - it('runs a command with empty output', async () => { - let toolpath: string - let args: string[] - if (IS_WINDOWS) { - toolpath = await io.which('cmd', true) - args = ['/c', 'echo.'] - } else { - toolpath = await io.which('echo', true) - args = [''] - } - - const command = new CommandHelper(`"${toolpath}"`, args, undefined, { - throwOnEmptyOutput: true - }) - try { - const result = await command.execute() - expect(result.stdout).toBe('') - } catch (err) { - expect(err.message).toContain('The command produced an empty output.') - } - }) -}) diff --git a/packages/helpers/package-lock.json b/packages/helpers/package-lock.json deleted file mode 100644 index 3e83649421..0000000000 --- a/packages/helpers/package-lock.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "@actions/helpers", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@actions/helpers", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "@actions/core": "^1.10.0", - "@actions/exec": "^1.1.1" - }, - "devDependencies": { - "@types/node": "^20.5.0" - } - }, - "node_modules/@actions/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz", - "integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==", - "dependencies": { - "@actions/http-client": "^2.0.1", - "uuid": "^8.3.2" - } - }, - "node_modules/@actions/exec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", - "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", - "dependencies": { - "@actions/io": "^1.0.1" - } - }, - "node_modules/@actions/http-client": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz", - "integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==", - "dependencies": { - "tunnel": "^0.0.6" - } - }, - "node_modules/@actions/io": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", - "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==" - }, - "node_modules/@types/node": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.0.tgz", - "integrity": "sha512-Mgq7eCtoTjT89FqNoTzzXg2XvCi5VMhRV6+I2aYanc6kQCBImeNaAYRs/DyoVqk1YEUJK5gN9VO7HRIdz4Wo3Q==", - "dev": true - }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - } - } -} diff --git a/packages/helpers/package.json b/packages/helpers/package.json deleted file mode 100644 index c4a5448611..0000000000 --- a/packages/helpers/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@actions/helpers", - "version": "1.0.0", - "description": "Helpers for creating actions", - "main": "lib/helpers.js", - "types": "lib/helpers.d.ts", - "directories": { - "lib": "lib", - "test": "__tests__" - }, - "files": [ - "lib", - "!.DS_Store" - ], - "publishConfig": { - "access": "public" - }, - "scripts": { - "audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json", - "test": "echo \"Error: run tests from root\" && exit 1", - "tsc": "tsc -p tsconfig.json" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/actions/toolkit.git", - "directory": "packages/helpers" - }, - "keywords": [ - "github", - "actions", - "helpers" - ], - "author": "", - "license": "MIT", - "bugs": { - "url": "https://github.com/actions/toolkit/issues" - }, - "homepage": "https://github.com/actions/toolkit#readme", - "devDependencies": { - "@types/node": "^20.5.0" - }, - "dependencies": { - "@actions/core": "^1.10.0", - "@actions/exec": "^1.1.1" - } -} diff --git a/packages/helpers/src/exec-command-wrapper.ts b/packages/helpers/src/exec-command-wrapper.ts deleted file mode 100644 index b0dd3ccf8a..0000000000 --- a/packages/helpers/src/exec-command-wrapper.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as exec from '@actions/exec' -import * as core from '@actions/core' - -export default class CommandHelper { - constructor( - private commandText: string, - private args: string[] = [], - private options: exec.ExecOptions | undefined = {}, - private config: { - throwOnError?: boolean - throwOnEmptyOutput?: boolean - failOnError?: boolean - failOnEmptyOutput?: boolean - } = {} - ) { - this.commandText = commandText - this.args = args - this.options = options - this.config.throwOnError = config.throwOnError ?? false - this.config.throwOnEmptyOutput = config.throwOnEmptyOutput ?? false - this.config.failOnError = config.failOnError ?? false - this.config.failOnEmptyOutput = config.failOnEmptyOutput ?? false - } - - async execute(): Promise { - try { - const output = await exec.getExecOutput( - this.commandText, - this.args, - this.options - ) - - if (this.config.throwOnError && output.stderr) { - this.onError(output.stderr).throw() - } - - if (this.config.throwOnEmptyOutput && output.stdout.trim() === '') { - this.onError(`The command produced an empty output.`).throw() - } - - if (this.config.failOnError && output.stderr) { - this.onError(output.stderr).fail() - } - - if (this.config.failOnEmptyOutput && output.stdout.trim() === '') { - this.onError(`The command produced an empty output.`).fail() - } - - return output - } catch (error) { - throw new Error((error as Error).message) - } - } - - private onError(errorMessage: string): { - throw: () => never - fail: () => void - } { - return { - throw: () => { - throw new Error(errorMessage) - }, - fail: () => { - core.setFailed(errorMessage) - } - } - } -} diff --git a/packages/helpers/src/helpers.ts b/packages/helpers/src/helpers.ts deleted file mode 100644 index 981c07b582..0000000000 --- a/packages/helpers/src/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './command-runner' diff --git a/packages/helpers/tsconfig.json b/packages/helpers/tsconfig.json deleted file mode 100644 index 3bb20cf8eb..0000000000 --- a/packages/helpers/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "baseUrl": "./", - "outDir": "./lib", - "declaration": true, - "rootDir": "./src" - }, - "include": [ - "./src" - ] -} \ No newline at end of file From b7fcb99778150674d0012b776645f2b3a67124da Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Thu, 12 Oct 2023 12:31:08 +0200 Subject: [PATCH 15/16] Update README.md --- packages/exec/README.md | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/exec/README.md b/packages/exec/README.md index 53a6bf5243..426a4f4533 100644 --- a/packages/exec/README.md +++ b/packages/exec/README.md @@ -55,3 +55,57 @@ const exec = require('@actions/exec'); await exec.exec('"/path/to/my-tool"', ['arg1']); ``` + +#### CommandRunner + +CommandRunner is a more feature-rich alternative to `exec.getExecOutput`, it adds another level of abstraction to adjust behavior depending on execution results. + +Example with echo command on different platforms +```js +const commandRunner = exec.createCommandRunner() + +// Set command and arguments that are platform-specific +if (IS_WINDOWS) { + runner.setCommand(await io.which('cmd', true)) + runner.setArgs(['/c', 'echo']) +} else { + runner.setCommand(await io.which('echo', true)) +} + +// Set arguments that should be added regardless of platform +runner.setArgs((...args) => [...args, 'hello', 'world']) + +// Run just like exec.getExecOutput +const { stdout, stderr, exitCode } = await runner.run() +``` + +Handling outputs example: +```js +await exec.createCommandRunner('echo', ['hello', 'world']) + .onExecutionError('fail', 'optional fail message') // will fail action if command failed to execute + .onStdError('log') // will log automatically generated message if command output has produced stderr + .onExitCode('> 0', 'throw') // will throw error on non-zero exit code + .run() +``` + +Handling options: +- `onEmptyOutput('throw' | 'fail' | 'log' | handler, [, message])` - triggers on empty output +- `onExecutionError('throw' | 'fail' | 'log' | handler, [, message])` - triggers when failed to execute command itself +- `onStdError('throw' | 'fail' | 'log' | handler, [, message])` - triggers when command reports that it was executed with errors +- `onError('throw' | 'fail' | 'log' | handler, [,message])` - triggers either when failed to execute command or when command has been executed with stderr +- `onSuccess('throw' | 'fail' | 'log' | handler, [, message])` - triggers when there's no errors and exitCode equals to zero +- `onSpecificError(RegExp | string | matcherFn, 'throw' | 'fail' | 'log' | handler, [, message])` - matches specific error to handle +- `onOutput(RegExp | string | matcherFn, 'throw' | 'fail' | 'log' | handler, [, message])` - matches specific stdout to handle +- `onExitCode(string | number, 'throw' | 'fail' | 'log' | handler, [, message])` - matches specific exitCode to handle, when exitCode is passed is string it can be prefixed with an operator, i.e: `> 0`, `>= 1`, `= 31`, etc. +- ` +on(eventOrEventArray, + 'throw' | 'fail' | 'log' | handler + [, message] + ) +` - matches specific event in fasion similar to other handlers, but can be set to be triggered by different events passed as array (i.e. `['execerr', 'stderr'] - any error`) or by events not occuring using `!` prefix (i.e. `'!stdout' - empty output`), allowed events: + - 'execerr' - command failed to run + - 'stderr' - command had run but produced stderr + - 'stdout' - non-empty stdout + - 'exitcode' - non-zero exit code + - 'ok' - non-zero exit code + no stderr + no execerr + From bfa86cc586ede0120704b69399d8431bf91c25b9 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Mon, 16 Oct 2023 11:31:09 +0200 Subject: [PATCH 16/16] Rearrange code and add comprehensive comments --- .../exec/src/command-runner/command-runner.ts | 190 ++++++++- packages/exec/src/command-runner/core.ts | 65 +++ .../exec/src/command-runner/get-events.ts | 55 +++ .../exec/src/command-runner/middleware.ts | 401 ------------------ .../middlware/action-middleware.ts | 158 +++++++ .../src/command-runner/middlware/index.ts | 6 + .../command-runner/middlware/match-error.ts | 71 ++++ .../command-runner/middlware/match-event.ts | 66 +++ .../middlware/match-exitcode.ts | 100 +++++ .../command-runner/middlware/match-output.ts | 55 +++ .../command-runner/middlware/pass-through.ts | 7 + packages/exec/src/command-runner/types.ts | 65 +-- packages/exec/src/command-runner/utils.ts | 30 +- packages/exec/src/exec.ts | 2 + 14 files changed, 816 insertions(+), 455 deletions(-) create mode 100644 packages/exec/src/command-runner/get-events.ts delete mode 100644 packages/exec/src/command-runner/middleware.ts create mode 100644 packages/exec/src/command-runner/middlware/action-middleware.ts create mode 100644 packages/exec/src/command-runner/middlware/index.ts create mode 100644 packages/exec/src/command-runner/middlware/match-error.ts create mode 100644 packages/exec/src/command-runner/middlware/match-event.ts create mode 100644 packages/exec/src/command-runner/middlware/match-exitcode.ts create mode 100644 packages/exec/src/command-runner/middlware/match-output.ts create mode 100644 packages/exec/src/command-runner/middlware/pass-through.ts diff --git a/packages/exec/src/command-runner/command-runner.ts b/packages/exec/src/command-runner/command-runner.ts index f2f0967ccb..523da872fb 100644 --- a/packages/exec/src/command-runner/command-runner.ts +++ b/packages/exec/src/command-runner/command-runner.ts @@ -1,6 +1,9 @@ import * as exec from '../exec' import {CommandRunnerBase} from './core' import { + ErrorMatcher, + ExitCodeMatcher, + OutputMatcher, failAction, matchEvent, matchExitCode, @@ -8,15 +11,12 @@ import { matchSpecificError, produceLog, throwError -} from './middleware' +} from './middlware' import { CommandRunnerActionType, CommandRunnerEventTypeExtended, CommandRunnerMiddleware, - CommandRunnerOptions, - ErrorMatcher, - ExitCodeMatcher, - OutputMatcher + CommandRunnerOptions } from './types' const commandRunnerActions = { @@ -26,6 +26,30 @@ const commandRunnerActions = { } as const export class CommandRunner extends CommandRunnerBase { + /** + * Sets middleware (default or custom) to be executed on command runner run + * @param event allows to set middleware on certain event + * - `execerr` - when error happens during command execution + * - `stderr` - when stderr is not empty + * - `stdout` - when stdout is not empty + * - `exitcode` - when exit code is not 0 + * - `ok` - when exit code is 0 and stderr is empty + * Each event can also be negated by prepending `!` to it, e.g. `!ok` + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .on('ok', 'log', 'Command executed successfully') + * .on('!ok', 'throw') + * .run() + * ``` + */ on( event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -40,6 +64,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command executed + * with empty stdout. + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onEmptyOutput('throw', 'Command did not produce an output') + * .run() + * ``` + */ onEmptyOutput( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -48,6 +89,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command failed + * to execute (either did not find such command or failed to spawn it). + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onExecutionError('throw', 'Command failed to execute') + * .run() + * ``` + */ onExecutionError( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -62,6 +120,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * non-empty stderr. + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onStdError('throw', 'Command produced an error') + * .run() + * ``` + */ onStdError( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -76,6 +151,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * non-empty stderr or failed to execute (either did not find such command or failed to spawn it). + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onError('throw', 'Command produced an error or failed to execute') + * .run() + * ``` + */ onError( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -83,6 +175,28 @@ export class CommandRunner extends CommandRunnerBase { return this.on(['execerr', 'stderr'], action, message) } + /** + * Sets middleware (default or custom) to be executed when command produced + * an error that matches provided matcher. + * @param matcher allows to match specific error, can be either a string (to match error message exactly), + * a regular expression (to match error message with it) or a function (to match error object with it) + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * await createCommandRunner() + * .setCommand('curl') + * .setArgs(['-f', 'http://example.com/']) + * .onSpecificError('Failed to connect to example.com port 80: Connection refused', 'throw', 'Failed to connect to example.com') + * .onSpecificError(/429/, log, 'Too many requests, retrying in 4 seconds') + * .onSpecificError(/429/, () => retryIn(4000)) + * .run() + * ``` + */ onSpecificError( matcher: ErrorMatcher, action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -98,6 +212,23 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * zero exit code and empty stderr. + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onSuccess('log', 'Command executed successfully') + * .run() + * ``` + */ onSuccess( action: CommandRunnerActionType | CommandRunnerMiddleware, message?: string @@ -105,6 +236,27 @@ export class CommandRunner extends CommandRunnerBase { return this.on('ok', action, message) } + /** + * Sets middleware (default or custom) to be executed when command produced an + * exit code that matches provided matcher. + * @param matcher allows to match specific exit code, can be either a number (to match exit code exactly) + * or a string to match exit code against operator and number, e.g. `'>= 0'` + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from event type) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from event type) + * - `log` - logs the message passed as second argument or a default one (inferred from event type) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * await createCommandRunner() + * .setCommand('curl') + * .setArgs(['-f', 'http://example.com/']) + * .onExitCode(0, 'log', 'Command executed successfully') + * .onExitCode('>= 400', 'throw', 'Command failed to execute') + * .run() + * ``` + */ onExitCode( matcher: ExitCodeMatcher, action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -120,6 +272,27 @@ export class CommandRunner extends CommandRunnerBase { return this } + /** + * Sets middleware (default or custom) to be executed when command produced + * the stdout that matches provided matcher. + * @param matcher allows to match specific stdout, can be either a string (to match stdout exactly), + * a regular expression (to match stdout with it) or a function (to match stdout with it) + * @param action allows to set action to be executed on event, it can be + * either default action (passed as string) or a custom middleware, default + * actions are: + * - `throw` - throws an error with message passed as second argument or a default one (inferred from matcher) + * - `fail` - fails the command with message passed as second argument or a default one (inferred from matcher) + * - `log` - logs the message passed as second argument or a default one (inferred from matcher) + * @param message optional message to be passed to action, is not relevant when action is a custom middleware + * @example ```typescript + * const runner = createCommandRunner('echo', ['hello']) + * await runner + * .onOutput('hello', 'log', 'Command executed successfully') + * .onOutput(/hello\S+/, 'log', 'What?') + * .onOutput(stdout => stdout.includes('world'), 'log', 'Huh') + * .run() + * ``` + */ onOutput( matcher: OutputMatcher, action: CommandRunnerActionType | CommandRunnerMiddleware, @@ -136,6 +309,13 @@ export class CommandRunner extends CommandRunnerBase { } } +/** + * Creates a command runner with provided command line, arguments and options + * @param commandLine command line to execute + * @param args arguments to pass to command + * @param options options to pass to command executor + * @returns command runner instance + */ export const createCommandRunner = ( commandLine = '', args: string[] = [], diff --git a/packages/exec/src/command-runner/core.ts b/packages/exec/src/command-runner/core.ts index e6917418da..795489abc6 100644 --- a/packages/exec/src/command-runner/core.ts +++ b/packages/exec/src/command-runner/core.ts @@ -17,6 +17,10 @@ export class CommandRunnerBase { private executor: typeof exec.exec = exec.exec ) {} + /** + * Sets command to be executed, passing a callback + * allows to modify command based on currently set command + */ setCommand(commandLine: string | ((commandLine: string) => string)): this { this.commandLine = typeof commandLine === 'function' @@ -26,6 +30,10 @@ export class CommandRunnerBase { return this } + /** + * Sets command arguments, passing a callback + * allows to modify arguments based on currently set arguments + */ setArgs(args: string[] | ((args: string[]) => string[])): this { this.args = typeof args === 'function' ? args(this.args) : [...this.args, ...args] @@ -33,6 +41,10 @@ export class CommandRunnerBase { return this } + /** + * Sets options for command executor (exec.exec by default), passing a callback + * allows to modify options based on currently set options + */ setOptions( options: | CommandRunnerOptions @@ -44,11 +56,37 @@ export class CommandRunnerBase { return this } + /** + * Sets arbitrary middleware to be executed on command runner run + * middleware is executed in the order it was added + * @param middleware middleware to be executed + * @example + * ```ts + * const runner = new CommandRunner() + * runner.use(async (ctx, next) => { + * console.log('before') + * const { + * exitCode // exit code of the command + * stdout // stdout of the command + * stderr // stderr of the command + * execerr // error thrown by the command executor + * commandLine // command line that was executed + * args // arguments that were passed to the command + * options // options that were passed to the command + * } = ctx + * await next() + * console.log('after') + * }) + * ``` + */ use(middleware: CommandRunnerMiddleware): this { this.middleware.push(promisifyFn(middleware)) return this } + /** + * Runs command with currently set options and arguments + */ async run( /* overrides command for this specific execution if not undefined */ commandLine?: string, @@ -117,9 +155,21 @@ export class CommandRunnerBase { } } +/** + * Composes multiple middleware into a single middleware + * implements a chain of responsibility pattern + * with next function passed to each middleware + * and each middleware being able to call next() to pass control to the next middleware + * or not call next() to stop the chain, + * it is also possible to run code after the next was called by using async/await + * for a cleanup or other purposes. + * This behavior is mostly implemented to be similar to express, koa or other middleware based frameworks + * in order to avoid confusion. Executing code after next() usually would not be needed. + */ export function composeMiddleware( middleware: CommandRunnerMiddleware[] ): PromisifiedFn { + // promisify all passed middleware middleware = middleware.map(mw => promisifyFn(mw)) return async ( @@ -128,6 +178,12 @@ export function composeMiddleware( ) => { let index = 0 + /** + * Picks the first not-yet-executed middleware from the list and + * runs it, passing itself as next function for + * that middleware to call, therefore would be called + * by each middleware in the chain + */ const nextLocal = async (): Promise => { if (index < middleware.length) { const currentMiddleware = middleware[index++] @@ -138,9 +194,18 @@ export function composeMiddleware( await currentMiddleware(context, nextLocal) } + /** + * If no middlware left to be executed + * will call the next funtion passed to the + * composed middleware + */ await nextGlobal() } + /** + * Starts the chain of middleware execution by + * calling nextLocal directly + */ await nextLocal() } } diff --git a/packages/exec/src/command-runner/get-events.ts b/packages/exec/src/command-runner/get-events.ts new file mode 100644 index 0000000000..5baac4ab59 --- /dev/null +++ b/packages/exec/src/command-runner/get-events.ts @@ -0,0 +1,55 @@ +import {CommandRunnerContext, CommandRunnerEventType} from './types' + +/** + * Keeps track of already computed events for context + * to avoid recomputing them + */ +let contextEvents: WeakMap< + CommandRunnerContext, + CommandRunnerEventType[] +> | null = null + +/** + * Returns event types that were triggered by the command execution + */ +export const getEvents = ( + ctx: CommandRunnerContext +): CommandRunnerEventType[] => { + const existingEvents = contextEvents?.get(ctx) + + if (existingEvents) { + return existingEvents + } + + const eventTypes = new Set() + + if (ctx.execerr) { + eventTypes.add('execerr') + } + + if (ctx.stderr) { + eventTypes.add('stderr') + } + + if (ctx.exitCode) { + eventTypes.add('exitcode') + } + + if (ctx.stdout) { + eventTypes.add('stdout') + } + + if (!ctx.stderr && !ctx.execerr && ctx.stdout !== null && !ctx.exitCode) { + eventTypes.add('ok') + } + + const result = [...eventTypes] + + if (!contextEvents) { + contextEvents = new WeakMap() + } + + contextEvents.set(ctx, result) + + return result +} diff --git a/packages/exec/src/command-runner/middleware.ts b/packages/exec/src/command-runner/middleware.ts deleted file mode 100644 index 3707e3e722..0000000000 --- a/packages/exec/src/command-runner/middleware.ts +++ /dev/null @@ -1,401 +0,0 @@ -import * as core from '@actions/core' -import { - CommandRunnerAction, - CommandRunnerContext, - CommandRunnerEventType, - CommandRunnerEventTypeExtended, - CommandRunnerMiddleware, - ErrorMatcher, - ExitCodeMatcher, - OutputMatcher -} from './types' -import {composeMiddleware} from './core' -import {gte, gt, lte, lt, eq, PromisifiedFn} from './utils' - -const getEventTypesFromContext = ( - ctx: CommandRunnerContext -): CommandRunnerEventType[] => { - const eventTypes = new Set() - - if (ctx.execerr) { - eventTypes.add('execerr') - } - - if (ctx.stderr) { - eventTypes.add('stderr') - } - - if (ctx.exitCode) { - eventTypes.add('exitcode') - } - - if (ctx.stdout) { - eventTypes.add('stdout') - } - - if (!ctx.stderr && !ctx.execerr && ctx.stdout !== null && !ctx.exitCode) { - eventTypes.add('ok') - } - - return [...eventTypes] -} - -/** - * Basic middleware - */ - -/** Calls next middleware */ -export const passThrough: () => PromisifiedFn = - () => async (_, next) => - next() - -/** - * Fails Github Action with the given message or with a default one depending on execution conditions. - */ -export const failAction: CommandRunnerAction = message => async ctx => { - const events = getEventTypesFromContext(ctx) - - if (message !== undefined) { - core.setFailed(typeof message === 'string' ? message : message(ctx, events)) - return - } - - if (events.includes('execerr')) { - core.setFailed( - `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` - ) - - return - } - - if (events.includes('stderr')) { - core.setFailed( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` - ) - - return - } - - if (!events.includes('stdout')) { - core.setFailed( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` - ) - - return - } - - core.setFailed( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) -} - -/** - * Throws an error with the given message or with a default one depending on execution conditions. - */ -export const throwError: CommandRunnerAction = message => { - return async ctx => { - const events = getEventTypesFromContext(ctx) - - if (message !== undefined) { - throw new Error( - typeof message === 'string' ? message : message(ctx, events) - ) - } - - if (events.includes('execerr')) { - throw new Error( - `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` - ) - } - - if (events.includes('stderr')) { - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` - ) - } - - if (!events.includes('stdout')) { - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` - ) - } - - throw new Error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) - } -} - -/** - * Logs a message with the given message or with a default one depending on execution conditions. - */ -export const produceLog: CommandRunnerAction = message => async (ctx, next) => { - const events = getEventTypesFromContext(ctx) - - if (message !== undefined) { - // core.info(typeof message === 'string' ? message : message(ctx, [])) - const messageText = - typeof message === 'string' ? message : message(ctx, events) - - if (events.includes('execerr')) { - core.error(messageText) - next() - return - } - - if (events.includes('stderr')) { - core.error(messageText) - next() - return - } - - if (!events.includes('stdout')) { - core.warning(messageText) - next() - return - } - - if (events.includes('ok')) { - core.notice(messageText) - next() - return - } - - core.info(messageText) - next() - return - } - - if (events.includes('execerr')) { - core.error( - `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` - ) - next() - return - } - - if (events.includes('stderr')) { - core.error( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` - ) - next() - return - } - - if (!events.includes('stdout')) { - core.warning( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` - ) - next() - return - } - - if (events.includes('ok')) { - core.notice( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) - next() - return - } - - core.info( - `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` - ) - next() -} - -/** - * Will call passed middleware if matching event has occured. - * Will call the next middleware otherwise. - */ -export const matchEvent = ( - eventType: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], - middleware?: CommandRunnerMiddleware[] -): PromisifiedFn => { - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - const expectedEventsPositiveArray = ( - Array.isArray(eventType) ? eventType : [eventType] - ).filter(e => !e.startsWith('!')) as CommandRunnerEventType[] - - const expectedEventsNegativeArray = ( - Array.isArray(eventType) ? eventType : [eventType] - ) - .filter(e => e.startsWith('!')) - .map(e => e.slice(1)) as CommandRunnerEventType[] - - const expectedEventsPositive = new Set(expectedEventsPositiveArray) - const expectedEventsNegative = new Set(expectedEventsNegativeArray) - - return async (ctx, next) => { - const currentEvents = getEventTypesFromContext(ctx) - let shouldRun = false - - if ( - expectedEventsPositive.size && - currentEvents.some(e => expectedEventsPositive.has(e)) - ) { - shouldRun = true - } - - if ( - expectedEventsNegative.size && - currentEvents.every(e => !expectedEventsNegative.has(e)) - ) { - shouldRun = true - } - - if (shouldRun) { - await composedMiddleware(ctx, next) - return - } - - next() - } -} - -/** - * Will call passed middleware if matching event has occured. - * Will call the next middleware otherwise. - */ -export const matchOutput = ( - matcher: OutputMatcher, - middleware?: CommandRunnerMiddleware[] -): PromisifiedFn => { - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - return async (ctx, next) => { - const output = ctx.stdout - - if (output === null) { - next() - return - } - - if (typeof matcher === 'function' && !matcher(output)) { - next() - return - } - - if (typeof matcher === 'string' && output !== matcher) { - next() - return - } - - if (matcher instanceof RegExp && !matcher.test(output)) { - next() - return - } - - await composedMiddleware(ctx, next) - } -} - -const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') - -const MATCHERS = { - '>=': gte, - '>': gt, - '<=': lte, - '<': lt, - '=': eq -} as const - -const parseExitCodeMatcher = ( - code: ExitCodeMatcher -): [keyof typeof MATCHERS, number] => { - if (typeof code === 'number') { - return ['=', code] - } - - code = removeWhitespaces(code) - - // just shortcuts for the most common cases - if (code.startsWith('=')) return ['=', Number(code.slice(1))] - if (code === '>0') return ['>', 0] - if (code === '<1') return ['<', 1] - - const match = code.match(/^([><]=?)(\d+)$/) - - if (match === null) { - throw new Error(`Invalid exit code matcher: ${code}`) - } - - const [, operator, number] = match - return [operator as keyof typeof MATCHERS, parseInt(number)] -} - -/** - * Will call passed middleware if matching exit code was returned. - * Will call the next middleware otherwise. - */ -export const matchExitCode = ( - code: ExitCodeMatcher, - middleware?: CommandRunnerMiddleware[] -): PromisifiedFn => { - const [operator, number] = parseExitCodeMatcher(code) - const matcherFn = MATCHERS[operator](number) - - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - return async (ctx, next) => { - // if exit code is undefined, NaN will not match anything - if (matcherFn(ctx.exitCode ?? NaN)) { - await composedMiddleware(ctx, next) - return - } - - next() - } -} - -export const matchSpecificError = ( - matcher: ErrorMatcher, - middleware?: - | CommandRunnerMiddleware[] - | PromisifiedFn[] -): PromisifiedFn => { - const composedMiddleware = composeMiddleware( - !middleware?.length ? [passThrough()] : middleware - ) - - return async (ctx, next) => { - if (ctx.execerr === null && ctx.stderr === null) { - next() - return - } - - const error: { - type: 'stderr' | 'execerr' - error: Error | null - message: string - } = { - type: ctx.execerr ? 'execerr' : 'stderr', - error: ctx.execerr ? ctx.execerr : null, - message: ctx.execerr ? ctx.execerr.message : ctx.stderr ?? '' - } - - if (typeof matcher === 'function' && !matcher(error)) { - next() - return - } - - if (typeof matcher === 'string' && error.message !== matcher) { - next() - return - } - - if (matcher instanceof RegExp && !matcher.test(error.message)) { - next() - return - } - - await composedMiddleware(ctx, next) - } -} diff --git a/packages/exec/src/command-runner/middlware/action-middleware.ts b/packages/exec/src/command-runner/middlware/action-middleware.ts new file mode 100644 index 0000000000..009bc7c7f0 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/action-middleware.ts @@ -0,0 +1,158 @@ +import * as core from '@actions/core' +import {CommandRunnerAction} from '../types' +import {getEvents} from '../get-events' + +/** + * Fails Github Action with the given message or with a default one depending on execution conditions. + */ +export const failAction: CommandRunnerAction = message => async ctx => { + const events = getEvents(ctx) + + if (message !== undefined) { + core.setFailed(typeof message === 'string' ? message : message(ctx, events)) + return + } + + if (events.includes('execerr')) { + core.setFailed( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + + return + } + + if (events.includes('stderr')) { + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + + return + } + + if (!events.includes('stdout')) { + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + + return + } + + core.setFailed( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) +} + +/** + * Throws an error with the given message or with a default one depending on execution conditions. + */ +export const throwError: CommandRunnerAction = message => { + return async ctx => { + const events = getEvents(ctx) + + if (message !== undefined) { + throw new Error( + typeof message === 'string' ? message : message(ctx, events) + ) + } + + if (events.includes('execerr')) { + throw new Error( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + } + + if (events.includes('stderr')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + } + + if (!events.includes('stdout')) { + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + } + + throw new Error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + } +} + +/** + * Logs a message with the given message or with a default one depending on execution conditions. + */ +export const produceLog: CommandRunnerAction = message => async (ctx, next) => { + const events = getEvents(ctx) + + if (message !== undefined) { + // core.info(typeof message === 'string' ? message : message(ctx, [])) + const messageText = + typeof message === 'string' ? message : message(ctx, events) + + if (events.includes('execerr')) { + core.error(messageText) + next() + return + } + + if (events.includes('stderr')) { + core.error(messageText) + next() + return + } + + if (!events.includes('stdout')) { + core.warning(messageText) + next() + return + } + + if (events.includes('ok')) { + core.notice(messageText) + next() + return + } + + core.info(messageText) + next() + return + } + + if (events.includes('execerr')) { + core.error( + `The command "${ctx.commandLine}" failed to run: ${ctx.execerr?.message}` + ) + next() + return + } + + if (events.includes('stderr')) { + core.error( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an error: ${ctx.stderr}` + ) + next() + return + } + + if (!events.includes('stdout')) { + core.warning( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced an empty output.` + ) + next() + return + } + + if (events.includes('ok')) { + core.notice( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + next() + return + } + + core.info( + `The command "${ctx.commandLine}" finished with exit code ${ctx.exitCode} and produced the following output: ${ctx.stdout}` + ) + next() +} diff --git a/packages/exec/src/command-runner/middlware/index.ts b/packages/exec/src/command-runner/middlware/index.ts new file mode 100644 index 0000000000..9097aaf5b9 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/index.ts @@ -0,0 +1,6 @@ +export * from './action-middleware' +export * from './match-error' +export * from './match-event' +export * from './match-exitcode' +export * from './match-output' +export * from './pass-through' diff --git a/packages/exec/src/command-runner/middlware/match-error.ts b/packages/exec/src/command-runner/middlware/match-error.ts new file mode 100644 index 0000000000..acca69689c --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-error.ts @@ -0,0 +1,71 @@ +import {composeMiddleware} from '../core' +import {passThrough} from './pass-through' +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn} from '../utils' + +/** + * Matcher types that are available to user to match error against + * and set middleware on + */ +export type ErrorMatcher = + | RegExp + | string + | ((error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + }) => boolean) + +/** + * Will call passed middleware if matching error has occured. + * If matching error has occured will call passed middleware. Will call the next middleware otherwise. + */ +export const matchSpecificError = ( + matcher: ErrorMatcher, + middleware?: + | CommandRunnerMiddleware[] + | PromisifiedFn[] +): PromisifiedFn => { + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + return async (ctx, next) => { + if (ctx.execerr === null && ctx.stderr === null) { + next() + return + } + + const error: { + type: 'stderr' | 'execerr' + error: Error | null + message: string + } = { + type: ctx.execerr ? 'execerr' : 'stderr', + error: ctx.execerr ? ctx.execerr : null, + message: ctx.execerr ? ctx.execerr.message : ctx.stderr ?? '' + } + + if (typeof matcher === 'function' && !matcher(error)) { + next() + return + } + + if (typeof matcher === 'string' && error.message !== matcher) { + next() + return + } + + if (matcher instanceof RegExp && !matcher.test(error.message)) { + next() + return + } + + await composedMiddleware(ctx, next) + } +} diff --git a/packages/exec/src/command-runner/middlware/match-event.ts b/packages/exec/src/command-runner/middlware/match-event.ts new file mode 100644 index 0000000000..20b7de5a12 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-event.ts @@ -0,0 +1,66 @@ +import {composeMiddleware} from '../core' +import {getEvents} from '../get-events' +import { + CommandRunnerEventTypeExtended, + CommandRunnerMiddleware, + CommandRunnerEventType +} from '../types' +import {PromisifiedFn} from '../utils' +import {passThrough} from './pass-through' + +/** + * Will call passed middleware if matching event has occured. + * Will call the next middleware otherwise. + */ +export const matchEvent = ( + eventType: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[], + middleware?: CommandRunnerMiddleware[] +): PromisifiedFn => { + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + const expectedEventsPositiveArray = ( + Array.isArray(eventType) ? eventType : [eventType] + ).filter(e => !e.startsWith('!')) as CommandRunnerEventType[] + + const expectedEventsNegativeArray = ( + Array.isArray(eventType) ? eventType : [eventType] + ) + .filter(e => e.startsWith('!')) + .map(e => e.slice(1)) as CommandRunnerEventType[] + + const expectedEventsPositive = new Set(expectedEventsPositiveArray) + const expectedEventsNegative = new Set(expectedEventsNegativeArray) + + return async (ctx, next) => { + const currentEvents = getEvents(ctx) + let shouldRun = false + + if ( + expectedEventsPositive.size && + currentEvents.some(e => expectedEventsPositive.has(e)) + ) { + shouldRun = true + } + + if ( + expectedEventsNegative.size && + currentEvents.every(e => !expectedEventsNegative.has(e)) + ) { + shouldRun = true + } + + if (shouldRun) { + await composedMiddleware(ctx, next) + return + } + + next() + } +} diff --git a/packages/exec/src/command-runner/middlware/match-exitcode.ts b/packages/exec/src/command-runner/middlware/match-exitcode.ts new file mode 100644 index 0000000000..a789117c8f --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-exitcode.ts @@ -0,0 +1,100 @@ +import {composeMiddleware} from '../core' +import {passThrough} from './pass-through' +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn, removeWhitespaces} from '../utils' + +/** + * Matcher types that are available to user to match exit code against + * and set middleware on + */ +export type ExitCodeMatcher = string | number + +/** + * Comparators + */ +export const lte = + (a: number) => + (b: number): boolean => + b <= a +export const gte = + (a: number) => + (b: number): boolean => + b >= a +export const lt = + (a: number) => + (b: number): boolean => + b < a +export const gt = + (a: number) => + (b: number): boolean => + b > a +export const eq = + (a: number) => + (b: number): boolean => + b === a + +const MATCHERS = { + '>=': gte, + '>': gt, + '<=': lte, + '<': lt, + '=': eq +} as const + +const parseExitCodeMatcher = ( + code: ExitCodeMatcher +): [keyof typeof MATCHERS, number] => { + if (typeof code === 'number') { + return ['=', code] + } + + code = removeWhitespaces(code) + + // just shortcuts for the most common cases + if (code.startsWith('=')) return ['=', Number(code.slice(1))] + if (code === '>0') return ['>', 0] + if (code === '<1') return ['<', 1] + + const match = code.match(/^([><]=?)(\d+)$/) + + if (match === null) { + throw new Error(`Invalid exit code matcher: ${code}`) + } + + const [, operator, number] = match + return [operator as keyof typeof MATCHERS, parseInt(number)] +} + +/** + * Will call passed middleware if matching exit code was returned. + * Will call the next middleware otherwise. Will also call next middleware + * if exit code is null (command did not run). + */ +export const matchExitCode = ( + code: ExitCodeMatcher, + middleware?: CommandRunnerMiddleware[] +): PromisifiedFn => { + const [operator, number] = parseExitCodeMatcher(code) + + // sets appropriate matching function + const matcherFn = MATCHERS[operator](number) + + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + return async (ctx, next) => { + // if exit code is undefined, NaN will not match anything + if (matcherFn(ctx.exitCode ?? NaN)) { + await composedMiddleware(ctx, next) + return + } + + next() + } +} diff --git a/packages/exec/src/command-runner/middlware/match-output.ts b/packages/exec/src/command-runner/middlware/match-output.ts new file mode 100644 index 0000000000..3e121ee1d1 --- /dev/null +++ b/packages/exec/src/command-runner/middlware/match-output.ts @@ -0,0 +1,55 @@ +import {composeMiddleware} from '../core' +import {passThrough} from './pass-through' +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn} from '../utils' + +/** + * Matcher types that are available to user to match output against + * and set middleware on + */ +export type OutputMatcher = RegExp | string | ((output: string) => boolean) + +/** + * Will call passed middleware if command produced a matching stdout. + * Will call the next middleware otherwise. Will also call the next middleware + * if stdout is null (command did not run). + */ +export const matchOutput = ( + matcher: OutputMatcher, + middleware?: CommandRunnerMiddleware[] +): PromisifiedFn => { + /** + * Composes passed middleware if any or replaces them + * with passThrough middleware if none were passed + * to avoid errors when calling composed middleware + */ + const composedMiddleware = composeMiddleware( + !middleware?.length ? [passThrough()] : middleware + ) + + return async (ctx, next) => { + const output = ctx.stdout + + if (output === null) { + next() + return + } + + if (typeof matcher === 'function' && !matcher(output)) { + next() + return + } + + if (typeof matcher === 'string' && output !== matcher) { + next() + return + } + + if (matcher instanceof RegExp && !matcher.test(output)) { + next() + return + } + + await composedMiddleware(ctx, next) + } +} diff --git a/packages/exec/src/command-runner/middlware/pass-through.ts b/packages/exec/src/command-runner/middlware/pass-through.ts new file mode 100644 index 0000000000..b48748412f --- /dev/null +++ b/packages/exec/src/command-runner/middlware/pass-through.ts @@ -0,0 +1,7 @@ +import {CommandRunnerMiddleware} from '../types' +import {PromisifiedFn} from '../utils' + +/** Calls next middleware */ +export const passThrough: () => PromisifiedFn = + () => async (_, next) => + next() diff --git a/packages/exec/src/command-runner/types.ts b/packages/exec/src/command-runner/types.ts index 7f4659aa87..5f2a9d03bf 100644 --- a/packages/exec/src/command-runner/types.ts +++ b/packages/exec/src/command-runner/types.ts @@ -1,41 +1,64 @@ import * as exec from '../exec' import {PromisifiedFn} from './utils' -/* CommandRunner core */ - +/** + * CommandRunner.prototype.run() outpout and context + * that is passed to each middleware + */ export interface CommandRunnerContext { - /* Inputs with which command was executed */ + /** Command that was executed */ commandLine: string + + /** Arguments with which command was executed */ args: string[] + + /** Command options with which command executor was ran */ options: exec.ExecOptions - /* Results of the execution */ + /** Error that was thrown when attempting to execute command */ execerr: Error | null + + /** Command's output that was passed to stderr if command did run, null otherwise */ stderr: string | null + + /** Command's output that was passed to stdout if command did run, null otherwise */ stdout: string | null + + /** Command's exit code if command did run, null otherwise */ exitCode: number | null } -/* Middlewares as used by the user */ +/** + * Base middleware shape + */ type _CommandRunnerMiddleware = ( ctx: CommandRunnerContext, next: () => Promise ) => void | Promise +/** + * Normalized middleware shape that is always promisified + */ export type CommandRunnerMiddleware = PromisifiedFn<_CommandRunnerMiddleware> -/* Command runner events handling and command runner actions */ - +/** + * Shape for the command runner default middleware creators + */ export type CommandRunnerAction = ( message?: | string | ((ctx: CommandRunnerContext, events: CommandRunnerEventType[]) => string) ) => PromisifiedFn -/* Command runner default actions types on which preset middleware exists */ +/** + * Default middleware identifires that can be uset to set respective action + * in copmposing middleware + */ export type CommandRunnerActionType = 'throw' | 'fail' | 'log' -/* Command runner event types as used internally passed to middleware for the user */ +/** + * Command runner event types on which middleware can be set + */ export type CommandRunnerEventType = | 'execerr' | 'stderr' @@ -43,27 +66,19 @@ export type CommandRunnerEventType = | 'exitcode' | 'ok' -/* Command runner event types as used by the user for filtering results */ +/** + * Extended event type that can be used to set middleware on event not happening + */ export type CommandRunnerEventTypeExtended = | CommandRunnerEventType | `!${CommandRunnerEventType}` +/** + * options that would be passed to the command executor (exec.exec by default) + * failOnStdErr and ignoreReturnCode are excluded as they are + * handled by the CommandRunner itself + */ export type CommandRunnerOptions = Omit< exec.ExecOptions, 'failOnStdErr' | 'ignoreReturnCode' > - -/* Matchers */ - -export type OutputMatcher = RegExp | string | ((output: string) => boolean) - -export type ExitCodeMatcher = string | number - -export type ErrorMatcher = - | RegExp - | string - | ((error: { - type: 'stderr' | 'execerr' - error: Error | null - message: string - }) => boolean) diff --git a/packages/exec/src/command-runner/utils.ts b/packages/exec/src/command-runner/utils.ts index 279f5c4955..6afe2c7d53 100644 --- a/packages/exec/src/command-runner/utils.ts +++ b/packages/exec/src/command-runner/utils.ts @@ -1,7 +1,6 @@ /** - * Promises + * Promisifies a a function type */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any export type PromisifiedFn any> = ( ...args: Parameters @@ -9,6 +8,9 @@ export type PromisifiedFn any> = ( ? ReturnType : Promise> +/** + * Promisifies a function + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const promisifyFn = any>( fn: T @@ -27,26 +29,6 @@ export const promisifyFn = any>( } /** - * Comparators + * Removes all whitespaces from a string */ - -export const lte = - (a: number) => - (b: number): boolean => - b <= a -export const gte = - (a: number) => - (b: number): boolean => - b >= a -export const lt = - (a: number) => - (b: number): boolean => - b < a -export const gt = - (a: number) => - (b: number): boolean => - b > a -export const eq = - (a: number) => - (b: number): boolean => - b === a +export const removeWhitespaces = (str: string): string => str.replace(/\s/g, '') diff --git a/packages/exec/src/exec.ts b/packages/exec/src/exec.ts index 2a67a912d5..0693d1c011 100644 --- a/packages/exec/src/exec.ts +++ b/packages/exec/src/exec.ts @@ -2,6 +2,8 @@ import {StringDecoder} from 'string_decoder' import {ExecOptions, ExecOutput, ExecListeners} from './interfaces' import * as tr from './toolrunner' +export {CommandRunner, createCommandRunner} from './command-runner' + export {ExecOptions, ExecOutput, ExecListeners} /**