@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.
@@ -1,8 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { stringify } from 'yaml';
3
3
 
4
- import { Terminal } from '@travetto/terminal';
5
- import { TimeUtil, RuntimeIndex, hasToJSON } from '@travetto/runtime';
4
+ import { Terminal, StyleUtil } from '@travetto/terminal';
5
+ import { TimeUtil, RuntimeIndex, hasToJSON, JSONUtil } from '@travetto/runtime';
6
6
 
7
7
  import type { TestEvent } from '../../model/event.ts';
8
8
  import type { SuitesSummary, TestConsumerShape } from '../types.ts';
@@ -41,15 +41,15 @@ export class TapEmitter implements TestConsumerShape {
41
41
  */
42
42
  onStart(): void {
43
43
  this.#start = Date.now();
44
- this.log(this.#enhancer.suiteName('TAP version 14')!);
44
+ this.log(this.#enhancer.suiteName('TAP version 14'));
45
45
  }
46
46
 
47
47
  /**
48
48
  * Output supplemental data (e.g. logs)
49
49
  */
50
- logMeta(meta: Record<string, unknown>): void {
50
+ logMeta(metadata: Record<string, unknown>): void {
51
51
  const lineLength = this.#terminal.width - 5;
52
- let body = stringify(meta, { lineWidth: lineLength, indent: 2 });
52
+ let body = stringify(metadata, { lineWidth: lineLength, indent: 2 });
53
53
  body = body.split('\n').map(line => ` ${line}`).join('\n');
54
54
  this.log(`---\n${this.#enhancer.objectInspect(body)}\n...`);
55
55
  }
@@ -61,7 +61,7 @@ export class TapEmitter implements TestConsumerShape {
61
61
  errorToString(error?: Error): string | undefined {
62
62
  if (error && error.name !== 'AssertionError') {
63
63
  if (error instanceof Error) {
64
- let out = JSON.stringify(hasToJSON(error) ? error.toJSON() : error, null, 2);
64
+ let out = JSONUtil.toUTF8(hasToJSON(error) ? error.toJSON() : error, { indent: 2 });
65
65
  if (this.#options?.verbose && error.stack) {
66
66
  out = `${out}\n${error.stack}`;
67
67
  }
@@ -79,23 +79,33 @@ export class TapEmitter implements TestConsumerShape {
79
79
  if (event.type === 'test' && event.phase === 'after') {
80
80
  const { test } = event;
81
81
  const suiteId = this.#enhancer.suiteName(test.classId);
82
- let header = `${suiteId} - ${this.#enhancer.testName(test.methodName)}`;
83
- if (test.description) {
84
- header += `: ${this.#enhancer.testDescription(test.description)}`;
85
- }
82
+ const suiteSourceFile = RuntimeIndex.getFromImport(test.import)!.sourceFile;
83
+ const testSourceFile = test.declarationImport ? RuntimeIndex.getFromImport(test.declarationImport)!.sourceFile : suiteSourceFile;
84
+
85
+ const header = [
86
+ StyleUtil.link(suiteId, `file://${suiteSourceFile}#${test.suiteLineStart ?? 1}`),
87
+ ' - ',
88
+ StyleUtil.link(this.#enhancer.testName(test.methodName), `file://${testSourceFile}#${test.lineStart}`),
89
+ ...test.description ? [`: ${this.#enhancer.testDescription(test.description)}`] : []
90
+ ].join('');
91
+
86
92
  this.log(`# ${header}`);
87
93
 
88
94
  // Handle each assertion
89
95
  if (test.assertions.length) {
90
96
  let subCount = 0;
91
97
  for (const asrt of test.assertions) {
98
+ const assertSourceFile = RuntimeIndex.getFromImport(asrt.import)!.sourceFile;
92
99
  const text = asrt.message ? `${asrt.text} (${this.#enhancer.failure(asrt.message)})` : asrt.text;
93
- const pth = asrt.import ? `./${path.relative(process.cwd(), RuntimeIndex.getFromImport(asrt.import)!.sourceFile)}` : '<unknown>';
100
+ const location = asrt.import ? `./${path.relative(process.cwd(), assertSourceFile)}` : '<unknown>';
94
101
  let subMessage = [
95
102
  this.#enhancer.assertNumber(++subCount),
96
103
  '-',
97
104
  this.#enhancer.assertDescription(text),
98
- `${this.#enhancer.assertFile(pth)}:${this.#enhancer.assertLine(asrt.line)}`
105
+ StyleUtil.link(
106
+ `${this.#enhancer.assertFile(location)}:${this.#enhancer.assertLine(asrt.line)}`,
107
+ `file://${assertSourceFile}#${asrt.line}`
108
+ )
99
109
  ].join(' ');
100
110
 
101
111
  if (asrt.error) {
@@ -124,10 +134,16 @@ export class TapEmitter implements TestConsumerShape {
124
134
  this.log(status);
125
135
 
126
136
  // Handle error
127
- if (test.status === 'failed' && test.error) {
128
- const msg = this.errorToString(test.error);
129
- if (msg) {
130
- this.logMeta({ error: msg });
137
+ switch (test.status) {
138
+ case 'errored':
139
+ case 'failed': {
140
+ if (test.error) {
141
+ const msg = this.errorToString(test.error);
142
+ if (msg) {
143
+ this.logMeta({ error: msg });
144
+ }
145
+ }
146
+ break;
131
147
  }
132
148
  }
133
149
 
@@ -162,13 +178,15 @@ export class TapEmitter implements TestConsumerShape {
162
178
  }
163
179
  }
164
180
 
165
- const allPassed = summary.failed === 0;
181
+ const allPassed = !summary.failed && !summary.errored;
166
182
 
167
183
  this.log([
168
184
  this.#enhancer[allPassed ? 'success' : 'failure']('Results'),
169
185
  `${this.#enhancer.total(summary.passed)}/${this.#enhancer.total(summary.total)},`,
170
186
  allPassed ? 'failed' : this.#enhancer.failure('failed'),
171
187
  `${this.#enhancer.total(summary.failed)}`,
188
+ allPassed ? 'errored' : this.#enhancer.failure('errored'),
189
+ `${this.#enhancer.total(summary.errored)}`,
172
190
  'skipped',
173
191
  this.#enhancer.total(summary.skipped),
174
192
  `# (Total Test Time: ${TimeUtil.asClock(summary.duration)}, Total Run Time: ${TimeUtil.asClock(Date.now() - this.#start)})`
@@ -24,18 +24,18 @@ export class XunitEmitter implements TestConsumerShape {
24
24
  /**
25
25
  * Process metadata information (e.g. logs)
26
26
  */
27
- buildMeta(meta: Record<string, unknown>): string {
28
- if (!meta) {
27
+ buildMeta(metadata: Record<string, unknown>): string {
28
+ if (!metadata) {
29
29
  return '';
30
30
  }
31
31
 
32
- for (const key of Object.keys(meta)) {
33
- if (!meta[key]) {
34
- delete meta[key];
32
+ for (const key of Object.keys(metadata)) {
33
+ if (!metadata[key]) {
34
+ delete metadata[key];
35
35
  }
36
36
  }
37
- if (Object.keys(meta).length) {
38
- let body = stringify(meta);
37
+ if (Object.keys(metadata).length) {
38
+ let body = stringify(metadata);
39
39
  body = body.split('\n').map(line => ` ${line}`).join('\n');
40
40
  return `<![CDATA[\n${body}\n]]>`;
41
41
  } else {
@@ -60,7 +60,8 @@ export class XunitEmitter implements TestConsumerShape {
60
60
 
61
61
  if (test.error) {
62
62
  const assertion = test.assertions.find(item => !!item.error)!;
63
- body = `<failure type="${assertion.text}" message="${encodeURIComponent(assertion.message!)}"><![CDATA[${assertion.error!.stack}]]></failure>`;
63
+ const node = test.status === 'failed' ? 'failure' : 'error';
64
+ body = `<${node} type="${assertion.text}" message="${encodeURIComponent(assertion.message!)}"><![CDATA[${assertion.error!.stack}]]></${node}>`;
64
65
  }
65
66
 
66
67
  const groupedByLevel: Record<string, string[]> = {};
@@ -90,7 +91,7 @@ export class XunitEmitter implements TestConsumerShape {
90
91
  time="${suite.duration}"
91
92
  tests="${suite.total}"
92
93
  failures="${suite.failed}"
93
- errors="${suite.failed}"
94
+ errors="${suite.errored}"
94
95
  skipped="${suite.skipped}"
95
96
  file="${RuntimeIndex.getFromImport(suite.import)!.sourceFile}"
96
97
  >
@@ -112,7 +113,8 @@ export class XunitEmitter implements TestConsumerShape {
112
113
  time="${summary.duration}"
113
114
  tests="${summary.total}"
114
115
  failures="${summary.failed}"
115
- errors="${summary.failed}"
116
+ errors="${summary.errored}"
117
+ skipped="${summary.skipped}"
116
118
  >
117
119
  ${this.#suites.join('\n')}
118
120
  </testsuites>
@@ -1,10 +1,8 @@
1
- import { castTo, type Class, type ClassInstance, describeFunction, getClass } from '@travetto/runtime';
1
+ import { castTo, type Class, type ClassInstance, getClass } from '@travetto/runtime';
2
2
 
3
3
  import type { SuiteConfig } from '../model/suite.ts';
4
4
  import { SuiteRegistryIndex } from '../registry/registry-index.ts';
5
5
 
6
- export type SuitePhase = 'beforeAll' | 'beforeEach' | 'afterAll' | 'afterEach';
7
-
8
6
  /**
9
7
  * Register a class to be defined as a test suite, and a candidate for testing
10
8
  * @param description The Suite description
@@ -17,11 +15,9 @@ export function Suite(...rest: Partial<SuiteConfig>[]): ClassDecorator;
17
15
  export function Suite(description: string, ...rest: Partial<SuiteConfig>[]): ClassDecorator;
18
16
  export function Suite(description?: string | Partial<SuiteConfig>, ...rest: Partial<SuiteConfig>[]): ClassDecorator {
19
17
  const decorator = (cls: Class): typeof cls => {
20
- const isAbstract = describeFunction(cls).abstract;
21
18
  SuiteRegistryIndex.getForRegister(cls).register(
22
19
  ...(typeof description !== 'string' && description ? [description] : []),
23
20
  ...rest,
24
- ...isAbstract ? [{ skip: true }] : [],
25
21
  ...(typeof description === 'string' ? [{ description }] : []),
26
22
  );
27
23
  return cls;
@@ -36,7 +32,9 @@ export function Suite(description?: string | Partial<SuiteConfig>, ...rest: Part
36
32
  */
37
33
  export function BeforeAll() {
38
34
  return (instance: ClassInstance, property: string, descriptor: PropertyDescriptor): PropertyDescriptor => {
39
- SuiteRegistryIndex.getForRegister(getClass(instance)).register({ beforeAll: [descriptor.value] });
35
+ SuiteRegistryIndex.getForRegister(getClass(instance)).register({
36
+ phaseHandlers: [{ beforeAll: (realInstance: unknown): unknown => descriptor.value.call(realInstance) }]
37
+ });
40
38
  return descriptor;
41
39
  };
42
40
  }
@@ -47,7 +45,9 @@ export function BeforeAll() {
47
45
  */
48
46
  export function BeforeEach() {
49
47
  return (instance: ClassInstance, property: string, descriptor: PropertyDescriptor): PropertyDescriptor => {
50
- SuiteRegistryIndex.getForRegister(getClass(instance)).register({ beforeEach: [descriptor.value] });
48
+ SuiteRegistryIndex.getForRegister(getClass(instance)).register({
49
+ phaseHandlers: [{ beforeEach: (realInstance: unknown): unknown => descriptor.value.call(realInstance) }]
50
+ });
51
51
  return descriptor;
52
52
  };
53
53
  }
@@ -58,7 +58,9 @@ export function BeforeEach() {
58
58
  */
59
59
  export function AfterAll() {
60
60
  return (instance: ClassInstance, property: string, descriptor: PropertyDescriptor): PropertyDescriptor => {
61
- SuiteRegistryIndex.getForRegister(getClass(instance)).register({ afterAll: [descriptor.value] });
61
+ SuiteRegistryIndex.getForRegister(getClass(instance)).register({
62
+ phaseHandlers: [{ afterAll: (realInstance: unknown): unknown => descriptor.value.call(realInstance) }]
63
+ });
62
64
  return descriptor;
63
65
  };
64
66
  }
@@ -69,7 +71,9 @@ export function AfterAll() {
69
71
  */
70
72
  export function AfterEach() {
71
73
  return (instance: ClassInstance, property: string, descriptor: PropertyDescriptor): PropertyDescriptor => {
72
- SuiteRegistryIndex.getForRegister(getClass(instance)).register({ afterEach: [descriptor.value] });
74
+ SuiteRegistryIndex.getForRegister(getClass(instance)).register({
75
+ phaseHandlers: [{ afterEach: (realInstance: unknown): unknown => descriptor.value.call(realInstance) }]
76
+ });
73
77
  return descriptor;
74
78
  };
75
79
  }
@@ -3,7 +3,7 @@ import { createHook, executionAsyncId } from 'node:async_hooks';
3
3
 
4
4
  import { type TimeSpan, TimeUtil, Util } from '@travetto/runtime';
5
5
 
6
- import { ExecutionError, TimeoutError } from './error.ts';
6
+ import { TestExecutionError, TimeoutError } from '../model/error.ts';
7
7
 
8
8
  const UNCAUGHT_ERR_EVENTS = ['unhandledRejection', 'uncaughtException'] as const;
9
9
 
@@ -13,7 +13,7 @@ export class Barrier {
13
13
  */
14
14
  static timeout(duration: number | TimeSpan, operation: string = 'Operation'): { promise: Promise<void>, resolve: () => unknown } {
15
15
  const resolver = Promise.withResolvers<void>();
16
- const durationMs = TimeUtil.asMillis(duration);
16
+ const durationMs = TimeUtil.duration(duration, 'ms');
17
17
  let timeout: NodeJS.Timeout;
18
18
  if (!durationMs) {
19
19
  resolver.resolve();
@@ -67,7 +67,7 @@ export class Barrier {
67
67
  await Util.queueMacroTask();
68
68
  i -= 1;
69
69
  if (i === 0) {
70
- throw new ExecutionError(`Pending promises: ${pending.size}`);
70
+ throw new TestExecutionError(`Pending promises: ${pending.size}`);
71
71
  }
72
72
  }
73
73
  },
@@ -1,10 +1,8 @@
1
- import { AssertionError } from 'node:assert';
2
-
3
1
  import { Env, TimeUtil, Runtime, castTo, classConstruct } from '@travetto/runtime';
4
2
  import { Registry } from '@travetto/registry';
5
3
 
6
4
  import type { TestConfig, TestResult, TestRun } from '../model/test.ts';
7
- import type { SuiteConfig, SuiteFailure, SuiteResult } from '../model/suite.ts';
5
+ import type { SuiteConfig, SuiteResult } from '../model/suite.ts';
8
6
  import type { TestConsumerShape } from '../consumer/types.ts';
9
7
  import { AssertCheck } from '../assert/check.ts';
10
8
  import { AssertCapture } from '../assert/capture.ts';
@@ -12,11 +10,10 @@ import { ConsoleCapture } from './console.ts';
12
10
  import { TestPhaseManager } from './phase.ts';
13
11
  import { AssertUtil } from '../assert/util.ts';
14
12
  import { Barrier } from './barrier.ts';
15
- import { ExecutionError } from './error.ts';
16
13
  import { SuiteRegistryIndex } from '../registry/registry-index.ts';
17
14
  import { TestModelUtil } from '../model/util.ts';
18
15
 
19
- const TEST_TIMEOUT = TimeUtil.fromValue(Env.TRV_TEST_TIMEOUT.value) ?? 5000;
16
+ const TEST_TIMEOUT = TimeUtil.duration(Env.TRV_TEST_TIMEOUT.value || 5000, 'ms');
20
17
 
21
18
  /**
22
19
  * Support execution of the tests
@@ -29,25 +26,21 @@ export class TestExecutor {
29
26
  this.#consumer = consumer;
30
27
  }
31
28
 
32
- /**
33
- * Handles communicating a suite-level error
34
- * @param failure
35
- * @param withSuite
36
- */
37
- #onSuiteFailure(failure: SuiteFailure, triggerSuite?: boolean): void {
38
- if (triggerSuite) {
39
- this.#consumer.onEvent({ type: 'suite', phase: 'before', suite: failure.suite });
29
+ #onSuiteTestError(result: TestResult, test: TestConfig): void {
30
+ this.#consumer.onEvent({ type: 'test', phase: 'before', test });
31
+ for (const assertion of result.assertions) {
32
+ this.#consumer.onEvent({ type: 'assertion', phase: 'after', assertion });
40
33
  }
34
+ this.#consumer.onEvent({ type: 'test', phase: 'after', test: result });
35
+ }
41
36
 
42
- this.#consumer.onEvent({ type: 'test', phase: 'before', test: failure.test });
43
- this.#consumer.onEvent({ type: 'assertion', phase: 'after', assertion: failure.assert });
44
- this.#consumer.onEvent({ type: 'test', phase: 'after', test: failure.testResult });
45
-
46
- if (triggerSuite) {
47
- this.#consumer.onEvent({
48
- type: 'suite', phase: 'after',
49
- suite: { ...castTo(failure.suite), failed: 1, passed: 0, total: 1, skipped: 0 }
50
- });
37
+ #recordSuiteErrors(suiteConfig: SuiteConfig, suiteResult: SuiteResult, errors: TestResult[]): void {
38
+ for (const test of errors) {
39
+ if (!suiteResult.tests[test.methodName]) {
40
+ this.#onSuiteTestError(test, suiteConfig.tests[test.methodName]);
41
+ suiteResult.errored += 1;
42
+ suiteResult.total += 1;
43
+ }
51
44
  }
52
45
  }
53
46
 
@@ -83,17 +76,27 @@ export class TestExecutor {
83
76
  #skipTest(test: TestConfig, result: SuiteResult): void {
84
77
  // Mark test start
85
78
  this.#consumer.onEvent({ type: 'test', phase: 'before', test });
86
- result.skipped++;
87
- this.#consumer.onEvent({ type: 'test', phase: 'after', test: { ...test, assertions: [], duration: 0, durationTotal: 0, output: [], status: 'skipped' } });
79
+ result.skipped += 1;
80
+ result.total += 1;
81
+ this.#consumer.onEvent({
82
+ type: 'test',
83
+ phase: 'after',
84
+ test: {
85
+ ...test,
86
+ suiteLineStart: result.lineStart,
87
+ assertions: [], duration: 0, durationTotal: 0, output: [], status: 'skipped'
88
+ }
89
+ });
88
90
  }
89
91
 
90
92
  /**
91
93
  * An empty suite result based on a suite config
92
94
  */
93
- createSuiteResult(suite: SuiteConfig): SuiteResult {
95
+ createSuiteResult(suite: SuiteConfig, override?: Partial<SuiteResult>): SuiteResult {
94
96
  return {
95
97
  passed: 0,
96
98
  failed: 0,
99
+ errored: 0,
97
100
  skipped: 0,
98
101
  unknown: 0,
99
102
  total: 0,
@@ -104,14 +107,15 @@ export class TestExecutor {
104
107
  classId: suite.classId,
105
108
  sourceHash: suite.sourceHash,
106
109
  duration: 0,
107
- tests: {}
110
+ tests: {},
111
+ ...override
108
112
  };
109
113
  }
110
114
 
111
115
  /**
112
116
  * Execute the test, capture output, assertions and promises
113
117
  */
114
- async executeTest(test: TestConfig): Promise<TestResult> {
118
+ async executeTest(test: TestConfig, suite: SuiteConfig): Promise<TestResult> {
115
119
 
116
120
  // Mark test start
117
121
  this.#consumer.onEvent({ type: 'test', phase: 'before', test });
@@ -123,11 +127,12 @@ export class TestExecutor {
123
127
  description: test.description,
124
128
  classId: test.classId,
125
129
  tags: test.tags,
130
+ suiteLineStart: suite.lineStart,
126
131
  lineStart: test.lineStart,
127
132
  lineEnd: test.lineEnd,
128
133
  lineBodyStart: test.lineBodyStart,
129
134
  import: test.import,
130
- sourceImport: test.sourceImport,
135
+ declarationImport: test.declarationImport,
131
136
  sourceHash: test.sourceHash,
132
137
  status: 'unknown',
133
138
  assertions: [],
@@ -148,28 +153,15 @@ export class TestExecutor {
148
153
  const consoleCapture = new ConsoleCapture().start(); // Capture all output from transpiled code
149
154
 
150
155
  // Run method and get result
151
- let error = await this.#executeTestMethod(test);
152
-
153
- if (!error) {
154
- error = AssertCheck.checkError(test.shouldThrow, error); // Rewrite error
155
- } else {
156
- if (error instanceof AssertionError) {
157
- // Pass, do nothing
158
- } else if (error instanceof ExecutionError) { // Errors that are not expected
159
- AssertCheck.checkUnhandled(test, error);
160
- } else if (test.shouldThrow) {
161
- error = AssertCheck.checkError(test.shouldThrow, error); // Rewrite error
162
- } else if (error instanceof Error) {
163
- AssertCheck.checkUnhandled(test, error);
164
- }
165
- }
156
+ const error = await this.#executeTestMethod(test);
157
+ const [status, finalError] = AssertCheck.validateTestResultError(test, error);
166
158
 
167
159
  Object.assign(result, {
168
- status: error ? 'failed' : 'passed',
160
+ status,
169
161
  output: consoleCapture.end(),
170
162
  assertions: getAssertions(),
171
163
  duration: Date.now() - startTime,
172
- ...(error ? { error } : {})
164
+ ...(finalError ? { error: finalError } : {})
173
165
  });
174
166
 
175
167
  // Mark completion
@@ -185,7 +177,20 @@ export class TestExecutor {
185
177
 
186
178
  suite.instance = classConstruct(suite.class);
187
179
 
188
- if (!tests.length || await this.#shouldSkip(suite, suite.instance)) {
180
+ const shouldSkip = await this.#shouldSkip(suite, suite.instance);
181
+
182
+ if (shouldSkip) {
183
+ this.#consumer.onEvent({
184
+ phase: 'after', type: 'suite',
185
+ suite: this.createSuiteResult(suite, {
186
+ status: 'skipped',
187
+ skipped: tests.length,
188
+ total: tests.length
189
+ })
190
+ });
191
+ }
192
+
193
+ if (shouldSkip || !tests.length) {
189
194
  return;
190
195
  }
191
196
 
@@ -200,7 +205,7 @@ export class TestExecutor {
200
205
  // Mark suite start
201
206
  this.#consumer.onEvent({ phase: 'before', type: 'suite', suite: { ...suite, tests: testConfigs } });
202
207
 
203
- const manager = new TestPhaseManager(suite, result, event => this.#onSuiteFailure(event));
208
+ const manager = new TestPhaseManager(suite);
204
209
 
205
210
  const originalEnv = { ...process.env };
206
211
 
@@ -220,31 +225,37 @@ export class TestExecutor {
220
225
  process.env = { ...suiteEnv };
221
226
 
222
227
  const testStart = Date.now();
223
-
224
- // Handle BeforeEach
225
- await manager.startPhase('each');
226
-
227
- // Run test
228
- const testResult = await this.executeTest(test);
229
- result[testResult.status]++;
230
- result.tests[testResult.methodName] = testResult;
231
-
232
- // Handle after each
233
- await manager.endPhase('each');
234
- testResult.durationTotal = Date.now() - testStart;
228
+ try {
229
+
230
+ // Handle BeforeEach
231
+ await manager.startPhase('each');
232
+
233
+ // Run test
234
+ const testResult = await this.executeTest(test, suite);
235
+ result.tests[testResult.methodName] = testResult;
236
+ result[testResult.status]++;
237
+ result.total += 1;
238
+
239
+ // Handle after each
240
+ await manager.endPhase('each');
241
+ testResult.durationTotal = Date.now() - testStart;
242
+ } catch (testError) {
243
+ const errors = await manager.errorPhase('each', testError, suite, test);
244
+ this.#recordSuiteErrors(suite, result, errors);
245
+ }
235
246
  }
236
247
 
237
248
  // Handle after all
238
249
  await manager.endPhase('all');
239
- } catch (error) {
240
- await manager.onError(error);
250
+ } catch (suiteError) {
251
+ const errors = await manager.errorPhase('all', suiteError, suite);
252
+ this.#recordSuiteErrors(suite, result, errors);
241
253
  }
242
254
 
243
255
  // Restore env
244
256
  process.env = { ...originalEnv };
245
257
 
246
258
  result.duration = Date.now() - startTime;
247
- result.total = result.passed + result.failed + result.skipped;
248
259
  result.status = TestModelUtil.countsToTestStatus(result);
249
260
 
250
261
  // Mark suite complete
@@ -262,12 +273,17 @@ export class TestExecutor {
262
273
  throw error;
263
274
  }
264
275
  console.error(error);
265
- this.#onSuiteFailure(AssertUtil.gernerateImportFailure(run.import, error));
276
+
277
+ // Fire import failure as a test failure for each test in the suite
278
+ const { result, test, suite } = AssertUtil.gernerateImportFailure(run.import, error);
279
+ this.#consumer.onEvent({ type: 'suite', phase: 'before', suite });
280
+ this.#onSuiteTestError(result, test);
281
+ this.#consumer.onEvent({ type: 'suite', phase: 'after', suite });
266
282
  return;
267
283
  }
268
284
 
269
285
  // Initialize registry (after loading the above)
270
- await Registry.finalizeForIndex(SuiteRegistryIndex);
286
+ Registry.finalizeForIndex(SuiteRegistryIndex);
271
287
 
272
288
  // Convert inbound arguments to specific tests to run
273
289
  const suites = SuiteRegistryIndex.getSuiteTests(run);
@@ -1,14 +1,11 @@
1
- import { Env, TimeUtil } from '@travetto/runtime';
1
+ import { describeFunction, Env, TimeUtil } from '@travetto/runtime';
2
2
 
3
- import type { SuiteConfig, SuiteFailure, SuiteResult } from '../model/suite.ts';
3
+ import type { SuiteConfig, SuitePhase } from '../model/suite.ts';
4
4
  import { AssertUtil } from '../assert/util.ts';
5
5
  import { Barrier } from './barrier.ts';
6
+ import type { TestConfig, TestResult } from '../model/test.ts';
6
7
 
7
- class TestBreakout extends Error {
8
- source?: Error;
9
- }
10
-
11
- const TEST_PHASE_TIMEOUT = TimeUtil.fromValue(Env.TRV_TEST_PHASE_TIMEOUT.value) ?? 15000;
8
+ const TEST_PHASE_TIMEOUT = TimeUtil.duration(Env.TRV_TEST_PHASE_TIMEOUT.value ?? 15000, 'ms');
12
9
 
13
10
  /**
14
11
  * Test Phase Execution Manager.
@@ -18,34 +15,30 @@ const TEST_PHASE_TIMEOUT = TimeUtil.fromValue(Env.TRV_TEST_PHASE_TIMEOUT.value)
18
15
  export class TestPhaseManager {
19
16
  #progress: ('all' | 'each')[] = [];
20
17
  #suite: SuiteConfig;
21
- #result: SuiteResult;
22
- #onSuiteFailure: (fail: SuiteFailure) => void;
23
18
 
24
- constructor(suite: SuiteConfig, result: SuiteResult, onSuiteFailure: (fail: SuiteFailure) => void) {
19
+ constructor(suite: SuiteConfig) {
25
20
  this.#suite = suite;
26
- this.#result = result;
27
- this.#onSuiteFailure = onSuiteFailure;
28
21
  }
29
22
 
30
23
  /**
31
24
  * Run a distinct phase of the test execution
32
25
  */
33
- async runPhase(phase: 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach'): Promise<void> {
26
+ async runPhase(phase: SuitePhase): Promise<void> {
34
27
  let error: Error | undefined;
35
- for (const fn of this.#suite[phase]) {
28
+ for (const handler of this.#suite.phaseHandlers) {
29
+ if (!handler[phase]) {
30
+ continue;
31
+ }
36
32
 
37
33
  // Ensure all the criteria below are satisfied before moving forward
38
- error = await Barrier.awaitOperation(TEST_PHASE_TIMEOUT, async () => fn.call(this.#suite.instance));
34
+ error = await Barrier.awaitOperation(TEST_PHASE_TIMEOUT, async () => handler[phase]?.(this.#suite.instance));
39
35
 
40
36
  if (error) {
41
- break;
37
+ const toThrow = new Error(phase, { cause: error });
38
+ Object.assign(toThrow, { import: describeFunction(handler.constructor) ?? undefined });
39
+ throw toThrow;
42
40
  }
43
41
  }
44
- if (error) {
45
- const tbo = new TestBreakout(`[[${phase}]]`);
46
- tbo.source = error;
47
- throw tbo;
48
- }
49
42
  }
50
43
 
51
44
  /**
@@ -65,29 +58,21 @@ export class TestPhaseManager {
65
58
  }
66
59
 
67
60
  /**
68
- * On error, handle stubbing out error for the phases in progress
61
+ * Handles if an error occurs during a phase, ensuring that we attempt to end the phase and then return the appropriate test results for the failure
69
62
  */
70
- async onError(error: Error | unknown): Promise<void> {
71
- if (!(error instanceof Error)) {
72
- throw error;
73
- }
63
+ async errorPhase(phase: 'all' | 'each', error: unknown, suite: SuiteConfig, test?: TestConfig): Promise<TestResult[]> {
64
+ try { await this.endPhase(phase); } catch { }
65
+ if (!(error instanceof Error)) { throw error; }
74
66
 
75
- for (const ph of this.#progress) {
76
- try {
77
- await this.runPhase(ph === 'all' ? 'afterAll' : 'afterEach');
78
- } catch { /* Do nothing */ }
67
+ // Don't propagate our own errors
68
+ if (error.message === 'afterAll' || error.message === 'afterEach') {
69
+ return [];
79
70
  }
80
71
 
81
- this.#progress = [];
82
-
83
- const failure = AssertUtil.generateSuiteFailure(
84
- this.#suite,
85
- error instanceof TestBreakout ? error.message : 'all',
86
- error instanceof TestBreakout ? error.source! : error
87
- );
88
-
89
- this.#onSuiteFailure(failure);
90
- this.#result.tests[failure.testResult.methodName] = failure.testResult;
91
- this.#result.failed++;
72
+ if (test) {
73
+ return [AssertUtil.generateSuiteTestFailure({ suite, error, test })];
74
+ } else {
75
+ return AssertUtil.generateSuiteTestFailures(suite, error);
76
+ }
92
77
  }
93
78
  }
@@ -65,7 +65,7 @@ export class RunUtil {
65
65
  }
66
66
  }
67
67
  } else {
68
- for await (const match of all) {
68
+ for (const match of all) {
69
69
  if (await this.isTestFile(match.sourceFile)) {
70
70
  yield match.import;
71
71
  }
@@ -97,13 +97,12 @@ export class RunUtil {
97
97
  ) :
98
98
  ((): boolean => true);
99
99
 
100
- const parsed: TestConfig[] = JSONUtil.parseSafe(digestProcess.stdout);
100
+ const parsed: TestConfig[] = JSONUtil.fromUTF8(digestProcess.stdout);
101
101
 
102
102
  const events = parsed.filter(testFilter).reduce((runs, test) => {
103
- if (!runs.has(test.classId)) {
104
- runs.set(test.classId, { import: test.import, classId: test.classId, methodNames: [], runId: Util.uuid(), metadata });
105
- }
106
- runs.get(test.classId)!.methodNames!.push(test.methodName);
103
+ runs.getOrInsert(test.classId,
104
+ { import: test.import, classId: test.classId, methodNames: [], runId: Util.uuid(), metadata }
105
+ ).methodNames!.push(test.methodName);
107
106
  return runs;
108
107
  }, new Map<string, TestRun>());
109
108
 
@@ -229,7 +228,7 @@ export class RunUtil {
229
228
  run => buildStandardTestManager(consumer, run),
230
229
  runs,
231
230
  {
232
- idleTimeoutMillis: TimeUtil.asMillis(10, 's'),
231
+ idleTimeoutMillis: TimeUtil.duration('10s', 'ms'),
233
232
  min: 1,
234
233
  max: consumerConfig.concurrency
235
234
  }