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

Pass test output to test hooks #723

Merged
merged 1 commit into from
Oct 26, 2023
Merged
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
73 changes: 73 additions & 0 deletions test/util/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,76 @@ Here is a complete list of the available annotations:
| hookTimeout | Time limit in seconds for the hooks to complete before the script block will be marked as failed | 300 |
| expectError | Ignore any errors that occur when the script block is executed | false |
| raw | By default a script block will be smartly interpreted to extract commands and distinguish this from sample output. Enabling this flag will executed the entire script block, assuming there is no output, and will not look for `$` as a prefix for commands | false |

## Hooks

Test hooks are designed to be able to wrap the script included in the content with instructions that can be run before or after. This can be especially helpful to perform assertions/validation.

Hooks are expressed using an annotation like so:

````
```bash hook=my-hook
$ echo "Showing hooks"
```
````

This will check for a shell script at `tests/hook-my-hook.sh` relative to the location of the Markdown file. For example if the Markdown above was in `introduction.md` the file structure would look something like this.

```
├── index.md
└── chapter1
   ├── tests
   │ └── hook-my-hook.sh
   └── introduction.md
```

The shell script will be invoked with a single argument with a value of either `before` or `after` indicating whether its being run as a before or after hook.

A good template to use for a hook file is:

```bash
set -Eeuo pipefail

before() {
echo "This hook executes before the test"
}

after() {
echo "This hook executes after the test"
}

"$@"
```

If the hook file fails then the associated test will fail:

```bash
set -Eeuo pipefail

before() {
echo "noop"
}

after() {
echo "This will fail the test"
exit 1
}

"$@"
```

When invoked as an "after" hook the stdout/stderr of the test script will be provided using the environment variable `TEST_OUTPUT`:

```bash
set -Eeuo pipefail

before() {
echo "noop"
}

after() {
echo "The actual test output: ${TEST_OUTPUT}"
}

"$@"
```
26 changes: 17 additions & 9 deletions test/util/src/lib/markdownsh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Mocha, { Suite, Test } from 'mocha'
import path from 'path'
import GlobToRegExp from 'glob-to-regexp'
import { assert } from 'chai'
import { DefaultShell, Shell, ShellError, ShellTimeout } from "./shell/shell.js";
import { DefaultShell, ExecutionResult, Shell, ShellError, ShellTimeout } from "./shell/shell.js";
import fs from 'fs'

export class MarkdownSh {
Expand Down Expand Up @@ -138,7 +138,7 @@ export class MarkdownSh {
this.debugMessage(`Calling suite ${hook} hook at ${hookPath}`)

if(!dryRun) {
let response = await shell.exec(`bash ${hookPath} ${hook}`, hookTimeout, false)
let response = await shell.exec(`bash ${hookPath} ${hook}`, hookTimeout, false, {})

this.debugMessage(response.output)
}
Expand Down Expand Up @@ -187,18 +187,22 @@ class CustomTest extends Test {
hookTimeout = testCase.hookTimeout
}

await this.hook(testCase, category, 'before', hookTimeout)
await this.hook(testCase, category, 'before', hookTimeout, {})

let result: ExecutionResult | undefined;

try {
await this.executeShell(testCase.command, testCase.timeout, testCase.expectError)
result = await this.executeShell(testCase.command, testCase.timeout, testCase.expectError, {})
}
catch(e: any) {
e.message = `Error running test case command at line ${testCase.lineNumber} - ${e.message}`

throw e
}

await this.hook(testCase, category, 'after', hookTimeout)
await this.hook(testCase, category, 'after', hookTimeout, {
'TEST_OUTPUT': result?.output
});

if(testCase.wait > 0) {
await this.sleep(testCase.wait * 1000)
Expand Down Expand Up @@ -233,14 +237,14 @@ class CustomTest extends Test {
});
}

async hook(testCase: Script, category: Category, hook: string, timeout: number) {
async hook(testCase: Script, category: Category, hook: string, timeout: number, env: { [key: string]: string | undefined }) {
if(testCase.hook) {
this.debugMessage(`Calling ${hook} hook ${testCase.hook}`)

const hookPath = `${category.path}/tests/hook-${testCase.hook}.sh`;

try {
const response = await this.executeShell(`bash ${hookPath} ${hook}`, timeout, false)
const response = await this.executeShell(`bash ${hookPath} ${hook}`, timeout, false, env)

this.debugMessage(`Completed ${hook} hook ${testCase.hook}`)
}
Expand All @@ -252,18 +256,22 @@ class CustomTest extends Test {
}
}

async executeShell(command: string, timeout: number, expectError: boolean) {
async executeShell(command: string, timeout: number, expectError: boolean, env: { [key: string]: string | undefined }): Promise<ExecutionResult | undefined> {

this.debugMessage(`Executing shell:
Command ${command}
Timeout ${timeout}
`)

if (!this.dryRun) {
let response = await this.shell.exec(command, timeout, expectError)
let response = await this.shell.exec(command, timeout, expectError, env)

this.debugMessage(response.output)

return response;
}

return undefined;
}

async sleep(ms: number): Promise<void> {
Expand Down
17 changes: 12 additions & 5 deletions test/util/src/lib/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as child from 'child_process';
import * as os from 'os';

export interface Shell {
exec:(command: string, timeout: number, expect: boolean) => Promise<ExecutionResult>
exec:(command: string, timeout: number, expect: boolean, additionalEnv: { [key: string]: string | undefined }) => Promise<ExecutionResult>
}

export class ExecutionResult {
Expand All @@ -19,7 +19,7 @@ export class DefaultShell implements Shell {

constructor(private beforeEach: string) {}

exec(command: string, timeout: number = 300, expect: boolean = false) : Promise<ExecutionResult> {
exec(command: string, timeout: number = 300, expect: boolean = false, additionalEnv: { [key: string]: string | undefined }) : Promise<ExecutionResult> {
if(!command) {
throw new Error("Command should not be empty")
}
Expand All @@ -32,7 +32,10 @@ export class DefaultShell implements Shell {
killSignal: 'SIGKILL',
stdio: ['inherit', 'pipe', 'pipe'],
shell: '/bin/bash',
env: this.environment
env: {
...this.environment,
...additionalEnv
}
});

const output = String(buffer);
Expand Down Expand Up @@ -61,7 +64,7 @@ export class DefaultShell implements Shell {
if(key.startsWith("BASH_FUNC")) {
processingFunction = true;
}
else {
else if(!(key in additionalEnv)) {
env[key] = val;
}
}
Expand Down Expand Up @@ -91,7 +94,11 @@ export class DefaultShell implements Shell {
throw new ShellTimeout(`Timed out after ${timeout} seconds`, e.stdout, e.stderr, timeout)
}

throw new ShellError(e.status, e.message, e.stdout, e.stderr)
if(!expect) {
throw new ShellError(e.status, e.message, e.stdout, e.stderr)
}

return Promise.resolve(new ExecutionResult(e.stderr));
}
}
}
Expand Down
Loading