Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement an async exit package #1

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions packages/exit/__tests__/exit.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import * as exit from '../src/exit'

it('exits successfully', () => {
it('exits successfully', async () => {
jest.spyOn(process, 'exit').mockImplementation()
exit.success()
await exit.success()
expect(process.exit).toHaveBeenCalledWith(0)
})

it('exits as a failure', () => {
it('exits as a failure', async () => {
jest.spyOn(process, 'exit').mockImplementation()
exit.failure()
await exit.failure()
expect(process.exit).toHaveBeenCalledWith(1)
})

it('exits neutrally', () => {
it('exits neutrally', async () => {
jest.spyOn(process, 'exit').mockImplementation()
exit.neutral()
await exit.neutral()
expect(process.exit).toHaveBeenCalledWith(78)
})

it('exits syncrhonously', () => {
jest.spyOn(process, 'exit').mockImplementation()
exit.success({sync: true})
expect(process.exit).toHaveBeenCalledWith(0)
})
69 changes: 60 additions & 9 deletions packages/exit/src/exit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import * as tty from 'tty'

/**
* Options for exiting an action
*/
export type ExitOpts = {
/**
* Exit immediately, without waiting for a drain
*/
sync?: boolean
}

/**
* The code to exit an action
*/
Expand All @@ -18,26 +30,65 @@ export enum ExitCode {
Neutral = 78
}

// TODO: These exit codes may not behave as expected on the new runtime, due to
// complexities of async logging and sync exiting.

/**
* Exit the action as a success.
*
* @param opts [[ExitOpts]] to use for this exit
*/
export function success() {
process.exit(ExitCode.Success)
export async function success(opts: ExitOpts = {}) {
await exit(ExitCode.Success, opts)
}

/**
* Exit the action as a failure.
*
* @param opts [[ExitOpts]] to use for this exit
*/
export function failure() {
process.exit(ExitCode.Failure)
export async function failure(opts: ExitOpts = {}) {
await exit(ExitCode.Failure, opts)
}

/**
* Exit the action neither a success or a failure
*
* @param opts [[ExitOpts]] to use for this exit
*/
export async function neutral(opts: ExitOpts = {}) {
await exit(ExitCode.Neutral, opts)
}

/**
* Exit after waiting for streams to drain (if needed).
*
* Since `process.exit` is synchronous, and writing to `process.stdout` and
* `process.stderr` are potentially asynchronous, this function waits for them
* to drain, if need be, before exiting.
*
* @param code The [[ExitCode]] to use when exiting
* @param opts [[ExitOpts]] to use for this exit
*/
async function exit(code: ExitCode, opts: ExitOpts) {
if (opts.sync) {
process.exit(code)
}

const stdout = process.stdout as tty.WriteStream
const stderr = process.stderr as tty.WriteStream

await Promise.all([stdout, stderr].map(drainStream))

process.exit(code)
}

/**
* Drain the given `stream`, if need be, or immediately return.
*
* @param stream A [tty.WriteStream](https://nodejs.org/dist/latest-v11.x/docs/api/tty.html#tty_class_tty_writestream) to drain
*/
export function neutral() {
process.exit(ExitCode.Neutral)
async function drainStream(stream: tty.WriteStream) {
if (stream.bufferSize > 0) {
return new Promise(resolve => stream.once('drain', () => resolve))
} else {
return Promise.resolve()
}
}
12 changes: 7 additions & 5 deletions packages/toolkit/__tests__/toolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ describe('.run', () => {
})

it('logs and fails when an error occurs', async () => {
const err = new Error()
const exitFailure = jest.fn()
const err = new Error('Error in run')

const logFatal = jest.fn()

await Toolkit.run(async tk => {
tk.exit.failure = exitFailure
tk.logger.fatal = logFatal
throw err
})

expect(exitFailure).toHaveBeenCalledWith(err)
expect(logFatal).toHaveBeenCalledWith(err)
expect(exitPkg.failure).toHaveBeenCalled()
})
})

Expand All @@ -43,7 +45,7 @@ it('asserts required keys are present', async () => {

new Toolkit({logger, requiredEnv: [missingKey]})

expect(exitPkg.failure).toHaveBeenCalled()
expect(exitPkg.failure).toHaveBeenCalledWith({sync: true})
expect(logger.fatal)
.toHaveBeenCalledWith(`The following environment variables are required for this action to run:
- __DOES_NOT_EXIST__`)
Expand Down
31 changes: 25 additions & 6 deletions packages/toolkit/src/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,50 @@ import {Signale} from 'signale'

/**
* A class that wraps some basic methods of exiting from an action
*
* ```typescript
* const exit = new Exit(signaleLogger)
* exit.success('Success!', {sync: true})
* ```
*/
export class Exit {
/**
* Create a new [[Exit]] instance.
*
* @param logger An instance of [Signale](https://github.com/klaussinani/signale) to write to
*/
constructor(private readonly logger: Signale) {}

/**
* Stop the action with a "success" status.
*
* @param message The message to log when exiting
* @param opts [[ExitOpts]] to use for the exit
*/
success(message?: string) {
success(message?: string, opts?: exit.ExitOpts) {
if (message) this.logger.success(message)
exit.success()
return exit.success(opts)
}

/**
* Stop the action with a "neutral" status.
*
* @param message The message to log when exiting
* @param opts [[ExitOpts]] to use for the exit
*/
neutral(message?: string) {
neutral(message?: string, opts?: exit.ExitOpts) {
if (message) this.logger.info(message)
exit.neutral()
return exit.neutral(opts)
}

/**
* Stop the action with a "failed" status.
*
* @param message The message to log when exiting
* @param opts [[ExitOpts]] to use for the exit
*/
failure(message?: string) {
failure(message?: string, opts?: exit.ExitOpts) {
if (message) this.logger.fatal(message)
exit.failure()
return exit.failure(opts)
}
}
24 changes: 22 additions & 2 deletions packages/toolkit/src/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export class Toolkit {
*
* If an error occurs, the error will be logged and the action will exit as a
* failure.
*
* ```typescript
* Toolkit.run(async toolkit => {
* toolkit.logger('Hello, world!')
* await toolkit.exit.success()
* })
* ```
*
* @param func The function to run
* @param opts [[ToolkitOptions]] with which to run the function
*/
static async run(func: ActionFn, opts?: ToolkitOptions) {
const tools = new Toolkit(opts)
Expand All @@ -37,7 +47,7 @@ export class Toolkit {
const ret = func(tools)
return ret instanceof Promise ? await ret : ret
} catch (err) {
tools.exit.failure(err)
await tools.exit.failure(err)
}
}

Expand All @@ -56,6 +66,11 @@ export class Toolkit {
*/
readonly token: string = process.env.GITHUB_TOKEN || ''

/**
* Create an instance of [[Toolkit]].
*
* @param opts [[ToolkitOptions]] for configuring the [[Toolkit]]
*/
constructor(opts: ToolkitOptions = {}) {
const logger = opts.logger || new Signale({config: {underlineLabel: false}})
this.logger = this.wrapLogger(logger)
Expand All @@ -68,6 +83,8 @@ export class Toolkit {

/**
* Ensure that the given keys are in the environment.
*
* @param keys A list of keys to be ensured are in the environment
*/
private checkRequiredEnv(keys: string[]) {
const missingEnv = keys.filter(key => !process.env.hasOwnProperty(key))
Expand All @@ -77,12 +94,15 @@ export class Toolkit {
const list = missingEnv.map(key => `- ${key}`).join('\n')

this.exit.failure(
`The following environment variables are required for this action to run:\n${list}`
`The following environment variables are required for this action to run:\n${list}`,
{sync: true}
)
}

/**
* Wrap a Signale logger so that its a callable class.
*
* @param logger A logger to wrap (and make callable)
*/
private wrapLogger(logger: Signale) {
// Create a callable function
Expand Down