1
0
Fork 0

Add middleware for command-runner

pull/1562/head
Nikolai Laevskii 2023-09-29 16:17:46 +02:00
parent 45c2409453
commit c00940a820
9 changed files with 1022 additions and 1 deletions

View File

@ -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)
})
})
})
})

View File

@ -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<S = unknown> extends CommandRunnerBase<S> {
on(
event: CommandRunnerEventTypeExtended | CommandRunnerEventTypeExtended[],
action: CommandRunnerActionType | CommandRunnerMiddleware<S>,
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<S>,
message?: string
): this {
this.on('no-stdout', action, message)
return this
}
onExecutionError(
action: CommandRunnerActionType | CommandRunnerMiddleware<S>,
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<S>,
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<S>,
message?: string
): this {
return this.on(['execerr', 'stderr'], action, message)
}
onSpecificError(
matcher: ErrorMatcher,
action: CommandRunnerActionType | CommandRunnerMiddleware<S>,
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<S>,
message?: string
): this {
return this.on('ok', action, message)
}
onExitCode(
matcher: ExitCodeMatcher,
action: CommandRunnerActionType | CommandRunnerMiddleware<S>,
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<S>,
message?: string
): this {
const middleware =
typeof action === 'string'
? [commandRunnerActions[action](message)]
: [action]
this.use(matchOutput(matcher, middleware as CommandRunnerMiddleware[]))
return this
}
}
export const commandPipeline = <S = unknown>(
commandLine: string,
args: string[] = [],
options: Record<string, unknown> = {}
): CommandRunner<S> =>
new CommandRunner(commandLine, args, options, exec.getExecOutput)

View File

@ -0,0 +1,125 @@
import * as exec from '@actions/exec'
import {
CommandRunnerContext,
CommandRunnerMiddleware,
CommandRunnerMiddlewarePromisified
} from './types'
export const promisifyCommandRunnerMiddleware =
(
middleware: CommandRunnerMiddleware<unknown>
): CommandRunnerMiddlewarePromisified =>
async (ctx, next) => {
return Promise.resolve(middleware(ctx, next))
}
export const composeCommandRunnerMiddleware =
(middleware: CommandRunnerMiddlewarePromisified[]) =>
async (context: CommandRunnerContext, nextGlobal: () => Promise<void>) => {
let index = 0
const nextLocal = async (): Promise<void> => {
if (index < middleware.length) {
const currentMiddleware = middleware[index++]
if (middleware === undefined) {
return
}
await currentMiddleware(context, nextLocal)
}
await nextGlobal()
}
await nextLocal()
}
export class CommandRunnerBase<S = unknown> {
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<S>): this {
this.middleware.push(
promisifyCommandRunnerMiddleware(
middleware as CommandRunnerMiddleware<unknown>
)
)
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<CommandRunnerContext<S>> {
const tmpArgs = this.getTmpArgs()
const context: CommandRunnerContext<S> = {
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<void> => Promise.resolve()
await composeCommandRunnerMiddleware(this.middleware)(context, next)
return context
}
}

View File

@ -0,0 +1 @@
export {commandPipeline, CommandRunner} from './command-runner'

View File

@ -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<CommandRunnerEventType>()
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<boolean>)
) => 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)
}
}

View File

@ -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()
})()

View File

@ -0,0 +1,44 @@
import * as exec from '@actions/exec'
/* CommandRunner core */
export interface CommandRunnerContext<S = unknown> {
/* 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<void>
) => Promise<void>
/* Middlewares as used by the user */
export type CommandRunnerMiddleware<S = unknown> = (
ctx: CommandRunnerContext<S>,
next: () => Promise<void>
) => void | Promise<void>
/* 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}`

View File

@ -1 +1 @@
export * from './command-runner'