@travetto/test 7.1.4 → 8.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ This module provides unit testing functionality that integrates with the framewo
21
21
  **Note**: All tests should be under the `**/*` folders. The pattern for tests is defined as as a standard glob using [Node](https://nodejs.org)'s built in globbing support.
22
22
 
23
23
  ## Definition
24
- A test suite is a collection of individual tests. All test suites are classes with the [@Suite](https://github.com/travetto/travetto/tree/main/module/test/src/decorator/suite.ts#L15) decorator. Tests are defined as methods on the suite class, using the [@Test](https://github.com/travetto/travetto/tree/main/module/test/src/decorator/test.ts#L25) decorator. All tests intrinsically support `async`/`await`.
24
+ A test suite is a collection of individual tests. All test suites are classes with the [@Suite](https://github.com/travetto/travetto/tree/main/module/test/src/decorator/suite.ts#L13) decorator. Tests are defined as methods on the suite class, using the [@Test](https://github.com/travetto/travetto/tree/main/module/test/src/decorator/test.ts#L25) decorator. All tests intrinsically support `async`/`await`.
25
25
 
26
26
  A simple example would be:
27
27
 
package/__index__.ts CHANGED
@@ -2,6 +2,7 @@ import type { } from './src/trv.d.ts';
2
2
  export * from './src/decorator/suite.ts';
3
3
  export * from './src/decorator/test.ts';
4
4
  export * from './src/model/suite.ts';
5
+ export * from './src/model/error.ts';
5
6
  export * from './src/model/test.ts';
6
7
  export * from './src/model/event.ts';
7
8
  export * from './src/model/util.ts';
@@ -10,5 +11,5 @@ export * from './src/registry/registry-adapter.ts';
10
11
  export * from './src/fixture.ts';
11
12
  export * from './src/consumer/types.ts';
12
13
  export * from './src/consumer/registry-index.ts';
13
- export * from './src/execute/error.ts';
14
+
14
15
  export { TestWatchEvent } from './src/worker/types.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/test",
3
- "version": "7.1.4",
3
+ "version": "8.0.0-alpha.1",
4
4
  "type": "module",
5
5
  "description": "Declarative test framework",
6
6
  "keywords": [
@@ -28,15 +28,15 @@
28
28
  "directory": "module/test"
29
29
  },
30
30
  "dependencies": {
31
- "@travetto/registry": "^7.1.4",
32
- "@travetto/runtime": "^7.1.4",
33
- "@travetto/terminal": "^7.1.4",
34
- "@travetto/worker": "^7.1.4",
31
+ "@travetto/registry": "^8.0.0-alpha.1",
32
+ "@travetto/runtime": "^8.0.0-alpha.1",
33
+ "@travetto/terminal": "^8.0.0-alpha.1",
34
+ "@travetto/worker": "^8.0.0-alpha.1",
35
35
  "yaml": "^2.8.2"
36
36
  },
37
37
  "peerDependencies": {
38
- "@travetto/cli": "^7.1.4",
39
- "@travetto/transformer": "^7.1.3"
38
+ "@travetto/cli": "^8.0.0-alpha.1",
39
+ "@travetto/transformer": "^8.0.0-alpha.1"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "@travetto/transformer": {
@@ -7,6 +7,7 @@ export interface CapturedAssertion extends Partial<Assertion> {
7
7
  line: number;
8
8
  text: string;
9
9
  operator: string;
10
+ unexpected?: boolean;
10
11
  }
11
12
 
12
13
  /**
@@ -1,19 +1,20 @@
1
1
  import assert from 'node:assert';
2
2
  import { isPromise } from 'node:util/types';
3
3
 
4
- import { AppError, type Class, castTo, castKey, asConstructable } from '@travetto/runtime';
4
+ import { RuntimeError, type Class, castTo, castKey, asConstructable } from '@travetto/runtime';
5
5
 
6
- import type { ThrowableError, TestConfig, Assertion } from '../model/test.ts';
6
+ import type { ThrowableError, TestConfig, Assertion, TestStatus } from '../model/test.ts';
7
7
  import { AssertCapture, type CapturedAssertion } from './capture.ts';
8
8
  import { AssertUtil } from './util.ts';
9
9
  import { ASSERT_FN_OPERATOR, OP_MAPPING } from './types.ts';
10
+ import { TestExecutionError } from '../model/error.ts';
10
11
 
11
12
  type StringFields<T> = {
12
13
  [K in Extract<keyof T, string>]:
13
14
  (T[K] extends string ? K : never)
14
15
  }[Extract<keyof T, string>];
15
16
 
16
- const isClass = (input: unknown): input is Class => input === Error || input === AppError || Object.getPrototypeOf(input) !== Object.getPrototypeOf(Function);
17
+ const isClass = (input: unknown): input is Class => input === Error || input === RuntimeError || Object.getPrototypeOf(input) !== Object.getPrototypeOf(Function);
17
18
 
18
19
  /**
19
20
  * Check assertion
@@ -272,18 +273,34 @@ export class AssertCheck {
272
273
  * Look for any unhandled exceptions
273
274
  */
274
275
  static checkUnhandled(test: TestConfig, error: Error | assert.AssertionError): void {
275
- let line = AssertUtil.getPositionOfError(error, test.sourceImport ?? test.import).line;
276
- if (line === 1) {
277
- line = test.lineStart;
278
- }
276
+ const { line } = AssertUtil.getPositionOfError(error) ?? {};
279
277
 
280
278
  AssertCapture.add({
281
- import: test.import,
282
- line,
279
+ import: test.declarationImport ?? test.import,
280
+ line: line ?? test.lineStart,
283
281
  operator: 'throws',
284
282
  error,
283
+ unexpected: true,
285
284
  message: error.message,
286
285
  text: ('operator' in error ? error.operator : '') || '(uncaught)'
287
286
  });
288
287
  }
288
+
289
+ /**
290
+ * Validate the test result based on the error and test configuration
291
+ */
292
+ static validateTestResultError(test: TestConfig, error: Error | undefined): [TestStatus, Error | undefined] {
293
+ if (error instanceof assert.AssertionError) {
294
+ return ['failed', error];
295
+ } else if (error instanceof TestExecutionError) {
296
+ this.checkUnhandled(test, error);
297
+ return ['errored', error];
298
+ } else if (error === undefined || test.shouldThrow) {
299
+ error = this.checkError(test.shouldThrow, error); // Rewrite error
300
+ return [error ? 'failed' : 'passed', error];
301
+ } else {
302
+ this.checkUnhandled(test, error);
303
+ return ['errored', error];
304
+ }
305
+ }
289
306
  }
@@ -1,10 +1,10 @@
1
1
  import util from 'node:util';
2
2
  import path from 'node:path';
3
3
 
4
- import { asFull, type Class, hasFunction, Runtime, RuntimeIndex } from '@travetto/runtime';
4
+ import { asFull, type Class, JSONUtil, hasFunction, Runtime, RuntimeIndex, Util } from '@travetto/runtime';
5
5
 
6
- import type { TestConfig, Assertion, TestResult } from '../model/test.ts';
7
- import type { SuiteConfig, SuiteFailure, SuiteResult } from '../model/suite.ts';
6
+ import type { TestConfig, TestResult } from '../model/test.ts';
7
+ import type { SuiteConfig, SuiteResult } from '../model/suite.ts';
8
8
 
9
9
  const isCleanable = hasFunction<{ toClean(): unknown }>('toClean');
10
10
 
@@ -22,7 +22,7 @@ export class AssertUtil {
22
22
  if (isCleanable(value)) {
23
23
  return value.toClean();
24
24
  } else if (value === null || value.constructor === Object || Array.isArray(value) || value instanceof Date) {
25
- return JSON.stringify(value);
25
+ return JSONUtil.toUTF8(value);
26
26
  }
27
27
  break;
28
28
  }
@@ -39,92 +39,92 @@ export class AssertUtil {
39
39
  /**
40
40
  * Determine file location for a given error and the stack trace
41
41
  */
42
- static getPositionOfError(error: Error, importLocation: string): { import: string, line: number } {
43
- const workingDirectory = Runtime.mainSourcePath;
44
- const lines = (error.stack ?? new Error().stack!)
45
- .replace(/[\\/]/g, '/')
46
- .split('\n')
47
- // Exclude node_modules, target self
48
- .filter(lineText => lineText.includes(workingDirectory) && (!lineText.includes('node_modules') || lineText.includes('/support/')));
49
-
50
- const filename = RuntimeIndex.getFromImport(importLocation)?.sourceFile!;
51
-
52
- let best = lines.filter(lineText => lineText.includes(filename))[0];
53
-
54
- if (!best) {
55
- [best] = lines.filter(lineText => lineText.includes(`${workingDirectory}/test`));
56
- }
57
-
58
- if (!best) {
59
- return { import: importLocation, line: 1 };
60
- }
61
-
62
- const pth = best.trim().split(/\s+/g).slice(1).pop()!;
63
- if (!pth) {
64
- return { import: importLocation, line: 1 };
65
- }
66
-
67
- const [file, lineNo] = pth
68
- .replace(/[()]/g, '')
69
- .replace(/^[A-Za-z]:/, '')
70
- .split(':');
71
-
72
- let line = parseInt(lineNo, 10);
73
- if (Number.isNaN(line)) {
74
- line = -1;
75
- }
76
-
77
- const outFileParts = file.split(workingDirectory.replace(/^[A-Za-z]:/, ''));
78
-
79
- const outFile = outFileParts.length > 1 ? outFileParts[1].replace(/^[\/]/, '') : filename;
80
-
81
- const result = { import: RuntimeIndex.getFromSource(outFile)?.import!, line };
82
-
83
- return result;
42
+ static getPositionOfError(error: Error): { import: string, line: number } | undefined {
43
+ const frames = Util.stackTraceToParts(error.stack ?? new Error().stack!)
44
+ .map(frame => {
45
+ const entry = RuntimeIndex.getEntry(frame.filename);
46
+ return { ...frame, import: entry?.import!, line: entry?.type === 'ts' ? frame.line : 1 };
47
+ });
48
+
49
+ return frames.find(frame => frame.import);
84
50
  }
85
51
 
86
52
  /**
87
53
  * Generate a suite error given a suite config, and an error
88
54
  */
89
- static generateSuiteFailure(suite: SuiteConfig, methodName: string, error: Error): SuiteFailure {
90
- const { import: imp, ...rest } = this.getPositionOfError(error, suite.import);
91
- let line = rest.line;
92
-
93
- if (line === 1 && suite.lineStart) {
94
- line = suite.lineStart;
95
- }
96
-
97
- const msg = error.message.split(/\n/)[0];
98
-
99
- const core = { import: imp, classId: suite.classId, methodName, sourceHash: suite.sourceHash };
100
- const coreAll = { ...core, description: msg, lineStart: line, lineEnd: line, lineBodyStart: line };
101
-
102
- const assert: Assertion = {
103
- ...core,
104
- operator: 'throw', error, line, message: msg, text: methodName
105
- };
55
+ static generateSuiteTestFailure(config: { suite: SuiteConfig, test: TestConfig, error: Error, importLocation?: string }): TestResult {
56
+ const { suite, test, error, importLocation } = config;
57
+ const testImport = importLocation ?? test.import;
58
+ const position = this.getPositionOfError(error);
59
+ const line = position?.line ?? (testImport === suite.import ? suite.lineStart : 1);
106
60
  const testResult: TestResult = {
107
- ...coreAll,
108
- status: 'failed', error, duration: 0, durationTotal: 0, assertions: [assert], output: []
109
- };
110
- const test: TestConfig = {
111
- ...coreAll,
112
- class: suite.class, skip: false
61
+ ...suite.tests[test.methodName],
62
+ suiteLineStart: suite.lineStart,
63
+ status: 'errored',
64
+ error,
65
+ duration: 0,
66
+ durationTotal: 0,
67
+ output: [],
68
+ assertions: [{
69
+ import: position?.import ?? testImport,
70
+ methodName: test.methodName,
71
+ classId: suite.classId,
72
+ operator: 'throw',
73
+ error,
74
+ line,
75
+ message: error.message.split(/\n/)[0],
76
+ text: test.methodName
77
+ }],
113
78
  };
114
79
 
115
- return { assert, testResult, test, suite };
80
+ return testResult;
116
81
  }
117
82
 
118
83
  /**
119
- * Define import failure as a SuiteFailure object
84
+ * Generate suite failure
120
85
  */
121
- static gernerateImportFailure(importLocation: string, error: Error): SuiteFailure {
86
+ static generateSuiteTestFailures(suite: SuiteConfig, error: Error): TestResult[] {
87
+ const finalError = error.cause instanceof Error ? error.cause : error;
88
+ return Object.values(suite.tests).map(test => this.generateSuiteTestFailure({ suite, test, error: finalError }));
89
+ }
90
+
91
+ /**
92
+ * Define import failure as a TestResult
93
+ */
94
+ static gernerateImportFailure(importLocation: string, error: Error): { result: TestResult, test: TestConfig, suite: SuiteResult & SuiteConfig } {
122
95
  const name = path.basename(importLocation);
123
96
  const classId = `${RuntimeIndex.getFromImport(importLocation)?.id}#${name}`;
124
97
  const suite = asFull<SuiteConfig & SuiteResult>({
125
98
  class: asFull<Class>({ name }), classId, duration: 0, lineStart: 1, lineEnd: 1, import: importLocation
126
99
  });
127
100
  error.message = error.message.replaceAll(Runtime.mainSourcePath, '.');
128
- return this.generateSuiteFailure(suite, 'require', error);
101
+ const result = this.generateSuiteTestFailure({
102
+ suite,
103
+ test: {
104
+ methodName: 'require',
105
+ classId,
106
+ import: importLocation,
107
+ class: suite.class,
108
+ lineBodyStart: 1,
109
+ lineStart: 1,
110
+ lineEnd: 1,
111
+ skip: false
112
+ },
113
+ error
114
+ });
115
+ const test: TestConfig = {
116
+ methodName: 'import',
117
+ classId,
118
+ import: importLocation,
119
+ declarationImport: importLocation,
120
+ lineStart: 0,
121
+ lineEnd: 0,
122
+ lineBodyStart: 0,
123
+ tags: [],
124
+ description: 'Import Failure',
125
+ skip: false,
126
+ class: undefined!
127
+ };
128
+ return { result, test, suite };
129
129
  }
130
130
  }
@@ -56,7 +56,15 @@ export class CumulativeSummaryConsumer extends DelegatingConsumer {
56
56
  onSuiteAfter(result: SuiteResult): SuiteResult {
57
57
  // Reset counts
58
58
  const suite = this.getSuite(result);
59
- const totals: Counts & { duration: number } = { passed: 0, failed: 0, skipped: 0, unknown: 0, total: 0, duration: 0 };
59
+ const totals: Counts & { duration: number } = {
60
+ passed: 0,
61
+ failed: 0,
62
+ skipped: 0,
63
+ errored: 0,
64
+ unknown: 0,
65
+ total: 0,
66
+ duration: 0
67
+ };
60
68
  for (const test of Object.values(suite.tests)) {
61
69
  totals[test.status] += 1;
62
70
  totals.total += 1;
@@ -1,9 +1,10 @@
1
1
  import type { Writable } from 'node:stream';
2
2
 
3
+ import { JSONUtil } from '@travetto/runtime';
4
+
3
5
  import type { TestEvent, TestRemoveEvent } from '../../model/event.ts';
4
6
  import type { TestConsumerShape } from '../types.ts';
5
7
  import { TestConsumer } from '../decorator.ts';
6
- import { CommunicationUtil } from '../../communication.ts';
7
8
 
8
9
  /**
9
10
  * Streams all test events a JSON payload, in an nd-json format
@@ -17,7 +18,7 @@ export class EventStreamer implements TestConsumerShape {
17
18
  }
18
19
 
19
20
  sendPayload(payload: unknown): void {
20
- this.#stream.write(`${CommunicationUtil.serialize(payload)}\n`);
21
+ this.#stream.write(`${JSONUtil.toUTF8(JSONUtil.cloneForTransmit(payload))}\n`);
21
22
  }
22
23
 
23
24
  onEvent(event: TestEvent): void {
@@ -1,9 +1,9 @@
1
1
  import { IpcChannel } from '@travetto/worker';
2
+ import { JSONUtil } from '@travetto/runtime';
2
3
 
3
4
  import type { TestEvent, TestRemoveEvent } from '../../model/event.ts';
4
5
  import type { TestConsumerShape } from '../types.ts';
5
6
  import { TestConsumer } from '../decorator.ts';
6
- import { CommunicationUtil } from '../../communication.ts';
7
7
 
8
8
  /**
9
9
  * Triggers each event as an IPC command to a parent process
@@ -12,7 +12,7 @@ import { CommunicationUtil } from '../../communication.ts';
12
12
  export class ExecutionEmitter extends IpcChannel<TestEvent> implements TestConsumerShape {
13
13
 
14
14
  sendPayload(payload: unknown & { type: string }): void {
15
- this.send(payload.type, CommunicationUtil.serializeToObject(payload));
15
+ this.send(payload.type, JSONUtil.cloneForTransmit(payload));
16
16
  }
17
17
 
18
18
  onEvent(event: TestEvent): void {
@@ -1,5 +1,7 @@
1
1
  import type { Writable } from 'node:stream';
2
2
 
3
+ import { JSONUtil } from '@travetto/runtime';
4
+
3
5
  import type { SuitesSummary } from '../types.ts';
4
6
  import { TestConsumer } from '../decorator.ts';
5
7
 
@@ -18,6 +20,6 @@ export class JSONEmitter {
18
20
  onEvent(): void { }
19
21
 
20
22
  onSummary(summary: SuitesSummary): void {
21
- this.#stream.write(JSON.stringify(summary, undefined, 2));
23
+ this.#stream.write(JSONUtil.toUTF8Pretty(summary));
22
24
  }
23
25
  }
@@ -10,6 +10,7 @@ export class TestResultsSummarizer implements TestConsumerShape {
10
10
  summary: SuitesSummary = {
11
11
  passed: 0,
12
12
  failed: 0,
13
+ errored: 0,
13
14
  skipped: 0,
14
15
  unknown: 0,
15
16
  total: 0,
@@ -21,6 +22,7 @@ export class TestResultsSummarizer implements TestConsumerShape {
21
22
  #merge(result: SuiteResult): void {
22
23
  this.summary.suites.push(result);
23
24
  this.summary.failed += result.failed;
25
+ this.summary.errored += result.errored;
24
26
  this.summary.passed += result.passed;
25
27
  this.summary.unknown += result.unknown;
26
28
  this.summary.skipped += result.skipped;
@@ -2,13 +2,14 @@ import { Util, AsyncQueue } from '@travetto/runtime';
2
2
  import { StyleUtil, Terminal, TerminalUtil } from '@travetto/terminal';
3
3
 
4
4
  import type { TestEvent } from '../../model/event.ts';
5
- import type { TestResult } from '../../model/test.ts';
5
+ import type { TestResult, TestStatus } from '../../model/test.ts';
6
6
 
7
7
  import type { SuitesSummary, TestConsumerShape, TestRunState } from '../types.ts';
8
8
  import { TestConsumer } from '../decorator.ts';
9
9
 
10
10
  import { TapEmitter } from './tap.ts';
11
11
  import { CONSOLE_ENHANCER, type TestResultsEnhancer } from '../enhancer.ts';
12
+ import type { SuiteResult } from '../../model/suite.ts';
12
13
 
13
14
  type Result = {
14
15
  key: string;
@@ -22,12 +23,7 @@ type Result = {
22
23
  @TestConsumer()
23
24
  export class TapSummaryEmitter implements TestConsumerShape {
24
25
 
25
- #timings = new Map([
26
- ['file', new Map<string, Result>()],
27
- ['module', new Map<string, Result>()],
28
- ['suite', new Map<string, Result>()],
29
- ['test', new Map<string, Result>()],
30
- ] as const);
26
+ #timings = new Map<'test' | 'module' | 'file' | 'suite', Map<string, Result>>();
31
27
 
32
28
  #terminal: Terminal;
33
29
  #results = new AsyncQueue<TestResult>();
@@ -42,6 +38,50 @@ export class TapSummaryEmitter implements TestConsumerShape {
42
38
  this.#consumer = new TapEmitter(this.#terminal, this.#enhancer);
43
39
  }
44
40
 
41
+ #computeTimings(suite: SuiteResult): void {
42
+ const [module] = suite.classId.split(/:/);
43
+ const [file] = suite.classId.split(/#/);
44
+
45
+ const testCount = Object.keys(suite.tests).length;
46
+
47
+ const foundModule = this.#timings
48
+ .getOrInsert('module', new Map<string, Result>())
49
+ .getOrInsert(module, { key: module, duration: 0, tests: 0 });
50
+
51
+ foundModule.duration += suite.duration;
52
+ foundModule.tests += testCount;
53
+
54
+ const foundFile = this.#timings
55
+ .getOrInsert('file', new Map<string, Result>())
56
+ .getOrInsert(file, { key: file, duration: 0, tests: 0 });
57
+
58
+ foundFile.duration += suite.duration;
59
+ foundFile.tests += testCount;
60
+
61
+ this.#timings
62
+ .getOrInsert('suite', new Map<string, Result>())
63
+ .set(suite.classId, {
64
+ key: suite.classId,
65
+ duration: suite.duration,
66
+ tests: testCount
67
+ });
68
+ }
69
+
70
+ #renderTimings(): void {
71
+ const count = +(this.#options?.count ?? 5);
72
+ this.#consumer.log('\n---');
73
+ for (const [title, results] of [...this.#timings.entries()].toSorted((a, b) => a[0].localeCompare(b[0]))) {
74
+ this.#consumer.log(`${this.#enhancer.suiteName(`Top ${count} slowest ${title}s`)}: `);
75
+ const top10 = [...results.values()].toSorted((a, b) => b.duration - a.duration).slice(0, count);
76
+
77
+ for (const result of top10) {
78
+ console.log(` * ${this.#enhancer.testName(result.key)} - ${this.#enhancer.total(result.duration)}ms / ${this.#enhancer.total(result.tests)} tests`);
79
+ }
80
+ this.#consumer.log('');
81
+ }
82
+ this.#consumer.log('...');
83
+ }
84
+
45
85
  setOptions(options?: Record<string, unknown>): Promise<void> | void {
46
86
  this.#options = options;
47
87
  this.#consumer.setOptions(options);
@@ -49,22 +89,20 @@ export class TapSummaryEmitter implements TestConsumerShape {
49
89
 
50
90
  async onStart(state: TestRunState): Promise<void> {
51
91
  this.#consumer.onStart();
52
-
53
- let failed = 0;
54
- let skipped = 0;
55
- let completed = 0;
92
+ const total: Record<TestStatus | 'count', number> = { errored: 0, failed: 0, passed: 0, skipped: 0, unknown: 0, count: 0 };
56
93
  const success = StyleUtil.getStyle({ text: '#e5e5e5', background: '#026020' }); // White on dark green
57
94
  const fail = StyleUtil.getStyle({ text: '#e5e5e5', background: '#8b0000' }); // White on dark red
58
95
  this.#progress = this.#terminal.streamToBottom(
59
96
  Util.mapAsyncIterable(
60
97
  this.#results,
61
98
  (value) => {
62
- failed += (value.status === 'failed' ? 1 : 0);
63
- skipped += (value.status === 'skipped' ? 1 : 0);
64
- completed += (value.status !== 'skipped' ? 1 : 0);
65
- return { value: `Tests %idx/%total [${failed} failed, ${skipped} skipped] -- ${value.classId}`, total: state.testCount, idx: completed };
99
+ total[value.status] += 1;
100
+ total.count += 1;
101
+ const statusLine = `${total.failed} failed, ${total.errored} errored, ${total.skipped} skipped`;
102
+ return { value: `Tests %idx/%total [${statusLine}] -- ${value.classId}`, total: state.testCount, idx: total.passed };
103
+
66
104
  },
67
- TerminalUtil.progressBarUpdater(this.#terminal, { style: () => ({ complete: failed ? fail : success }) })
105
+ TerminalUtil.progressBarUpdater(this.#terminal, { style: () => ({ complete: (total.failed || total.errored) ? fail : success }) })
68
106
  ),
69
107
  { minDelay: 100 }
70
108
  );
@@ -77,40 +115,16 @@ export class TapSummaryEmitter implements TestConsumerShape {
77
115
  if (test.status === 'failed') {
78
116
  this.#consumer.onEvent(event);
79
117
  }
80
- const tests = this.#timings.get('test')!;
118
+ const tests = this.#timings.getOrInsert('test', new Map<string, Result>());
81
119
  tests.set(`${event.test.classId}/${event.test.methodName}`, {
82
120
  key: `${event.test.classId}/${event.test.methodName}`,
83
121
  duration: test.duration,
84
122
  tests: 1
85
123
  });
86
124
  } else if (event.type === 'suite' && event.phase === 'after') {
87
- const [module] = event.suite.classId.split(/:/);
88
- const [file] = event.suite.classId.split(/#/);
89
-
90
- const modules = this.#timings.get('module')!;
91
- const files = this.#timings.get('file')!;
92
- const suites = this.#timings.get('suite')!;
93
-
94
- if (!modules!.has(module)) {
95
- modules.set(module, { key: module, duration: 0, tests: 0 });
96
- }
97
-
98
- if (!files.has(file)) {
99
- files.set(file, { key: file, duration: 0, tests: 0 });
125
+ if (this.#options?.timings) {
126
+ this.#computeTimings(event.suite);
100
127
  }
101
-
102
- const testCount = Object.keys(event.suite.tests).length;
103
-
104
- suites.set(event.suite.classId, {
105
- key: event.suite.classId,
106
- duration: event.suite.duration,
107
- tests: testCount
108
- });
109
-
110
- files.get(file)!.duration += event.suite.duration;
111
- files.get(file)!.tests += testCount;
112
- modules.get(module)!.duration += event.suite.duration;
113
- modules.get(module)!.tests += testCount;
114
128
  }
115
129
  }
116
130
 
@@ -120,21 +134,10 @@ export class TapSummaryEmitter implements TestConsumerShape {
120
134
  async onSummary(summary: SuitesSummary): Promise<void> {
121
135
  this.#results.close();
122
136
  await this.#progress;
123
- await this.#consumer.onSummary?.(summary);
137
+ this.#consumer.onSummary?.(summary);
124
138
 
125
139
  if (this.#options?.timings) {
126
- const count = +(this.#options?.count ?? 5);
127
- await this.#consumer.log('\n---');
128
- for (const [title, results] of [...this.#timings.entries()].toSorted((a, b) => a[0].localeCompare(b[0]))) {
129
- await this.#consumer.log(`${this.#enhancer.suiteName(`Top ${count} slowest ${title}s`)}: `);
130
- const top10 = [...results.values()].toSorted((a, b) => b.duration - a.duration).slice(0, count);
131
-
132
- for (const result of top10) {
133
- console.log(` * ${this.#enhancer.testName(result.key)} - ${this.#enhancer.total(result.duration)}ms / ${this.#enhancer.total(result.tests)} tests`);
134
- }
135
- await this.#consumer.log('');
136
- }
137
- await this.#consumer.log('...');
140
+ this.#renderTimings();
138
141
  }
139
142
  }
140
143
  }