autotel-vitest 0.1.0

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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reporter.ts"],"names":[],"mappings":"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","file":"reporter.js","sourcesContent":["/**\n * Optional Vitest reporter that creates OTel spans for each test and suite.\n * Runs in the runner process; ensure autotel.init() is called in globalSetup so spans are exported.\n *\n * Use when you want test/suite timing and hierarchy in OTLP from the runner side.\n * For \"test → instrumented code\" in one trace (worker side), use the test fixture.\n *\n * @example\n * // vitest.config.ts\n * import { defineConfig } from 'vitest/config';\n *\n * export default defineConfig({\n * test: {\n * reporters: ['default', 'autotel-vitest/reporter'],\n * globalSetup: './globalSetup.ts', // must call init()\n * },\n * });\n */\n\nimport type { Reporter, TestCase, TestModule, TestSuite } from 'vitest/node';\nimport { getTracer, SpanStatusCode } from 'autotel';\n\nconst TRACER_NAME = 'vitest-reporter';\nconst TRACER_VERSION = '0.1.0';\n\ntype SpanEntry = {\n span: ReturnType<ReturnType<typeof getTracer>['startSpan']>;\n moduleId: string;\n};\n\n/** Convert a vitest TestError-like object to a standard Error for OTel. */\nfunction toError(testError: { message?: string; stack?: string }): Error {\n const err = new Error(testError.message ?? 'Unknown error');\n if (testError.stack) err.stack = testError.stack;\n return err;\n}\n\n/**\n * Vitest Reporter that creates one span per test and one per suite (as parents).\n * Requires autotel.init() in globalSetup so spans are exported.\n */\nclass OtelReporter implements Reporter {\n private testSpans = new Map<string, SpanEntry>();\n private suiteSpans = new Map<string, SpanEntry>();\n\n onTestCaseReady(testCase: TestCase): void {\n const tracer = getTracer(TRACER_NAME, TRACER_VERSION);\n const moduleId = testCase.module.moduleId ?? '';\n const span = tracer.startSpan(`test:${testCase.name}`, {\n attributes: {\n 'test.name': testCase.name,\n 'test.fullName': testCase.fullName,\n 'test.file': moduleId,\n },\n });\n this.testSpans.set(testCase.id, { span, moduleId });\n }\n\n onTestCaseResult(testCase: TestCase): void {\n const entry = this.testSpans.get(testCase.id);\n if (!entry) return;\n\n const result = testCase.result();\n if (result.state === 'failed') {\n entry.span.setStatus({ code: SpanStatusCode.ERROR });\n if (result.errors && result.errors.length > 0) {\n for (const error of result.errors) {\n entry.span.recordException(toError(error));\n }\n }\n }\n entry.span.end();\n this.testSpans.delete(testCase.id);\n }\n\n onTestSuiteReady(testSuite: TestSuite): void {\n const tracer = getTracer(TRACER_NAME, TRACER_VERSION);\n const moduleId = testSuite.module.moduleId ?? '';\n const span = tracer.startSpan(`suite:${testSuite.name}`, {\n attributes: {\n 'suite.name': testSuite.name,\n 'suite.file': moduleId,\n },\n });\n this.suiteSpans.set(testSuite.id, { span, moduleId });\n }\n\n onTestSuiteResult(testSuite: TestSuite): void {\n const entry = this.suiteSpans.get(testSuite.id);\n if (!entry) return;\n\n const state = testSuite.state();\n if (state === 'failed') {\n entry.span.setStatus({ code: SpanStatusCode.ERROR });\n }\n entry.span.end();\n this.suiteSpans.delete(testSuite.id);\n }\n\n onTestModuleEnd(testModule: TestModule): void {\n const moduleId = testModule.moduleId;\n // Clean up any remaining spans for this specific module\n for (const [key, entry] of this.testSpans) {\n if (entry.moduleId === moduleId) {\n entry.span.end();\n this.testSpans.delete(key);\n }\n }\n for (const [key, entry] of this.suiteSpans) {\n if (entry.moduleId === moduleId) {\n entry.span.end();\n this.suiteSpans.delete(key);\n }\n }\n }\n}\n\nexport { OtelReporter };\nexport default OtelReporter;\n"]}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "autotel-vitest",
3
+ "version": "0.1.0",
4
+ "description": "Vitest fixture for OpenTelemetry: one span per test so all instrumented code is filterable by test run",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./reporter": {
16
+ "types": "./dist/reporter.d.ts",
17
+ "import": "./dist/reporter.js",
18
+ "require": "./dist/reporter.cjs"
19
+ }
20
+ },
21
+ "files": ["dist", "src", "README.md"],
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "dev": "tsup --watch",
25
+ "type-check": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "test:compat": "vitest run --config integration/vitest.config.ts",
28
+ "test:watch": "vitest",
29
+ "lint": "eslint src",
30
+ "clean": "rimraf dist"
31
+ },
32
+ "dependencies": {
33
+ "autotel": "workspace:*"
34
+ },
35
+ "peerDependencies": {
36
+ "vitest": ">=2.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "vitest": {
40
+ "optional": false
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "tsup": "^8.5.1",
45
+ "typescript": "^5.9.3",
46
+ "vitest": "^4.0.18"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/jagreehal/autotel",
51
+ "directory": "packages/autotel-vitest"
52
+ }
53
+ }
@@ -0,0 +1,230 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ type FixtureFn = (
4
+ args: { task: { name: string; file?: { name: string }; suite?: { name: string }; meta: Record<string, unknown> } },
5
+ use: (span: unknown) => Promise<void>,
6
+ ) => Promise<void>;
7
+
8
+ type Fixtures = {
9
+ _otelTestSpan?: FixtureFn | [FixtureFn, { auto: true }];
10
+ };
11
+
12
+ const state: { fixtures?: Fixtures } = {};
13
+ let spanIdCounter = 0;
14
+ const createdSpans: Array<{
15
+ end: ReturnType<typeof vi.fn>;
16
+ recordException: ReturnType<typeof vi.fn>;
17
+ setStatus: ReturnType<typeof vi.fn>;
18
+ spanContext: () => { traceId: string; spanId: string };
19
+ }> = [];
20
+
21
+ vi.mock('vitest', async () => {
22
+ const actual = await vi.importActual<typeof import('vitest')>('vitest');
23
+ return {
24
+ ...actual,
25
+ test: {
26
+ ...actual.test,
27
+ extend: (fixtures: Fixtures) => {
28
+ state.fixtures = fixtures;
29
+ return fixtures;
30
+ },
31
+ },
32
+ };
33
+ });
34
+
35
+ vi.mock('autotel', () => ({
36
+ SpanStatusCode: { UNSET: 0, OK: 1, ERROR: 2 },
37
+ context: {
38
+ active: () => ({}),
39
+ with: (_ctx: unknown, fn: () => Promise<unknown>) => fn(),
40
+ },
41
+ getTracer: () => ({
42
+ startSpan: (_name: string, _options?: unknown) => {
43
+ const id = String(++spanIdCounter);
44
+ const span = {
45
+ end: vi.fn(),
46
+ recordException: vi.fn(),
47
+ setStatus: vi.fn(),
48
+ spanContext: () => ({ traceId: `trace-${id}`, spanId: `span-${id}` }),
49
+ };
50
+ createdSpans.push(span);
51
+ return span;
52
+ },
53
+ }),
54
+ otelTrace: {
55
+ setSpan: () => ({}),
56
+ },
57
+ getAutotelTracerProvider: vi.fn(() => ({})),
58
+ getTraceContext: vi.fn(() => null),
59
+ resolveTraceUrl: vi.fn(() => undefined),
60
+ isTracing: vi.fn(() => false),
61
+ enrichWithTraceContext: vi.fn((obj: unknown) => obj),
62
+ }));
63
+
64
+ let mockDrainResult: unknown[] = [];
65
+ vi.mock('autotel/test-span-collector', () => ({
66
+ TestSpanCollector: class {
67
+ export = vi.fn();
68
+ drainTrace = vi.fn(() => mockDrainResult);
69
+ shutdown = vi.fn(() => Promise.resolve());
70
+ forceFlush = vi.fn(() => Promise.resolve());
71
+ },
72
+ }));
73
+
74
+ vi.mock('autotel/processors', () => ({
75
+ SimpleSpanProcessor: class {
76
+ constructor() {}
77
+ },
78
+ }));
79
+
80
+ describe('autotel-vitest fixture', () => {
81
+ afterEach(() => {
82
+ state.fixtures = undefined;
83
+ createdSpans.length = 0;
84
+ spanIdCounter = 0;
85
+ mockDrainResult = [];
86
+ vi.resetModules();
87
+ });
88
+
89
+ it('creates a span for each test via the _otelTestSpan fixture', async () => {
90
+ await import('./index');
91
+
92
+ const spanFixture = state.fixtures?._otelTestSpan;
93
+ const spanFixtureFn = Array.isArray(spanFixture) ? spanFixture[0] : spanFixture;
94
+ expect(spanFixtureFn).toBeTypeOf('function');
95
+
96
+ await spanFixtureFn?.(
97
+ {
98
+ task: {
99
+ name: 'creates user',
100
+ file: { name: 'user.test.ts' },
101
+ suite: { name: 'UserService' },
102
+ meta: {},
103
+ },
104
+ },
105
+ async () => {},
106
+ );
107
+
108
+ expect(createdSpans).toHaveLength(1);
109
+ expect(createdSpans[0].end).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it('ends the span after the test completes', async () => {
113
+ await import('./index');
114
+
115
+ const spanFixture = state.fixtures?._otelTestSpan;
116
+ const spanFixtureFn = Array.isArray(spanFixture) ? spanFixture[0] : spanFixture;
117
+
118
+ let spanDuringTest: unknown;
119
+
120
+ await spanFixtureFn?.(
121
+ {
122
+ task: {
123
+ name: 'test end timing',
124
+ file: { name: 'timing.test.ts' },
125
+ suite: { name: '' },
126
+ meta: {},
127
+ },
128
+ },
129
+ async (span) => {
130
+ spanDuringTest = span;
131
+ // Span should not yet be ended during the test
132
+ expect(createdSpans[0].end).not.toHaveBeenCalled();
133
+ },
134
+ );
135
+
136
+ // Span should be ended after use() resolves
137
+ expect(spanDuringTest).toBeDefined();
138
+ expect(createdSpans[0].end).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ it('sets error status when the test throws', async () => {
142
+ await import('./index');
143
+
144
+ const spanFixture = state.fixtures?._otelTestSpan;
145
+ const spanFixtureFn = Array.isArray(spanFixture) ? spanFixture[0] : spanFixture;
146
+
147
+ const err = new Error('test failure');
148
+
149
+ await expect(
150
+ spanFixtureFn?.(
151
+ {
152
+ task: {
153
+ name: 'failing test',
154
+ file: { name: 'fail.test.ts' },
155
+ suite: { name: '' },
156
+ meta: {},
157
+ },
158
+ },
159
+ async () => {
160
+ throw err;
161
+ },
162
+ ),
163
+ ).rejects.toThrow('test failure');
164
+
165
+ const span = createdSpans[0];
166
+ expect(span.setStatus).toHaveBeenCalledWith({ code: 2 });
167
+ expect(span.recordException).toHaveBeenCalledWith(err);
168
+ expect(span.end).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ it('attaches otelSpans to task.meta when collector returns spans', async () => {
172
+ mockDrainResult = [
173
+ { spanId: 'span-1', name: 'test:my-test', startTimeMs: 1000, durationMs: 100, status: 'ok' },
174
+ ];
175
+
176
+ await import('./index');
177
+
178
+ const spanFixture = state.fixtures?._otelTestSpan;
179
+ const spanFixtureFn = Array.isArray(spanFixture) ? spanFixture[0] : spanFixture;
180
+
181
+ const meta: Record<string, unknown> = {};
182
+ await spanFixtureFn?.(
183
+ {
184
+ task: {
185
+ name: 'my-test',
186
+ file: { name: 'test.ts' },
187
+ suite: { name: '' },
188
+ meta,
189
+ },
190
+ },
191
+ async () => {},
192
+ );
193
+
194
+ expect(meta.otelSpans).toEqual([
195
+ { spanId: 'span-1', name: 'test:my-test', startTimeMs: 1000, durationMs: 100, status: 'ok' },
196
+ ]);
197
+ });
198
+
199
+ it('uses auto: true to activate for every test', async () => {
200
+ await import('./index');
201
+
202
+ const spanFixture = state.fixtures?._otelTestSpan;
203
+ expect(Array.isArray(spanFixture)).toBe(true);
204
+ if (Array.isArray(spanFixture)) {
205
+ expect(spanFixture[1]).toEqual({ auto: true });
206
+ }
207
+ });
208
+ });
209
+
210
+ describe('trace context helper re-exports', () => {
211
+ it('re-exports getTraceContext', async () => {
212
+ const mod = await import('./index');
213
+ expect(mod.getTraceContext).toBeTypeOf('function');
214
+ });
215
+
216
+ it('re-exports resolveTraceUrl', async () => {
217
+ const mod = await import('./index');
218
+ expect(mod.resolveTraceUrl).toBeTypeOf('function');
219
+ });
220
+
221
+ it('re-exports isTracing', async () => {
222
+ const mod = await import('./index');
223
+ expect(mod.isTracing).toBeTypeOf('function');
224
+ });
225
+
226
+ it('re-exports enrichWithTraceContext', async () => {
227
+ const mod = await import('./index');
228
+ expect(mod.enrichWithTraceContext).toBeTypeOf('function');
229
+ });
230
+ });
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * autotel-vitest
3
+ *
4
+ * Vitest fixture that creates one OTel span per test so all autotel-instrumented
5
+ * code executed during a test automatically creates child spans under it;
6
+ * making every test run filterable in your OTLP backend.
7
+ *
8
+ * @example
9
+ * // vitest.config.ts: globalSetup calls init({ service: 'unit-tests' })
10
+ * // In spec:
11
+ * import { test, expect } from 'autotel-vitest';
12
+ * test('creates user', async () => {
13
+ * await userService.createUser({ email: 'test@example.com' });
14
+ * // All trace()/span() calls become children of the test span
15
+ * });
16
+ */
17
+
18
+ import { test as base } from 'vitest';
19
+ import {
20
+ getTracer,
21
+ getAutotelTracerProvider,
22
+ context as otelContext,
23
+ otelTrace,
24
+ SpanStatusCode,
25
+ } from 'autotel';
26
+ import { TestSpanCollector } from 'autotel/test-span-collector';
27
+ import { SimpleSpanProcessor } from 'autotel/processors';
28
+
29
+ const TRACER_NAME = 'vitest-tests';
30
+ const TRACER_VERSION = '0.1.0';
31
+
32
+ let collector: TestSpanCollector | null = null;
33
+
34
+ function ensureCollector(): TestSpanCollector {
35
+ if (!collector) {
36
+ collector = new TestSpanCollector();
37
+ const provider = getAutotelTracerProvider();
38
+ if ('addSpanProcessor' in provider) {
39
+ (provider as any).addSpanProcessor(new SimpleSpanProcessor(collector));
40
+ }
41
+ }
42
+ return collector;
43
+ }
44
+
45
+ export const test = base.extend({
46
+ _otelTestSpan: [
47
+ async ({ task }, use) => {
48
+ ensureCollector();
49
+ const tracer = getTracer(TRACER_NAME, TRACER_VERSION);
50
+ const span = tracer.startSpan(`test:${task.name}`, {
51
+ attributes: {
52
+ 'test.name': task.name,
53
+ 'test.file': task.file?.name ?? '',
54
+ 'test.suite': task.suite?.name ?? '',
55
+ },
56
+ });
57
+ const ctx = otelTrace.setSpan(otelContext.active(), span);
58
+ try {
59
+ await otelContext.with(ctx, () => use(span));
60
+ } catch (error) {
61
+ span.setStatus({ code: SpanStatusCode.ERROR });
62
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
63
+ throw error;
64
+ } finally {
65
+ span.end();
66
+ const traceId = span.spanContext().traceId;
67
+ const rootSpanId = span.spanContext().spanId;
68
+ const spans = collector!.drainTrace(traceId, rootSpanId);
69
+ if (spans.length > 0) {
70
+ (task.meta as Record<string, unknown>).otelSpans = spans;
71
+ }
72
+ }
73
+ },
74
+ { auto: true },
75
+ ],
76
+ });
77
+
78
+ export { expect, describe, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
79
+
80
+ // Re-export all autotel/testing utilities
81
+ export {
82
+ createTraceCollector,
83
+ assertTraceCreated,
84
+ assertTraceSucceeded,
85
+ assertTraceFailed,
86
+ assertNoErrors,
87
+ assertTraceDuration,
88
+ waitForTrace,
89
+ getTraceDuration,
90
+ createMockLogger,
91
+ type TraceCollector,
92
+ type TestSpan,
93
+ type LogCollector,
94
+ type LogEntry,
95
+ } from 'autotel/testing';
96
+
97
+ // Re-export trace context helpers for DX convenience
98
+ export {
99
+ getTraceContext,
100
+ resolveTraceUrl,
101
+ isTracing,
102
+ enrichWithTraceContext,
103
+ } from 'autotel';
104
+
105
+ export type { OtelTraceContext } from 'autotel';
@@ -0,0 +1,193 @@
1
+ import { afterEach, describe, it, expect, vi } from 'vitest';
2
+
3
+ const spans: Array<{
4
+ end: ReturnType<typeof vi.fn>;
5
+ recordException: ReturnType<typeof vi.fn>;
6
+ setStatus: ReturnType<typeof vi.fn>;
7
+ }> = [];
8
+
9
+ vi.mock('autotel', () => ({
10
+ SpanStatusCode: { ERROR: 2 },
11
+ context: {
12
+ active: () => ({}),
13
+ with: (_ctx: unknown, fn: () => void) => fn(),
14
+ },
15
+ getTracer: () => ({
16
+ startSpan: (_name: string, _options?: unknown) => {
17
+ const span = {
18
+ end: vi.fn(),
19
+ recordException: vi.fn(),
20
+ setStatus: vi.fn(),
21
+ };
22
+ spans.push(span);
23
+ return span;
24
+ },
25
+ }),
26
+ otelTrace: {
27
+ setSpan: () => ({}),
28
+ },
29
+ }));
30
+
31
+ function makeTestCase(overrides: {
32
+ id: string;
33
+ name: string;
34
+ moduleId?: string;
35
+ result?: { state: string; errors?: Array<{ message?: string; stack?: string }> };
36
+ }) {
37
+ return {
38
+ id: overrides.id,
39
+ name: overrides.name,
40
+ fullName: overrides.name,
41
+ module: { moduleId: overrides.moduleId ?? 'test.ts' },
42
+ result: () => overrides.result ?? { state: 'passed', errors: undefined },
43
+ } as any;
44
+ }
45
+
46
+ function makeTestSuite(overrides: {
47
+ id: string;
48
+ name: string;
49
+ moduleId?: string;
50
+ state?: string;
51
+ }) {
52
+ return {
53
+ id: overrides.id,
54
+ name: overrides.name,
55
+ module: { moduleId: overrides.moduleId ?? 'test.ts' },
56
+ state: () => overrides.state ?? 'passed',
57
+ } as any;
58
+ }
59
+
60
+ describe('OtelReporter', () => {
61
+ afterEach(() => {
62
+ spans.length = 0;
63
+ vi.resetModules();
64
+ });
65
+
66
+ it('creates a span on test ready and ends it on test result', async () => {
67
+ const { OtelReporter } = await import('./reporter');
68
+ const reporter = new OtelReporter();
69
+
70
+ const testCase = makeTestCase({ id: 'test-1', name: 'my test' });
71
+
72
+ reporter.onTestCaseReady!(testCase);
73
+ expect(spans).toHaveLength(1);
74
+ expect(spans[0].end).not.toHaveBeenCalled();
75
+
76
+ reporter.onTestCaseResult!(testCase);
77
+ expect(spans[0].end).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it('sets error status and records exception on failure', async () => {
81
+ const { OtelReporter } = await import('./reporter');
82
+ const reporter = new OtelReporter();
83
+
84
+ const testCase = makeTestCase({
85
+ id: 'test-fail',
86
+ name: 'failing test',
87
+ result: {
88
+ state: 'failed',
89
+ errors: [{ message: 'assertion failed', stack: 'Error: assertion failed\n at ...' }],
90
+ },
91
+ });
92
+
93
+ reporter.onTestCaseReady!(testCase);
94
+ reporter.onTestCaseResult!(testCase);
95
+
96
+ const span = spans[0];
97
+ expect(span.setStatus).toHaveBeenCalledWith({ code: 2 });
98
+ expect(span.recordException).toHaveBeenCalled();
99
+ expect(span.end).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ it('tracks parallel tests independently (no key collisions)', async () => {
103
+ const { OtelReporter } = await import('./reporter');
104
+ const reporter = new OtelReporter();
105
+
106
+ const testA = makeTestCase({ id: 'test-a', name: 'shared title' });
107
+ const testB = makeTestCase({ id: 'test-b', name: 'shared title' });
108
+
109
+ reporter.onTestCaseReady!(testA);
110
+ reporter.onTestCaseReady!(testB);
111
+
112
+ reporter.onTestCaseResult!(testA);
113
+
114
+ expect(spans).toHaveLength(2);
115
+ expect(spans[0].end).toHaveBeenCalledTimes(1);
116
+ expect(spans[1].end).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('creates suite spans', async () => {
120
+ const { OtelReporter } = await import('./reporter');
121
+ const reporter = new OtelReporter();
122
+
123
+ const suite = makeTestSuite({ id: 'suite-1', name: 'UserService' });
124
+
125
+ reporter.onTestSuiteReady!(suite);
126
+ expect(spans).toHaveLength(1);
127
+
128
+ reporter.onTestSuiteResult!(suite);
129
+ expect(spans[0].end).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ it('marks suite span as error when suite fails', async () => {
133
+ const { OtelReporter } = await import('./reporter');
134
+ const reporter = new OtelReporter();
135
+
136
+ const suite = makeTestSuite({ id: 'suite-fail', name: 'FailingSuite', state: 'failed' });
137
+
138
+ reporter.onTestSuiteReady!(suite);
139
+ reporter.onTestSuiteResult!(suite);
140
+
141
+ expect(spans[0].setStatus).toHaveBeenCalledWith({ code: 2 });
142
+ expect(spans[0].end).toHaveBeenCalledTimes(1);
143
+ });
144
+
145
+ it('does not error when onTestCaseResult is called without onTestCaseReady', async () => {
146
+ const { OtelReporter } = await import('./reporter');
147
+ const reporter = new OtelReporter();
148
+
149
+ const testCase = makeTestCase({ id: 'orphan', name: 'orphan test' });
150
+
151
+ // Should not throw
152
+ reporter.onTestCaseResult!(testCase);
153
+ expect(spans).toHaveLength(0);
154
+ });
155
+
156
+ it('does not end test spans from other modules when a module ends', async () => {
157
+ const { OtelReporter } = await import('./reporter');
158
+ const reporter = new OtelReporter();
159
+
160
+ const moduleA = { moduleId: 'a.test.ts' } as any;
161
+
162
+ const testInA = makeTestCase({ id: 'a-1', name: 'test a', moduleId: 'a.test.ts' });
163
+ const testInB = makeTestCase({ id: 'b-1', name: 'test b', moduleId: 'b.test.ts' });
164
+
165
+ reporter.onTestCaseReady!(testInA);
166
+ reporter.onTestCaseReady!(testInB);
167
+
168
+ reporter.onTestModuleEnd!(moduleA);
169
+
170
+ expect(spans).toHaveLength(2);
171
+ expect(spans[0].end).toHaveBeenCalledTimes(1);
172
+ expect(spans[1].end).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('does not end suite spans from other modules when a module ends', async () => {
176
+ const { OtelReporter } = await import('./reporter');
177
+ const reporter = new OtelReporter();
178
+
179
+ const moduleA = { moduleId: 'a.test.ts' } as any;
180
+
181
+ const suiteInA = makeTestSuite({ id: 'suite-a', name: 'suite a', moduleId: 'a.test.ts' });
182
+ const suiteInB = makeTestSuite({ id: 'suite-b', name: 'suite b', moduleId: 'b.test.ts' });
183
+
184
+ reporter.onTestSuiteReady!(suiteInA);
185
+ reporter.onTestSuiteReady!(suiteInB);
186
+
187
+ reporter.onTestModuleEnd!(moduleA);
188
+
189
+ expect(spans).toHaveLength(2);
190
+ expect(spans[0].end).toHaveBeenCalledTimes(1);
191
+ expect(spans[1].end).not.toHaveBeenCalled();
192
+ });
193
+ });