|
| 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 | +} |
0 commit comments