@travetto/test 7.0.0-rc.2 → 7.0.0-rc.3

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
@@ -231,8 +231,7 @@ Usage: test [options] [first:string] [globs...:string]
231
231
  Options:
232
232
  -f, --format <string> Output format for test results (default: "tap")
233
233
  -c, --concurrency <number> Number of tests to run concurrently (default: 9)
234
- -m, --mode <single|standard> Test run mode (default: "standard")
235
- -t, --tags <string> Tags to target or exclude
234
+ -t, --tags <string> Tags to target or exclude when using globs
236
235
  -o, --format-options <string> Format options
237
236
  -h, --help display help for command
238
237
  ```
package/__index__.ts CHANGED
@@ -4,6 +4,7 @@ export * from './src/decorator/test.ts';
4
4
  export * from './src/model/suite.ts';
5
5
  export * from './src/model/test.ts';
6
6
  export * from './src/model/event.ts';
7
+ export * from './src/model/util.ts';
7
8
  export * from './src/registry/registry-index.ts';
8
9
  export * from './src/registry/registry-adapter.ts';
9
10
  export * from './src/fixture.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/test",
3
- "version": "7.0.0-rc.2",
3
+ "version": "7.0.0-rc.3",
4
4
  "description": "Declarative test framework",
5
5
  "keywords": [
6
6
  "unit-testing",
@@ -27,15 +27,15 @@
27
27
  "directory": "module/test"
28
28
  },
29
29
  "dependencies": {
30
- "@travetto/registry": "^7.0.0-rc.2",
31
- "@travetto/runtime": "^7.0.0-rc.2",
32
- "@travetto/terminal": "^7.0.0-rc.2",
33
- "@travetto/worker": "^7.0.0-rc.2",
30
+ "@travetto/registry": "^7.0.0-rc.3",
31
+ "@travetto/runtime": "^7.0.0-rc.3",
32
+ "@travetto/terminal": "^7.0.0-rc.3",
33
+ "@travetto/worker": "^7.0.0-rc.3",
34
34
  "yaml": "^2.8.1"
35
35
  },
36
36
  "peerDependencies": {
37
- "@travetto/cli": "^7.0.0-rc.2",
38
- "@travetto/transformer": "^7.0.0-rc.2"
37
+ "@travetto/cli": "^7.0.0-rc.3",
38
+ "@travetto/transformer": "^7.0.0-rc.3"
39
39
  },
40
40
  "peerDependenciesMeta": {
41
41
  "@travetto/transformer": {
@@ -96,7 +96,7 @@ export class AssertUtil {
96
96
 
97
97
  const msg = error.message.split(/\n/)[0];
98
98
 
99
- const core = { import: imp, classId: suite.classId, methodName };
99
+ const core = { import: imp, classId: suite.classId, methodName, sourceHash: suite.sourceHash };
100
100
  const coreAll = { ...core, description: msg, lineStart: line, lineEnd: line, lineBodyStart: line };
101
101
 
102
102
  const assert: Assertion = {
@@ -0,0 +1,66 @@
1
+ import { AppError, hasToJSON, JSONUtil } from '@travetto/runtime';
2
+
3
+ /**
4
+ * Tools for communication serialization/deserialization especially with errors
5
+ */
6
+ export class CommunicationUtil {
7
+
8
+ /**
9
+ * Serialize to JSON
10
+ */
11
+ static serialize<T>(out: T): string {
12
+ return JSON.stringify(out, function (key, value) {
13
+ const objectValue = this[key];
14
+ if (objectValue && objectValue instanceof Error) {
15
+ return {
16
+ $: true,
17
+ ...hasToJSON(objectValue) ? objectValue.toJSON() : objectValue,
18
+ name: objectValue.name,
19
+ message: objectValue.message,
20
+ stack: objectValue.stack,
21
+ };
22
+ } else if (typeof value === 'bigint') {
23
+ return `${value.toString()}$n`;
24
+ } else {
25
+ return value;
26
+ }
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Serialize to a standard object, instead of a string
32
+ */
33
+ static serializeToObject<R = Record<string, unknown>, T = unknown>(out: T): R {
34
+ return JSONUtil.parseSafe(this.serialize(out));
35
+ }
36
+
37
+ /**
38
+ * Deserialize from JSON
39
+ */
40
+ static deserialize<T = unknown>(input: string): T {
41
+ return JSONUtil.parseSafe(input, function (key, value) {
42
+ if (value && typeof value === 'object' && '$' in value) {
43
+ const error = AppError.fromJSON(value) ?? new Error();
44
+ if (!(error instanceof AppError)) {
45
+ const { $: _, ...rest } = value;
46
+ Object.assign(error, rest);
47
+ }
48
+ error.message = value.message;
49
+ error.stack = value.stack;
50
+ error.name = value.name;
51
+ return error;
52
+ } else if (typeof value === 'string' && /^\d+[$]n$/.test(value)) {
53
+ return BigInt(value.split('$')[0]);
54
+ } else {
55
+ return value;
56
+ }
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Deserialize from a standard object, instead of a string
62
+ */
63
+ static deserializeFromObject<R = unknown, T = R>(input: T): R {
64
+ return this.deserialize<R>(JSON.stringify(input));
65
+ }
66
+ }
@@ -2,7 +2,7 @@ import { RuntimeIndex, type Class } from '@travetto/runtime';
2
2
  import { Registry, RegistryIndex, RegistryIndexStore } from '@travetto/registry';
3
3
 
4
4
  import type { TestConsumerShape } from './types.ts';
5
- import type { RunState } from '../execute/types.ts';
5
+ import type { TestConsumerConfig } from '../execute/types.ts';
6
6
  import { TestConsumerRegistryAdapter } from './registry-adapter.ts';
7
7
 
8
8
  /**
@@ -25,15 +25,17 @@ export class TestConsumerRegistryIndex implements RegistryIndex {
25
25
 
26
26
  /**
27
27
  * Get a consumer instance that supports summarization
28
- * @param consumer The consumer identifier or the actual consumer
28
+ * @param consumerConfig The consumer configuration
29
29
  */
30
- static getInstance(state: Pick<RunState, 'consumer' | 'consumerOptions'>): Promise<TestConsumerShape> {
31
- return this.#instance.getInstance(state);
30
+ static getInstance(consumerConfig: TestConsumerConfig): Promise<TestConsumerShape> {
31
+ return this.#instance.getInstance(consumerConfig);
32
32
  }
33
33
 
34
34
  #initialized: Promise<void>;
35
35
  store = new RegistryIndexStore(TestConsumerRegistryAdapter);
36
36
 
37
+ /** @private */ constructor(source: unknown) { Registry.validateConstructor(source); }
38
+
37
39
  /**
38
40
  * Manual initialization when running outside of the bootstrap process
39
41
  */
@@ -50,8 +52,6 @@ export class TestConsumerRegistryIndex implements RegistryIndex {
50
52
  }
51
53
  }
52
54
 
53
- process(): void { }
54
-
55
55
  /**
56
56
  * Get types
57
57
  */
@@ -69,7 +69,7 @@ export class TestConsumerRegistryIndex implements RegistryIndex {
69
69
  * Get a consumer instance that supports summarization
70
70
  * @param consumer The consumer identifier or the actual consumer
71
71
  */
72
- async getInstance(state: Pick<RunState, 'consumer' | 'consumerOptions'>): Promise<TestConsumerShape> {
72
+ async getInstance(state: Pick<TestConsumerConfig, 'consumer' | 'consumerOptions'>): Promise<TestConsumerShape> {
73
73
  await (this.#initialized ??= this.#init());
74
74
  for (const cls of this.store.getClasses()) {
75
75
  const adapter = this.store.get(cls);
@@ -1,13 +1,18 @@
1
- import { existsSync } from 'node:fs';
2
-
3
- import { type Class, RuntimeIndex } from '@travetto/runtime';
4
-
5
1
  import type { TestConsumerShape } from '../types.ts';
6
- import type { TestEvent } from '../../model/event.ts';
7
- import type { TestResult } from '../../model/test.ts';
8
- import type { SuiteResult } from '../../model/suite.ts';
2
+ import type { TestEvent, TestRemoveEvent } from '../../model/event.ts';
3
+ import type { TestConfig, TestDiffSource, TestResult } from '../../model/test.ts';
4
+ import type { Counts, SuiteConfig, SuiteResult } from '../../model/suite.ts';
9
5
  import { DelegatingConsumer } from './delegating.ts';
10
- import { SuiteRegistryIndex } from '../../registry/registry-index.ts';
6
+ import { SuiteCore } from '../../model/common.ts';
7
+ import { TestModelUtil } from '../../model/util.ts';
8
+
9
+ type ClassId = string;
10
+ type ImportName = string;
11
+
12
+ type CumulativeTestResult = Pick<TestResult, 'sourceHash' | 'status' | 'duration'>;
13
+ type CumulativeSuiteResult = Pick<SuiteCore, 'import' | 'classId' | 'sourceHash'> & {
14
+ tests: Record<string, CumulativeTestResult>;
15
+ };
11
16
 
12
17
  /**
13
18
  * Cumulative Summary consumer
@@ -16,77 +21,101 @@ export class CumulativeSummaryConsumer extends DelegatingConsumer {
16
21
  /**
17
22
  * Total state of all tests run so far
18
23
  */
19
- #state: Record<string, Record<string, TestResult['status']>> = {};
24
+ #state: Record<ImportName, Record<ClassId, CumulativeSuiteResult>> = {};
20
25
 
21
26
  constructor(target: TestConsumerShape) {
22
27
  super([target]);
23
28
  }
24
29
 
25
- /**
26
- * Summarize a given test suite using the new result and the historical
27
- * state
28
- */
29
- summarizeSuite(test: TestResult): SuiteResult {
30
- // Was only loading to verify existence (TODO: double-check)
31
- if (existsSync(RuntimeIndex.getFromImport(test.import)!.sourceFile)) {
32
- (this.#state[test.classId] ??= {})[test.methodName] = test.status;
33
- const SuiteCls = SuiteRegistryIndex.getClasses().find(cls => cls.Ⲑid === test.classId);
34
- return SuiteCls ? this.computeTotal(SuiteCls) : this.removeClass(test.classId);
35
- } else {
36
- return this.removeClass(test.classId);
37
- }
30
+ getSuite(core: Pick<SuiteCore, 'import' | 'classId'>): CumulativeSuiteResult {
31
+ return this.#state[core.import]?.[core.classId];
38
32
  }
39
33
 
40
- /**
41
- * Remove a class
42
- */
43
- removeClass(clsId: string): SuiteResult {
44
- this.#state[clsId] = {};
45
- return {
46
- classId: clsId, passed: 0, failed: 0, skipped: 0, total: 0, tests: [], duration: 0, import: '', lineStart: 0, lineEnd: 0
47
- };
34
+ getOrCreateSuite({ tests: _, ...core }: SuiteConfig | SuiteResult): CumulativeSuiteResult {
35
+ return (this.#state[core.import] ??= {})[core.classId] ??= { ...core, tests: {} };
48
36
  }
49
37
 
50
- /**
51
- * Compute totals
52
- */
53
- computeTotal(cls: Class): SuiteResult {
54
- const suite = SuiteRegistryIndex.getConfig(cls);
55
- const total = Object.values(suite.tests).reduce((map, config) => {
56
- const status = this.#state[config.classId][config.methodName] ?? 'unknown';
57
- map[status] += 1;
58
- return map;
59
- }, { skipped: 0, passed: 0, failed: 0, unknown: 0 });
38
+ onTestBefore(config: TestConfig): TestConfig {
39
+ const suite = this.getSuite(config);
40
+ suite.tests[config.methodName] = { sourceHash: config.sourceHash, status: 'unknown', duration: 0 };
41
+ return config;
42
+ }
43
+
44
+ onTestAfter(result: TestResult): TestResult {
45
+ const test = this.getSuite(result).tests[result.methodName];
46
+ Object.assign(test, { status: result.status, duration: result.duration });
47
+ return result;
48
+ }
60
49
 
61
- return {
62
- classId: suite.classId,
63
- passed: total.passed,
64
- failed: total.failed,
65
- skipped: total.skipped,
66
- import: suite.import,
67
- lineStart: suite.lineStart,
68
- lineEnd: suite.lineEnd,
69
- total: total.failed + total.passed,
70
- tests: [],
71
- duration: 0
72
- };
50
+ onSuiteBefore(config: SuiteConfig): SuiteConfig {
51
+ const suite = this.getOrCreateSuite(config);
52
+ suite.sourceHash = config.sourceHash;
53
+ return config;
54
+ }
55
+
56
+ onSuiteAfter(result: SuiteResult): SuiteResult {
57
+ // Reset counts
58
+ const suite = this.getSuite(result);
59
+ const totals: Counts & { duration: number } = { passed: 0, failed: 0, skipped: 0, unknown: 0, total: 0, duration: 0 };
60
+ for (const test of Object.values(suite.tests)) {
61
+ totals[test.status] += 1;
62
+ totals.total += 1;
63
+ totals.duration += test.duration ?? 0;
64
+ }
65
+ return { ...result, ...totals, status: TestModelUtil.countsToTestStatus(totals) };
66
+ }
67
+
68
+ removeTest(importName: string, classId?: string, methodName?: string): void {
69
+ if (methodName && classId && importName) {
70
+ delete this.getSuite({ import: importName, classId }).tests[methodName];
71
+ } else if (classId && importName) {
72
+ delete this.#state[importName][classId];
73
+ } else if (importName) {
74
+ delete this.#state[importName];
75
+ }
76
+ }
77
+
78
+ transformRemove(event: TestRemoveEvent): TestRemoveEvent {
79
+ this.removeTest(event.import, event.classId, event.methodName);
80
+ return event;
73
81
  }
74
82
 
75
83
  /**
76
- * Listen for event, process the full event, and if the event is an after test,
77
- * send a full suite summary
84
+ * Handle cumulative events, and emit a summarized summary
78
85
  */
79
- onEventDone(event: TestEvent): void {
86
+ transform(event: TestEvent): TestEvent | undefined {
80
87
  try {
81
- if (event.type === 'test' && event.phase === 'after') {
82
- this.onEvent({
83
- type: 'suite',
84
- phase: 'after',
85
- suite: this.summarizeSuite(event.test),
86
- });
88
+ if (event.type === 'suite') {
89
+ if (event.phase === 'before') {
90
+ return { ...event, suite: this.onSuiteBefore(event.suite) };
91
+ } else if (event.phase === 'after') {
92
+ return { ...event, suite: this.onSuiteAfter(event.suite) };
93
+ }
94
+ } else if (event.type === 'test') {
95
+ if (event.phase === 'before') {
96
+ return { ...event, test: this.onTestBefore(event.test) };
97
+ } else if (event.phase === 'after') {
98
+ return { ...event, test: this.onTestAfter(event.test) };
99
+ }
87
100
  }
101
+ return event;
88
102
  } catch (error) {
89
103
  console.warn('Summarization Error', { error });
90
104
  }
91
105
  }
106
+
107
+ /**
108
+ * Produce diff source for import file
109
+ */
110
+ produceDiffSource(importName: string): TestDiffSource {
111
+ const output: TestDiffSource = {};
112
+ for (const [clsId, suite] of Object.entries(this.#state[importName] || {})) {
113
+ const methods: TestDiffSource[string]['methods'] = {};
114
+ for (const [methodName, test] of Object.entries(suite.tests)) {
115
+ methods[methodName] = test.sourceHash!;
116
+ }
117
+ output[clsId] = { sourceHash: suite.sourceHash!, methods };
118
+ }
119
+ return output;
120
+ }
92
121
  }
@@ -1,13 +1,11 @@
1
1
  import type { SuitesSummary, TestConsumerShape, TestRunState } from '../types.ts';
2
- import type { TestEvent } from '../../model/event.ts';
2
+ import type { TestEvent, TestRemoveEvent } from '../../model/event.ts';
3
3
 
4
4
  /**
5
5
  * Delegating event consumer
6
6
  */
7
7
  export abstract class DelegatingConsumer implements TestConsumerShape {
8
8
  #consumers: TestConsumerShape[];
9
- #transformer?: (event: TestEvent) => typeof event;
10
- #filter?: (event: TestEvent) => boolean;
11
9
 
12
10
  constructor(consumers: TestConsumerShape[]) {
13
11
  this.#consumers = consumers;
@@ -16,34 +14,38 @@ export abstract class DelegatingConsumer implements TestConsumerShape {
16
14
  }
17
15
  }
18
16
 
19
- withTransformer(transformer: (event: TestEvent) => typeof event): this {
20
- this.#transformer = transformer;
21
- return this;
22
- }
23
-
24
- withFilter(filter: (event: TestEvent) => boolean): this {
25
- this.#filter = filter;
26
- return this;
27
- }
28
-
29
17
  async onStart(state: TestRunState): Promise<void> {
30
18
  for (const consumer of this.#consumers) {
31
19
  await consumer.onStart?.(state);
32
20
  }
33
21
  }
34
22
 
35
- onEvent(event: TestEvent): void {
36
- if (this.#transformer) {
37
- event = this.#transformer(event);
23
+ onRemoveEvent(event: TestRemoveEvent): void {
24
+ let result = event;
25
+ if (this.transformRemove) {
26
+ result = this.transformRemove(event) ?? event;
38
27
  }
39
- if (this.#filter?.(event) === false) {
40
- return;
28
+ if (result) {
29
+ for (const consumer of this.#consumers) {
30
+ consumer.onRemoveEvent?.(result);
31
+ }
41
32
  }
33
+ }
34
+
35
+ delegateEvent(event: TestEvent): void {
42
36
  for (const consumer of this.#consumers) {
43
37
  consumer.onEvent(event);
44
38
  }
39
+ }
45
40
 
46
- this.onEventDone?.(event);
41
+ onEvent(event: TestEvent): void {
42
+ let result = event;
43
+ if (this.transform) {
44
+ result = this.transform(event) ?? event;
45
+ }
46
+ if (result) {
47
+ this.delegateEvent(result);
48
+ }
47
49
  }
48
50
 
49
51
  async summarize(summary?: SuitesSummary): Promise<void> {
@@ -54,5 +56,6 @@ export abstract class DelegatingConsumer implements TestConsumerShape {
54
56
  }
55
57
  }
56
58
 
57
- onEventDone?(event: TestEvent): void;
59
+ transform?(event: TestEvent): TestEvent | undefined;
60
+ transformRemove?(event: TestRemoveEvent): TestRemoveEvent | undefined;
58
61
  }
@@ -1,10 +1,9 @@
1
1
  import { Writable } from 'node:stream';
2
2
 
3
- import { Util } from '@travetto/runtime';
4
-
5
- import type { TestEvent } from '../../model/event.ts';
3
+ import type { TestEvent, TestRemoveEvent } from '../../model/event.ts';
6
4
  import type { TestConsumerShape } from '../types.ts';
7
5
  import { TestConsumer } from '../decorator.ts';
6
+ import { CommunicationUtil } from '../../communication.ts';
8
7
 
9
8
  /**
10
9
  * Streams all test events a JSON payload, in an nd-json format
@@ -17,7 +16,15 @@ export class EventStreamer implements TestConsumerShape {
17
16
  this.#stream = stream;
18
17
  }
19
18
 
19
+ sendPayload(payload: unknown): void {
20
+ this.#stream.write(`${CommunicationUtil.serialize(payload)}\n`);
21
+ }
22
+
20
23
  onEvent(event: TestEvent): void {
21
- this.#stream.write(`${Util.serializeToJSON(event)}\n`);
24
+ this.sendPayload(event);
25
+ }
26
+
27
+ onRemoveEvent(event: TestRemoveEvent): void {
28
+ this.sendPayload(event);
22
29
  }
23
30
  }
@@ -1,16 +1,25 @@
1
1
  import { IpcChannel } from '@travetto/worker';
2
- import { Util } from '@travetto/runtime';
3
2
 
4
- import type { TestEvent } from '../../model/event.ts';
3
+ import type { TestEvent, TestRemoveEvent } from '../../model/event.ts';
5
4
  import type { TestConsumerShape } from '../types.ts';
6
5
  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
10
10
  */
11
11
  @TestConsumer()
12
12
  export class ExecutionEmitter extends IpcChannel<TestEvent> implements TestConsumerShape {
13
+
14
+ sendPayload(payload: unknown & { type: string }): void {
15
+ this.send(payload.type, CommunicationUtil.serializeToObject(payload));
16
+ }
17
+
13
18
  onEvent(event: TestEvent): void {
14
- this.send(event.type, JSON.parse(Util.serializeToJSON(event)));
19
+ this.sendPayload(event);
20
+ }
21
+
22
+ onRemoveEvent(event: TestRemoveEvent): void {
23
+ this.sendPayload(event);
15
24
  }
16
25
  }
@@ -15,8 +15,9 @@ export class RunnableTestConsumer extends DelegatingConsumer {
15
15
  this.#results = consumers.find(consumer => !!consumer.onSummary) ? new TestResultsSummarizer() : undefined;
16
16
  }
17
17
 
18
- onEventDone(event: TestEvent): void {
18
+ transform(event: TestEvent): TestEvent | undefined {
19
19
  this.#results?.onEvent(event);
20
+ return event;
20
21
  }
21
22
 
22
23
  async summarizeAsBoolean(): Promise<boolean> {
@@ -11,6 +11,7 @@ export class TestResultsSummarizer implements TestConsumerShape {
11
11
  passed: 0,
12
12
  failed: 0,
13
13
  skipped: 0,
14
+ unknown: 0,
14
15
  total: 0,
15
16
  duration: 0,
16
17
  suites: [],
@@ -21,16 +22,17 @@ export class TestResultsSummarizer implements TestConsumerShape {
21
22
  this.summary.suites.push(result);
22
23
  this.summary.failed += result.failed;
23
24
  this.summary.passed += result.passed;
25
+ this.summary.unknown += result.unknown;
24
26
  this.summary.skipped += result.skipped;
25
27
  this.summary.duration += result.duration;
26
- this.summary.total += (result.failed + result.passed + result.skipped);
28
+ this.summary.total += result.total;
27
29
  }
28
30
 
29
31
  /**
30
32
  * Merge all test results into a single Suite Result
31
33
  */
32
34
  onEvent(event: TestEvent): void {
33
- if (event.phase === 'after' && event.type === 'suite') {
35
+ if (event.type === 'suite' && event.phase === 'after') {
34
36
  this.#merge(event.suite);
35
37
  }
36
38
  }
@@ -99,16 +99,18 @@ export class TapSummaryEmitter implements TestConsumerShape {
99
99
  files.set(file, { key: file, duration: 0, tests: 0 });
100
100
  }
101
101
 
102
+ const testCount = Object.keys(event.suite.tests).length;
103
+
102
104
  suites.set(event.suite.classId, {
103
105
  key: event.suite.classId,
104
106
  duration: event.suite.duration,
105
- tests: event.suite.tests.length
107
+ tests: testCount
106
108
  });
107
109
 
108
110
  files.get(file)!.duration += event.suite.duration;
109
- files.get(file)!.tests += event.suite.tests.length;
111
+ files.get(file)!.tests += testCount;
110
112
  modules.get(module)!.duration += event.suite.duration;
111
- modules.get(module)!.tests += event.suite.tests.length;
113
+ modules.get(module)!.tests += testCount;
112
114
  }
113
115
  }
114
116
 
@@ -1,7 +1,7 @@
1
1
  import { Class } from '@travetto/runtime';
2
2
 
3
- import { TestEvent } from '../model/event.ts';
4
- import { Counts, SuiteResult } from '../model/suite.ts';
3
+ import type { TestEvent, TestRemoveEvent } from '../model/event.ts';
4
+ import type { Counts, SuiteResult } from '../model/suite.ts';
5
5
 
6
6
  /**
7
7
  * All suite results
@@ -52,6 +52,10 @@ export interface TestConsumerShape {
52
52
  * Handle individual tests events
53
53
  */
54
54
  onEvent(event: TestEvent): void;
55
+ /**
56
+ * Handle when a remove event is fired
57
+ */
58
+ onRemoveEvent?(event: TestRemoveEvent): void;
55
59
  /**
56
60
  * Summarize all results
57
61
  */