Skip to content

Commit

Permalink
feat(test): add command assert plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
jlenon7 committed Sep 5, 2023
1 parent 804b374 commit c486c42
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 16 deletions.
2 changes: 2 additions & 0 deletions bin/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
*/

import { Config } from '@athenna/config'
import { command } from '#src/testing/plugins/index'
import { Runner, assert, specReporter } from '@athenna/test'

Config.set('meta', import.meta.url)

await Runner.setTsEnv()
.addPlugin(assert())
.addPlugin(command())
.addReporter(specReporter())
.addPath('tests/unit/**/*.ts')
.setCliArgs(process.argv.slice(2))
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@athenna/artisan",
"version": "4.5.0",
"version": "4.6.0",
"description": "The Athenna CLI application. Built on top of commander and inspired in @adonisjs/ace.",
"license": "MIT",
"author": "João Lenon <lenon@athenna.io>",
Expand Down Expand Up @@ -37,13 +37,13 @@
".": "./src/index.js",
"./types": "./src/types/index.js",
"./package.json": "./package.json",
"./testing/plugins": "./src/testing/plugins/index.js",
"./kernels/ConsoleKernel": "./src/kernels/ConsoleKernel.js",
"./handlers/ConsoleExceptionHandler": "./src/handlers/ConsoleExceptionHandler.js",
"./providers/ArtisanProvider": "./src/providers/ArtisanProvider.js",
"./commands/ListCommand": "./src/commands/ListCommand.js",
"./commands/ConfigureCommand": "./src/commands/ConfigureCommand.js",
"./commands/MakeCommandCommand": "./src/commands/MakeCommandCommand.js",
"./commands/TemplateCustomizeCommand": "./src/commands/TemplateCustomizeCommand.js"
"./commands/MakeCommandCommand": "./src/commands/MakeCommandCommand.js"
},
"imports": {
"#bin/*": "./bin/*.js",
Expand All @@ -65,7 +65,7 @@
"ora": "^6.3.1"
},
"devDependencies": {
"@athenna/common": "^4.9.1",
"@athenna/common": "^4.9.2",
"@athenna/config": "^4.3.0",
"@athenna/ioc": "^4.1.0",
"@athenna/logger": "^4.1.0",
Expand Down
6 changes: 3 additions & 3 deletions src/artisan/ArtisanImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { Config } from '@athenna/config'
import { Decorator } from '#src/helpers/Decorator'
import { Commander } from '#src/artisan/Commander'
import { BaseCommand } from '#src/artisan/BaseCommand'
import { Exec, Is, Options, Path } from '@athenna/common'
import { CommanderHandler } from '#src/handlers/CommanderHandler'
import { Exec, Is, Options, Path, type CommandOutput } from '@athenna/common'

export class ArtisanImpl {
/**
Expand Down Expand Up @@ -107,7 +107,7 @@ export class ArtisanImpl {
/**
* Call an Artisan command inside a child process.
* This method needs to execute a file to bootstrap
* under the hood, by default the "Path.pwd(`artisan.${Path.ext()}`)"
* under the hood, by default the "Path.bootstrap(`artisan.${Path.ext()}`)"
* is used.
*
* @example
Expand All @@ -120,7 +120,7 @@ export class ArtisanImpl {
public async callInChild(
command: string,
path = Path.bootstrap(`artisan.${Path.ext()}`),
): Promise<{ stdout: string; stderr: string }> {
): Promise<CommandOutput> {
const separator = platform() === 'win32' ? '&' : '&&'
const executor = `cd ${Path.pwd()} ${separator} sh node`

Expand Down
51 changes: 51 additions & 0 deletions src/testing/plugins/command/TestCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @athenna/artisan
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { Artisan } from '#src'
import { Assert } from '@japa/assert'
import type { CommandOutput } from '@athenna/common'
import { TestOutput } from '#src/testing/plugins/command/TestOutput'

export class TestCommand {
/**
* The Artisan file path that will be used to run commands.
*/
public static artisanPath = undefined

/**
* Set the artisan file path.
*/
public static setArtisanPath(path: string): typeof TestCommand {
this.artisanPath = path

return this
}

/**
* Japa assert class instance.
*/
public assert = new Assert()

/**
* Instantiate TestOutput class from stdout, stderr and exitCode.
*/
public createOutput(output: CommandOutput): TestOutput {
return new TestOutput(this.assert, output)
}

/**
* Run the command and return the TestOutput instance
* to make assertions.
*/
public async run(command: string): Promise<TestOutput> {
return Artisan.callInChild(command, TestCommand.artisanPath).then(output =>
this.createOutput(output),
)
}
}
115 changes: 115 additions & 0 deletions src/testing/plugins/command/TestOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @athenna/artisan
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { inspect } from 'node:util'
import type { Assert } from '@athenna/test'
import type { CommandOutput } from '@athenna/common'

export class TestOutput {
/**
* Japa assert class instance.
*/
public assert: Assert

/**
* The command output object.
*/
public output: CommandOutput

public constructor(assert: Assert, output: CommandOutput) {
this.assert = assert
this.output = output
}

/**
* Assert the exit code of the output.
*
* @example
* ```js
* output.assertExitCode(1)
* ```
*/
public assertExitCode(code: number) {
this.assert.deepEqual(this.output.exitCode, code)
}

/**
* Assert the exit code of the output.
*
* @example
* ```js
* output.assertIsNotExitCode(1)
* ```
*/
public assertIsNotExitCode(code: number) {
this.assert.notDeepEqual(this.output.exitCode, code)
}

/**
* Assert the command exists with zero exit code.
*/
public assertSucceeded() {
return this.assertExitCode(0)
}

/**
* Assert the command exists with non-zero exit code.
*/
public assertFailed() {
return this.assertIsNotExitCode(0)
}

/**
* Assert command to log the expected message.
*/
public assertLogged(message: string, stream?: 'stdout' | 'stderr') {
const existsInStdout = this.output.stdout.includes(message)
const existsInStderr = this.output.stdout.includes(message)

this.assert.isTrue(existsInStdout || existsInStderr)

if (stream === 'stdout' && existsInStderr) {
return this.assert.fail(
`Expected message "${message}" to be logged in "stdout" but it was logged in "stderr"`,
)
}

if (stream === 'stderr' && existsInStdout) {
return this.assert.fail(
`Expected message "${message}" to be logged in "stderr" but it was logged in "stdout"`,
)
}
}

/**
* Assert command to log the expected message.
*/
public assertLogMatches(regex: RegExp, stream?: 'stdout' | 'stderr') {
const existsInStdout = regex.test(this.output.stdout)
const existsInStderr = regex.test(this.output.stderr)

this.assert.isTrue(existsInStdout || existsInStderr)

if (stream === 'stdout' && existsInStderr) {
return this.assert.fail(
`Expected message to be matched in ${inspect(
'stdout',
)} but it was found in ${inspect('stderr')}`,
)
}

if (stream === 'stderr' && existsInStdout) {
return this.assert.fail(
`Expected message to be matched in ${inspect(
'stderr',
)} but it was found in ${inspect('stdout')}`,
)
}
}
}
28 changes: 28 additions & 0 deletions src/testing/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @athenna/artisan
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { TestCommand } from '#src/testing/plugins/command/TestCommand'

declare module '@japa/runner' {
interface TestContext {
command: TestCommand
}
}

export * from '#src/testing/plugins/command/TestCommand'
export * from '#src/testing/plugins/command/TestOutput'

/**
* Command plugin registers the command macro to the test context.
*/
export function command() {
return function (_config, _runner, classes) {
classes.TestContext.macro('command', new TestCommand())
}
}
3 changes: 3 additions & 0 deletions tests/helpers/BaseCommandTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { File, Folder } from '@athenna/common'
import { LoggerProvider } from '@athenna/logger'
import { ExitFaker, AfterEach, BeforeEach } from '@athenna/test'
import { ConsoleKernel, ArtisanProvider, CommanderHandler } from '#src'
import { TestCommand } from '#src/testing/plugins/index'

export class BaseCommandTest {
public artisan = Path.pwd('bin/artisan.ts')
Expand All @@ -22,6 +23,8 @@ export class BaseCommandTest {
public async beforeEach() {
ExitFaker.fake()

TestCommand.setArtisanPath(this.artisan)

process.env.ARTISAN_TESTING = 'true'

await Config.loadAll(Path.stubs('config'))
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/testing/plugins/CommandPluginTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @athenna/artisan
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { Test, type Context } from '@athenna/test'
import { BaseCommandTest } from '#tests/helpers/BaseCommandTest'

export default class CommandPluginTest extends BaseCommandTest {
@Test()
public async shouldBeAbleToExecuteCommandsUsingCommandPlugin({ command }: Context) {
const output = await command.run('test hello')

output.assertSucceeded()
output.assertLogged('hello notRequiredArg undefined notRequiredOption')
output.assertLogged("true tests|node_modules [ 'tests', 'node_modules' ]")
}

@Test()
public async shouldBeAbleToAssertThatTheExitCodeIsNotSomeValue({ command }: Context) {
const output = await command.run('test hello')

output.assertIsNotExitCode(1)
}

@Test()
public async shouldBeAbleToAssertThatTheLogMessageMatchesARegex({ command }: Context) {
const output = await command.run('test hello')

output.assertLogMatches(/hello notRequiredArg undefined notRequiredOption/)
}

@Test()
public async shouldBeAbleToAssertThatTheCommandFailed({ command }: Context) {
const output = await command.run('test')

output.assertFailed()
}

@Test()
public async shouldBeAbleToCheckAndManipulateTheCommandOutput({ assert, command }: Context) {
const output = await command.run('test hello')

assert.isDefined(output.output.stdout)
assert.isDefined(output.output.stderr)
assert.isDefined(output.output.exitCode)
}
}

0 comments on commit c486c42

Please sign in to comment.