Skip to content

Commit b8b1d1c

Browse files
authoredMay 30, 2024··
Merge pull request #5284 from NomicFoundation/test-reporter
Introduce a custom `node:test` reporter
·
2 parents 9eda5c8 + c8ae4cb commit b8b1d1c

19 files changed

+1134
-0
lines changed
 

‎pnpm-lock.yaml

+67
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const { createConfig } = require("../../config-v-next/eslint.cjs");
2+
3+
module.exports = createConfig(__filename, ["src/reporter.ts"]);
4+
5+
module.exports.overrides.push({
6+
files: ["integration-tests/**/*.ts"],
7+
rules: {
8+
"import/no-extraneous-dependencies": [
9+
"error",
10+
{
11+
devDependencies: true,
12+
},
13+
],
14+
// Disabled until this gets resolved https://github.com/nodejs/node/issues/51292
15+
"@typescript-eslint/no-floating-promises": "off",
16+
},
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Node modules
2+
/node_modules
3+
4+
# Compilation output
5+
/dist
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/node_modules
2+
/dist
3+
CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Nomic Foundation
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Hardhat's `node:test` reporter
2+
3+
This package includes Hardhat's `node:test` reporter.
4+
5+
This project is heavily inspired by https://github.com/voxpelli/node-test-pretty-reporter
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Integration tests
2+
3+
This folder contains integration tests for the reporter. They don't use `node:test` as driver of the test runs, as you can't run `node:test` within `node:test`.
4+
5+
Instead, the script `index.ts` runs all the tests in each fixture folder, comparing the reporter's results with `result.txt`.
6+
7+
## Running each test manually
8+
9+
You can run each of the fixture test manually from the package root by building it and running `node --import tsx/esm --test --test-reporter=./dist/src/reporter.js integration-tests/fixture-tests/example-test/*.ts`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
1
2+
2
3+
Foooo
4+
✔ test
5+
1) test with cause
6+
child
7+
2) asdasd
8+
9+
a
10+
aa
11+
aaa
12+
✔ aaaa
13+
14+
top level test
15+
✔ bar
16+
17+
✔ top level test
18+
19+
in describe
20+
3) foo
21+
22+
testing before each
23+
nested
24+
✔ neseted foo
25+
✔ neseted foo 2
26+
27+
28+
+ todo test
29+
30+
- skipped test
31+
32+
33+
6 passing (104ms)
34+
2 failing
35+
1 skipped
36+
1 todo
37+
1 cancelled
38+
39+
1) Foooo
40+
test with cause:
41+
42+
Error: withCause
43+
 at TestContext.<anonymous> (integration-tests/fixture-tests/example-test/test.ts:1:136)
44+
 at Test.runInAsyncScope (node:async_hooks:206:9)
45+
 ... 6 lines matching cause stack trace ...
46+
 at async Test.processPendingSubtests (node:internal/test_runner/test:533:7) {
47+
 [cause]: Error: cause
48+
 at TestContext.<anonymous> (integration-tests/fixture-tests/example-test/test.ts:1:165)
49+
 at Test.runInAsyncScope (node:async_hooks:206:9)
50+
 at Test.run (node:internal/test_runner/test:824:25)
51+
 at Suite.processPendingSubtests (node:internal/test_runner/test:533:18)
52+
 at Test.postRun (node:internal/test_runner/test:923:19)
53+
 at Test.run (node:internal/test_runner/test:866:12)
54+
 at async Promise.all (index 0)
55+
 at async Suite.run (node:internal/test_runner/test:1183:7)
56+
 at async Test.processPendingSubtests (node:internal/test_runner/test:533:7)
57+
}
58+
59+
2) Foooo
60+
child
61+
asdasd:
62+
63+
Error: Different arrays
64+
- Expected
65+
+ Received
66+
67+
 Array [
68+
 1,
69+
- 2,
70+
+ 3,
71+
 3,
72+
 ]
73+
74+
 at TestContext.<anonymous> (integration-tests/fixture-tests/example-test/test.ts:1:239)
75+
 at Test.runInAsyncScope (node:async_hooks:206:9)
76+
 ... 7 lines matching cause stack trace ...
