@ontrails/core 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 +15 -0
- package/README.md +179 -0
- package/dist/adapters.d.ts +39 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +2 -0
- package/dist/adapters.js.map +1 -0
- package/dist/blob-ref.d.ts +20 -0
- package/dist/blob-ref.d.ts.map +1 -0
- package/dist/blob-ref.js +22 -0
- package/dist/blob-ref.js.map +1 -0
- package/dist/branded.d.ts +36 -0
- package/dist/branded.d.ts.map +1 -0
- package/dist/branded.js +89 -0
- package/dist/branded.js.map +1 -0
- package/dist/collections.d.ts +31 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +60 -0
- package/dist/collections.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/context.js.map +1 -0
- package/dist/derive.d.ts +33 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +122 -0
- package/dist/derive.js.map +1 -0
- package/dist/errors.d.ts +83 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +142 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +45 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +17 -0
- package/dist/event.js.map +1 -0
- package/dist/fetch.d.ts +15 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +102 -0
- package/dist/fetch.js.map +1 -0
- package/dist/guards.d.ts +17 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +25 -0
- package/dist/guards.js.map +1 -0
- package/dist/health.d.ts +18 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +5 -0
- package/dist/health.js.map +1 -0
- package/dist/hike.d.ts +36 -0
- package/dist/hike.d.ts.map +1 -0
- package/dist/hike.js +20 -0
- package/dist/hike.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +24 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +17 -0
- package/dist/job.js.map +1 -0
- package/dist/layer.d.ts +17 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +21 -0
- package/dist/layer.js.map +1 -0
- package/dist/path-security.d.ts +28 -0
- package/dist/path-security.d.ts.map +1 -0
- package/dist/path-security.js +63 -0
- package/dist/path-security.js.map +1 -0
- package/dist/patterns/bulk.d.ts +15 -0
- package/dist/patterns/bulk.d.ts.map +1 -0
- package/dist/patterns/bulk.js +14 -0
- package/dist/patterns/bulk.js.map +1 -0
- package/dist/patterns/change.d.ts +10 -0
- package/dist/patterns/change.d.ts.map +1 -0
- package/dist/patterns/change.js +10 -0
- package/dist/patterns/change.js.map +1 -0
- package/dist/patterns/date-range.d.ts +10 -0
- package/dist/patterns/date-range.d.ts.map +1 -0
- package/dist/patterns/date-range.js +10 -0
- package/dist/patterns/date-range.js.map +1 -0
- package/dist/patterns/index.d.ts +9 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +9 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/patterns/pagination.d.ts +18 -0
- package/dist/patterns/pagination.d.ts.map +1 -0
- package/dist/patterns/pagination.js +18 -0
- package/dist/patterns/pagination.js.map +1 -0
- package/dist/patterns/progress.d.ts +11 -0
- package/dist/patterns/progress.d.ts.map +1 -0
- package/dist/patterns/progress.js +11 -0
- package/dist/patterns/progress.js.map +1 -0
- package/dist/patterns/sorting.d.ts +13 -0
- package/dist/patterns/sorting.d.ts.map +1 -0
- package/dist/patterns/sorting.js +10 -0
- package/dist/patterns/sorting.js.map +1 -0
- package/dist/patterns/status.d.ts +15 -0
- package/dist/patterns/status.d.ts.map +1 -0
- package/dist/patterns/status.js +9 -0
- package/dist/patterns/status.js.map +1 -0
- package/dist/patterns/timestamps.d.ts +10 -0
- package/dist/patterns/timestamps.d.ts.map +1 -0
- package/dist/patterns/timestamps.js +10 -0
- package/dist/patterns/timestamps.js.map +1 -0
- package/dist/redaction/index.d.ts +4 -0
- package/dist/redaction/index.d.ts.map +1 -0
- package/dist/redaction/index.js +3 -0
- package/dist/redaction/index.js.map +1 -0
- package/dist/redaction/patterns.d.ts +9 -0
- package/dist/redaction/patterns.d.ts.map +1 -0
- package/dist/redaction/patterns.js +39 -0
- package/dist/redaction/patterns.js.map +1 -0
- package/dist/redaction/redactor.d.ts +27 -0
- package/dist/redaction/redactor.d.ts.map +1 -0
- package/dist/redaction/redactor.js +89 -0
- package/dist/redaction/redactor.js.map +1 -0
- package/dist/resilience.d.ts +34 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +164 -0
- package/dist/resilience.js.map +1 -0
- package/dist/result.d.ts +57 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +145 -0
- package/dist/result.js.map +1 -0
- package/dist/serialization.d.ts +27 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +115 -0
- package/dist/serialization.js.map +1 -0
- package/dist/topo.d.ts +18 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +74 -0
- package/dist/topo.js.map +1 -0
- package/dist/trail.d.ts +83 -0
- package/dist/trail.d.ts.map +1 -0
- package/dist/trail.js +16 -0
- package/dist/trail.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-topo.d.ts +24 -0
- package/dist/validate-topo.d.ts.map +1 -0
- package/dist/validate-topo.js +108 -0
- package/dist/validate-topo.js.map +1 -0
- package/dist/validation.d.ts +27 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +134 -0
- package/dist/validation.js.map +1 -0
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +57 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/blob-ref.test.ts +103 -0
- package/src/__tests__/branded.test.ts +148 -0
- package/src/__tests__/collections.test.ts +126 -0
- package/src/__tests__/context.test.ts +66 -0
- package/src/__tests__/derive.test.ts +159 -0
- package/src/__tests__/errors.test.ts +309 -0
- package/src/__tests__/event.test.ts +82 -0
- package/src/__tests__/fetch.test.ts +217 -0
- package/src/__tests__/guards.test.ts +102 -0
- package/src/__tests__/hike.test.ts +117 -0
- package/src/__tests__/job.test.ts +98 -0
- package/src/__tests__/layer.test.ts +224 -0
- package/src/__tests__/path-security.test.ts +114 -0
- package/src/__tests__/patterns.test.ts +273 -0
- package/src/__tests__/redaction.test.ts +244 -0
- package/src/__tests__/resilience.test.ts +246 -0
- package/src/__tests__/result.test.ts +155 -0
- package/src/__tests__/serialization.test.ts +236 -0
- package/src/__tests__/topo.test.ts +184 -0
- package/src/__tests__/trail.test.ts +179 -0
- package/src/__tests__/validate-topo.test.ts +201 -0
- package/src/__tests__/validation.test.ts +283 -0
- package/src/__tests__/workspace.test.ts +183 -0
- package/src/adapters.ts +68 -0
- package/src/blob-ref.ts +39 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/context.ts +18 -0
- package/src/derive.ts +223 -0
- package/src/errors.ts +196 -0
- package/src/event.ts +77 -0
- package/src/fetch.ts +138 -0
- package/src/guards.ts +37 -0
- package/src/health.ts +23 -0
- package/src/hike.ts +77 -0
- package/src/index.ts +158 -0
- package/src/job.ts +20 -0
- package/src/layer.ts +44 -0
- package/src/path-security.ts +90 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +47 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/result.ts +180 -0
- package/src/serialization.ts +183 -0
- package/src/topo.ts +123 -0
- package/src/trail.ts +130 -0
- package/src/types.ts +58 -0
- package/src/validate-topo.ts +151 -0
- package/src/validation.ts +182 -0
- package/src/workspace.ts +77 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { Result } from '../result.js';
|
|
6
|
+
import { topo } from '../topo.js';
|
|
7
|
+
import type { TopoIssue } from '../validate-topo.js';
|
|
8
|
+
import { validateTopo } from '../validate-topo.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
// oxlint-disable-next-line require-await -- satisfies async interface
|
|
15
|
+
const noop = async () => Result.ok();
|
|
16
|
+
|
|
17
|
+
const mockTrail = (
|
|
18
|
+
id: string,
|
|
19
|
+
overrides?: {
|
|
20
|
+
examples?: readonly {
|
|
21
|
+
name: string;
|
|
22
|
+
input: unknown;
|
|
23
|
+
expected?: unknown;
|
|
24
|
+
error?: string;
|
|
25
|
+
}[];
|
|
26
|
+
output?: z.ZodType;
|
|
27
|
+
}
|
|
28
|
+
) => ({
|
|
29
|
+
id,
|
|
30
|
+
implementation: noop,
|
|
31
|
+
input: z.object({ name: z.string() }),
|
|
32
|
+
kind: 'trail' as const,
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const mockHike = (id: string, follows: readonly string[]) => ({
|
|
37
|
+
follows,
|
|
38
|
+
id,
|
|
39
|
+
implementation: noop,
|
|
40
|
+
input: z.object({ q: z.string() }),
|
|
41
|
+
kind: 'hike' as const,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const mockEvent = (id: string, from?: readonly string[]) => ({
|
|
45
|
+
from,
|
|
46
|
+
id,
|
|
47
|
+
kind: 'event' as const,
|
|
48
|
+
payload: z.object({ data: z.string() }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Extract issues from a failed validateTopo result. */
|
|
52
|
+
const extractIssues = (result: Result<void, Error>): TopoIssue[] => {
|
|
53
|
+
if (result.isOk()) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const ctx = (result.error as { context?: { issues?: TopoIssue[] } }).context;
|
|
57
|
+
return (ctx?.issues ?? []) as TopoIssue[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Tests
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe('validateTopo', () => {
|
|
65
|
+
test('valid topo passes', () => {
|
|
66
|
+
const app = topo('app', {
|
|
67
|
+
add: mockTrail('entity.add'),
|
|
68
|
+
onboard: mockHike('entity.onboard', ['entity.add']),
|
|
69
|
+
updated: mockEvent('entity.updated', ['entity.add']),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const result = validateTopo(app);
|
|
73
|
+
expect(result.isOk()).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('hike follows', () => {
|
|
77
|
+
test('hike following non-existent trail fails', () => {
|
|
78
|
+
const app = topo('app', {
|
|
79
|
+
onboard: mockHike('entity.onboard', ['entity.missing']),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = validateTopo(app);
|
|
83
|
+
expect(result.isErr()).toBe(true);
|
|
84
|
+
|
|
85
|
+
const issues = extractIssues(result);
|
|
86
|
+
expect(issues).toHaveLength(1);
|
|
87
|
+
expect(issues[0]?.rule).toBe('follows-exist');
|
|
88
|
+
expect(issues[0]?.message).toContain('entity.missing');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('hike following itself fails', () => {
|
|
92
|
+
const app = topo('app', {
|
|
93
|
+
loop: mockHike('entity.loop', ['entity.loop']),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = validateTopo(app);
|
|
97
|
+
expect(result.isErr()).toBe(true);
|
|
98
|
+
|
|
99
|
+
const issues = extractIssues(result);
|
|
100
|
+
expect(issues).toHaveLength(1);
|
|
101
|
+
expect(issues[0]?.rule).toBe('no-self-follow');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('example validation', () => {
|
|
106
|
+
test('example with invalid input fails', () => {
|
|
107
|
+
const app = topo('app', {
|
|
108
|
+
show: mockTrail('entity.show', {
|
|
109
|
+
examples: [{ input: { name: 123 }, name: 'Bad input' }],
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const result = validateTopo(app);
|
|
114
|
+
expect(result.isErr()).toBe(true);
|
|
115
|
+
|
|
116
|
+
const issues = extractIssues(result);
|
|
117
|
+
expect(issues).toHaveLength(1);
|
|
118
|
+
expect(issues[0]?.rule).toBe('example-input-valid');
|
|
119
|
+
expect(issues[0]?.message).toContain('Bad input');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('example with expected output but no output schema warns', () => {
|
|
123
|
+
const app = topo('app', {
|
|
124
|
+
show: mockTrail('entity.show', {
|
|
125
|
+
examples: [
|
|
126
|
+
{
|
|
127
|
+
expected: { result: 'ok' },
|
|
128
|
+
input: { name: 'test' },
|
|
129
|
+
name: 'Has expected',
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = validateTopo(app);
|
|
136
|
+
expect(result.isErr()).toBe(true);
|
|
137
|
+
|
|
138
|
+
const issues = extractIssues(result);
|
|
139
|
+
expect(issues).toHaveLength(1);
|
|
140
|
+
expect(issues[0]?.rule).toBe('output-schema-present');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('error example with invalid input is allowed', () => {
|
|
144
|
+
const app = topo('app', {
|
|
145
|
+
show: mockTrail('entity.show', {
|
|
146
|
+
examples: [
|
|
147
|
+
{
|
|
148
|
+
error: 'ValidationError',
|
|
149
|
+
input: { name: 123 },
|
|
150
|
+
name: 'Error case',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = validateTopo(app);
|
|
157
|
+
expect(result.isOk()).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('event origins', () => {
|
|
162
|
+
test('event with non-existent origin fails', () => {
|
|
163
|
+
const app = topo('app', {
|
|
164
|
+
updated: mockEvent('entity.updated', ['entity.ghost']),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = validateTopo(app);
|
|
168
|
+
expect(result.isErr()).toBe(true);
|
|
169
|
+
|
|
170
|
+
const issues = extractIssues(result);
|
|
171
|
+
expect(issues).toHaveLength(1);
|
|
172
|
+
expect(issues[0]?.rule).toBe('event-origin-exists');
|
|
173
|
+
expect(issues[0]?.message).toContain('entity.ghost');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('event without origins is accepted', () => {
|
|
177
|
+
const app = topo('app', {
|
|
178
|
+
updated: mockEvent('entity.updated'),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = validateTopo(app);
|
|
182
|
+
expect(result.isOk()).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('collects multiple issues', () => {
|
|
187
|
+
const app = topo('app', {
|
|
188
|
+
broken: mockHike('entity.broken', ['entity.missing']),
|
|
189
|
+
show: mockTrail('entity.show', {
|
|
190
|
+
examples: [{ input: { name: 123 }, name: 'Bad' }],
|
|
191
|
+
}),
|
|
192
|
+
updated: mockEvent('entity.updated', ['entity.ghost']),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const result = validateTopo(app);
|
|
196
|
+
expect(result.isErr()).toBe(true);
|
|
197
|
+
|
|
198
|
+
const issues = extractIssues(result);
|
|
199
|
+
expect(issues).toHaveLength(3);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { ValidationError } from '../errors.js';
|
|
6
|
+
import {
|
|
7
|
+
validateInput,
|
|
8
|
+
validateOutput,
|
|
9
|
+
formatZodIssues,
|
|
10
|
+
zodToJsonSchema,
|
|
11
|
+
} from '../validation.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// validateInput
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
describe('validateInput', () => {
|
|
18
|
+
const schema = z.object({
|
|
19
|
+
age: z.number().min(0),
|
|
20
|
+
name: z.string(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('returns Ok for valid data', () => {
|
|
24
|
+
const result = validateInput(schema, { age: 30, name: 'Alice' });
|
|
25
|
+
expect(result.isOk()).toBe(true);
|
|
26
|
+
expect(result.unwrap()).toEqual({ age: 30, name: 'Alice' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns Err with ValidationError for invalid data', () => {
|
|
30
|
+
const result = validateInput(schema, { name: 123 });
|
|
31
|
+
expect(result.isErr()).toBe(true);
|
|
32
|
+
const err = result as unknown as { error: ValidationError };
|
|
33
|
+
expect(err.error).toBeInstanceOf(ValidationError);
|
|
34
|
+
expect(err.error.category).toBe('validation');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('includes formatted issues in error message', () => {
|
|
38
|
+
const result = validateInput(schema, {});
|
|
39
|
+
expect(result.isErr()).toBe(true);
|
|
40
|
+
const err = result as unknown as { error: ValidationError };
|
|
41
|
+
expect(err.error.message).toContain('name');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('attaches ZodError as cause', () => {
|
|
45
|
+
const result = validateInput(schema, {});
|
|
46
|
+
expect(result.isErr()).toBe(true);
|
|
47
|
+
const err = result as unknown as { error: ValidationError };
|
|
48
|
+
expect(err.error.cause).toBeInstanceOf(z.ZodError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('attaches issues in context', () => {
|
|
52
|
+
const result = validateInput(schema, {});
|
|
53
|
+
expect(result.isErr()).toBe(true);
|
|
54
|
+
const err = result as unknown as { error: ValidationError };
|
|
55
|
+
expect(err.error.context).toBeDefined();
|
|
56
|
+
expect(Array.isArray(err.error.context?.['issues'])).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('works with simple string schema', () => {
|
|
60
|
+
const str = z.string().min(1);
|
|
61
|
+
expect(validateInput(str, 'hello').isOk()).toBe(true);
|
|
62
|
+
expect(validateInput(str, '').isErr()).toBe(true);
|
|
63
|
+
expect(validateInput(str, 42).isErr()).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// validateOutput
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('validateOutput', () => {
|
|
72
|
+
const schema = z.object({
|
|
73
|
+
id: z.string(),
|
|
74
|
+
score: z.number(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('returns Ok for valid data', () => {
|
|
78
|
+
const result = validateOutput(schema, { id: 'abc', score: 42 });
|
|
79
|
+
expect(result.isOk()).toBe(true);
|
|
80
|
+
expect(result.unwrap()).toEqual({ id: 'abc', score: 42 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('returns Err with ValidationError for invalid data', () => {
|
|
84
|
+
const result = validateOutput(schema, { id: 123 });
|
|
85
|
+
expect(result.isErr()).toBe(true);
|
|
86
|
+
const err = result as unknown as { error: ValidationError };
|
|
87
|
+
expect(err.error).toBeInstanceOf(ValidationError);
|
|
88
|
+
expect(err.error.category).toBe('validation');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('error message includes "Output validation failed" prefix', () => {
|
|
92
|
+
const result = validateOutput(schema, {});
|
|
93
|
+
expect(result.isErr()).toBe(true);
|
|
94
|
+
const err = result as unknown as { error: ValidationError };
|
|
95
|
+
expect(err.error.message).toContain('Output validation failed');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('attaches ZodError as cause', () => {
|
|
99
|
+
const result = validateOutput(schema, {});
|
|
100
|
+
expect(result.isErr()).toBe(true);
|
|
101
|
+
const err = result as unknown as { error: ValidationError };
|
|
102
|
+
expect(err.error.cause).toBeInstanceOf(z.ZodError);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('attaches issues in context', () => {
|
|
106
|
+
const result = validateOutput(schema, {});
|
|
107
|
+
expect(result.isErr()).toBe(true);
|
|
108
|
+
const err = result as unknown as { error: ValidationError };
|
|
109
|
+
expect(err.error.context).toBeDefined();
|
|
110
|
+
expect(Array.isArray(err.error.context?.['issues'])).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// formatZodIssues
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe('formatZodIssues', () => {
|
|
119
|
+
test('formats issues with paths', () => {
|
|
120
|
+
const schema = z.object({ email: z.string().email() });
|
|
121
|
+
const parsed = schema.safeParse({ email: 'not-email' });
|
|
122
|
+
expect(parsed.success).toBe(false);
|
|
123
|
+
const failed1 = parsed as unknown as { error: z.ZodError };
|
|
124
|
+
const messages1 = formatZodIssues(failed1.error.issues);
|
|
125
|
+
expect(messages1.length).toBeGreaterThan(0);
|
|
126
|
+
expect(messages1[0]).toMatch(/^email: /);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('formats root-level issues without path prefix', () => {
|
|
130
|
+
const schema = z.string();
|
|
131
|
+
const parsed = schema.safeParse(42);
|
|
132
|
+
expect(parsed.success).toBe(false);
|
|
133
|
+
const failed = parsed as unknown as { error: z.ZodError };
|
|
134
|
+
const messages = formatZodIssues(failed.error.issues);
|
|
135
|
+
expect(messages.length).toBeGreaterThan(0);
|
|
136
|
+
// Root issues have no "path: " prefix — message starts directly
|
|
137
|
+
// (the message itself may contain colons, but won't start with "path: ")
|
|
138
|
+
expect(messages[0]).not.toMatch(/^\w+: /);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('formats nested paths with dot notation', () => {
|
|
142
|
+
const schema = z.object({
|
|
143
|
+
user: z.object({ name: z.string() }),
|
|
144
|
+
});
|
|
145
|
+
const parsed = schema.safeParse({ user: { name: 123 } });
|
|
146
|
+
expect(parsed.success).toBe(false);
|
|
147
|
+
const failed = parsed as unknown as { error: z.ZodError };
|
|
148
|
+
const messages = formatZodIssues(failed.error.issues);
|
|
149
|
+
expect(messages[0]).toMatch(/^user\.name: /);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// zodToJsonSchema
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('zodToJsonSchema', () => {
|
|
158
|
+
describe('primitives', () => {
|
|
159
|
+
test('converts z.string()', () => {
|
|
160
|
+
expect(zodToJsonSchema(z.string())).toEqual({ type: 'string' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('converts z.number()', () => {
|
|
164
|
+
expect(zodToJsonSchema(z.number())).toEqual({ type: 'number' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('converts z.boolean()', () => {
|
|
168
|
+
expect(zodToJsonSchema(z.boolean())).toEqual({ type: 'boolean' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('converts z.literal()', () => {
|
|
172
|
+
expect(zodToJsonSchema(z.literal('hello'))).toEqual({ const: 'hello' });
|
|
173
|
+
expect(zodToJsonSchema(z.literal(42))).toEqual({ const: 42 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('converts z.enum()', () => {
|
|
177
|
+
expect(zodToJsonSchema(z.enum(['a', 'b', 'c']))).toEqual({
|
|
178
|
+
enum: ['a', 'b', 'c'],
|
|
179
|
+
type: 'string',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('converts z.array()', () => {
|
|
184
|
+
expect(zodToJsonSchema(z.array(z.string()))).toEqual({
|
|
185
|
+
items: { type: 'string' },
|
|
186
|
+
type: 'array',
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('objects', () => {
|
|
192
|
+
test('converts z.object() with required fields', () => {
|
|
193
|
+
const schema = z.object({ age: z.number(), name: z.string() });
|
|
194
|
+
const result = zodToJsonSchema(schema);
|
|
195
|
+
expect(result).toEqual({
|
|
196
|
+
properties: {
|
|
197
|
+
age: { type: 'number' },
|
|
198
|
+
name: { type: 'string' },
|
|
199
|
+
},
|
|
200
|
+
required: ['age', 'name'],
|
|
201
|
+
type: 'object',
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('converts z.object() with optional fields', () => {
|
|
206
|
+
const schema = z.object({
|
|
207
|
+
name: z.string(),
|
|
208
|
+
nickname: z.string().optional(),
|
|
209
|
+
});
|
|
210
|
+
const result = zodToJsonSchema(schema);
|
|
211
|
+
expect(result).toEqual({
|
|
212
|
+
properties: {
|
|
213
|
+
name: { type: 'string' },
|
|
214
|
+
nickname: { type: 'string' },
|
|
215
|
+
},
|
|
216
|
+
required: ['name'],
|
|
217
|
+
type: 'object',
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('converts z.object() with default fields', () => {
|
|
222
|
+
const schema = z.object({
|
|
223
|
+
name: z.string(),
|
|
224
|
+
role: z.string().default('user'),
|
|
225
|
+
});
|
|
226
|
+
const result = zodToJsonSchema(schema);
|
|
227
|
+
expect(result).toEqual({
|
|
228
|
+
properties: {
|
|
229
|
+
name: { type: 'string' },
|
|
230
|
+
role: { default: 'user', type: 'string' },
|
|
231
|
+
},
|
|
232
|
+
required: ['name'],
|
|
233
|
+
type: 'object',
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('handles nested objects', () => {
|
|
238
|
+
const schema = z.object({
|
|
239
|
+
user: z.object({ name: z.string() }),
|
|
240
|
+
});
|
|
241
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
242
|
+
properties: {
|
|
243
|
+
user: {
|
|
244
|
+
properties: { name: { type: 'string' } },
|
|
245
|
+
required: ['name'],
|
|
246
|
+
type: 'object',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
required: ['user'],
|
|
250
|
+
type: 'object',
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('modifiers and combinators', () => {
|
|
256
|
+
test('converts z.union()', () => {
|
|
257
|
+
const schema = z.union([z.string(), z.number()]);
|
|
258
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
259
|
+
anyOf: [{ type: 'string' }, { type: 'number' }],
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('preserves z.describe()', () => {
|
|
264
|
+
const schema = z.string().describe('A user name');
|
|
265
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
266
|
+
description: 'A user name',
|
|
267
|
+
type: 'string',
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('handles z.nullable()', () => {
|
|
272
|
+
const schema = z.string().nullable();
|
|
273
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
274
|
+
anyOf: [{ type: 'string' }, { type: 'null' }],
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('returns empty object for unknown types', () => {
|
|
279
|
+
// z.any() is not in our coverage list
|
|
280
|
+
expect(zodToJsonSchema(z.any())).toEqual({});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { resolve, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { NotFoundError } from '../errors.js';
|
|
7
|
+
import {
|
|
8
|
+
findWorkspaceRoot,
|
|
9
|
+
isInsideWorkspace,
|
|
10
|
+
getRelativePath,
|
|
11
|
+
} from '../workspace.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const createTempDir = (): string =>
|
|
18
|
+
mkdtempSync(join(tmpdir(), 'trails-ws-test-'));
|
|
19
|
+
|
|
20
|
+
const writeJson = (dir: string, filename: string, data: unknown): void => {
|
|
21
|
+
writeFileSync(join(dir, filename), JSON.stringify(data, null, 2));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Set up nested workspaces: outer > inner > packages/lib. Returns the deepest path. */
|
|
25
|
+
const setupNestedWorkspaces = (outer: string): string => {
|
|
26
|
+
writeJson(outer, 'package.json', { name: 'outer', workspaces: ['inner/*'] });
|
|
27
|
+
const inner = join(outer, 'inner', 'nested');
|
|
28
|
+
mkdirSync(inner, { recursive: true });
|
|
29
|
+
writeJson(join(outer, 'inner'), 'package.json', {
|
|
30
|
+
name: 'inner',
|
|
31
|
+
workspaces: ['packages/*'],
|
|
32
|
+
});
|
|
33
|
+
const deep = join(inner, 'packages', 'lib');
|
|
34
|
+
mkdirSync(deep, { recursive: true });
|
|
35
|
+
return deep;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// findWorkspaceRoot
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
describe('findWorkspaceRoot', () => {
|
|
43
|
+
test('finds workspace root when started inside a nested directory', async () => {
|
|
44
|
+
const root = createTempDir();
|
|
45
|
+
try {
|
|
46
|
+
writeJson(root, 'package.json', {
|
|
47
|
+
name: 'my-workspace',
|
|
48
|
+
workspaces: ['packages/*'],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const nested = join(root, 'packages', 'core', 'src');
|
|
52
|
+
mkdirSync(nested, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const result = await findWorkspaceRoot(nested);
|
|
55
|
+
expect(result.isOk()).toBe(true);
|
|
56
|
+
expect(result.unwrap()).toBe(resolve(root));
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(root, { force: true, recursive: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('skips package.json without workspaces field', async () => {
|
|
63
|
+
const root = createTempDir();
|
|
64
|
+
try {
|
|
65
|
+
// Root has workspaces
|
|
66
|
+
writeJson(root, 'package.json', {
|
|
67
|
+
name: 'root',
|
|
68
|
+
workspaces: ['packages/*'],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Nested package has no workspaces
|
|
72
|
+
const pkg = join(root, 'packages', 'core');
|
|
73
|
+
mkdirSync(pkg, { recursive: true });
|
|
74
|
+
writeJson(pkg, 'package.json', { name: '@scope/core' });
|
|
75
|
+
|
|
76
|
+
const result = await findWorkspaceRoot(pkg);
|
|
77
|
+
expect(result.isOk()).toBe(true);
|
|
78
|
+
expect(result.unwrap()).toBe(resolve(root));
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(root, { force: true, recursive: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('returns the closest workspace root', async () => {
|
|
85
|
+
const outer = createTempDir();
|
|
86
|
+
try {
|
|
87
|
+
const deep = setupNestedWorkspaces(outer);
|
|
88
|
+
const result = await findWorkspaceRoot(deep);
|
|
89
|
+
expect(result.isOk()).toBe(true);
|
|
90
|
+
// Should find inner workspace first (closest ancestor)
|
|
91
|
+
expect(result.unwrap()).toBe(resolve(join(outer, 'inner')));
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(outer, { force: true, recursive: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('returns NotFoundError when no workspace root exists', async () => {
|
|
98
|
+
const dir = createTempDir();
|
|
99
|
+
try {
|
|
100
|
+
// No package.json at all
|
|
101
|
+
const result = await findWorkspaceRoot(dir);
|
|
102
|
+
// This will either find one above in the actual filesystem or fail.
|
|
103
|
+
// We walk up to /, so it depends on the host. Use a deep isolated path.
|
|
104
|
+
// Since tmpdir might be under a workspace, let's just check the type
|
|
105
|
+
// is correct if it does fail. When the host has a workspace root above
|
|
106
|
+
// tmpdir, the result will be Ok — both outcomes are acceptable.
|
|
107
|
+
const isAcceptable =
|
|
108
|
+
// oxlint-disable-next-line no-conditional-in-test -- host-dependent: tmpdir may sit under a real workspace
|
|
109
|
+
result.isOk() ||
|
|
110
|
+
(result as unknown as { error: Error }).error instanceof NotFoundError;
|
|
111
|
+
expect(isAcceptable).toBe(true);
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(dir, { force: true, recursive: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('handles malformed package.json gracefully', async () => {
|
|
118
|
+
const root = createTempDir();
|
|
119
|
+
try {
|
|
120
|
+
// Write invalid JSON
|
|
121
|
+
writeFileSync(join(root, 'package.json'), 'not json {{{');
|
|
122
|
+
|
|
123
|
+
const result = await findWorkspaceRoot(root);
|
|
124
|
+
// Should not throw — just skip and keep walking.
|
|
125
|
+
// On some hosts tmpdir sits under a real workspace, so Ok is valid too.
|
|
126
|
+
const isAcceptable =
|
|
127
|
+
// oxlint-disable-next-line no-conditional-in-test -- host-dependent: tmpdir may sit under a real workspace
|
|
128
|
+
result.isOk() ||
|
|
129
|
+
(result as unknown as { error: Error }).error instanceof NotFoundError;
|
|
130
|
+
expect(isAcceptable).toBe(true);
|
|
131
|
+
} finally {
|
|
132
|
+
rmSync(root, { force: true, recursive: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// isInsideWorkspace
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
describe('isInsideWorkspace', () => {
|
|
142
|
+
test('returns true for a file inside the workspace', () => {
|
|
143
|
+
expect(
|
|
144
|
+
isInsideWorkspace('/project/packages/core/src/index.ts', '/project')
|
|
145
|
+
).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('returns false for a file outside the workspace', () => {
|
|
149
|
+
expect(isInsideWorkspace('/other/file.ts', '/project')).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('returns false for the workspace root itself', () => {
|
|
153
|
+
// The root directory itself is not \"inside\" the workspace
|
|
154
|
+
expect(isInsideWorkspace('/project', '/project')).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('handles relative-looking paths by resolving them', () => {
|
|
158
|
+
expect(
|
|
159
|
+
isInsideWorkspace('/project/packages/../packages/core', '/project')
|
|
160
|
+
).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// getRelativePath
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
describe('getRelativePath', () => {
|
|
169
|
+
test('returns relative path from workspace root', () => {
|
|
170
|
+
expect(
|
|
171
|
+
getRelativePath('/project/packages/core/src/index.ts', '/project')
|
|
172
|
+
).toBe('packages/core/src/index.ts');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('returns .. segments for paths outside workspace', () => {
|
|
176
|
+
const rel = getRelativePath('/other/file.ts', '/project');
|
|
177
|
+
expect(rel.startsWith('..')).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('returns empty string for the root itself', () => {
|
|
181
|
+
expect(getRelativePath('/project', '/project')).toBe('');
|
|
182
|
+
});
|
|
183
|
+
});
|