Skip to content
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Enjoying **Poku**? [Give him a star to show your support](https://github.com/wel

> [!TIP]
>
> Run multiple suites as one: independent concurrency, reporters, plugins, and quiet mode per suite, with isolated executions and a single consolidated report at the end.
> - Run multiple suites as one: independent concurrency, reporters, plugins, and quiet mode per suite, with isolated executions and a single consolidated report at the end.
> - Combine with [**@pokujs/coverage**](https://github.com/pokujs/coverage) to merge the suites automatically on teardown.

---

Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@
"@biomejs/biome": "^1.9.4",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/node": "^25.3.3",
"poku": "^4.2.0",
"poku": "^4.3.0",
"prettier": "^3.8.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"peerDependencies": {
"poku": "^4.2.0"
"poku": "^4.3.0"
},
"publishConfig": {
"access": "public"
Expand Down
179 changes: 98 additions & 81 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,86 +4,103 @@ import process from 'node:process';
import { kill, envFile as loadEnvFile, poku } from 'poku';
import { onSigint, reporterRegistry } from 'poku/plugins';

export const multiSuite = (suites: ConfigFile[]): PokuPlugin => ({
name: 'multi-suite',
discoverFiles: () => [],
setup: async (context: PluginContext) => {
const overallStart = new Date();
const overallHrtime = process.hrtime();

const onSignal = () => {
process.stdout.write('\u001B[?25h');
process.exit(1);
};

process.removeListener('SIGINT', onSigint);
process.once('SIGINT', onSignal);

let finalCode: 0 | 1 = 0;

for (const { include, envFile, kill: suiteKill, ...config } of suites) {
const dirs = include ? ([] as string[]).concat(include) : ['.'];
const tasks: Promise<unknown>[] = [];

if (envFile) tasks.push(loadEnvFile(envFile));
if (suiteKill) {
if (suiteKill.port && suiteKill.port.length > 0)
tasks.push(kill.port(suiteKill.port));
if (suiteKill.range && suiteKill.range.length > 0) {
for (const [from, to] of suiteKill.range)
tasks.push(kill.range(from, to));
const stripLifecycle = (plugin: PokuPlugin): PokuPlugin => ({
name: plugin.name,
runner: plugin.runner,
onTestProcess: plugin.onTestProcess,
ipc: plugin.ipc,
});

export const multiSuite = (suites: ConfigFile[]): PokuPlugin => {
const self: PokuPlugin = {
name: 'multi-suite',
discoverFiles: () => [],
setup: async (context: PluginContext) => {
const overallStart = new Date();
const overallHrtime = process.hrtime();

const onSignal = () => {
process.stdout.write('\u001B[?25h');
process.exit(1);
};

process.removeListener('SIGINT', onSigint);
process.once('SIGINT', onSignal);

let finalCode: 0 | 1 = 0;

for (const { include, envFile, kill: suiteKill, ...config } of suites) {
const dirs = include ? ([] as string[]).concat(include) : ['.'];
const tasks: Promise<unknown>[] = [];

if (envFile) tasks.push(loadEnvFile(envFile));
if (suiteKill) {
if (suiteKill.port && suiteKill.port.length > 0)
tasks.push(kill.port(suiteKill.port));
if (suiteKill.range && suiteKill.range.length > 0) {
for (const [from, to] of suiteKill.range)
tasks.push(kill.range(from, to));
}
if (suiteKill.pid && suiteKill.pid.length > 0)
tasks.push(kill.pid(suiteKill.pid));
}
if (suiteKill.pid && suiteKill.pid.length > 0)
tasks.push(kill.pid(suiteKill.pid));
if (tasks.length > 0) await Promise.all(tasks);

const { reporter: suiteReporterConfig } = config;
const suiteBase =
typeof suiteReporterConfig === 'function'
? suiteReporterConfig(context.configs)
: typeof suiteReporterConfig === 'string' &&
suiteReporterConfig in reporterRegistry
? reporterRegistry[suiteReporterConfig](context.configs)
: context.reporter;

const suiteReporter = () => ({
...suiteBase,
onRunResult() {},
onExit() {},
});

const outerPlugins = context.configs.plugins ?? [];
const forwardedOuter = outerPlugins
.filter((p) => p !== self)
.map(stripLifecycle);
const subSuitePlugins = config.plugins ?? [];
const mergedPlugins = [...forwardedOuter, ...subSuitePlugins];

const code = await poku(dirs, {
...config,
plugins: mergedPlugins,
reporter: suiteReporter,
noExit: true,
});

if (code !== 0) finalCode = 1;
}
if (tasks.length > 0) await Promise.all(tasks);

const { reporter: suiteReporterConfig } = config;
const suiteBase =
typeof suiteReporterConfig === 'function'
? suiteReporterConfig(context.configs)
: typeof suiteReporterConfig === 'string' &&
suiteReporterConfig in reporterRegistry
? reporterRegistry[suiteReporterConfig](context.configs)
: context.reporter;

const suiteReporter = () => ({
...suiteBase,
onRunResult() {},
onExit() {},
});

const code = await poku(dirs, {
plugins: config.plugins ?? [],
...config,
reporter: suiteReporter,
noExit: true,
});

if (code !== 0) finalCode = 1;
}

const elapsed = process.hrtime(overallHrtime);

context.timespan.started = overallStart;
context.timespan.duration = elapsed[0] * 1e3 + elapsed[1] / 1e6;
context.timespan.finished = new Date();

if (!context.configs.quiet) {
context.reporter.onRunResult({
code: finalCode,
timespan: context.timespan,
results: context.results,
});

context.reporter.onExit({
code: finalCode,
timespan: context.timespan,
results: context.results,
});
}

process.removeListener('SIGINT', onSignal);
process.exitCode = finalCode;
},
});

const elapsed = process.hrtime(overallHrtime);

context.timespan.started = overallStart;
context.timespan.duration = elapsed[0] * 1e3 + elapsed[1] / 1e6;
context.timespan.finished = new Date();

if (!context.configs.quiet) {
context.reporter.onRunResult({
code: finalCode,
timespan: context.timespan,
results: context.results,
});

context.reporter.onExit({
code: finalCode,
timespan: context.timespan,
results: context.results,
});
}

process.removeListener('SIGINT', onSignal);
process.exitCode = finalCode;
},
};
return self;
};
3 changes: 3 additions & 0 deletions test/__fixtures__/forward-plugins/suite-a/a.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import process from 'node:process';

process.exit(0);
3 changes: 3 additions & 0 deletions test/__fixtures__/forward-plugins/suite-b/a.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import process from 'node:process';

process.exit(0);
72 changes: 72 additions & 0 deletions test/integration/forward-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { PokuPlugin } from 'poku/plugins';
import { mkdir } from 'node:fs/promises';
import { assert, describe, it, poku } from 'poku';
import { multiSuite } from '../../src/index.js';

const OUTER_DIR = 'test/__fixtures__/empty';

describe('Plugin: multi-suite forwards per-file hooks to sub-suites', async () => {
await mkdir(OUTER_DIR, { recursive: true });

await it('outer runner/onTestProcess fire per sub-suite file; setup/teardown run once', async () => {
const runnerCalls: string[] = [];
const onTestProcessCalls: string[] = [];
let setupCount = 0;
let teardownCount = 0;

const probe: PokuPlugin = {
name: 'probe',
setup: () => {
setupCount += 1;
},
teardown: () => {
teardownCount += 1;
},
runner: (command, file) => {
runnerCalls.push(file);
return command;
},
onTestProcess: (_child, file) => {
onTestProcessCalls.push(file);
},
};

const originalExitCode = process.exitCode;

await poku(OUTER_DIR, {
noExit: true,
quiet: true,
plugins: [
probe,
multiSuite([
{ include: 'test/__fixtures__/forward-plugins/suite-a' },
{ include: 'test/__fixtures__/forward-plugins/suite-b' },
]),
],
});

process.exitCode = originalExitCode;

assert.strictEqual(setupCount, 1, 'outer setup must run exactly once');
assert.strictEqual(
teardownCount,
1,
'outer teardown must run exactly once'
);
assert.strictEqual(
runnerCalls.length,
2,
'runner must fire per sub-suite test file'
);
assert.ok(
runnerCalls.some((f) => f.endsWith('suite-a/a.test.ts')) &&
runnerCalls.some((f) => f.endsWith('suite-b/a.test.ts')),
'runner must see both sub-suite files'
);
assert.strictEqual(
onTestProcessCalls.length,
2,
'onTestProcess must fire per sub-suite test file'
);
});
});
Loading