77+
 at Array.map (<anonymous>) {
78+
 [cause]: Error: cause
79+
 at TestContext.<anonymous> (integration-tests/fixture-tests/example-test/test.ts:1:275)
80+
 at Test.runInAsyncScope (node:async_hooks:206:9)
81+
 at Test.run (node:internal/test_runner/test:824:25)
82+
 at Test.start (node:internal/test_runner/test:721:17)
83+
 at node:internal/test_runner/test:1181:71
84+
 at node:internal/per_context/primordials:488:82
85+
 at new Promise (<anonymous>)
86+
 at new SafePromise (node:internal/per_context/primordials:456:29)
87+
 at node:internal/per_context/primordials:488:9
88+
 at Array.map (<anonymous>)
89+
}
90+
91+
3) in describe
92+
foo:
93+
94+
Test cancelled by parent error
95+
 This test was cancelled due to an error in its parent suite/it or test/it, or in one of its before/beforeEach
96+
97+
4) in describe:
98+
99+
Error: before
100+
 at SuiteContext.<anonymous> (integration-tests/fixture-tests/example-test/test.ts:1:696)
101+
 at TestHook.runInAsyncScope (node:async_hooks:206:9)
102+
 at TestHook.run (node:internal/test_runner/test:824:25)
103+
 at TestHook.run (node:internal/test_runner/test:1073:18)
104+
 at TestHook.run (node:internal/util:528:20)
105+
 at node:internal/test_runner/test:744:20
106+
 at async Suite.runHook (node:internal/test_runner/test:742:7)
107+
 at async Suite.run (node:internal/test_runner/test:1177:7)
108+
 at async Test.processPendingSubtests (node:internal/test_runner/test:533:7)
