@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.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +23 -0
- package/README.md +221 -0
- package/dist/all.d.ts +30 -0
- package/dist/all.d.ts.map +1 -0
- package/dist/all.js +47 -0
- package/dist/all.js.map +1 -0
- package/dist/assertions.d.ts +49 -0
- package/dist/assertions.d.ts.map +1 -0
- package/dist/assertions.js +84 -0
- package/dist/assertions.js.map +1 -0
- package/dist/context.d.ts +19 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +33 -0
- package/dist/context.js.map +1 -0
- package/dist/contracts.d.ts +16 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +56 -0
- package/dist/contracts.js.map +1 -0
- package/dist/detours.d.ts +12 -0
- package/dist/detours.d.ts.map +1 -0
- package/dist/detours.js +30 -0
- package/dist/detours.js.map +1 -0
- package/dist/examples.d.ts +22 -0
- package/dist/examples.d.ts.map +1 -0
- package/dist/examples.js +187 -0
- package/dist/examples.js.map +1 -0
- package/dist/harness-cli.d.ts +21 -0
- package/dist/harness-cli.d.ts.map +1 -0
- package/dist/harness-cli.js +213 -0
- package/dist/harness-cli.js.map +1 -0
- package/dist/harness-mcp.d.ts +21 -0
- package/dist/harness-mcp.d.ts.map +1 -0
- package/dist/harness-mcp.js +50 -0
- package/dist/harness-mcp.js.map +1 -0
- package/dist/hike.d.ts +32 -0
- package/dist/hike.d.ts.map +1 -0
- package/dist/hike.js +169 -0
- package/dist/hike.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +87 -0
- package/dist/logger.js.map +1 -0
- package/dist/trail.d.ts +20 -0
- package/dist/trail.d.ts.map +1 -0
- package/dist/trail.js +80 -0
- package/dist/trail.js.map +1 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +23 -0
- package/src/__tests__/context.test.ts +60 -0
- package/src/__tests__/contracts.test.ts +68 -0
- package/src/__tests__/detours.test.ts +55 -0
- package/src/__tests__/examples.test.ts +176 -0
- package/src/__tests__/hike.test.ts +164 -0
- package/src/__tests__/logger.test.ts +136 -0
- package/src/__tests__/trail.test.ts +99 -0
- package/src/all.ts +55 -0
- package/src/assertions.ts +108 -0
- package/src/context.ts +42 -0
- package/src/contracts.ts +85 -0
- package/src/detours.ts +44 -0
- package/src/examples.ts +314 -0
- package/src/harness-cli.ts +310 -0
- package/src/harness-mcp.ts +65 -0
- package/src/hike.ts +283 -0
- package/src/index.ts +40 -0
- package/src/logger.ts +125 -0
- package/src/trail.ts +116 -0
- package/src/types.ts +117 -0
- package/tsconfig.json +9 -0
- 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
|
+
});
|