@synnaxlabs/x 0.54.1 → 0.54.2
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 +7 -7
- package/dist/src/binary/codec.d.ts.map +1 -1
- package/dist/src/color/color.d.ts +1 -1
- package/dist/src/color/palette.d.ts +2 -2
- package/dist/src/deep/atKeys.d.ts +27 -0
- package/dist/src/deep/atKeys.d.ts.map +1 -0
- package/dist/src/deep/atKeys.spec.d.ts +2 -0
- package/dist/src/deep/atKeys.spec.d.ts.map +1 -0
- package/dist/src/deep/external.d.ts +1 -0
- package/dist/src/deep/external.d.ts.map +1 -1
- package/dist/src/fmt/external.d.ts +3 -0
- package/dist/src/fmt/external.d.ts.map +1 -0
- package/dist/src/fmt/index.d.ts +2 -0
- package/dist/src/fmt/index.d.ts.map +1 -0
- package/dist/src/fmt/path.d.ts +13 -0
- package/dist/src/fmt/path.d.ts.map +1 -0
- package/dist/src/fmt/path.spec.d.ts +2 -0
- package/dist/src/fmt/path.spec.d.ts.map +1 -0
- package/dist/src/fmt/value.d.ts +23 -0
- package/dist/src/fmt/value.d.ts.map +1 -0
- package/dist/src/fmt/value.spec.d.ts +2 -0
- package/dist/src/fmt/value.spec.d.ts.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/label/types.gen.d.ts +2 -2
- package/dist/src/narrow/narrow.d.ts +9 -0
- package/dist/src/narrow/narrow.d.ts.map +1 -1
- package/dist/src/primitive/primitive.d.ts +10 -0
- package/dist/src/primitive/primitive.d.ts.map +1 -1
- package/dist/src/status/status.d.ts +14 -2
- package/dist/src/status/status.d.ts.map +1 -1
- package/dist/src/status/types.gen.d.ts +1 -1
- package/dist/src/telem/series.d.ts +4 -4
- package/dist/src/telem/telem.d.ts +10 -10
- package/dist/src/zod/external.d.ts +1 -0
- package/dist/src/zod/external.d.ts.map +1 -1
- package/dist/src/zod/parse.d.ts +47 -0
- package/dist/src/zod/parse.d.ts.map +1 -0
- package/dist/src/zod/parse.spec.d.ts +2 -0
- package/dist/src/zod/parse.spec.d.ts.map +1 -0
- package/dist/x.cjs +16 -7
- package/dist/x.js +2190 -1869
- package/package.json +3 -3
- package/src/binary/codec.ts +3 -2
- package/src/deep/atKeys.spec.ts +107 -0
- package/src/deep/atKeys.ts +49 -0
- package/src/deep/external.ts +1 -0
- package/src/fmt/external.ts +11 -0
- package/src/fmt/index.ts +10 -0
- package/src/fmt/path.spec.ts +46 -0
- package/src/fmt/path.ts +30 -0
- package/src/fmt/value.spec.ts +206 -0
- package/src/fmt/value.ts +83 -0
- package/src/index.ts +1 -0
- package/src/narrow/narrow.spec.ts +43 -0
- package/src/narrow/narrow.ts +15 -0
- package/src/primitive/primitive.spec.ts +51 -0
- package/src/primitive/primitive.ts +12 -0
- package/src/status/status.spec.ts +146 -0
- package/src/status/status.ts +65 -18
- package/src/zod/external.ts +1 -0
- package/src/zod/parse.spec.ts +702 -0
- package/src/zod/parse.ts +519 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
// Copyright 2026 Synnax Labs, Inc.
|
|
2
|
+
//
|
|
3
|
+
// Use of this software is governed by the Business Source License included in the file
|
|
4
|
+
// licenses/BSL.txt.
|
|
5
|
+
//
|
|
6
|
+
// As of the Change Date specified in that file, in accordance with the Business Source
|
|
7
|
+
// License, use of this software will be governed by the Apache License, Version 2.0,
|
|
8
|
+
// included in the file licenses/APL.txt.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { errors } from "@/errors";
|
|
14
|
+
import { status } from "@/status";
|
|
15
|
+
import { zod } from "@/zod";
|
|
16
|
+
|
|
17
|
+
const asParseError = (e: unknown): zod.ParseError => {
|
|
18
|
+
if (!zod.ParseError.matches(e)) throw new Error("expected ParseError");
|
|
19
|
+
return e as zod.ParseError;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const parseExpectingError = (
|
|
23
|
+
schema: z.ZodType,
|
|
24
|
+
value: unknown,
|
|
25
|
+
opts?: zod.ParseOptions,
|
|
26
|
+
): zod.ParseError => {
|
|
27
|
+
try {
|
|
28
|
+
zod.parse(schema, value, opts);
|
|
29
|
+
throw new Error("expected parse to throw");
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return asParseError(e);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("zod.parse", () => {
|
|
36
|
+
describe("success", () => {
|
|
37
|
+
it("should return the parsed value when parsing succeeds", () => {
|
|
38
|
+
const schema = z.object({ name: z.string(), age: z.number() });
|
|
39
|
+
expect(zod.parse(schema, { name: "Alice", age: 30 })).toEqual({
|
|
40
|
+
name: "Alice",
|
|
41
|
+
age: 30,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should apply transforms and coercions like schema.parse", () => {
|
|
46
|
+
const schema = z.object({ count: z.coerce.number() });
|
|
47
|
+
expect(zod.parse(schema, { count: "42" })).toEqual({ count: 42 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should not throw on the success path", () => {
|
|
51
|
+
expect(() => zod.parse(z.string(), "hello")).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("ParseError shape", () => {
|
|
56
|
+
it("should carry the typed error discriminator", () => {
|
|
57
|
+
const err = parseExpectingError(z.string(), 42);
|
|
58
|
+
expect(err.name).toBe("zod.parse");
|
|
59
|
+
expect(err.type).toBe("zod.parse");
|
|
60
|
+
expect(zod.ParseError.matches(err)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should not match unrelated errors via ParseError.matches", () => {
|
|
64
|
+
expect(zod.ParseError.matches(new Error("unrelated"))).toBe(false);
|
|
65
|
+
expect(zod.ParseError.matches("string")).toBe(false);
|
|
66
|
+
expect(zod.ParseError.matches(null)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should preserve the original input on the error", () => {
|
|
70
|
+
const input = { port: "8080" };
|
|
71
|
+
expect(parseExpectingError(z.object({ port: z.number() }), input).input).toBe(
|
|
72
|
+
input,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should retain the zod error as cause", () => {
|
|
77
|
+
expect(parseExpectingError(z.string(), 42).cause).toBeInstanceOf(z.ZodError);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should use the default label when none is provided", () => {
|
|
81
|
+
expect(parseExpectingError(z.string(), 42).label).toBe("value");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should store the label when provided", () => {
|
|
85
|
+
expect(parseExpectingError(z.string(), 42, { label: "task config" }).label).toBe(
|
|
86
|
+
"task config",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should store the context when provided", () => {
|
|
91
|
+
expect(
|
|
92
|
+
parseExpectingError(
|
|
93
|
+
z.object({ port: z.number() }),
|
|
94
|
+
{ port: "x" },
|
|
95
|
+
{ context: { taskKey: "tk-1" } },
|
|
96
|
+
).context,
|
|
97
|
+
).toEqual({ taskKey: "tk-1" });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("message formatting", () => {
|
|
102
|
+
it("should render a root-level scalar mismatch", () => {
|
|
103
|
+
expect(parseExpectingError(z.string(), 42).message).toBe(
|
|
104
|
+
`Failed to parse value (1 issue)
|
|
105
|
+
|
|
106
|
+
× expected string, received 42`,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should render a single top-level field failure with parent view", () => {
|
|
111
|
+
expect(
|
|
112
|
+
parseExpectingError(
|
|
113
|
+
z.object({ port: z.number() }),
|
|
114
|
+
{ port: "8080" },
|
|
115
|
+
{ label: "thing" },
|
|
116
|
+
).message,
|
|
117
|
+
).toBe(
|
|
118
|
+
`Failed to parse thing (1 issue)
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
✗ "port": "8080" × expected number
|
|
122
|
+
}`,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should render multiple top-level sibling failures inline", () => {
|
|
127
|
+
expect(
|
|
128
|
+
parseExpectingError(z.object({ name: z.string(), port: z.number() }), {
|
|
129
|
+
name: 42,
|
|
130
|
+
port: "8080",
|
|
131
|
+
}).message,
|
|
132
|
+
).toBe(
|
|
133
|
+
`Failed to parse value (2 issues)
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
✗ "name": 42, × expected string
|
|
137
|
+
✗ "port": "8080" × expected number
|
|
138
|
+
}`,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should group siblings under a shared nested parent with context keys", () => {
|
|
143
|
+
expect(
|
|
144
|
+
parseExpectingError(
|
|
145
|
+
z.object({
|
|
146
|
+
config: z.object({
|
|
147
|
+
autoStart: z.boolean(),
|
|
148
|
+
port: z.number(),
|
|
149
|
+
host: z.string(),
|
|
150
|
+
sampleRate: z.number(),
|
|
151
|
+
}),
|
|
152
|
+
}),
|
|
153
|
+
{
|
|
154
|
+
config: {
|
|
155
|
+
autoStart: false,
|
|
156
|
+
port: "8080",
|
|
157
|
+
host: 42,
|
|
158
|
+
sampleRate: 10,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{ label: "task" },
|
|
162
|
+
).message,
|
|
163
|
+
).toBe(
|
|
164
|
+
`Failed to parse task (2 issues)
|
|
165
|
+
|
|
166
|
+
at config
|
|
167
|
+
{
|
|
168
|
+
"autoStart": false,
|
|
169
|
+
✗ "port": "8080", × expected number
|
|
170
|
+
✗ "host": 42, × expected string
|
|
171
|
+
"sampleRate": 10
|
|
172
|
+
}`,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should render a nested array element with sibling context", () => {
|
|
177
|
+
expect(
|
|
178
|
+
parseExpectingError(
|
|
179
|
+
z.object({
|
|
180
|
+
channels: z.array(z.object({ name: z.string(), port: z.number() })),
|
|
181
|
+
}),
|
|
182
|
+
{
|
|
183
|
+
channels: [
|
|
184
|
+
{ name: "temp", port: 8080 },
|
|
185
|
+
{ name: "pressure", port: "oops" },
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
{ label: "task config" },
|
|
189
|
+
).message,
|
|
190
|
+
).toBe(
|
|
191
|
+
`Failed to parse task config (1 issue)
|
|
192
|
+
|
|
193
|
+
at channels[1]
|
|
194
|
+
{
|
|
195
|
+
"name": "pressure",
|
|
196
|
+
✗ "port": "oops" × expected number
|
|
197
|
+
}`,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("should render a top-level array element failure with array parent view", () => {
|
|
202
|
+
expect(parseExpectingError(z.array(z.number()), [1, 2, "three"]).message).toBe(
|
|
203
|
+
`Failed to parse value (1 issue)
|
|
204
|
+
|
|
205
|
+
[
|
|
206
|
+
1,
|
|
207
|
+
2,
|
|
208
|
+
✗ "three" × expected number
|
|
209
|
+
]`,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should render a missing required field as a synthetic entry", () => {
|
|
214
|
+
expect(
|
|
215
|
+
parseExpectingError(
|
|
216
|
+
z.object({
|
|
217
|
+
task: z.number(),
|
|
218
|
+
running: z.boolean(),
|
|
219
|
+
data: z.object({}),
|
|
220
|
+
}),
|
|
221
|
+
{ task: 1, running: true },
|
|
222
|
+
{ label: "status" },
|
|
223
|
+
).message,
|
|
224
|
+
).toBe(
|
|
225
|
+
`Failed to parse status (1 issue)
|
|
226
|
+
|
|
227
|
+
{
|
|
228
|
+
"task": 1,
|
|
229
|
+
"running": true,
|
|
230
|
+
✗ "data": <missing> × expected object
|
|
231
|
+
}`,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should render unrecognized keys as per-key marks", () => {
|
|
236
|
+
expect(
|
|
237
|
+
parseExpectingError(z.strictObject({ name: z.string() }), {
|
|
238
|
+
name: "Alice",
|
|
239
|
+
extra: 1,
|
|
240
|
+
another: 2,
|
|
241
|
+
}).message,
|
|
242
|
+
).toBe(
|
|
243
|
+
`Failed to parse value (2 issues)
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
"name": "Alice",
|
|
247
|
+
✗ "extra": 1, × unexpected key
|
|
248
|
+
✗ "another": 2 × unexpected key
|
|
249
|
+
}`,
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should render enum mismatches at the root flat", () => {
|
|
254
|
+
expect(parseExpectingError(z.enum(["a", "b", "c"]), "d").message).toBe(
|
|
255
|
+
`Failed to parse value (1 issue)
|
|
256
|
+
|
|
257
|
+
× expected one of ["a","b","c"], received "d"`,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should render too_small at the root flat", () => {
|
|
262
|
+
expect(parseExpectingError(z.number().min(10), 5).message).toBe(
|
|
263
|
+
`Failed to parse value (1 issue)
|
|
264
|
+
|
|
265
|
+
× number too small: expected >=10, received 5`,
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should render too_big at the root flat", () => {
|
|
270
|
+
expect(parseExpectingError(z.string().max(3), "hello").message).toBe(
|
|
271
|
+
`Failed to parse value (1 issue)
|
|
272
|
+
|
|
273
|
+
× string too large: expected <=3, received "hello"`,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("should respect exclusive lower bound for .gt()", () => {
|
|
278
|
+
expect(parseExpectingError(z.number().gt(10), 10).message).toBe(
|
|
279
|
+
`Failed to parse value (1 issue)
|
|
280
|
+
|
|
281
|
+
× number too small: expected >10, received 10`,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("should respect exclusive upper bound for .lt()", () => {
|
|
286
|
+
expect(parseExpectingError(z.number().lt(10), 10).message).toBe(
|
|
287
|
+
`Failed to parse value (1 issue)
|
|
288
|
+
|
|
289
|
+
× number too large: expected <10, received 10`,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("should render invalid_format for email", () => {
|
|
294
|
+
expect(parseExpectingError(z.email(), "not-an-email").message).toBe(
|
|
295
|
+
`Failed to parse value (1 issue)
|
|
296
|
+
|
|
297
|
+
× expected email format, received "not-an-email"`,
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("should render invalid_format for url", () => {
|
|
302
|
+
expect(parseExpectingError(z.url(), "not-a-url").message).toBe(
|
|
303
|
+
`Failed to parse value (1 issue)
|
|
304
|
+
|
|
305
|
+
× expected url format, received "not-a-url"`,
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("should render invalid_format for uuid", () => {
|
|
310
|
+
expect(parseExpectingError(z.uuid(), "not-a-uuid").message).toBe(
|
|
311
|
+
`Failed to parse value (1 issue)
|
|
312
|
+
|
|
313
|
+
× expected uuid format, received "not-a-uuid"`,
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("should render not_multiple_of", () => {
|
|
318
|
+
expect(parseExpectingError(z.number().multipleOf(5), 7).message).toBe(
|
|
319
|
+
`Failed to parse value (1 issue)
|
|
320
|
+
|
|
321
|
+
× expected multiple of 5, received 7`,
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("should render custom refinement messages", () => {
|
|
326
|
+
const schema = z.number().refine((n) => n > 0, { message: "must be positive" });
|
|
327
|
+
expect(parseExpectingError(schema, -5).message).toBe(
|
|
328
|
+
`Failed to parse value (1 issue)
|
|
329
|
+
|
|
330
|
+
× must be positive, received -5`,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("should render a nested invalid_format with the parent view", () => {
|
|
335
|
+
expect(
|
|
336
|
+
parseExpectingError(
|
|
337
|
+
z.object({ email: z.email() }),
|
|
338
|
+
{ email: "foo" },
|
|
339
|
+
{ label: "contact" },
|
|
340
|
+
).message,
|
|
341
|
+
).toBe(
|
|
342
|
+
`Failed to parse contact (1 issue)
|
|
343
|
+
|
|
344
|
+
{
|
|
345
|
+
✗ "email": "foo" × expected email format
|
|
346
|
+
}`,
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("should render a nested not_multiple_of with the parent view", () => {
|
|
351
|
+
expect(
|
|
352
|
+
parseExpectingError(
|
|
353
|
+
z.object({ count: z.number().multipleOf(10) }),
|
|
354
|
+
{ count: 7 },
|
|
355
|
+
{ label: "spec" },
|
|
356
|
+
).message,
|
|
357
|
+
).toBe(
|
|
358
|
+
`Failed to parse spec (1 issue)
|
|
359
|
+
|
|
360
|
+
{
|
|
361
|
+
✗ "count": 7 × expected multiple of 10
|
|
362
|
+
}`,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("should render a record field with a bad value via invalid_element recursion", () => {
|
|
367
|
+
expect(
|
|
368
|
+
parseExpectingError(
|
|
369
|
+
z.record(z.string(), z.number()),
|
|
370
|
+
{ a: 1, b: "bad", c: 3 },
|
|
371
|
+
{ label: "scores" },
|
|
372
|
+
).message,
|
|
373
|
+
).toBe(
|
|
374
|
+
`Failed to parse scores (1 issue)
|
|
375
|
+
|
|
376
|
+
{
|
|
377
|
+
"a": 1,
|
|
378
|
+
✗ "b": "bad", × expected number
|
|
379
|
+
"c": 3
|
|
380
|
+
}`,
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("should keep a marked array index visible past the default truncation", () => {
|
|
385
|
+
expect(
|
|
386
|
+
parseExpectingError(z.array(z.number()), [
|
|
387
|
+
0,
|
|
388
|
+
1,
|
|
389
|
+
2,
|
|
390
|
+
3,
|
|
391
|
+
4,
|
|
392
|
+
5,
|
|
393
|
+
"oops",
|
|
394
|
+
7,
|
|
395
|
+
8,
|
|
396
|
+
9,
|
|
397
|
+
10,
|
|
398
|
+
11,
|
|
399
|
+
12,
|
|
400
|
+
]).message,
|
|
401
|
+
).toBe(
|
|
402
|
+
`Failed to parse value (1 issue)
|
|
403
|
+
|
|
404
|
+
[
|
|
405
|
+
0,
|
|
406
|
+
1,
|
|
407
|
+
2,
|
|
408
|
+
3,
|
|
409
|
+
4,
|
|
410
|
+
5,
|
|
411
|
+
✗ "oops", × expected number
|
|
412
|
+
7,
|
|
413
|
+
"…(+5 more)"
|
|
414
|
+
]`,
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("should render a context footer when context is provided", () => {
|
|
419
|
+
expect(
|
|
420
|
+
parseExpectingError(
|
|
421
|
+
z.object({ port: z.number() }),
|
|
422
|
+
{ port: "8080" },
|
|
423
|
+
{
|
|
424
|
+
label: "device",
|
|
425
|
+
context: { deviceKey: "dev-1", make: "labjack" },
|
|
426
|
+
},
|
|
427
|
+
).message,
|
|
428
|
+
).toBe(
|
|
429
|
+
`Failed to parse device (1 issue)
|
|
430
|
+
|
|
431
|
+
{
|
|
432
|
+
✗ "port": "8080" × expected number
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
context: deviceKey=dev-1, make=labjack`,
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("should omit the context footer when context is empty", () => {
|
|
440
|
+
expect(
|
|
441
|
+
parseExpectingError(z.string(), 42, { label: "thing", context: {} }).message,
|
|
442
|
+
).toBe(
|
|
443
|
+
`Failed to parse thing (1 issue)
|
|
444
|
+
|
|
445
|
+
× expected string, received 42`,
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("should pluralize issues correctly: one says '1 issue'", () => {
|
|
450
|
+
const firstLine = parseExpectingError(z.string(), 42).message.split("\n")[0];
|
|
451
|
+
expect(firstLine).toBe("Failed to parse value (1 issue)");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("should pluralize issues correctly: two says '2 issues'", () => {
|
|
455
|
+
const firstLine = parseExpectingError(
|
|
456
|
+
z.object({ a: z.string(), b: z.string() }),
|
|
457
|
+
{ a: 1, b: 2 },
|
|
458
|
+
).message.split("\n")[0];
|
|
459
|
+
expect(firstLine).toBe("Failed to parse value (2 issues)");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("union handling", () => {
|
|
464
|
+
it("should flatten invalid_union to the deepest-reaching branch", () => {
|
|
465
|
+
const schema = z.union([
|
|
466
|
+
z.object({ kind: z.literal("x"), x: z.object({ deep: z.string() }) }),
|
|
467
|
+
z.object({ kind: z.literal("y"), y: z.number() }),
|
|
468
|
+
]);
|
|
469
|
+
expect(parseExpectingError(schema, { kind: "x", x: { deep: 42 } }).message).toBe(
|
|
470
|
+
`Failed to parse value (1 issue)
|
|
471
|
+
|
|
472
|
+
at x
|
|
473
|
+
{
|
|
474
|
+
✗ "deep": 42 × expected string
|
|
475
|
+
}`,
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("should flatten a deeply nested discriminated union", () => {
|
|
480
|
+
const schema = z.discriminatedUnion("kind", [
|
|
481
|
+
z.object({
|
|
482
|
+
kind: z.literal("a"),
|
|
483
|
+
payload: z.object({ value: z.number() }),
|
|
484
|
+
}),
|
|
485
|
+
z.object({
|
|
486
|
+
kind: z.literal("b"),
|
|
487
|
+
payload: z.object({ value: z.string() }),
|
|
488
|
+
}),
|
|
489
|
+
]);
|
|
490
|
+
expect(
|
|
491
|
+
parseExpectingError(schema, {
|
|
492
|
+
kind: "a",
|
|
493
|
+
payload: { value: "not-a-number" },
|
|
494
|
+
}).message,
|
|
495
|
+
).toBe(
|
|
496
|
+
`Failed to parse value (1 issue)
|
|
497
|
+
|
|
498
|
+
at payload
|
|
499
|
+
{
|
|
500
|
+
✗ "value": "not-a-number" × expected number
|
|
501
|
+
}`,
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("should flatten a real-world nested optional union to the exact failing leaf", () => {
|
|
506
|
+
const statusZ = z.object({
|
|
507
|
+
details: z.object({ data: z.object({}).nullable() }),
|
|
508
|
+
});
|
|
509
|
+
const taskZ = z.object({ key: z.number(), status: statusZ });
|
|
510
|
+
const responseZ = z.object({
|
|
511
|
+
tasks: z.array(taskZ).nullable().optional(),
|
|
512
|
+
});
|
|
513
|
+
expect(
|
|
514
|
+
parseExpectingError(
|
|
515
|
+
responseZ,
|
|
516
|
+
{
|
|
517
|
+
tasks: [
|
|
518
|
+
{
|
|
519
|
+
key: 1,
|
|
520
|
+
status: { details: { task: 1, running: true } },
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
{ label: "task" },
|
|
525
|
+
).message,
|
|
526
|
+
).toBe(
|
|
527
|
+
`Failed to parse task (1 issue)
|
|
528
|
+
|
|
529
|
+
at tasks[0].status.details
|
|
530
|
+
{
|
|
531
|
+
"task": 1,
|
|
532
|
+
"running": true,
|
|
533
|
+
✗ "data": <missing> × expected object
|
|
534
|
+
}`,
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
describe("truncation", () => {
|
|
540
|
+
it("should bound the error message length for huge failing values", () => {
|
|
541
|
+
const huge = Array.from({ length: 10_000 }, (_, i) => i);
|
|
542
|
+
const err = parseExpectingError(z.string(), huge);
|
|
543
|
+
expect(err.message.length).toBeLessThan(2_000);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe("non-ZodError exceptions", () => {
|
|
548
|
+
it("should not wrap non-zod errors", () => {
|
|
549
|
+
const schema = z.string().transform(() => {
|
|
550
|
+
throw new Error("boom");
|
|
551
|
+
});
|
|
552
|
+
expect(() => zod.parse(schema, "hi")).toThrow();
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
describe("toStatus", () => {
|
|
557
|
+
const makeError = () =>
|
|
558
|
+
parseExpectingError(
|
|
559
|
+
z.object({ config: z.object({ port: z.number(), host: z.string() }) }),
|
|
560
|
+
{ config: { port: "8080", host: "localhost" } },
|
|
561
|
+
{ label: "task config", context: { taskKey: "tk-1" } },
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
it("should return a concise headline as the message", () => {
|
|
565
|
+
expect(makeError().toStatus().message).toBe("Failed to parse task config");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("should return the full formatted breakdown as the description", () => {
|
|
569
|
+
expect(makeError().toStatus().description).toBe(
|
|
570
|
+
`Failed to parse task config (1 issue)
|
|
571
|
+
|
|
572
|
+
at config
|
|
573
|
+
{
|
|
574
|
+
✗ "port": "8080", × expected number
|
|
575
|
+
"host": "localhost"
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
context: taskKey=tk-1`,
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("should return the input, issues, and context in the details", () => {
|
|
583
|
+
const details = makeError().toStatus().details as Record<string, unknown>;
|
|
584
|
+
expect(details.input).toEqual({
|
|
585
|
+
config: { port: "8080", host: "localhost" },
|
|
586
|
+
});
|
|
587
|
+
expect(details.context).toEqual({ taskKey: "tk-1" });
|
|
588
|
+
expect(Array.isArray(details.issues)).toBe(true);
|
|
589
|
+
expect((details.issues as unknown[]).length).toBe(1);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("should include the raw zod issues for programmatic inspection", () => {
|
|
593
|
+
const err = makeError();
|
|
594
|
+
const details = err.toStatus().details as Record<string, unknown>;
|
|
595
|
+
expect(details.issues).toBe(err.issues);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("should omit the context key when no context is provided", () => {
|
|
599
|
+
const err = parseExpectingError(z.string(), 42);
|
|
600
|
+
const details = err.toStatus().details as Record<string, unknown>;
|
|
601
|
+
expect(details.input).toBe(42);
|
|
602
|
+
expect(details.context).toBeUndefined();
|
|
603
|
+
expect(Array.isArray(details.issues)).toBe(true);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
describe("status.fromException integration", () => {
|
|
608
|
+
const makeError = () =>
|
|
609
|
+
parseExpectingError(
|
|
610
|
+
z.object({
|
|
611
|
+
channels: z.array(z.object({ name: z.string(), port: z.number() })),
|
|
612
|
+
owner: z.string(),
|
|
613
|
+
}),
|
|
614
|
+
{
|
|
615
|
+
channels: [{ name: "temp", port: "oops" }],
|
|
616
|
+
owner: "alice",
|
|
617
|
+
},
|
|
618
|
+
{ label: "task config", context: { taskKey: "tk-1" } },
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const expectedDescription = `Failed to parse task config (1 issue)
|
|
622
|
+
|
|
623
|
+
at channels[0]
|
|
624
|
+
{
|
|
625
|
+
"name": "temp",
|
|
626
|
+
✗ "port": "oops" × expected number
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
context: taskKey=tk-1`;
|
|
630
|
+
|
|
631
|
+
it("should set the status message from toStatus.message", () => {
|
|
632
|
+
expect(status.fromException(makeError()).message).toBe(
|
|
633
|
+
"Failed to parse task config",
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("should set the status description from toStatus.description", () => {
|
|
638
|
+
expect(status.fromException(makeError()).description).toBe(expectedDescription);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("should merge input, issues, and context into status.details", () => {
|
|
642
|
+
const s = status.fromException(makeError());
|
|
643
|
+
const details = s.details as Record<string, unknown>;
|
|
644
|
+
expect(details.context).toEqual({ taskKey: "tk-1" });
|
|
645
|
+
expect(details.input).toEqual({
|
|
646
|
+
channels: [{ name: "temp", port: "oops" }],
|
|
647
|
+
owner: "alice",
|
|
648
|
+
});
|
|
649
|
+
expect(Array.isArray(details.issues)).toBe(true);
|
|
650
|
+
expect((details.issues as unknown[]).length).toBe(1);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("should still expose issues on the underlying ParseError", () => {
|
|
654
|
+
const err = makeError();
|
|
655
|
+
expect(err.issues.length).toBeGreaterThan(0);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("should prefix a caller-provided message with the parse headline", () => {
|
|
659
|
+
expect(status.fromException(makeError(), "Saving failed").message).toBe(
|
|
660
|
+
"Saving failed: Failed to parse task config",
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("should include a populated stack and the original error in details", () => {
|
|
665
|
+
const details = status.fromException(makeError()).details as Record<
|
|
666
|
+
string,
|
|
667
|
+
unknown
|
|
668
|
+
>;
|
|
669
|
+
expect(typeof details.stack).toBe("string");
|
|
670
|
+
expect(details.error).toBeInstanceOf(Error);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe("errors registry round-trip", () => {
|
|
675
|
+
const makeError = () =>
|
|
676
|
+
parseExpectingError(
|
|
677
|
+
z.object({ port: z.number(), name: z.string(), host: z.string() }),
|
|
678
|
+
{ port: "8080", name: 42, host: "localhost" },
|
|
679
|
+
{ label: "task config", context: { taskKey: "tk-1" } },
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
it("should encode a ParseError with the correct type discriminator", () => {
|
|
683
|
+
expect(errors.encode(makeError()).type).toBe("zod.parse");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("should decode a ParseError payload back into a matching instance", () => {
|
|
687
|
+
const decoded = asParseError(errors.decode(errors.encode(makeError())));
|
|
688
|
+
expect(decoded.label).toBe("task config");
|
|
689
|
+
expect(decoded.context).toEqual({ taskKey: "tk-1" });
|
|
690
|
+
expect(decoded.issues).toHaveLength(2);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("should round-trip the input through the registry", () => {
|
|
694
|
+
const decoded = asParseError(errors.decode(errors.encode(makeError())));
|
|
695
|
+
expect(decoded.input).toEqual({
|
|
696
|
+
port: "8080",
|
|
697
|
+
name: 42,
|
|
698
|
+
host: "localhost",
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
});
|