109+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { before, beforeEach, describe, it } from "node:test";
2+
3+
describe("Foooo", () => {
4+
it("test", async () => {});
5+
6+
it("test with cause", async () => {
7+
throw new Error("withCause", { cause: new Error("cause") });
8+
});
9+
10+
describe("child", () => {
11+
it("asdasd", () => {
12+
const error = new Error("Different arrays", {
13+
cause: new Error("cause"),
14+
});
15+
16+
Object.defineProperty(error, "expected", {
17+
configurable: false,
18+
enumerable: false,
19+
get() {
20+
return [1, 2, 3];
21+
},
22+
});
23+
24+
Object.defineProperty(error, "actual", {
25+
configurable: false,
26+
enumerable: false,
27+
get() {
28+
return [1, 3, 3];
29+
},
30+
});
31+
32+
throw error;
33+
});
34+
});
35+
});
36+
37+
describe("a", () => {
38+
describe("aa", () => {
39+
describe("aaa", () => {
40+
it("aaaa", () => {});
41+
});
42+
});
43+
});
44+
45+
it("top level test", async (t) => {
46+
await t.test("bar", () => {});
47+
});
48+
49+
describe("in describe", () => {
50+
before(() => {
51+
throw new Error("before");
52+
});
53+
54+
beforeEach(() => {
55+
throw new Error("before each");
56+
});
57+
58+
it("foo", async (t) => {
59+
await t.test("foo/bar", () => {});
60+
61+
throw new Error("asd");
62+
});
63+
});
64+
65+
describe("testing before each", () => {
66+
describe("nested", () => {
67+
it("neseted foo", async () => {
68+
console.log("1");
69+
});
70+
71+
it("neseted foo 2", async () => {
72+
console.log("2");
73+
});
74+
});
75+
});
76+
77+
it.todo("todo test", async () => {});
78+
79+
it.skip("skipped test", async () => {});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { run } from "node:test";
2+
3+
import {
4+
isDirectory,
5+
readdir,
6+
getAllFilesMatching,
7+
readUtf8File,
8+
} from "@nomicfoundation/hardhat-utils/fs";
9+
import { diff } from "jest-diff";
10+
11+
import reporter from "../src/reporter.js";
12+
const SHOW_OUTPUT = process.argv.includes("--show-output");
13+
14+
for (const entry of await readdir(import.meta.dirname + "/fixture-tests")) {
15+
const entryPath = import.meta.dirname + "/fixture-tests/" + entry;
16+
if (await isDirectory(entryPath)) {
17+
console.log("Running integration test: " + entry);
18+
19+
const testFiles = await getAllFilesMatching(entryPath, (file) =>
20+
file.endsWith(".ts"),
21+
);
22+
23+
const outputChunks = [];
24+
25+
const reporterStream = run({
26+
files: testFiles,
27+
}).compose(reporter);
28+
29+
for await (const chunk of reporterStream) {
30+
outputChunks.push(chunk);
31+
}
32+
33+
const output = outputChunks.join("");
34+
const expectedOutput = await readUtf8File(entryPath + "/result.txt");
35+
36+
const normalizedOutput = normalizeOutputs(output);
37+
const normalizedExpectedOutput = normalizeOutputs(expectedOutput);
38+
39+
if (normalizedOutput !== normalizedExpectedOutput) {
40+
console.log("Normalized outputs differ:");
41+
console.log(diff(normalizedExpectedOutput, normalizedOutput));
42+
process.exitCode = 1;
43+
} else {
44+
console.log(" Passed");
45+
}
46+
47+
if (SHOW_OUTPUT) {
48+
console.log();
49+
console.log();
50+
console.log(output);
51+
}
52+
53+
console.log();
54+
console.log();
55+
console.log();
56+
console.log();
57+
}
58+
}
59+
60+
function normalizeOutputs(output: string): string {
61+
return output.replace(/\(\d+ms\)/, "(Xms)").replaceAll("\r\n", "\n");
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"name": "@nomicfoundation/hardhat-node-test-reporter",
3+
"version": "3.0.0",
4+
"description": "A node:test reporter",
5+
"homepage": "https://github.com/nomicfoundation/hardhat/tree/v-next/v-next/hardhat-node-test-reporter",
6+
"repository": "github:nomicfoundation/hardhat",
7+
"author": "Nomic Foundation",
8+
"license": "MIT",
9+
"type": "module",
10+
"exports": {
11+
".": "./dist/src/reporter.js"
12+
},
13+
"keywords": [
14+
"ethereum",
15+
"smart-contracts",
16+
"hardhat"
17+
],
18+
"scripts": {
19+
"lint": "pnpm prettier --check && pnpm eslint",
20+
"lint:fix": "pnpm prettier --write && pnpm eslint --fix",
21+
"eslint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" \"integration-tests/**/*.ts\"",
22+
"prettier": "prettier \"**/*.{ts,js,md,json}\"",
23+
"test": "glob --cmd=\"node --import tsx/esm --test\" \"test/**/*.ts\"",
24+
"test:github": "glob --cmd=\"node --import tsx/esm --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout\" \"test/**/*.ts\"",
25+
"test:integration": "node --import tsx/esm integration-tests/index.ts --color",
26+
"posttest": "pnpm test:integration",
27+
"posttest:github": "pnpm test:integration",
28+
"pretest": "pnpm build",
29+
"build": "tsc --build .",
30+
"prepublishOnly": "pnpm build",
31+
"clean": "rimraf dist"
32+
},
33+
"files": [
34+
"dist/src/",
35+
"src/",
36+
"CHANGELOG.md",
37+
"LICENSE",
38+
"README.md"
39+
],
40+
"devDependencies": {
41+
"@nomicfoundation/eslint-plugin-hardhat-internal-rules": "workspace:^",
42+
"@nomicfoundation/eslint-plugin-slow-imports": "workspace:^",
43+
"@nomicfoundation/hardhat-utils": "workspace:^3.0.0",
44+
"@reporters/github": "^1.7.0",
45+
"@types/node": "^20.0.0",
46+
"@typescript-eslint/eslint-plugin": "^7.7.1",
47+
"@typescript-eslint/parser": "^7.7.1",
48+
"eslint": "8.57.0",
49+
"eslint-config-prettier": "9.1.0",
50+
"eslint-import-resolver-typescript": "^3.6.1",
51+
"eslint-plugin-import": "2.29.1",
52+
"eslint-plugin-no-only-tests": "3.1.0",
53+
"expect-type": "^0.19.0",
54+
"glob": "^10.3.12",
55+
"prettier": "3.2.5",
56+
"rimraf": "^5.0.5",
57+
"tsx": "^4.7.1",
58+
"typescript": "~5.4.0",
59+
"typescript-eslint": "7.7.1"
60+
},
61+
"dependencies": {
62+
"chalk": "^5.3.0",
63+
"jest-diff": "^29.7.0"
64+
}
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { TestEventData } from "./node-types.js";
2+
3+
export interface GlobalDiagnostics {
4+
tests: number;
5+
suites: number;
6+
pass: number;
7+
fail: number;
8+
cancelled: number;
9+
skipped: number;
10+
todo: number;
11+
// eslint-disable-next-line @typescript-eslint/naming-convention -- keeping this alingned with the node:test event
12+
duration_ms: number;
13+
}
14+
15+
/**
16+
* This function receives all the diagnostics that have been emitted by the test
17+
* run, and tries to parse a set of well-known global diagnostics that node:test
18+
* emits to report the overall status of the test run.
19+
*
20+
* If the diagnostics are not recognized, or can't be parsed effectively, they
21+
* are returned as `unsedDiagnostics`, so that we can print them at the end.
22+
*/
23+
export function processGlobalDiagnostics(
24+
diagnostics: Array<TestEventData["test:diagnostic"]>,
25+
): {
26+
globalDiagnostics: GlobalDiagnostics;
27+
unusedDiagnostics: Array<TestEventData["test:diagnostic"]>;
28+
} {
29+
const globalDiagnostics: GlobalDiagnostics = {
30+
tests: 0,
31+
suites: 0,
32+
pass: 0,
33+
fail: 0,
34+
cancelled: 0,
35+
skipped: 0,
36+
todo: 0,
37+
duration_ms: 0,
38+
};
39+
40+
const unusedDiagnostics = [];
41+
for (const diagnostic of diagnostics) {
42+
if (diagnostic.nesting !== 0) {
43+
unusedDiagnostics.push(diagnostic);
44+
continue;
45+
}
46+
47+
const [name, numberString] = diagnostic.message.split(" ");
48+
if (!(name in globalDiagnostics) || numberString === undefined) {
49+
unusedDiagnostics.push(diagnostic);
50+
continue;
51+
}
52+
53+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions --
54+
We checked that thsi is a key of globalDiagnostics */
55+
const nameAsKey = name as keyof GlobalDiagnostics;
56+
57+
try {
58+
const value = parseFloat(numberString);
59+
60+
globalDiagnostics[nameAsKey] = value;
61+
} catch {
62+
// If this throwed, the format of the diagnostic isn't what we expected,
63+
// so we just print it as an unused diagnostic.
64+
unusedDiagnostics.push(diagnostic);
65+
}
66+
}
67+
68+
return { globalDiagnostics, unusedDiagnostics };
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { pathToFileURL } from "node:url";
2+
import { inspect } from "node:util";
3+
4+
import chalk from "chalk";
5+
import { diff } from "jest-diff";
6+
7+
import {
8+
cleanupTestFailError,
9+
isCancelledByParentError,
10+
} from "./node-test-error-utils.js";
11+
12+
// TODO: Clean up the node internal fames from the stack trace
13+
export function formatError(error: Error): string {
14+
if (isCancelledByParentError(error)) {
15+
return (
16+
chalk.red("Test cancelled by parent error") +
17+
"\n" +
18+
chalk.gray(
19+
" This test was cancelled due to an error in its parent suite/it or test/it, or in one of its before/beforeEach",
20+
)
21+
);
22+
}
23+
24+
error = cleanupTestFailError(error);
25+
26+
const defaultFormat = inspect(error);
27+
const indexOfMessage = defaultFormat.indexOf(error.message);
28+
29+
let title: string;
30+
let stack: string;
31+
if (indexOfMessage !== -1) {
32+
title = defaultFormat.substring(0, indexOfMessage + error.message.length);
33+
stack = defaultFormat
34+
.substring(indexOfMessage + error.message.length)
35+
.replace(/^(\r?\n)*/, "");
36+
} else {
37+
title = error.message;
38+
stack = error.stack ?? "";
39+
}
40+
41+
title = chalk.red(title);
42+
stack = replaceFileUrlsWithRelativePaths(stack);
43+
stack = chalk.gray(stack);
44+
45+
const diffResult = getErrorDiff(error);
46+
47+
if (diffResult === undefined) {
48+
return `${title}
49+
${stack}`;
50+
}
51+
52+
return `${title}
53+
${diffResult}
54+
55+
${stack}`;
56+
}
57+
58+
// TODO: Do this in a more robust way and that works well with windows
59+
function replaceFileUrlsWithRelativePaths(stack: string): string {
60+
return stack.replaceAll(
61+
"(" + pathToFileURL(process.cwd() + "/").toString(),
62+
"(",
63+
);
64+
}
65+
66+
function isDiffableError(
67+
error: Error,
68+
): error is Error & { actual: any; expected: any } {
69+
return (
70+
"expected" in error && "actual" in error && error.expected !== undefined
71+
);
72+
}
73+
74+
function getErrorDiff(error: Error): string | undefined {
75+
if (!isDiffableError(error)) {
76+
return undefined;
77+
}
78+
79+
if ("showDiff" in error && error.showDiff === false) {
80+
return undefined;
81+
}
82+
83+
return diff(error.expected, error.actual) ?? undefined;
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import chalk from "chalk";
2+
3+
import { GlobalDiagnostics } from "./diagnostics.js";
4+
import { formatError } from "./error-formatting.js";
5+
import { TestEventData } from "./node-types.js";
6+
7+
export const INFO_SYMBOL = chalk.blue("\u2139");
8+
export const SUCCESS_SYMBOL = chalk.green("✔");
9+
10+
export interface Failure {
11+
index: number;
12+
testFail: TestEventData["test:fail"];
13+
contextStack: Array<TestEventData["test:start"]>;
14+
}
15+
16+
export function formatTestContext(
17+
contextStack: Array<TestEventData["test:start"]>,
18+
prefix = "",
19+
suffix = "",
20+
): string {
21+
const contextFragments: string[] = [];
22+
23+
const prefixLength = prefix.length;
24+
25+
for (const [i, parentTest] of contextStack.entries()) {
26+
const indentation = nestingToIndentationLength(parentTest.nesting);
27+
28+
if (i === 0) {
29+
contextFragments.push(indent(prefix, indentation));
30+
} else {
31+
contextFragments.push("\n");
32+
contextFragments.push(indent("", indentation + prefixLength));
33+
}
34+
35+
contextFragments.push(parentTest.name);
36+
}
37+
38+
contextFragments.push(suffix);
39+
40+
return contextFragments.join("");
41+
}
42+
43+
export function formatTestPass(passData: TestEventData["test:pass"]): string {
44+
let msg: string;
45+
46+
if (passData.skip === true || typeof passData.skip === "string") {
47+
// TODO: show skip reason
48+
msg = chalk.cyan(`- ${passData.name}`);
49+
} else if (passData.todo === true || typeof passData.todo === "string") {
50+
// TODO: show todo reason
51+
msg = chalk.blue(`+ ${passData.name}`);
52+
} else {
53+
msg = chalk.gray(`${SUCCESS_SYMBOL} ${passData.name}`);
54+
}
55+
56+
return indent(msg, nestingToIndentationLength(passData.nesting));
57+
}
58+
59+
export function formatTestFailure(failure: Failure): string {
60+
return indent(
61+
chalk.red(`${formatFailureIndex(failure.index)}) ${failure.testFail.name}`),
62+
nestingToIndentationLength(failure.testFail.nesting),
63+
);
64+
}
65+
66+
export function formatFailureReason(failure: Failure): string {
67+
return `${formatTestContext(
68+
failure.contextStack,
69+
`${formatFailureIndex(failure.index)}) `,
70+
":",
71+
)}
72+
73+
${indent(formatError(failure.testFail.details.error), 3)}`;
74+
}
75+
76+
export function formatSlowTestInfo(durationMs: number): string {
77+
return ` ${chalk.red(chalk.italic(`(${Math.floor(durationMs)}ms)`))}`;
78+
}
79+
80+
export function formatGlobalDiagnostics(
81+
diagnostics: GlobalDiagnostics,
82+
): string {
83+
let result =
84+
chalk.green(`${diagnostics.pass} passing`) +
85+
chalk.gray(` (${Math.floor(diagnostics.duration_ms)}ms)`);
86+
87+
if (diagnostics.fail > 0) {
88+
result += chalk.red(`
89+
${diagnostics.fail} failing`);
90+
}
91+
92+
if (diagnostics.skipped > 0) {
93+
result += chalk.cyan(`
94+
${diagnostics.skipped} skipped`);
95+
}
96+
97+
if (diagnostics.todo > 0) {
98+
result += chalk.blue(`
99+
${diagnostics.todo} todo`);
100+
}
101+
102+
if (diagnostics.cancelled > 0) {
103+
result += chalk.gray(`
104+
${diagnostics.cancelled} cancelled`);
105+
}
106+
107+
return result;
108+
}
109+
110+
export function formatUnusedDiagnostics(
111+
unusedDiagnostics: Array<TestEventData["test:diagnostic"]>,
112+
): string {
113+
return unusedDiagnostics
114+
.map(({ message }) => `${INFO_SYMBOL} ${message}`)
115+
.join("\n");
116+
}
117+
118+
function formatFailureIndex(index: number): string {
119+
return (index + 1).toString();
120+
}
121+
122+
function nestingToIndentationLength(nesting: number): number {
123+
return (nesting + 1) * 2;
124+
}
125+
126+
function indent(str: string, spaces: number): string {
127+
const padding = " ".repeat(spaces);
128+
return str.replace(/^/gm, padding);
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Returns true if the error represents that a test/suite failed because one of
3+
* its subtests failed.
4+
*/
5+
export function isSubtestFailedError(error: Error): boolean {
6+
return (
7+
"code" in error &&
8+
"failureType" in error &&
9+
error.code === "ERR_TEST_FAILURE" &&
10+
error.failureType === "subtestsFailed"
11+
);
12+
}
13+
14+
/**
15+
* Returns true if the error represents that a test was cancelled because its
16+
* parent failed.
17+
*/
18+
export function isCancelledByParentError(error: Error): boolean {
19+
return (
20+
"code" in error &&
21+
"failureType" in error &&
22+
error.code === "ERR_TEST_FAILURE" &&
23+
error.failureType === "cancelledByParent"
24+
);
25+
}
26+
27+
/**
28+
* Cleans the test:fail event error, as it's usually wrapped by a node:test
29+
* error.
30+
*/
31+
export function cleanupTestFailError(error: Error): Error {
32+
if (
33+
"code" in error &&
34+
error.code === "ERR_TEST_FAILURE" &&
35+
error.cause instanceof Error
36+
) {
37+
return error.cause;
38+
}
39+
40+
return error;
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { TestEvent } from "node:test/reporters";
2+
3+
/**
4+
* This is missing from `@types/node` 20, so we define our own version of it,
5+
* based on the Node 22 docs.
6+
*/
7+
export interface TestCompletedEventData {
8+
column?: number;
9+
details: {
10+
passed: boolean;
11+
/* eslint-disable-next-line @typescript-eslint/naming-convention -- Keeping
12+
this aligned with the node:test event */
13+
duration_ms: number;
14+
error?: Error;
15+
type?: string;
16+
};
17+
file?: string;
18+
line?: number;
19+
name: string;
20+
nesting: number;
21+
testNumber: number;
22+
todo?: string | boolean;
23+
skip?: string | boolean;
24+
}
25+
26+
/**
27+
* This is missing from `@types/node` 20, so we define our own version of it,
28+
* based on the Node 22 docs.
29+
*/
30+
export interface TestCoverageEventData {
31+
summary: {
32+
files: Array<{
33+
path: string;
34+
totalLineCount: number;
35+
totalBranchCount: number;
36+
totalFunctionCount: number;
37+
coveredLineCount: number;
38+
coveredBranchCount: number;
39+
coveredFunctionCount: number;
40+
coveredLinePercent: number;
41+
coveredBranchPercent: number;
42+
coveredFunctionPercent: number;
43+
functions: Array<{
44+
name: string;
45+
line: number;
46+
count: number;
47+
}>;
48+
branches: Array<{
49+
line: number;
50+
count: number;
51+
}>;
52+
lines: Array<{
53+
line: number;
54+
count: number;
55+
}>;
56+
}>;
57+
totals: {
58+
totalLineCount: number;
59+
totalBranchCount: number;
60+
totalFunctionCount: number;
61+
coveredLineCount: number;
62+
coveredBranchCount: number;
63+
coveredFunctionCount: number;
64+
coveredLinePercent: number;
65+
coveredBranchPercent: number;
66+
coveredFunctionPercent: number;
67+
};
68+
workingDirectory: string;
69+
};
70+
nesting: number;
71+
}
72+
73+
/**
74+
* This is a fixed version of `@types/node`, as that one is incomplete, at least
75+
* in its version 20.
76+
*/
77+
export type CorrectedTestEvent =
78+
| TestEvent
79+
| { type: "test:complete"; data: TestCompletedEventData }
80+
| { type: "test:coverage"; data: TestCoverageEventData };
81+
82+
/**
83+
* A map from event type to its data type.
84+
*/
85+
export type TestEventData = UnionToObject<CorrectedTestEvent>;
86+
87+
type UnionToObject<T extends { type: string }> = {
88+
[K in T as K["type"]]: K extends { type: K["type"]; data: infer D }
89+
? D
90+
: never;
91+
};
92+
93+
/**
94+
* The type of the event source that the reporter will receive.
95+
*/
96+
export type TestEventSource = AsyncGenerator<CorrectedTestEvent, void>;
97+
98+
/**
99+
* The type of the result of the reporter.
100+
*/
101+
export type TestReporterResult = AsyncGenerator<string, void>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import chalk from "chalk";
2+
3+
import { processGlobalDiagnostics } from "./diagnostics.js";
4+
import {
5+
formatFailureReason,
6+
formatGlobalDiagnostics,
7+
formatUnusedDiagnostics,
8+
formatTestContext,
9+
formatTestFailure,
10+
formatTestPass,
11+
formatSlowTestInfo,
12+
Failure,
13+
} from "./formatting.js";
14+
import { isSubtestFailedError } from "./node-test-error-utils.js";
15+
import {
16+
TestEventData,
17+
TestEventSource,
18+
TestReporterResult,
19+
} from "./node-types.js";
20+
21+
export const SLOW_TEST_THRESHOLD = 75;
22+
23+
/**
24+
* This is a node:test reporter that tries to mimic Mocha's default reporter, as
25+
* close as possible.
26+
*
27+
* It is designed to output information about the test runs as soon as possible
28+
* and in test defintion order.
29+
*
30+
* Once the test run ends, it will output global information about it, based on
31+
* the diagnostics emitted by node:test, and any custom or unrecognized
32+
* diagnostics message.
33+
*
34+
* Finally, it will output the failure reasons for all the failed tests.
35+
*
36+
* @param source
37+
*/
38+
export default async function* customReporter(
39+
source: TestEventSource,
40+
): TestReporterResult {
41+
/**
42+
* The test reporter works by keeping a stack of the currently executing[1]
43+
* tests and suites. We use it to keep track of the context of a test, so that
44+
* when it passes or fails, we can print that context (if necessary), before
45+
* its results.
46+
*
47+
* Printing the context of a test will normally imply printing all the
48+
* describes where it is nested.
49+
*
50+
* As the context may be shared by more than one test, and we don't want to
51+
* repeate it, we keep track of the last printed context element. We do this
52+
* by keeping track of its index in the stack.
53+
*
54+
* We also keep track of any diagnostic message that its reported by node:test
55+
* and at the end we try to parse the global diagnostics to gather information
56+
* about the test run. If during this parsing we don't recognize or can't
57+
* properly parse one of this diagnostics, we will print it at the end.
58+
*
59+
* Whenever a test fails, we pre-format its failure reason, so that don't need
60+
* to keep the failure event in memory, and we can still print the failure
61+
* reason at the end.
62+
*
63+
* This code is structed in the following way:
64+
* - We use an async generator to process the events as they come, printing
65+
* the information as soon as possible.
66+
* - Instead of printing, we yield string.
67+
* - Any formatting that needs to be done is done in the formatting module.
68+
* - The formatting module exports functions that generate strings for the
69+
* different parts of the test run's output. They do not print anything, and
70+
* they never end in a newline. Any newline between different parts of the
71+
* output is added by the generator.
72+
* - The generaor drives the high-level format of the output, and only uses
73+
* the formatting functions to generate repetitive parts of it.
74+
*
75+
* [1] As reporter by node:test, in defintion order, which may differ from
76+
* actual execution order.
77+
*/
78+
79+
const stack: Array<TestEventData["test:start"]> = [];
80+
81+
let lastPrintedIndex: number | undefined;
82+
83+
const diagnostics: Array<TestEventData["test:diagnostic"]> = [];
84+
85+
const preFormattedFailureReasons: string[] = [];
86+
87+
for await (const event of source) {
88+
switch (event.type) {
89+
case "test:diagnostic": {
90+
diagnostics.push(event.data);
91+
break;
92+
}
93+
case "test:start": {
94+
stack.push(event.data);
95+
break;
96+
}
97+
case "test:pass":
98+
case "test:fail": {
99+
if (event.data.details.type === "suite") {
100+
// If a suite failed for a reason other than a subtest failing, we
101+
// want to print its failure.
102+
if (event.type === "test:fail") {
103+
if (!isSubtestFailedError(event.data.details.error)) {
104+
preFormattedFailureReasons.push(
105+
formatFailureReason({
106+
index: preFormattedFailureReasons.length,
107+
testFail: event.data,
108+
contextStack: stack,
109+
}),
110+
);
111+
}
112+
}
113+
114+
// If a suite/describe was already printed, we need to descrease
115+
// the lastPrintedIndex, as we are removing it from the stack.
116+
//
117+
// If its nesting was 0, we print an empty line to separate top-level
118+
// describes.
119+
if (event.data.nesting === 0) {
120+
lastPrintedIndex = undefined;
121+
yield "\n";
122+
} else {
123+
if (lastPrintedIndex !== undefined) {
124+
lastPrintedIndex = lastPrintedIndex - 1;
125+
126+
if (lastPrintedIndex < 0) {
127+
lastPrintedIndex = undefined;
128+
}
129+
}
130+
}
131+
132+
// Remove the current test from the stack, as it was just processed
133+
stack.pop();
134+
continue;
135+
}
136+
137+
// If we have printed everything except the current element in the stack
138+
// all of it's context/hierarchy has been printed (e.g. its describes).
139+
//
140+
// Otherwise, we print all the unprinted elements in the stack, except
141+
// for the last one, which is the current test.
142+
if (lastPrintedIndex !== stack.length - 2) {
143+
yield formatTestContext(
144+
stack.slice(
145+
lastPrintedIndex !== undefined ? lastPrintedIndex + 1 : 0,
146+
-1,
147+
),
148+
);
149+
yield "\n";
150+
lastPrintedIndex = stack.length - 2;
151+
}
152+
153+
if (event.type === "test:pass") {
154+
yield formatTestPass(event.data);
155+
} else {
156+
const failure: Failure = {
157+
index: preFormattedFailureReasons.length,
158+
testFail: event.data,
159+
contextStack: stack,
160+
};
161+
162+
// We format the failure reason and store it in an array, so that we
163+
// can output it at the end.
164+
preFormattedFailureReasons.push(formatFailureReason(failure));
165+
166+
yield formatTestFailure(failure);
167+
}
168+
169+
// If the test was slow, we print a message about it
170+
if (event.data.details.duration_ms > SLOW_TEST_THRESHOLD) {
171+
yield formatSlowTestInfo(event.data.details.duration_ms);
172+
}
173+
174+
yield "\n";
175+
176+
// Top-level tests are separated by an empty line
177+
if (event.data.nesting === 0) {
178+
yield "\n";
179+
}
180+
181+
// Remove the current test from the stack, as it was just processed it
182+
stack.pop();
183+
break;
184+
}
185+
case "test:stderr": {
186+
yield event.data.message;
187+
break;
188+
}
189+
case "test:stdout": {
190+
yield event.data.message;
191+
break;
192+
}
193+
case "test:plan": {
194+
// Do nothing
195+
break;
196+
}
197+
case "test:enqueue": {
198+
// Do nothing
199+
break;
200+
}
201+
case "test:dequeue": {
202+
// Do nothing
203+
break;
204+
}
205+
case "test:watch:drained": {
206+
// Do nothing
207+
break;
208+
}
209+
case "test:complete": {
210+
// Do nothing
211+
break;
212+
}
213+
case "test:coverage": {
214+
yield chalk.red("\nTest coverage not supported by this reporter\n");
215+
break;
216+
}
217+
/* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check --
218+
We have this extra check here becase we know the @types/node type is
219+
unreliable */
220+
default: {
221+
const _isNever: never = event;
222+
void _isNever;
223+
224+
yield chalk.red(`Unsuported node:test event:`, event);
225+
break;
226+
}
227+
}
228+
}
229+
230+
const { globalDiagnostics, unusedDiagnostics } =
231+
processGlobalDiagnostics(diagnostics);
232+
233+
yield "\n";
234+
yield formatGlobalDiagnostics(globalDiagnostics);
235+
236+
if (unusedDiagnostics.length > 0) {
237+
yield "\n";
238+
yield formatUnusedDiagnostics(unusedDiagnostics);
239+
}
240+
241+
yield "\n\n";
242+
243+
for (const reason of preFormattedFailureReasons) {
244+
yield reason;
245+
yield "\n\n";
246+
}
247+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { TestEvent } from "node:test/reporters";
2+
3+
import { describe, it } from "node:test";
4+
5+
describe("Missing @types/node definitions", () => {
6+
it("Should miss test:coverage from TestEvent", () => {
7+
const testCoverageEventIsNotTyped: "test:coverage" extends TestEvent["type"]
8+
? true
9+
: false = false;
10+
void testCoverageEventIsNotTyped;
11+
});
12+
13+
it("Should miss test:completed from TestEvent", () => {
14+
const testCompletedEventIsNotTyped: "test:completed" extends TestEvent["type"]
15+
? true
16+
: false = false;
17+
void testCompletedEventIsNotTyped;
18+
});
19+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "../../config-v-next/tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"composite": true,
6+
"incremental": true
7+
},
8+
"exclude": ["./dist", "./node_modules"],
9+
"references": [
10+
{
11+
"path": "../hardhat-utils"
12+
}
13+
]
14+
}

0 commit comments

Comments
 (0)
Please sign in to comment.