Skip to content

Commit

Permalink
feat(describe): onFinish hook (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
privatenumber authored Aug 28, 2023
1 parent 9aa5a83 commit c6e03d4
Show file tree
Hide file tree
Showing 11 changed files with 184 additions and 106 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
{
"allow": [
"describe",
"test"
"test",
"onFinish"
]
}
]
Expand Down
62 changes: 62 additions & 0 deletions src/create-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type {
Context,
PendingTests,
} from './types.js';
import { createTest } from './create-test.js';
import { createDescribe } from './create-describe.js'; // eslint-disable-line import/no-cycle
import {
describe as topLevelDescribe,
test as topLevelTest,
} from './top-level-context.js';

export const createContext = (
description?: string,
): Context => {
const callbacks: Context['callbacks'] = {
onFinish: [],
};

const pendingTests: PendingTests = [];

const test = description ? createTest(`${description} ›`, pendingTests) : topLevelTest;
const describe = description ? createDescribe(`${description} ›`, pendingTests) : topLevelDescribe;

const context: Context = {
test,
describe,
runTestSuite: (
testSuite,
...args
) => {
const runningTestSuite = (async () => {
let maybeTestSuiteModule = await testSuite;

if ('default' in maybeTestSuiteModule) {
maybeTestSuiteModule = maybeTestSuiteModule.default;
}

/**
* When ESM is compiled to CJS, it's possible the entire module
* gets assigned as an object o default. In this case,
* it needs to be unwrapped again.
*/
if ('default' in maybeTestSuiteModule) {
maybeTestSuiteModule = maybeTestSuiteModule.default;
}

return maybeTestSuiteModule.apply(context, args);
})();

pendingTests.push(runningTestSuite);

return runningTestSuite;
},
onFinish(callback) {
callbacks.onFinish.push(callback);
},
pendingTests,
callbacks,
};

return context;
};
63 changes: 13 additions & 50 deletions src/create-describe.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
import type { Describe, Context, PendingTests } from './types.js';
import { createTest } from './create-test.js';
import type {
Describe,
PendingTests,
} from './types.js';
import { consoleError } from './logger.js';

/**
* This accepts a promises array that can have more promises
* in it by the time every promise is resolved.
*
* This keeps waiting on the new it until the promises array
* is empty.
*/
async function waitAllPromises(promises: Promise<unknown>[]) {
while (promises.length > 0) {
const currentPromises = promises.splice(0);
await Promise.all(currentPromises);
}
}
import { waitAllPromises } from './utils/wait-all-promises.js';
import { createContext } from './create-context.js'; // eslint-disable-line import/no-cycle

