@ontrails/testing 1.0.0-beta.1 → 1.0.0-beta.11
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-lint.log +1 -1
- package/CHANGELOG.md +166 -0
- package/README.md +25 -7
- package/dist/all.d.ts +2 -1
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js.map +1 -1
- package/dist/context.d.ts +28 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +70 -11
- package/dist/context.js.map +1 -1
- package/dist/contracts.d.ts +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +25 -10
- package/dist/contracts.js.map +1 -1
- package/dist/examples.d.ts +4 -3
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +63 -68
- package/dist/examples.js.map +1 -1
- package/dist/follows.d.ts +38 -0
- package/dist/follows.d.ts.map +1 -0
- package/dist/{hike.js → follows.js} +71 -28
- package/dist/follows.js.map +1 -0
- package/dist/harness-mcp.d.ts.map +1 -1
- package/dist/harness-mcp.js +5 -2
- package/dist/harness-mcp.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/trail.js +1 -1
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/all.test.ts +64 -0
- package/src/__tests__/context.test.ts +92 -2
- package/src/__tests__/contracts.test.ts +122 -5
- package/src/__tests__/detours.test.ts +3 -3
- package/src/__tests__/examples.test.ts +285 -22
- package/src/__tests__/follows.test.ts +589 -0
- package/src/__tests__/trail.test.ts +4 -4
- package/src/all.ts +5 -1
- package/src/context.ts +121 -13
- package/src/contracts.ts +43 -12
- package/src/examples.ts +111 -105
- package/src/{hike.ts → follows.ts} +138 -43
- package/src/harness-mcp.ts +5 -2
- package/src/index.ts +6 -4
- package/src/trail.ts +1 -1
- package/src/types.ts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/hike.d.ts +0 -32
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js.map +0 -1
- package/src/__tests__/hike.test.ts +0 -164
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/testing",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
|
-
"@ontrails/cli": "
|
|
18
|
-
"@ontrails/core": "
|
|
19
|
-
"@ontrails/logging": "
|
|
20
|
-
"@ontrails/mcp": "
|
|
21
|
-
"zod": "
|
|
17
|
+
"@ontrails/cli": "^1.0.0-beta.8",
|
|
18
|
+
"@ontrails/core": "^1.0.0-beta.8",
|
|
19
|
+
"@ontrails/logging": "^1.0.0-beta.8",
|
|
20
|
+
"@ontrails/mcp": "^1.0.0-beta.8",
|
|
21
|
+
"zod": "^4.3.5"
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { Result, service, trail, topo } from '@ontrails/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { testAll } from '../all.js';
|
|
7
|
+
|
|
8
|
+
const mockDbService = service('db.mock.all', {
|
|
9
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
10
|
+
mock: () => ({ source: 'mock' }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const mockedTrail = trail('service.mocked.all', {
|
|
14
|
+
description: 'Trail that uses a mocked service through testAll',
|
|
15
|
+
examples: [
|
|
16
|
+
{
|
|
17
|
+
expected: { source: 'mock' },
|
|
18
|
+
input: {},
|
|
19
|
+
name: 'Uses auto-resolved service mock',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
input: z.object({}),
|
|
23
|
+
output: z.object({ source: z.string() }),
|
|
24
|
+
run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
|
|
25
|
+
services: [mockDbService],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const overrideTrail = trail('service.override.all', {
|
|
29
|
+
description: 'Trail that prefers explicit overrides over mock factories',
|
|
30
|
+
examples: [
|
|
31
|
+
{
|
|
32
|
+
expected: { source: 'override' },
|
|
33
|
+
input: {},
|
|
34
|
+
name: 'Explicit service override wins',
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
input: z.object({}),
|
|
38
|
+
output: z.object({ source: z.string() }),
|
|
39
|
+
run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
|
|
40
|
+
services: [mockDbService],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('testAll service mocks', () => {
|
|
44
|
+
// eslint-disable-next-line jest/require-hook
|
|
45
|
+
testAll(
|
|
46
|
+
topo('test-all-service-mock-app', {
|
|
47
|
+
mockDbService,
|
|
48
|
+
mockedTrail,
|
|
49
|
+
} as Record<string, unknown>)
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('testAll explicit service overrides', () => {
|
|
54
|
+
// eslint-disable-next-line jest/require-hook
|
|
55
|
+
testAll(
|
|
56
|
+
topo('test-all-service-override-app', {
|
|
57
|
+
mockDbService,
|
|
58
|
+
overrideTrail,
|
|
59
|
+
} as Record<string, unknown>),
|
|
60
|
+
{
|
|
61
|
+
services: { 'db.mock.all': { source: 'override' } },
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Result, service, topo } from '@ontrails/core';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createFollowContext,
|
|
7
|
+
createTestContext,
|
|
8
|
+
mergeTestContext,
|
|
9
|
+
resolveMockServices,
|
|
10
|
+
} from '../context.js';
|
|
4
11
|
import type { TestLogger } from '../types.js';
|
|
5
12
|
|
|
6
13
|
// ---------------------------------------------------------------------------
|
|
@@ -10,11 +17,12 @@ import type { TestLogger } from '../types.js';
|
|
|
10
17
|
describe('createTestContext', () => {
|
|
11
18
|
test('produces a valid TrailContext with no args', () => {
|
|
12
19
|
const ctx = createTestContext();
|
|
20
|
+
expect(ctx.cwd).toBe(process.cwd());
|
|
13
21
|
expect(ctx.requestId).toBe('test-request-001');
|
|
14
22
|
expect(ctx.signal).toBeDefined();
|
|
15
23
|
expect(ctx.signal.aborted).toBe(false);
|
|
16
24
|
expect(ctx.logger).toBeDefined();
|
|
17
|
-
expect(ctx.workspaceRoot).
|
|
25
|
+
expect(ctx.workspaceRoot).toBe(process.cwd());
|
|
18
26
|
});
|
|
19
27
|
|
|
20
28
|
test('default logger is a TestLogger', () => {
|
|
@@ -47,6 +55,7 @@ describe('createTestContext', () => {
|
|
|
47
55
|
|
|
48
56
|
test('overrides cwd', () => {
|
|
49
57
|
const ctx = createTestContext({ cwd: '/tmp/test' });
|
|
58
|
+
expect(ctx.cwd).toBe('/tmp/test');
|
|
50
59
|
expect(ctx.workspaceRoot).toBe('/tmp/test');
|
|
51
60
|
});
|
|
52
61
|
|
|
@@ -58,3 +67,84 @@ describe('createTestContext', () => {
|
|
|
58
67
|
expect(env).toEqual({ CUSTOM: '1', NODE_ENV: 'test' });
|
|
59
68
|
});
|
|
60
69
|
});
|
|
70
|
+
|
|
71
|
+
describe('createFollowContext', () => {
|
|
72
|
+
test('returns configured ok response for registered id', async () => {
|
|
73
|
+
const follow = createFollowContext({
|
|
74
|
+
responses: { 'entity.add': Result.ok({ id: '1', name: 'Alpha' }) },
|
|
75
|
+
});
|
|
76
|
+
const result = await follow('entity.add', { name: 'Alpha' });
|
|
77
|
+
expect(result.unwrap()).toEqual({ id: '1', name: 'Alpha' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('returns configured err response for registered id', async () => {
|
|
81
|
+
const err = new Error('conflict');
|
|
82
|
+
const follow = createFollowContext({
|
|
83
|
+
responses: { 'entity.add': Result.err(err) },
|
|
84
|
+
});
|
|
85
|
+
const result = await follow('entity.add', {});
|
|
86
|
+
expect(result.isErr()).toBe(true);
|
|
87
|
+
expect(result.error?.message).toBe('conflict');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('returns err with descriptive message for unregistered id', async () => {
|
|
91
|
+
const follow = createFollowContext();
|
|
92
|
+
const result = await follow('unknown.trail', {});
|
|
93
|
+
expect(result.isErr()).toBe(true);
|
|
94
|
+
expect(result.error?.message).toContain('unknown.trail');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('no options defaults to empty responses', async () => {
|
|
98
|
+
const follow = createFollowContext();
|
|
99
|
+
const result = await follow('any.id', {});
|
|
100
|
+
expect(result.isErr()).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('mergeTestContext', () => {
|
|
105
|
+
test('merges service overrides into extensions and the service accessor', () => {
|
|
106
|
+
const ctx = mergeTestContext(
|
|
107
|
+
{
|
|
108
|
+
extensions: { existing: true },
|
|
109
|
+
},
|
|
110
|
+
{ 'db.main': { source: 'mock' } }
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(ctx.extensions).toEqual({
|
|
114
|
+
'db.main': { source: 'mock' },
|
|
115
|
+
existing: true,
|
|
116
|
+
});
|
|
117
|
+
expect(ctx.service<{ source: string }>('db.main').source).toBe('mock');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('resolveMockServices', () => {
|
|
122
|
+
test('creates fresh mock services for each invocation', async () => {
|
|
123
|
+
let mockCalls = 0;
|
|
124
|
+
const mockable = service(`service.mock.${Bun.randomUUIDv7()}`, {
|
|
125
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
126
|
+
mock: () => {
|
|
127
|
+
mockCalls += 1;
|
|
128
|
+
return Promise.resolve({ source: 'mock' });
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const app = topo('mock-app', { mockable } as Record<string, unknown>);
|
|
132
|
+
|
|
133
|
+
const first = await resolveMockServices(app);
|
|
134
|
+
const second = await resolveMockServices(app);
|
|
135
|
+
|
|
136
|
+
expect(first).toEqual({ [mockable.id]: { source: 'mock' } });
|
|
137
|
+
expect(second).toEqual(first);
|
|
138
|
+
expect(second[mockable.id]).not.toBe(first[mockable.id]);
|
|
139
|
+
expect(mockCalls).toBe(2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('skips services without mock factories', async () => {
|
|
143
|
+
const plain = service(`service.plain.${Bun.randomUUIDv7()}`, {
|
|
144
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
145
|
+
});
|
|
146
|
+
const app = topo('plain-app', { plain } as Record<string, unknown>);
|
|
147
|
+
|
|
148
|
+
expect(await resolveMockServices(app)).toEqual({});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { Result, trail, topo } from '@ontrails/core';
|
|
3
|
+
import { Result, service, trail, topo } from '@ontrails/core';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
|
|
6
6
|
import { testContracts } from '../contracts.js';
|
|
@@ -18,24 +18,100 @@ const validTrail = trail('valid', {
|
|
|
18
18
|
name: 'Valid output',
|
|
19
19
|
},
|
|
20
20
|
],
|
|
21
|
-
implementation: (input: { name: string }) =>
|
|
22
|
-
Result.ok({ id: 1, name: input.name }),
|
|
23
21
|
input: z.object({ name: z.string() }),
|
|
24
22
|
output: z.object({ id: z.number(), name: z.string() }),
|
|
23
|
+
run: (input: { name: string }) => Result.ok({ id: 1, name: input.name }),
|
|
25
24
|
});
|
|
26
25
|
|
|
27
26
|
/** Trail without output schema -- should be skipped. */
|
|
28
27
|
const noSchemaTrail = trail('noschema', {
|
|
29
28
|
examples: [{ expected: 10, input: { x: 5 }, name: 'No schema' }],
|
|
30
|
-
implementation: (input: { x: number }) => Result.ok(input.x * 2),
|
|
31
29
|
input: z.object({ x: z.number() }),
|
|
30
|
+
run: (input: { x: number }) => Result.ok(input.x * 2),
|
|
32
31
|
});
|
|
33
32
|
|
|
34
33
|
/** Trail without examples -- should be skipped. */
|
|
35
34
|
const noExamplesTrail = trail('noexamples', {
|
|
36
|
-
implementation: (input: { x: number }) => Result.ok({ value: input.x }),
|
|
37
35
|
input: z.object({ x: z.number() }),
|
|
38
36
|
output: z.object({ value: z.number() }),
|
|
37
|
+
run: (input: { x: number }) => Result.ok({ value: input.x }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Composition trail
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Composition trail whose implementation matches the output schema. */
|
|
45
|
+
const compositionTrail = trail('composition.valid', {
|
|
46
|
+
examples: [
|
|
47
|
+
{
|
|
48
|
+
expected: { total: 3 },
|
|
49
|
+
input: { a: 1, b: 2 },
|
|
50
|
+
name: 'Valid composition output',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
follow: ['valid'],
|
|
54
|
+
input: z.object({ a: z.number(), b: z.number() }),
|
|
55
|
+
output: z.object({ total: z.number() }),
|
|
56
|
+
run: (input: { a: number; b: number }) =>
|
|
57
|
+
Result.ok({ total: input.a + input.b }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const transformedInputTrail = trail('contract.transformed', {
|
|
61
|
+
examples: [
|
|
62
|
+
{
|
|
63
|
+
expected: { value: 2 },
|
|
64
|
+
input: { value: '1' },
|
|
65
|
+
name: 'Raw contract input is only transformed once',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
input: z
|
|
69
|
+
.object({ value: z.string() })
|
|
70
|
+
.transform(({ value }) => ({ value: Number(value) + 1 })),
|
|
71
|
+
output: z.object({ value: z.number() }),
|
|
72
|
+
run: (input: { value: number }) => Result.ok({ value: input.value }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const undeclaredContractDbService = service('db.undeclared.contracts', {
|
|
76
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
77
|
+
mock: () => ({ source: 'mock' }),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const undeclaredContractTrail = trail('service.undeclared.contracts', {
|
|
81
|
+
examples: [
|
|
82
|
+
{
|
|
83
|
+
expected: { hasInjectedService: false },
|
|
84
|
+
input: {},
|
|
85
|
+
name: 'Undeclared services are not preloaded into contract contexts',
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
input: z.object({}),
|
|
89
|
+
output: z.object({ hasInjectedService: z.literal(false) }),
|
|
90
|
+
run: (_input, ctx) =>
|
|
91
|
+
Result.ok({
|
|
92
|
+
hasInjectedService:
|
|
93
|
+
ctx.extensions?.[undeclaredContractDbService.id] !== undefined,
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const ctxOverrideContractService = service('db.mock.contracts', {
|
|
98
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
99
|
+
mock: () => ({ source: 'mock' }),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const ctxOverrideContractTrail = trail('service.ctx.contracts', {
|
|
103
|
+
examples: [
|
|
104
|
+
{
|
|
105
|
+
expected: { source: 'ctx' },
|
|
106
|
+
input: {},
|
|
107
|
+
name: 'Context extensions beat auto-resolved contract mocks',
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
input: z.object({}),
|
|
111
|
+
output: z.object({ source: z.literal('ctx') }),
|
|
112
|
+
run: (_input, ctx) =>
|
|
113
|
+
Result.ok({ source: ctxOverrideContractService.from(ctx).source }),
|
|
114
|
+
services: [ctxOverrideContractService],
|
|
39
115
|
});
|
|
40
116
|
|
|
41
117
|
// ---------------------------------------------------------------------------
|
|
@@ -66,3 +142,44 @@ describe('testContracts: skips trails without examples', () => {
|
|
|
66
142
|
// Trail without examples is skipped -- no contract tests generated
|
|
67
143
|
});
|
|
68
144
|
});
|
|
145
|
+
|
|
146
|
+
describe('testContracts: validates composition trail output schemas', () => {
|
|
147
|
+
// eslint-disable-next-line jest/require-hook
|
|
148
|
+
testContracts(
|
|
149
|
+
topo('test-app', { compositionTrail } as Record<string, unknown>)
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('testContracts: raw transformed input', () => {
|
|
154
|
+
// eslint-disable-next-line jest/require-hook
|
|
155
|
+
testContracts(
|
|
156
|
+
topo('transformed-contract-app', {
|
|
157
|
+
transformedInputTrail,
|
|
158
|
+
} as Record<string, unknown>)
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('testContracts: context extension overrides', () => {
|
|
163
|
+
// eslint-disable-next-line jest/require-hook
|
|
164
|
+
testContracts(
|
|
165
|
+
topo('ctx-contract-app', {
|
|
166
|
+
ctxOverrideContractService,
|
|
167
|
+
ctxOverrideContractTrail,
|
|
168
|
+
} as Record<string, unknown>),
|
|
169
|
+
{
|
|
170
|
+
ctx: {
|
|
171
|
+
extensions: { 'db.mock.contracts': { source: 'ctx' } },
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('testContracts service declarations', () => {
|
|
178
|
+
// eslint-disable-next-line jest/require-hook
|
|
179
|
+
testContracts(
|
|
180
|
+
topo('undeclared-contract-service-app', {
|
|
181
|
+
undeclaredContractDbService,
|
|
182
|
+
undeclaredContractTrail,
|
|
183
|
+
} as Record<string, unknown>)
|
|
184
|
+
);
|
|
185
|
+
});
|
|
@@ -13,18 +13,18 @@ const showTrail = trail('entity.show', {
|
|
|
13
13
|
detours: {
|
|
14
14
|
related: ['entity.list'],
|
|
15
15
|
},
|
|
16
|
-
implementation: (input: { id: string }) => Result.ok({ id: input.id }),
|
|
17
16
|
input: z.object({ id: z.string() }),
|
|
17
|
+
run: (input: { id: string }) => Result.ok({ id: input.id }),
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
const listTrail = trail('entity.list', {
|
|
21
|
-
implementation: () => Result.ok([]),
|
|
22
21
|
input: z.object({}),
|
|
22
|
+
run: () => Result.ok([]),
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
const noDetoursTrail = trail('entity.plain', {
|
|
26
|
-
implementation: () => Result.ok('ok'),
|
|
27
26
|
input: z.object({}),
|
|
27
|
+
run: () => Result.ok('ok'),
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|