@ontrails/testing 1.0.0-beta.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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +23 -0
  5. package/README.md +221 -0
  6. package/dist/all.d.ts +30 -0
  7. package/dist/all.d.ts.map +1 -0
  8. package/dist/all.js +47 -0
  9. package/dist/all.js.map +1 -0
  10. package/dist/assertions.d.ts +49 -0
  11. package/dist/assertions.d.ts.map +1 -0
  12. package/dist/assertions.js +84 -0
  13. package/dist/assertions.js.map +1 -0
  14. package/dist/context.d.ts +19 -0
  15. package/dist/context.d.ts.map +1 -0
  16. package/dist/context.js +33 -0
  17. package/dist/context.js.map +1 -0
  18. package/dist/contracts.d.ts +16 -0
  19. package/dist/contracts.d.ts.map +1 -0
  20. package/dist/contracts.js +56 -0
  21. package/dist/contracts.js.map +1 -0
  22. package/dist/detours.d.ts +12 -0
  23. package/dist/detours.d.ts.map +1 -0
  24. package/dist/detours.js +30 -0
  25. package/dist/detours.js.map +1 -0
  26. package/dist/examples.d.ts +22 -0
  27. package/dist/examples.d.ts.map +1 -0
  28. package/dist/examples.js +187 -0
  29. package/dist/examples.js.map +1 -0
  30. package/dist/harness-cli.d.ts +21 -0
  31. package/dist/harness-cli.d.ts.map +1 -0
  32. package/dist/harness-cli.js +213 -0
  33. package/dist/harness-cli.js.map +1 -0
  34. package/dist/harness-mcp.d.ts +21 -0
  35. package/dist/harness-mcp.d.ts.map +1 -0
  36. package/dist/harness-mcp.js +50 -0
  37. package/dist/harness-mcp.js.map +1 -0
  38. package/dist/hike.d.ts +32 -0
  39. package/dist/hike.d.ts.map +1 -0
  40. package/dist/hike.js +169 -0
  41. package/dist/hike.js.map +1 -0
  42. package/dist/index.d.ts +14 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +16 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/logger.d.ts +15 -0
  47. package/dist/logger.d.ts.map +1 -0
  48. package/dist/logger.js +87 -0
  49. package/dist/logger.js.map +1 -0
  50. package/dist/trail.d.ts +20 -0
  51. package/dist/trail.d.ts.map +1 -0
  52. package/dist/trail.js +80 -0
  53. package/dist/trail.js.map +1 -0
  54. package/dist/types.d.ts +80 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +5 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +23 -0
  59. package/src/__tests__/context.test.ts +60 -0
  60. package/src/__tests__/contracts.test.ts +68 -0
  61. package/src/__tests__/detours.test.ts +55 -0
  62. package/src/__tests__/examples.test.ts +176 -0
  63. package/src/__tests__/hike.test.ts +164 -0
  64. package/src/__tests__/logger.test.ts +136 -0
  65. package/src/__tests__/trail.test.ts +99 -0
  66. package/src/all.ts +55 -0
  67. package/src/assertions.ts +108 -0
  68. package/src/context.ts +42 -0
  69. package/src/contracts.ts +85 -0
  70. package/src/detours.ts +44 -0
  71. package/src/examples.ts +314 -0
  72. package/src/harness-cli.ts +310 -0
  73. package/src/harness-mcp.ts +65 -0
  74. package/src/hike.ts +283 -0
  75. package/src/index.ts +40 -0
  76. package/src/logger.ts +125 -0
  77. package/src/trail.ts +116 -0
  78. package/src/types.ts +117 -0
  79. package/tsconfig.json +9 -0
  80. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,68 @@
