@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.
Files changed (55) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +166 -0
  3. package/README.md +25 -7
  4. package/dist/all.d.ts +2 -1
  5. package/dist/all.d.ts.map +1 -1
  6. package/dist/all.js.map +1 -1
  7. package/dist/context.d.ts +28 -2
  8. package/dist/context.d.ts.map +1 -1
  9. package/dist/context.js +70 -11
  10. package/dist/context.js.map +1 -1
  11. package/dist/contracts.d.ts +3 -2
  12. package/dist/contracts.d.ts.map +1 -1
  13. package/dist/contracts.js +25 -10
  14. package/dist/contracts.js.map +1 -1
  15. package/dist/examples.d.ts +4 -3
  16. package/dist/examples.d.ts.map +1 -1
  17. package/dist/examples.js +63 -68
  18. package/dist/examples.js.map +1 -1
  19. package/dist/follows.d.ts +38 -0
  20. package/dist/follows.d.ts.map +1 -0
  21. package/dist/{hike.js → follows.js} +71 -28
  22. package/dist/follows.js.map +1 -0
  23. package/dist/harness-mcp.d.ts.map +1 -1
  24. package/dist/harness-mcp.js +5 -2
  25. package/dist/harness-mcp.js.map +1 -1
  26. package/dist/index.d.ts +6 -4
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/trail.js +1 -1
  31. package/dist/trail.js.map +1 -1
  32. package/dist/types.d.ts +2 -2
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +6 -6
  35. package/src/__tests__/all.test.ts +64 -0
  36. package/src/__tests__/context.test.ts +92 -2
  37. package/src/__tests__/contracts.test.ts +122 -5
  38. package/src/__tests__/detours.test.ts +3 -3
  39. package/src/__tests__/examples.test.ts +285 -22
  40. package/src/__tests__/follows.test.ts +589 -0
  41. package/src/__tests__/trail.test.ts +4 -4
  42. package/src/all.ts +5 -1
  43. package/src/context.ts +121 -13
  44. package/src/contracts.ts +43 -12
  45. package/src/examples.ts +111 -105
  46. package/src/{hike.ts → follows.ts} +138 -43
  47. package/src/harness-mcp.ts +5 -2
  48. package/src/index.ts +6 -4
  49. package/src/trail.ts +1 -1
  50. package/src/types.ts +3 -3
  51. package/tsconfig.tsbuildinfo +1 -1
  52. package/dist/hike.d.ts +0 -32
  53. package/dist/hike.d.ts.map +0 -1
  54. package/dist/hike.js.map +0 -1
  55. 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.1",
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": "workspace:^",
18
- "@ontrails/core": "workspace:^",
19
- "@ontrails/logging": "workspace:^",
20
- "@ontrails/mcp": "workspace:^",
21
- "zod": "catalog:"
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 { createTestContext } from '../context.js';
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).toBeDefined();
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
  // ---------------------------------------------------------------------------