@ontrails/testing 1.0.0-beta.2 → 1.0.0-beta.21
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/CHANGELOG.md +396 -5
- package/README.md +86 -22
- package/package.json +34 -5
- package/src/all-established.ts +168 -0
- package/src/all.ts +51 -12
- package/src/assertions.ts +253 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +200 -14
- package/src/contracts.ts +45 -21
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +414 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +302 -177
- package/src/harness-cli.ts +83 -58
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +46 -16
- package/src/http.ts +10 -0
- package/src/index.ts +22 -14
- package/src/logger.ts +3 -1
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +1 -1
- package/src/types.ts +24 -52
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/all.d.ts +0 -30
- package/dist/all.d.ts.map +0 -1
- package/dist/all.js +0 -47
- package/dist/all.js.map +0 -1
- package/dist/assertions.d.ts +0 -49
- package/dist/assertions.d.ts.map +0 -1
- package/dist/assertions.js +0 -84
- package/dist/assertions.js.map +0 -1
- package/dist/context.d.ts +0 -19
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -16
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -56
- package/dist/contracts.js.map +0 -1
- package/dist/detours.d.ts +0 -12
- package/dist/detours.d.ts.map +0 -1
- package/dist/detours.js +0 -30
- package/dist/detours.js.map +0 -1
- package/dist/examples.d.ts +0 -22
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -187
- package/dist/examples.js.map +0 -1
- package/dist/harness-cli.d.ts +0 -21
- package/dist/harness-cli.d.ts.map +0 -1
- package/dist/harness-cli.js +0 -213
- package/dist/harness-cli.js.map +0 -1
- package/dist/harness-mcp.d.ts +0 -21
- package/dist/harness-mcp.d.ts.map +0 -1
- package/dist/harness-mcp.js +0 -50
- package/dist/harness-mcp.js.map +0 -1
- package/dist/hike.d.ts +0 -32
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js +0 -169
- package/dist/hike.js.map +0 -1
- package/dist/index.d.ts +0 -14
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -16
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts +0 -15
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -87
- package/dist/logger.js.map +0 -1
- package/dist/trail.d.ts +0 -20
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -80
- package/dist/trail.js.map +0 -1
- package/dist/types.d.ts +0 -80
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/__tests__/context.test.ts +0 -60
- package/src/__tests__/contracts.test.ts +0 -68
- package/src/__tests__/detours.test.ts +0 -55
- package/src/__tests__/examples.test.ts +0 -176
- package/src/__tests__/hike.test.ts +0 -164
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/trail.test.ts +0 -99
- package/src/hike.ts +0 -283
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,164 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
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
|
-
});
|
package/src/hike.ts
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* testHike — composition-aware scenario testing for hikes.
|
|
3
|
-
*
|
|
4
|
-
* Tests the composition graph: which trails were followed, in what order,
|
|
5
|
-
* and supports failure injection from followed trail examples.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, expect, test } from 'bun:test';
|
|
9
|
-
|
|
10
|
-
import type { AnyHike, AnyTrail, FollowFn, TrailContext } from '@ontrails/core';
|
|
11
|
-
import {
|
|
12
|
-
InternalError,
|
|
13
|
-
Result,
|
|
14
|
-
ValidationError,
|
|
15
|
-
validateInput,
|
|
16
|
-
} from '@ontrails/core';
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
assertErrorMatch,
|
|
20
|
-
assertFullMatch,
|
|
21
|
-
assertSchemaMatch,
|
|
22
|
-
expectOk,
|
|
23
|
-
} from './assertions.js';
|
|
24
|
-
import { mergeTestContext } from './context.js';
|
|
25
|
-
import type { HikeScenario } from './types.js';
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Follow trace
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
interface FollowRecord {
|
|
32
|
-
readonly id: string;
|
|
33
|
-
readonly input: unknown;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Injection helpers
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Find an error example on a trail by name or description substring.
|
|
42
|
-
*/
|
|
43
|
-
const findErrorExample = (
|
|
44
|
-
trailDef: AnyTrail,
|
|
45
|
-
description: string
|
|
46
|
-
): string | undefined => {
|
|
47
|
-
const example = trailDef.examples?.find(
|
|
48
|
-
(ex) =>
|
|
49
|
-
ex.error !== undefined &&
|
|
50
|
-
(ex.description?.includes(description) || ex.name.includes(description))
|
|
51
|
-
);
|
|
52
|
-
return example?.error;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Try to inject an error from a followed trail's example.
|
|
57
|
-
* Returns undefined when no injection is configured for this trail ID.
|
|
58
|
-
*/
|
|
59
|
-
const tryInjectError = (
|
|
60
|
-
id: string,
|
|
61
|
-
scenario: HikeScenario,
|
|
62
|
-
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
63
|
-
): Result<unknown, Error> | undefined => {
|
|
64
|
-
const injection = scenario.injectFromExample?.[id];
|
|
65
|
-
if (injection === undefined) {
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const trailDef = trailsMap?.get(id);
|
|
70
|
-
if (trailDef === undefined) {
|
|
71
|
-
return Result.err(
|
|
72
|
-
new InternalError(`Cannot inject: trail "${id}" not in topo`)
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
const errorName = findErrorExample(trailDef, injection);
|
|
76
|
-
if (errorName === undefined) {
|
|
77
|
-
return Result.err(
|
|
78
|
-
new InternalError(
|
|
79
|
-
`No error example matching "${injection}" on trail "${id}"`
|
|
80
|
-
)
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
return Result.err(new Error(errorName));
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Execute a trail from the map, validating input first.
|
|
88
|
-
*/
|
|
89
|
-
const executeFromMap = (
|
|
90
|
-
id: string,
|
|
91
|
-
input: unknown,
|
|
92
|
-
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
93
|
-
ctx: TrailContext
|
|
94
|
-
): Result<unknown, Error> | Promise<Result<unknown, Error>> | undefined => {
|
|
95
|
-
const trailDef = trailsMap?.get(id);
|
|
96
|
-
if (trailDef === undefined) {
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const validated = validateInput(trailDef.input, input);
|
|
101
|
-
if (validated.isErr()) {
|
|
102
|
-
return validated;
|
|
103
|
-
}
|
|
104
|
-
return trailDef.implementation(validated.value, ctx);
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
// Follow factory
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Build a recording follow function that optionally injects errors.
|
|
113
|
-
*/
|
|
114
|
-
const createRecordingFollow = (
|
|
115
|
-
trace: FollowRecord[],
|
|
116
|
-
scenario: HikeScenario,
|
|
117
|
-
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
118
|
-
baseFollow: FollowFn | undefined,
|
|
119
|
-
ctx: TrailContext
|
|
120
|
-
): FollowFn => {
|
|
121
|
-
// The generic O on FollowFn is erased at runtime; the cast is safe
|
|
122
|
-
// because callers narrow via isOk/isErr before accessing the value.
|
|
123
|
-
const follow = (id: string, input: unknown) => {
|
|
124
|
-
trace.push({ id, input });
|
|
125
|
-
|
|
126
|
-
const injected = tryInjectError(id, scenario, trailsMap);
|
|
127
|
-
if (injected !== undefined) {
|
|
128
|
-
return Promise.resolve(injected);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (baseFollow !== undefined) {
|
|
132
|
-
return baseFollow(id, input);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const executed = executeFromMap(id, input, trailsMap, ctx);
|
|
136
|
-
if (executed !== undefined) {
|
|
137
|
-
return Promise.resolve(executed);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return Promise.resolve(Result.ok());
|
|
141
|
-
};
|
|
142
|
-
return follow as FollowFn;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
// Scenario assertions
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
|
|
149
|
-
const assertScenarioResult = (
|
|
150
|
-
result: Result<unknown, Error>,
|
|
151
|
-
scenario: HikeScenario,
|
|
152
|
-
hikeDef: AnyHike
|
|
153
|
-
): void => {
|
|
154
|
-
if (scenario.expectValue !== undefined) {
|
|
155
|
-
assertFullMatch(result, scenario.expectValue);
|
|
156
|
-
} else if (scenario.expectErr !== undefined) {
|
|
157
|
-
assertErrorMatch(result, scenario.expectErr, scenario.expectErrMessage);
|
|
158
|
-
} else if (scenario.expectErrMessage !== undefined) {
|
|
159
|
-
expect(result.isErr()).toBe(true);
|
|
160
|
-
if (result.isErr()) {
|
|
161
|
-
expect(result.error.message).toContain(scenario.expectErrMessage);
|
|
162
|
-
}
|
|
163
|
-
} else if (scenario.expectOk === true) {
|
|
164
|
-
expect(result.isOk()).toBe(true);
|
|
165
|
-
assertSchemaMatch(result, hikeDef.output);
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const assertFollowTrace = (
|
|
170
|
-
trace: readonly FollowRecord[],
|
|
171
|
-
scenario: HikeScenario
|
|
172
|
-
): void => {
|
|
173
|
-
if (scenario.expectFollowed !== undefined) {
|
|
174
|
-
const followedIds = trace.map((r) => r.id);
|
|
175
|
-
expect(followedIds).toEqual([...scenario.expectFollowed]);
|
|
176
|
-
}
|
|
177
|
-
if (scenario.expectFollowedCount !== undefined) {
|
|
178
|
-
const counts: Record<string, number> = {};
|
|
179
|
-
for (const record of trace) {
|
|
180
|
-
counts[record.id] = (counts[record.id] ?? 0) + 1;
|
|
181
|
-
}
|
|
182
|
-
expect(counts).toEqual({ ...scenario.expectFollowedCount });
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
const handleValidationError = (
|
|
187
|
-
validated: Result<unknown, Error>,
|
|
188
|
-
scenario: HikeScenario
|
|
189
|
-
): boolean => {
|
|
190
|
-
if (!validated.isErr()) {
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
if (scenario.expectErr === ValidationError) {
|
|
194
|
-
expect(validated.error).toBeInstanceOf(ValidationError);
|
|
195
|
-
if (scenario.expectErrMessage !== undefined) {
|
|
196
|
-
expect(validated.error.message).toContain(scenario.expectErrMessage);
|
|
197
|
-
}
|
|
198
|
-
return true;
|
|
199
|
-
}
|
|
200
|
-
throw new Error(
|
|
201
|
-
`Input validation failed unexpectedly: ${validated.error.message}`
|
|
202
|
-
);
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
// ---------------------------------------------------------------------------
|
|
206
|
-
// Scenario runner
|
|
207
|
-
// ---------------------------------------------------------------------------
|
|
208
|
-
|
|
209
|
-
const buildTestContext = (
|
|
210
|
-
scenario: HikeScenario,
|
|
211
|
-
ctx: Partial<TrailContext> | undefined,
|
|
212
|
-
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
213
|
-
): { trace: FollowRecord[]; testCtx: TrailContext } => {
|
|
214
|
-
const trace: FollowRecord[] = [];
|
|
215
|
-
const baseCtx = mergeTestContext(ctx);
|
|
216
|
-
const follow = createRecordingFollow(
|
|
217
|
-
trace,
|
|
218
|
-
scenario,
|
|
219
|
-
trailsMap,
|
|
220
|
-
baseCtx.follow,
|
|
221
|
-
baseCtx
|
|
222
|
-
);
|
|
223
|
-
return { testCtx: { ...baseCtx, follow }, trace };
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
const runScenario = async (
|
|
227
|
-
hikeDef: AnyHike,
|
|
228
|
-
scenario: HikeScenario,
|
|
229
|
-
ctx: Partial<TrailContext> | undefined,
|
|
230
|
-
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
231
|
-
): Promise<void> => {
|
|
232
|
-
const validated = validateInput(hikeDef.input, scenario.input);
|
|
233
|
-
if (handleValidationError(validated, scenario)) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const { trace, testCtx } = buildTestContext(scenario, ctx, trailsMap);
|
|
238
|
-
const result = await hikeDef.implementation(expectOk(validated), testCtx);
|
|
239
|
-
assertFollowTrace(trace, scenario);
|
|
240
|
-
assertScenarioResult(result, scenario, hikeDef);
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
// ---------------------------------------------------------------------------
|
|
244
|
-
// testHike
|
|
245
|
-
// ---------------------------------------------------------------------------
|
|
246
|
-
|
|
247
|
-
/** Options for testHike that provide trail definitions for injection. */
|
|
248
|
-
export interface TestHikeOptions {
|
|
249
|
-
/** Partial context overrides. */
|
|
250
|
-
readonly ctx?: Partial<TrailContext> | undefined;
|
|
251
|
-
/** Map of trail ID to trail definition, used for injectFromExample. */
|
|
252
|
-
readonly trails?: ReadonlyMap<string, AnyTrail> | undefined;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Generate a describe block for a hike with one test per scenario.
|
|
257
|
-
*
|
|
258
|
-
* @example
|
|
259
|
-
* ```ts
|
|
260
|
-
* testHike(onboardHike, [
|
|
261
|
-
* {
|
|
262
|
-
* description: "follows add then relate",
|
|
263
|
-
* input: { name: "Alpha" },
|
|
264
|
-
* expectOk: true,
|
|
265
|
-
* expectFollowed: ["entity.add", "entity.relate"],
|
|
266
|
-
* },
|
|
267
|
-
* ]);
|
|
268
|
-
* ```
|
|
269
|
-
*/
|
|
270
|
-
export const testHike = (
|
|
271
|
-
hikeDef: AnyHike,
|
|
272
|
-
scenarios: readonly HikeScenario[],
|
|
273
|
-
options?: TestHikeOptions
|
|
274
|
-
): void => {
|
|
275
|
-
describe(hikeDef.id, () => {
|
|
276
|
-
test.each([...scenarios])(
|
|
277
|
-
'$description',
|
|
278
|
-
async (scenario: HikeScenario) => {
|
|
279
|
-
await runScenario(hikeDef, scenario, options?.ctx, options?.trails);
|
|
280
|
-
}
|
|
281
|
-
);
|
|
282
|
-
});
|
|
283
|
-
};
|
package/tsconfig.json
DELETED
package/tsconfig.tsbuildinfo
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"root":["./src/all.ts","./src/assertions.ts","./src/context.ts","./src/contracts.ts","./src/detours.ts","./src/examples.ts","./src/harness-cli.ts","./src/harness-mcp.ts","./src/hike.ts","./src/index.ts","./src/logger.ts","./src/trail.ts","./src/types.ts"],"version":"5.9.3"}
|