1
+ import { describe, test } from 'bun:test';
2
+
3
+ import { Result, trail, topo } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { testContracts } from '../contracts.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Test trails
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** Trail whose implementation matches the output schema. */
13
+ const validTrail = trail('valid', {
14
+ examples: [
15
+ {
16
+ expected: { id: 1, name: 'Alpha' },
17
+ input: { name: 'Alpha' },
18
+ name: 'Valid output',
19
+ },
20
+ ],
21
+ implementation: (input: { name: string }) =>
22
+ Result.ok({ id: 1, name: input.name }),
23
+ input: z.object({ name: z.string() }),
24
+ output: z.object({ id: z.number(), name: z.string() }),
25
+ });
26
+
27
+ /** Trail without output schema -- should be skipped. */
28
+ const noSchemaTrail = trail('noschema', {
29
+ examples: [{ expected: 10, input: { x: 5 }, name: 'No schema' }],
30
+ implementation: (input: { x: number }) => Result.ok(input.x * 2),
31
+ input: z.object({ x: z.number() }),
32
+ });
33
+
34
+ /** Trail without examples -- should be skipped. */
35
+ const noExamplesTrail = trail('noexamples', {
36
+ implementation: (input: { x: number }) => Result.ok({ value: input.x }),
37
+ input: z.object({ x: z.number() }),
38
+ output: z.object({ value: z.number() }),
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Tests
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe('testContracts: valid output matches schema', () => {
46
+ // eslint-disable-next-line jest/require-hook
47
+ testContracts(topo('test-app', { validTrail } as Record<string, unknown>));
48
+ });
49
+
50
+ describe('testContracts: skips trails without output schemas', () => {
51
+ // eslint-disable-next-line jest/require-hook
52
+ testContracts(topo('test-app', { noSchemaTrail } as Record<string, unknown>));
53
+
54
+ test('no-op marker', () => {
55
+ // Trail without output schema is skipped -- no contract tests generated
56
+ });
57
+ });
58
+
59
+ describe('testContracts: skips trails without examples', () => {
60
+ // eslint-disable-next-line jest/require-hook
61
+ testContracts(
62
+ topo('test-app', { noExamplesTrail } as Record<string, unknown>)
63
+ );
64
+
65
+ test('no-op marker', () => {
66
+ // Trail without examples is skipped -- no contract tests generated
67
+ });
68
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, test } from 'bun:test';
2
+
3
+ import { Result, trail, topo } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { testDetours } from '../detours.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Test trails
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const showTrail = trail('entity.show', {
13
+ detours: {
14
+ related: ['entity.list'],
15
+ },
16
+ implementation: (input: { id: string }) => Result.ok({ id: input.id }),
17
+ input: z.object({ id: z.string() }),
18
+ });
19
+
20
+ const listTrail = trail('entity.list', {
21
+ implementation: () => Result.ok([]),
22
+ input: z.object({}),
23
+ });
24
+
25
+ const noDetoursTrail = trail('entity.plain', {
26
+ implementation: () => Result.ok('ok'),
27
+ input: z.object({}),
28
+ });
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Tests
32
+ // ---------------------------------------------------------------------------
33
+
34
+ describe('testDetours: all targets exist', () => {
35
+ // eslint-disable-next-line jest/require-hook
36
+ testDetours(
37
+ topo('test-app', {
38
+ listTrail,
39
+ showTrail,
40
+ } as Record<string, unknown>)
41
+ );
42
+ });
43
+
44
+ describe('testDetours: skips trails without detours', () => {
45
+ // eslint-disable-next-line jest/require-hook
46
+ testDetours(
47
+ topo('test-app', {
48
+ noDetoursTrail,
49
+ } as Record<string, unknown>)
50
+ );
51
+
52
+ test('no-op marker', () => {
53
+ // Trail without detours is skipped -- no detour tests generated
54
+ });
55
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, test } from 'bun:test';
2
+
3
+ import { NotFoundError, Result, hike, trail, topo } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { testExamples } from '../examples.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Test trails
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const greetTrail = trail('greet', {
13
+ description: 'Greet someone',
14
+ examples: [
15
+ {
16
+ expected: { greeting: 'Hello, Alice' },
17
+ input: { name: 'Alice' },
18
+ name: 'Greet Alice',
19
+ },
20
+ ],
21
+ implementation: (input: { name: string }) =>
22
+ Result.ok({ greeting: `Hello, ${input.name}` }),
23
+ input: z.object({ name: z.string() }),
24
+ output: z.object({ greeting: z.string() }),
25
+ });
26
+
27
+ const searchTrail = trail('search', {
28
+ description: 'Search for things',
29
+ examples: [
30
+ {
31
+ input: { query: 'test' },
32
+ name: 'Schema-only search',
33
+ },
34
+ ],
35
+ implementation: (input: { query: string }) =>
36
+ Result.ok({ results: [`result for ${input.query}`] }),
37
+ input: z.object({ query: z.string() }),
38
+ output: z.object({ results: z.array(z.string()) }),
39
+ });
40
+
41
+ const entityTrail = trail('entity.show', {
42
+ description: 'Show entity',
43
+ examples: [
44
+ {
45
+ expected: { id: 1, name: 'Alpha' },
46
+ input: { name: 'Alpha' },
47
+ name: 'Show entity by name',
48
+ },
49
+ {
50
+ error: 'NotFoundError',
51
+ input: { name: 'missing' },
52
+ name: 'Entity not found returns NotFoundError',
53
+ },
54
+ ],
55
+ implementation: (input: { name: string }) => {
56
+ if (input.name === 'missing') {
57
+ return Result.err(new NotFoundError('Entity not found'));
58
+ }
59
+ return Result.ok({ id: 1, name: input.name });
60
+ },
61
+ input: z.object({ name: z.string() }),
62
+ output: z.object({ id: z.number(), name: z.string() }),
63
+ });
64
+
65
+ const noExamplesTrail = trail('noexamples', {
66
+ implementation: (input: { x: number }) => Result.ok(input.x * 2),
67
+ input: z.object({ x: z.number() }),
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Test hikes (for follows coverage)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const addTrail = trail('entity.add', {
75
+ description: 'Add an entity',
76
+ implementation: (input: { name: string }) =>
77
+ Result.ok({ id: '1', name: input.name }),
78
+ input: z.object({ name: z.string() }),
79
+ output: z.object({ id: z.string(), name: z.string() }),
80
+ });
81
+
82
+ const relateTrail = trail('entity.relate', {
83
+ description: 'Relate entities',
84
+ implementation: (input: { from: string; to: string }) =>
85
+ Result.ok({ from: input.from, to: input.to }),
86
+ input: z.object({ from: z.string(), to: z.string() }),
87
+ output: z.object({ from: z.string(), to: z.string() }),
88
+ });
89
+
90
+ const onboardHike = hike('entity.onboard', {
91
+ description: 'Onboard a new entity',
92
+ examples: [
93
+ {
94
+ expected: { id: '1', name: 'Alpha' },
95
+ input: { name: 'Alpha' },
96
+ name: 'Onboard Alpha',
97
+ },
98
+ ],
99
+ follows: ['entity.add', 'entity.relate'],
100
+ implementation: async (input: { name: string }, ctx) => {
101
+ if (!ctx.follow) {
102
+ return Result.err(new Error('follow not available'));
103
+ }
104
+ const addResult = await ctx.follow('entity.add', input);
105
+ if (addResult.isErr()) {
106
+ return addResult;
107
+ }
108
+ const relateResult = await ctx.follow('entity.relate', {
109
+ from: 'root',
110
+ to: (addResult.value as { id: string }).id,
111
+ });
112
+ if (relateResult.isErr()) {
113
+ return relateResult;
114
+ }
115
+ return Result.ok({ id: '1', name: input.name });
116
+ },
117
+ input: z.object({ name: z.string() }),
118
+ output: z.object({ id: z.string(), name: z.string() }),
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Tests
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('testExamples', () => {
126
+ // eslint-disable-next-line jest/require-hook
127
+ testExamples(
128
+ topo('test-app', {
129
+ entityTrail,
130
+ greetTrail,
131
+ noExamplesTrail,
132
+ searchTrail,
133
+ } as Record<string, unknown>)
134
+ );
135
+
136
+ // Also test with custom context (static form)
137
+ describe('with custom context', () => {
138
+ // eslint-disable-next-line jest/require-hook
139
+ testExamples(topo('ctx-app', { greetTrail } as Record<string, unknown>), {
140
+ requestId: 'custom-request',
141
+ });
142
+ });
143
+
144
+ // Test with factory form (each test gets a fresh context)
145
+ describe('with context factory', () => {
146
+ // eslint-disable-next-line jest/require-hook
147
+ testExamples(
148
+ topo('factory-app', { greetTrail } as Record<string, unknown>),
149
+ () => ({ requestId: `factory-${crypto.randomUUID()}` })
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('testExamples skips trails with no examples', () => {
155
+ // eslint-disable-next-line jest/require-hook
156
+ testExamples(
157
+ topo('skip-app', {
158
+ noExamplesTrail,
159
+ } as Record<string, unknown>)
160
+ );
161
+
162
+ test('no-op marker', () => {
163
+ // This test exists so the describe block is not empty
164
+ });
165
+ });
166
+
167
+ describe('testExamples follows coverage for hikes', () => {
168
+ // eslint-disable-next-line jest/require-hook
169
+ testExamples(
170
+ topo('hike-app', {
171
+ addTrail,
172
+ onboardHike,
173
+ relateTrail,
174
+ } as Record<string, unknown>)
175
+ );
176
+ });
@@ -0,0 +1,164 @@
1
+ import { describe } from 'bun:test';
2
+
3
+ import type { AnyTrail, TrailContext } from '@ontrails/core';
4
+ import { Result, hike, trail } from '@ontrails/core';
5
+ import { z } from 'zod';
6
+
7
+ import { testHike } from '../hike.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Test trails (followed by hikes)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const addTrail = trail('entity.add', {
14
+ description: 'Add an entity',
15
+ examples: [
16
+ { input: { name: 'Alpha' }, name: 'success' },
17
+ {
18
+ description: 'duplicate name',
19
+ error: 'AlreadyExistsError',
20
+ input: { name: '' },
21
+ name: 'duplicate',
22
+ },
23
+ ],
24
+ implementation: (input: { name: string }) =>
25
+ Result.ok({ id: '1', name: input.name }),
26
+ input: z.object({ name: z.string() }),
27
+ output: z.object({ id: z.string(), name: z.string() }),
28
+ });
29
+
30
+ const relateTrail = trail('entity.relate', {
31
+ description: 'Relate two entities',
32
+ implementation: (input: { from: string; to: string }) =>
33
+ Result.ok({ from: input.from, to: input.to }),
34
+ input: z.object({ from: z.string(), to: z.string() }),
35
+ output: z.object({ from: z.string(), to: z.string() }),
36
+ });
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Test hike
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const onboardHike = hike('entity.onboard', {
43
+ follows: ['entity.add', 'entity.relate'],
44
+ implementation: async (
45
+ input: { name: string; relatedTo: string },
46
+ ctx: TrailContext
47
+ ) => {
48
+ if (!ctx.follow) {
49
+ return Result.err(new Error('follow not available'));
50
+ }
51
+ const addResult = await ctx.follow<{ id: string; name: string }>(
52
+ 'entity.add',
53
+ { name: input.name }
54
+ );
55
+ if (addResult.isErr()) {
56
+ return Result.err(addResult.error);
57
+ }
58
+
59
+ const relateResult = await ctx.follow<{ from: string; to: string }>(
60
+ 'entity.relate',
61
+ { from: addResult.value.name, to: input.relatedTo }
62
+ );
63
+ if (relateResult.isErr()) {
64
+ return Result.err(relateResult.error);
65
+ }
66
+
67
+ return Result.ok({
68
+ name: addResult.value.name,
69
+ relatedTo: relateResult.value.to,
70
+ });
71
+ },
72
+ input: z.object({ name: z.string(), relatedTo: z.string() }),
73
+ output: z.object({ name: z.string(), relatedTo: z.string() }),
74
+ });
75
+
76
+ const trailsMap = new Map<string, AnyTrail>([
77
+ ['entity.add', addTrail],
78
+ ['entity.relate', relateTrail],
79
+ ]);
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Tests
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const opts = { trails: trailsMap };
86
+
87
+ describe('testHike: expectOk', () => {
88
+ // eslint-disable-next-line jest/require-hook
89
+ testHike(
90
+ onboardHike,
91
+ [
92
+ {
93
+ description: 'basic onboard succeeds',
94
+ expectOk: true,
95
+ input: { name: 'Alpha', relatedTo: 'Beta' },
96
+ },
97
+ ],
98
+ opts
99
+ );
100
+ });
101
+
102
+ describe('testHike: expectFollowed', () => {
103
+ // eslint-disable-next-line jest/require-hook
104
+ testHike(
105
+ onboardHike,
106
+ [
107
+ {
108
+ description: 'follows add then relate in order',
109
+ expectFollowed: ['entity.add', 'entity.relate'],
110
+ expectOk: true,
111
+ input: { name: 'Alpha', relatedTo: 'Beta' },
112
+ },
113
+ ],
114
+ opts
115
+ );
116
+ });
117
+
118
+ describe('testHike: expectFollowedCount', () => {
119
+ // eslint-disable-next-line jest/require-hook
120
+ testHike(
121
+ onboardHike,
122
+ [
123
+ {
124
+ description: 'each trail followed exactly once',
125
+ expectFollowedCount: { 'entity.add': 1, 'entity.relate': 1 },
126
+ expectOk: true,
127
+ input: { name: 'Alpha', relatedTo: 'Beta' },
128
+ },
129
+ ],
130
+ opts
131
+ );
132
+ });
133
+
134
+ describe('testHike: injectFromExample', () => {
135
+ // eslint-disable-next-line jest/require-hook
136
+ testHike(
137
+ onboardHike,
138
+ [
139
+ {
140
+ description: 'inject duplicate error from add trail example',
141
+ expectErr: Error,
142
+ expectErrMessage: 'AlreadyExistsError',
143
+ injectFromExample: { 'entity.add': 'duplicate' },
144
+ input: { name: 'Alpha', relatedTo: 'Beta' },
145
+ },
146
+ ],
147
+ opts
148
+ );
149
+ });
150
+
151
+ describe('testHike: expectValue', () => {
152
+ // eslint-disable-next-line jest/require-hook
153
+ testHike(
154
+ onboardHike,
155
+ [
156
+ {
157
+ description: 'exact value match',
158
+ expectValue: { name: 'Alpha', relatedTo: 'Beta' },
159
+ input: { name: 'Alpha', relatedTo: 'Beta' },
160
+ },
161
+ ],
162
+ opts
163
+ );
164
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { createTestLogger } from '../logger.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Tests
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe('createTestLogger: basic operations', () => {
10
+ test('captures info entries', () => {
11
+ const logger = createTestLogger();
12
+ logger.info('hello');
13
+ logger.warn('world');
14
+ expect(logger.entries).toHaveLength(2);
15
+ const [first] = logger.entries;
16
+ expect(first).toBeDefined();
17
+ expect(first?.level).toBe('info');
18
+ expect(first?.message).toBe('hello');
19
+ });
20
+
21
+ test('captures warn entries', () => {
22
+ const logger = createTestLogger();
23
+ logger.info('hello');
24
+ logger.warn('world');
25
+ const [, second] = logger.entries;
26
+ expect(second).toBeDefined();
27
+ expect(second?.level).toBe('warn');
28
+ expect(second?.message).toBe('world');
29
+ });
30
+
31
+ test('entries contain all logged records', () => {
32
+ const logger = createTestLogger();
33
+ logger.trace('t');
34
+ logger.debug('d');
35
+ logger.info('i');
36
+ logger.warn('w');
37
+ logger.error('e');
38
+ logger.fatal('f');
39
+ expect(logger.entries).toHaveLength(6);
40
+ });
41
+
42
+ test('clear() empties the entries array', () => {
43
+ const logger = createTestLogger();
44
+ logger.info('one');
45
+ logger.info('two');
46
+ expect(logger.entries).toHaveLength(2);
47
+ logger.clear();
48
+ expect(logger.entries).toHaveLength(0);
49
+ });
50
+
51
+ test('find() filters entries by predicate', () => {
52
+ const logger = createTestLogger();
53
+ logger.info('alpha');
54
+ logger.warn('beta');
55
+ logger.info('gamma');
56
+
57
+ const warnings = logger.find((r) => r.level === 'warn');
58
+ expect(warnings).toHaveLength(1);
59
+ expect(warnings[0]?.message).toBe('beta');
60
+ });
61
+ });
62
+
63
+ describe('createTestLogger: assertLogged', () => {
64
+ test('passes when matching entry exists', () => {
65
+ const logger = createTestLogger();
66
+ logger.info('operation completed');
67
+ expect(() => logger.assertLogged('info', 'completed')).not.toThrow();
68
+ });
69
+
70
+ test('fails when no matching entry exists', () => {
71
+ const logger = createTestLogger();
72
+ logger.info('something else');
73
+ expect(() => logger.assertLogged('error', 'not found')).toThrow(
74
+ /Expected a log entry/
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('createTestLogger: child loggers', () => {
80
+ test('child() captures to the same entries array', () => {
81
+ const logger = createTestLogger();
82
+ const child = logger.child({ component: 'db' });
83
+
84
+ logger.info('parent message');
85
+ child.info('child message');
86
+
87
+ expect(logger.entries).toHaveLength(2);
88
+ expect(logger.entries[0]?.message).toBe('parent message');
89
+ expect(logger.entries[1]?.message).toBe('child message');
90
+ expect(logger.entries[1]?.metadata).toEqual({ component: 'db' });
91
+ });
92
+
93
+ test('child logger inherits parent metadata', () => {
94
+ const logger = createTestLogger();
95
+ const child = logger.child({ requestId: 'abc' });
96
+ const grandchild = child.child({ trailId: 'greet' });
97
+
98
+ grandchild.info('deep log');
99
+
100
+ expect(logger.entries).toHaveLength(1);
101
+ expect(logger.entries[0]?.metadata).toEqual({
102
+ requestId: 'abc',
103
+ trailId: 'greet',
104
+ });
105
+ });
106
+ });
107
+
108
+ describe('createTestLogger: configuration', () => {
109
+ test('respects minimum log level', () => {
110
+ const logger = createTestLogger({ level: 'warn' });
111
+ logger.debug('should be filtered');
112
+ logger.info('also filtered');
113
+ logger.warn('should appear');
114
+ logger.error('also appears');
115
+ expect(logger.entries).toHaveLength(2);
116
+ expect(logger.entries[0]?.level).toBe('warn');
117
+ expect(logger.entries[1]?.level).toBe('error');
118
+ });
119
+
120
+ test('has name property', () => {
121
+ const logger = createTestLogger();
122
+ expect(logger.name).toBe('test');
123
+ });
124
+
125
+ test('entries have timestamps', () => {
126
+ const logger = createTestLogger();
127
+ logger.info('timestamped');
128
+ expect(logger.entries[0]?.timestamp).toBeInstanceOf(Date);
129
+ });
130
+
131
+ test('entries have category matching logger name', () => {
132
+ const logger = createTestLogger();
133
+ logger.info('categorized');
134
+ expect(logger.entries[0]?.category).toBe('test');
135
+ });
136
+ });
@@ -0,0 +1,99 @@
1
+ import { describe } from 'bun:test';
2
+
3
+ import { NotFoundError, Result, ValidationError, trail } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { testTrail } from '../trail.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Test trails
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const greetTrail = trail('greet', {
13
+ implementation: (input: { name: string }) =>
14
+ Result.ok({ greeting: `Hello, ${input.name}` }),
15
+ input: z.object({ name: z.string() }),
16
+ output: z.object({ greeting: z.string() }),
17
+ });
18
+
19
+ const failTrail = trail('fail', {
20
+ implementation: (input: { id: string }) => {
21
+ if (input.id === 'missing') {
22
+ return Result.err(new NotFoundError('Not found: missing'));
23
+ }
24
+ return Result.ok({ id: input.id });
25
+ },
26
+ input: z.object({ id: z.string() }),
27
+ });
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Tests
31
+ //
32
+ // Each call to testTrail registers describe/test blocks.
33
+ // We call them at the describe scope so bun:test can discover them.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('testTrail: expectOk', () => {
37
+ // eslint-disable-next-line jest/require-hook
38
+ testTrail(greetTrail, [
39
+ { description: 'valid greeting', expectOk: true, input: { name: 'Bob' } },
40
+ ]);
41
+ });
42
+
43
+ describe('testTrail: expectValue', () => {
44
+ // eslint-disable-next-line jest/require-hook
45
+ testTrail(greetTrail, [
46
+ {
47
+ description: 'exact match',
48
+ expectValue: { greeting: 'Hello, Charlie' },
49
+ input: { name: 'Charlie' },
50
+ },
51
+ ]);
52
+ });
53
+
54
+ describe('testTrail: expectErr', () => {
55
+ // eslint-disable-next-line jest/require-hook
56
+ testTrail(failTrail, [
57
+ {
58
+ description: 'not found error',
59
+ expectErr: NotFoundError,
60
+ input: { id: 'missing' },
61
+ },
62
+ ]);
63
+ });
64
+
65
+ describe('testTrail: expectErrMessage', () => {
66
+ // eslint-disable-next-line jest/require-hook
67
+ testTrail(failTrail, [
68
+ {
69
+ description: 'error message contains substring',
70
+ expectErr: NotFoundError,
71
+ expectErrMessage: 'Not found',
72
+ input: { id: 'missing' },
73
+ },
74
+ ]);
75
+ });
76
+
77
+ describe('testTrail: invalid input with ValidationError', () => {
78
+ // eslint-disable-next-line jest/require-hook
79
+ testTrail(greetTrail, [
80
+ {
81
+ description: 'invalid input caught by validation',
82
+ expectErr: ValidationError,
83
+ input: { name: 123 },
84
+ },
85
+ ]);
86
+ });
87
+
88
+ describe('testTrail: multiple scenarios', () => {
89
+ // eslint-disable-next-line jest/require-hook
90
+ testTrail(greetTrail, [
91
+ { description: 'scenario 1', expectOk: true, input: { name: 'A' } },
92
+ { description: 'scenario 2', expectOk: true, input: { name: 'B' } },
93
+ {
94
+ description: 'scenario 3',
95
+ expectValue: { greeting: 'Hello, C' },
96
+ input: { name: 'C' },
97
+ },
98
+ ]);
99
+ });