export function createDescribe(
prefix?: string,
Expand All @@ -28,43 +18,12 @@ export function createDescribe(
description = `${prefix} ${description}`;
}

const childTests: PendingTests = [];
const context = createContext(description);

try {
const inProgress = (async () => {
const context: Context = {
test: createTest(`${description} ›`, childTests),
describe: createDescribe(`${description} ›`, childTests),
runTestSuite: (
testSuite,
...args
) => {
const runningTestSuite = (async () => {
let maybeTestSuiteModule = await testSuite;

if ('default' in maybeTestSuiteModule) {
maybeTestSuiteModule = maybeTestSuiteModule.default;
}

/**
* When ESM is compiled to CJS, it's possible the entire module
* gets assigned as an object o default. In this case,
* it needs to be unwrapped again.
*/
if ('default' in maybeTestSuiteModule) {
maybeTestSuiteModule = maybeTestSuiteModule.default;
}

return maybeTestSuiteModule.apply(context, args);
})();

childTests.push(runningTestSuite);

return runningTestSuite;
},
};

await callback(context);
await waitAllPromises(childTests);
await waitAllPromises(context.pendingTests);
})();

if (pendingTests) {
Expand All @@ -75,6 +34,10 @@ export function createDescribe(
} catch (error) {
consoleError(error);
process.exitCode = 1;
} finally {
for (const onFinish of context.callbacks.onFinish) {
await onFinish();
}
}
};
}
4 changes: 2 additions & 2 deletions src/create-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
TestApi,
TestMeta,
onTestFailCallback,
onTestFinishCallback,
Callback,
PendingTests,
} from './types.js';
import {
Expand All @@ -23,7 +23,7 @@ const throwOnTimeout = async (

type Callbacks = {
onTestFail: onTestFailCallback[];
onTestFinish: onTestFinishCallback[];
onTestFinish: Callback[];
};

const runTest = async (testMeta: TestMeta) => {
Expand Down
35 changes: 6 additions & 29 deletions src/test-suite.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,20 @@
import {
describe,
test,
} from './top-level-context.js';
import type {
Context,
TestSuiteCallback,
TestSuite,
} from './types.js';
import { createContext } from './create-context.js';

const defaultContext: Context = {
describe,
test,
runTestSuite: async (
testSuiteImport,
...args
) => {
let testSuiteModule = await testSuiteImport;

if ('default' in testSuiteModule) {
testSuiteModule = testSuiteModule.default;
}

// Handle twice if ESM is compiled to CJS
if ('default' in testSuiteModule) {
testSuiteModule = testSuiteModule.default;
}

return testSuiteModule.apply(defaultContext, args);
},
};
const defaultContext = createContext();

export function testSuite<
Callback extends TestSuiteCallback,
>(
callback: Callback,
): TestSuite<Callback> {
return function (...callbackArgs) {
return callback(
this || defaultContext,
return async function (...callbackArgs) {
const context = this || defaultContext;
await callback(
context,
...callbackArgs,
);
};
Expand Down
2 changes: 1 addition & 1 deletion src/top-level-context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createTest } from './create-test.js';
import { createDescribe } from './create-describe.js';
import { createDescribe } from './create-describe.js'; // eslint-disable-line import/no-cycle

export const test = createTest();
export const describe = createDescribe();
10 changes: 8 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ type RunTestSuite = <
...args: InferCallback<Callback>['args']
) => InferCallback<Callback>['returnType'];

export type Callback = () => void;

export type onTestFailCallback = (error: Error) => void;
export type onTestFinishCallback = () => void;
export type TestApi = {
onTestFail: (callback: onTestFailCallback) => void;
onTestFinish: (callback: onTestFinishCallback) => void;
onTestFinish: (callback: Callback) => void;
};

type TestFunction = (api: TestApi) => void;
Expand All @@ -62,6 +63,11 @@ export type Context = {
describe: Describe;
test: Test;
runTestSuite: RunTestSuite;
onFinish: (callback: Callback) => void;
pendingTests: PendingTests;
callbacks: {
onFinish: Callback[];
};
};

export type PendingTests = Promise<unknown>[];
Expand Down
15 changes: 15 additions & 0 deletions src/utils/wait-all-promises.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* This accepts a promises array that can have more promises
* in it by the time every promise is resolved.
*
* This keeps waiting on the new it until the promises array
* is empty.
*/
export const waitAllPromises = async (
promises: Promise<unknown>[],
) => {
while (promises.length > 0) {
const currentPromises = promises.splice(0);
await Promise.all(currentPromises);
}
};
52 changes: 32 additions & 20 deletions tests/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { execaNode } from 'execa';
import { test, expect, describe } from '#manten';

const expectMatchInOrder = (
stdout: string,
expectedOrder: string[],
) => {
const matches = expectedOrder
.map(line => [line, stdout.indexOf(line)] as const)
.sort((lineA, lineB) => lineA[1] - lineB[1])
.map(([line]) => line);

expect(matches).toStrictEqual(expectedOrder);
};

const env = { NODE_DISABLE_COLORS: '0' };

test('Should prevent console.log hijack', async () => {
Expand Down Expand Up @@ -58,7 +70,11 @@ describe('asynchronous', ({ test }) => {
const testProcess = await execaNode('./tests/specs/asynchronous-concurrent', { env });

expect(testProcess.exitCode).toBe(0);
expect(testProcess.stdout).toMatch('✔ B\n✔ C\n✔ A');
expectMatchInOrder(testProcess.stdout, [
'✔ B',
'✔ C',
'✔ A',
]);
expect(testProcess.stdout).toMatch('3 passed');
expect(testProcess.stdout).not.toMatch('failed');
});
Expand All @@ -78,25 +94,21 @@ describe('asynchronous', ({ test }) => {
});
});

describe('hooks', async ({ test }) => {
let testFailCalled: Error;
let testFinishCalled = false;

await test('expected to fail', ({ onTestFail, onTestFinish }) => {
onTestFail((error) => {
testFailCalled = error;
});

onTestFinish(() => {
testFinishCalled = true;
});

throw new Error('hello');
test('hooks', async () => {
const testProcess = await execaNode('./tests/specs/hooks', {
env,
reject: false,
});

test('confirm hooks', () => {
expect(testFailCalled).toBeInstanceOf(Error);
expect(testFinishCalled).toBe(true);
process.exitCode = 0;
});
expect(testProcess.exitCode).toBe(1);
expectMatchInOrder(testProcess.stdout, [
'test start',
'test error hello',
'test finish',
'test suite start',
'test suite describe start',
'test suite describe finish',
'describe finish',
'test suite finish',
]);
});
40 changes: 40 additions & 0 deletions tests/specs/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, testSuite } from '#manten';

describe('describe', ({ test, onFinish, runTestSuite }) => {
onFinish(() => {
console.log('describe finish');
});

test('hooks', ({ onTestFail, onTestFinish }) => {
console.log('test start');
onTestFail((error) => {
console.log('test error', error.message);
});

onTestFinish(() => {
console.log('test finish');
});

throw new Error('hello');
});

runTestSuite(testSuite(({ describe, onFinish }) => {
console.log('test suite start');

onFinish(() => {
/**
* This is triggered after "describe finish" because
* it shares the same context as the first describe
*/
console.log('test suite finish');
});

describe('test suite', ({ onFinish }) => {
console.log('test suite describe start');

onFinish(() => {
console.log('test suite describe finish');
});
});
}));
});
4 changes: 3 additions & 1 deletion tests/utils/set-timeout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// 'timers/promises' polyfill until Node 12 is deprecated
export const setTimeout = (duration: number) => new Promise((resolve) => {
export const setTimeout = (
duration: number,
) => new Promise((resolve) => {
global.setTimeout(resolve, duration);
});

0 comments on commit c6e03d4

Please sign in to comment.