1import { assert, describe, it } from "@effect/vitest"
2import * as testAssert from "@effect/vitest/utils"
3import {
4 Cause,
5 Clock,
6 Context,
7 Deferred,
8 Effect,
9 Exit,
10 Fiber,
11 Layer,
12 Ref,
13 Result,
14 Schema,
15 SchemaGetter,
16 SchemaIssue
17} from "effect"
18import * as SchemaTransformation from "effect/SchemaTransformation"
19import { TestClock, TestSchema } from "effect/testing"
20import { AssertionError } from "node:assert"
21
22describe("TestSchema", () => {
23 describe("ast.fields.equals", () => {
24 const equals = TestSchema.Asserts.ast.fields.equals
25
26 it("compares string and symbol fields by AST", () => {
27 const key = Symbol("field")
28 const fields = () => ({ field: Schema.Literal("value"), [key]: Schema.Literal("value") })
29 const left = fields()
30 const right = fields()
31 assert.notStrictEqual(left[key], right[key])
32 equals(left, right)
33 })
34
35 it("rejects missing or structurally different symbol fields", () => {
36 const key = Symbol("field")
37 assert.throws(() => equals({ [key]: Schema.Literal("left") }, { [key]: Schema.Literal("right") }))
38 assert.throws(() => equals({ [key]: Schema.Literal("value") }, {}))
39 assert.throws(() =>
40 equals(
41 { [key]: Schema.Literal("value").annotate({ title: "Field" }) },
42 { [key]: Schema.Literal("value") }
43 )
44 )
45 assert.throws(() =>
46 equals({ [key]: Schema.optionalKey(Schema.Literal("value")) }, { [key]: Schema.Literal("value") })
47 )
48 })
49
50 it("preserves symbol identity even with equal descriptions", () => {
51 const left: symbol = Symbol("field")
52 const right: symbol = Symbol("field")
53 const schema = Schema.Literal("value")
54 assert.notStrictEqual(left, right)
55 assert.throws(() => equals({ [left]: schema }, { [right]: schema }))
56 })
57
58 it("preserves own __proto__ and constructor fields", () => {
59 const fields = () => ({ ["__proto__"]: Schema.Literal("proto"), constructor: Schema.Literal("ctor") })
60 const left = fields()
61 assert.isTrue(Object.hasOwn(left, "__proto__"))
62 assert.isTrue(Object.hasOwn(left, "constructor"))
63 equals(left, fields())
64 assert.throws(() => equals(left, { constructor: Schema.Literal("ctor") }))
65 assert.throws(() => equals(left, { ["__proto__"]: Schema.Literal("other"), constructor: Schema.Literal("ctor") }))
66 })
67
68 it("uses the Struct own-key contract for non-enumerable fields", () => {
69 const key = Symbol("hidden")
70 const fields = (value: string): Schema.Struct.Fields =>
71 Object.defineProperties({}, {
72 hidden: { value: Schema.Literal(value) },
73 [key]: { value: Schema.Literal(value) }
74 })
75 const left = fields("value")
76 const right = fields("value")
77 assert.deepStrictEqual(Object.keys(left), [])
78 assert.deepStrictEqual(Schema.Struct(left).ast.propertySignatures.map((field) => field.name), ["hidden", key])
79 equals(left, right)
80 equals(left, { hidden: Schema.Literal("value"), [key]: Schema.Literal("value") })
81 assert.throws(() => equals(left, fields("other")))
82 })
83 })
84
85 it("decoding", async () => {
86 const schema = Schema.FiniteFromString.check(Schema.isGreaterThan(0))
87 const asserts = new TestSchema.Asserts(schema)
88 const decoding = asserts.decoding()
89 await decoding.succeed("1", 1)
90 await decoding.fail("-1", `Expected a value greater than 0`)
91 await decoding.fail("a", `Expected a finite number`)
92 })
93
94 it("decoding.provide", async () => {
95 class Service extends Context.Service<Service, { fallback: Effect.Effect<string> }>()("Service") {}
96
97 const schema = Schema.String.pipe(
98 Schema.decode({
99 decode: SchemaGetter.checkEffect((s) =>
100 Effect.gen(function*() {
101 yield* Service
102 if (s.length === 0) {
103 return new SchemaIssue.InvalidValue({
104 message: "input should not be empty string"
105 })
106 }
107 })
108 ),
109 encode: SchemaGetter.passthrough()
110 })
111 )
112 const asserts = new TestSchema.Asserts(schema)
113
114 const decoding = asserts.decoding().provide(Service, { fallback: Effect.succeed("b") })
115 await decoding.succeed("a")
116 await decoding.fail("", "input should not be empty string")
117 })
118
119 it("encoding", async () => {
120 const schema = Schema.FiniteFromString.check(Schema.isGreaterThan(0))
121 const asserts = new TestSchema.Asserts(schema)
122 const encoding = asserts.encoding()
123 await encoding.succeed(1, "1")
124 await encoding.fail(-1, `Expected a value greater than 0`)
125 })
126
127 it("encoding.provide", async () => {
128 class Service extends Context.Service<Service, { fallback: Effect.Effect<string> }>()("Service") {}
129
130 const schema = Schema.String.pipe(
131 Schema.decode({
132 decode: SchemaGetter.passthrough(),
133 encode: SchemaGetter.checkEffect((s) =>
134 Effect.gen(function*() {
135 yield* Service
136 if (s.length === 0) {
137 return new SchemaIssue.InvalidValue({
138 message: "input should not be empty string"
139 })
140 }
141 })
142 )
143 })
144 )
145 const asserts = new TestSchema.Asserts(schema)
146
147 const encoding = asserts.encoding().provide(Service, { fallback: Effect.succeed("b") })
148 await encoding.succeed("a")
149 await encoding.fail("", "input should not be empty string")
150 })
151
152 it("verifyRoundTrip", async () => {
153 const schema = Schema.FiniteFromString.check(Schema.isGreaterThan(0))
154 const asserts = new TestSchema.Asserts(schema)
155 await asserts.verifyRoundTrip({ runs: 20, seed: "lossless" })
156 })
157
158 it("verifyRoundTrip reports a shrunk input and replay", async () => {
159 const schema = Schema.Number.pipe(
160 Schema.decodeTo(
161 Schema.Number,
162 SchemaTransformation.transform({ decode: (value) => value, encode: () => 0 })
163 )
164 )
165 const asserts = new TestSchema.Asserts(schema)
166
167 await testAssert.throwsAsync(
168 () => asserts.verifyRoundTrip({ runs: 20, seed: "lossy" }),
169 (error) => {
170 assert.instanceOf(error, Error)
171 assert.match(error.message, /Property falsified/)
172 assert.match(error.message, /Shrunk input:/)
173 assert.match(error.message, /Replay:/)
174 }
175 )
176 })
177
178 it("verifyGeneration bounds residual discards", () => {
179 const schema = Schema.Null.check(Schema.makeFilter(() => false))
180 const asserts = new TestSchema.Asserts(schema)
181
182 assert.throws(
183 () => asserts.arbitrary().verifyGeneration({ runs: 1, maxDiscards: 2, seed: "exhausted" }),
184 /Property exhausted after 0 run\(s\) and 3 discard\(s\)/
185 )
186 })
187
188 for (const method of ["decoding", "encoding", "make"] as const) {
189 describe(`${method} Effect assertions`, () => {
190 it.effect("defers parsing and reruns it on every execution", () =>
191 Effect.gen(function*() {
192 let calls = 0
193 const schema = Schema.String.check(Schema.makeFilter((value) => {
194 calls++
195 return value === "ok" || "Expected ok"
196 }))
197 const assertions = new TestSchema.Asserts(schema)[method]()
198 const success = assertions.succeedEffect("ok")
199 const failure = assertions.failEffect("bad", "Expected ok")
200 assert.strictEqual(calls, 0)
201
202 yield* success
203 yield* success
204 yield* failure
205 assert.strictEqual(calls, 3)
206 }))
207
208 it.effect("preserves explicit undefined and reports mismatches as defects", () =>
209 Effect.gen(function*() {
210 const assertions = new TestSchema.Asserts(Schema.Unknown)[method]()
211 yield* assertions.succeedEffect("value")
212 yield* assertions.succeedEffect(undefined)
213 const exit = yield* Effect.exit(assertions.succeedEffect("value", undefined))
214 assert.isTrue(Exit.isFailure(exit))
215 if (Exit.isFailure(exit)) {
216 assert.isFalse(Cause.hasFails(exit.cause))
217 const error = Cause.squash(exit.cause)
218 assert.instanceOf(error, AssertionError)
219 assert.deepStrictEqual(error.actual, Result.succeed("value"))
220 assert.deepStrictEqual(error.expected, Result.succeed(undefined))
221 }
222
223 const strings = new TestSchema.Asserts(
224 Schema.String.check(Schema.makeFilter((value) => value.length > 0 || "Expected nonempty string"))
225 )[method]()
226 for (
227 const effect of [
228 strings.succeedEffect(""),
229 strings.failEffect("valid", "Expected string"),
230 strings.failEffect(123, "wrong message")
231 ]
232 ) {
233 const exit = yield* Effect.exit(effect)
234 assert.isTrue(Exit.isFailure(exit))
235 if (Exit.isFailure(exit)) {
236 assert.isFalse(Cause.hasFails(exit.cause))
237 assert.instanceOf(Cause.squash(exit.cause), AssertionError)
238 }
239 }
240 }))
241
242 it("preserves Promise assertion errors and explicit undefined", async () => {
243 const assertions = new TestSchema.Asserts(Schema.Unknown)[method]()
244 await assertions.succeed("value")
245 await testAssert.throwsAsync(() => assertions.succeed("value", undefined), (error) => {
246 assert.instanceOf(error, AssertionError)
247 assert.deepStrictEqual(error.actual, Result.succeed("value"))
248 assert.deepStrictEqual(error.expected, Result.succeed(undefined))
249 assert.strictEqual(error.operator, "deepStrictEqual")
250 })
251 await testAssert.throwsAsync(() => assertions.fail("value", "wrong message"), (error) => {
252 assert.instanceOf(error, AssertionError)
253 })
254 })
255
256 it.effect("forwards parsing options", () =>
257 Effect.gen(function*() {
258 const schema = Schema.String.check(
259 Schema.makeFilter((_, __, options) => options.errors === "all" || "Expected all errors")
260 )
261 const asserts = new TestSchema.Asserts(schema)
262 yield* asserts[method]({ parseOptions: { errors: "all" } }).succeedEffect("value")
263 yield* asserts[method]().failEffect("value", "Expected all errors")
264 }))
265
266 it.effect("preserves thrown schema defects in success and failure assertions", () =>
267 Effect.gen(function*() {
268 const defect = new Error("schema defect")
269 const schema = Schema.String.check(Schema.makeFilter(() => {
270 throw defect
271 }))
272 const assertions = new TestSchema.Asserts(schema)[method]()
273 const success = assertions.succeedEffect("value")
274 const failure = assertions.failEffect("value", "schema defect")
275
276 assert.deepStrictEqual(yield* Effect.exit(success), Exit.die(defect))
277 assert.deepStrictEqual(yield* Effect.exit(failure), Exit.die(defect))
278 }))
279 })
280 }
281
282 it.effect("asserts decoded and encoded transformations", () =>
283 Effect.gen(function*() {
284 const asserts = new TestSchema.Asserts(Schema.NumberFromString)
285 yield* asserts.decoding().succeedEffect("42", 42)
286 yield* asserts.encoding().succeedEffect(42, "42")
287 yield* asserts.decoding().failEffect(null, "Expected string")
288 yield* asserts.encoding().failEffect(null, "Expected number")
289 }))
290
291 it.effect("make uses constructor defaults from the test environment and honors disableChecks", () =>
292 Effect.gen(function*() {
293 const schema = Schema.Struct({
294 time: Schema.Number.pipe(Schema.withConstructorDefault(Clock.currentTimeMillis))
295 })
296 const make = new TestSchema.Asserts(schema).make()
297 const assertion = make.succeedEffect({}, { time: 123 })
298 yield* TestClock.setTime(123)
299 yield* assertion
300
301 const checked = new TestSchema.Asserts(Schema.Number.check(Schema.isGreaterThan(0)))
302 yield* checked.make().failEffect(-1, "Expected a value greater than 0")
303 yield* checked.make({ disableChecks: true }).succeedEffect(-1)
304 }))
305
306 for (const method of ["decoding", "encoding"] as const) {
307 it.effect(`${method} preserves validation failures and defective finalizers`, () =>
308 Effect.gen(function*() {
309 const defect = new Error("cleanup failed")
310 const finalize = Effect.ensuring(Effect.die(defect))
311 const schema = method === "decoding"
312 ? Schema.String.pipe(Schema.middlewareDecoding(finalize))
313 : Schema.String.pipe(Schema.middlewareEncoding(finalize))
314 const assertions = new TestSchema.Asserts(schema)[method]()
315
316 for (
317 const effect of [
318 assertions.succeedEffect(123, "value"),
319 assertions.failEffect(123, "Expected string")
320 ]
321 ) {
322 const exit = yield* Effect.exit(effect)
323 assert.isTrue(Exit.isFailure(exit))
324 if (Exit.isFailure(exit)) {
325 assert.deepStrictEqual(exit.cause.reasons.map((reason) => reason._tag), ["Die", "Die"])
326 const [validation, cleanup] = exit.cause.reasons
327 assert.isTrue(Cause.isDieReason(validation) && SchemaIssue.isIssue(validation.defect))
328 assert.isTrue(Cause.isDieReason(cleanup) && cleanup.defect === defect)
329 }
330 }
331
332 const asserts = new TestSchema.Asserts(schema)
333 yield* Effect.promise(() =>
334 testAssert.throwsAsync(
335 () =>
336 method === "decoding"
337 ? asserts.decoding().fail(123, "Expected string")
338 : asserts.encoding().fail(123, "Expected string"),
339 (error) => {
340 assert.isTrue(SchemaIssue.isIssue(error))
341 }
342 )
343 )
344 }))
345
346 it.effect(`${method} preserves interruption alongside annotated validation failures`, () =>
347 Effect.gen(function*() {
348 class Metadata extends Context.Service<Metadata, string>()("Metadata") {}
349 const annotations = Context.make(Metadata, "validation")
350 const issue = new SchemaIssue.InvalidValue({ message: "schema issue" })
351 const interrupt = Cause.makeInterruptReason(123)
352 const failure = Effect.failCause(Cause.fromReasons([
353 Cause.makeFailReason(issue).annotate(annotations),
354 interrupt
355 ]))
356 const schema = method === "decoding"
357 ? Schema.String.pipe(Schema.middlewareDecoding(() => failure))
358 : Schema.String.pipe(Schema.middlewareEncoding(() => failure))
359 const assertions = new TestSchema.Asserts(schema)[method]()
360
361 for (
362 const effect of [
363 assertions.succeedEffect("value"),
364 assertions.failEffect("value", "schema issue")
365 ]
366 ) {
367 const exit = yield* Effect.exit(effect)
368 assert.isTrue(Exit.isFailure(exit))
369 if (Exit.isFailure(exit)) {
370 assert.deepStrictEqual(exit.cause.reasons.map((reason) => reason._tag), ["Die", "Interrupt"])
371 const [validation, interruption] = exit.cause.reasons
372 assert.isTrue(Cause.isDieReason(validation) && SchemaIssue.isIssue(validation.defect))
373 if (Cause.isDieReason(validation) && SchemaIssue.isIssue(validation.defect)) {
374 assert.strictEqual(SchemaIssue.defaultFormatter(validation.defect), "schema issue")
375 }
376 assert.strictEqual(Context.getOrUndefined(Cause.reasonAnnotations(validation), Metadata), "validation")
377 assert.deepStrictEqual(interruption, interrupt)
378 }
379 }
380 }))
381
382 it.effect(`round trips preserve mixed causes from ${method}`, () =>
383 Effect.gen(function*() {
384 const issue = new SchemaIssue.InvalidValue({ message: "schema issue" })
385 const defect = new Error("cleanup failed")
386 const failure = Effect.fail(issue).pipe(Effect.ensuring(Effect.die(defect)))
387 const schema = method === "decoding"
388 ? Schema.String.pipe(Schema.middlewareDecoding(() => failure))
389 : Schema.String.pipe(Schema.middlewareEncoding(() => failure))
390
391 const exit = yield* Effect.exit(new TestSchema.Asserts(schema).verifyRoundTripEffect({ runs: 1, seed: 1 }))
392 assert.isTrue(Exit.isFailure(exit))
393 if (Exit.isFailure(exit)) {
394 assert.deepStrictEqual(exit.cause.reasons.map((reason) => reason._tag), ["Die", "Die"])
395 const [validation, cleanup] = exit.cause.reasons
396 assert.isTrue(Cause.isDieReason(validation) && SchemaIssue.isIssue(validation.defect))
397 if (Cause.isDieReason(validation) && SchemaIssue.isIssue(validation.defect)) {
398 assert.strictEqual(SchemaIssue.defaultFormatter(validation.defect), "schema issue")
399 }
400 assert.isTrue(Cause.isDieReason(cleanup) && cleanup.defect === defect)
401 }
402 }))
403
404 it.effect(`${method} inherits layer services and supports provide`, () =>
405 Effect.gen(function*() {
406 class Expected extends Context.Service<Expected, string>()("Expected") {}
407 const check = SchemaGetter.checkEffect((value: string) =>
408 Effect.map(Expected, (expected) =>
409 value === expected ? undefined : new SchemaIssue.InvalidValue({ message: "Unexpected value" }))
410 )
411 const schema = Schema.String.pipe(Schema.decode({ decode: check, encode: check }))
412 const assertions = new TestSchema.Asserts(schema)[method]()
413
414 yield* Effect.gen(function*() {
415 yield* assertions.succeedEffect("provided")
416 yield* assertions.failEffect("wrong", "Unexpected value")
417 }).pipe(Effect.provide(Layer.succeed(Expected, "provided")))
418
419 yield* assertions.provide(Expected, "local").succeedEffect("local")
420 yield* assertions.provide(Expected, "local").failEffect("wrong", "Unexpected value")
421 }))
422
423 it.effect(`${method} reads the test clock`, () =>
424 Effect.gen(function*() {
425 const check = SchemaGetter.checkEffect((value: number) =>
426 Effect.map(Clock.currentTimeMillis, (now) =>
427 value === now ? undefined : new SchemaIssue.InvalidValue({ message: "Unexpected time" }))
428 )
429 const schema = Schema.Number.pipe(Schema.decode({ decode: check, encode: check }))
430 const assertions = new TestSchema.Asserts(schema)[method]()
431 yield* TestClock.setTime(123)
432 yield* assertions.succeedEffect(123)
433 yield* assertions.failEffect(0, "Unexpected time")
434 }))
435 }
436
437 it.effect("make preserves mixed causes from constructor defaults", () =>
438 Effect.gen(function*() {
439 const issue = new SchemaIssue.InvalidValue({ message: "schema issue" })
440 const defect = new Error("cleanup failed")
441 const schema = Schema.Struct({
442 value: Schema.String.pipe(
443 Schema.withConstructorDefault(Effect.fail(issue).pipe(Effect.ensuring(Effect.die(defect))))
444 )
445 })
446 const assertions = new TestSchema.Asserts(schema).make()
447
448 const exit = yield* Effect.exit(assertions.failEffect({}, "schema issue"))
449 assert.isTrue(Exit.isFailure(exit))
450 if (Exit.isFailure(exit)) {
451 assert.deepStrictEqual(exit.cause.reasons.map((reason) => reason._tag), ["Die", "Die"])
452 const [validation, cleanup] = exit.cause.reasons
453 assert.isTrue(Cause.isDieReason(validation) && SchemaIssue.isIssue(validation.defect))
454 if (Cause.isDieReason(validation) && SchemaIssue.isIssue(validation.defect)) {
455 assert.include(SchemaIssue.defaultFormatter(validation.defect), "schema issue")
456 }
457 assert.isTrue(Cause.isDieReason(cleanup) && cleanup.defect === defect)
458 }
459 }))
460
461 for (const method of ["decoding", "encoding", "roundTrip"] as const) {
462 it.effect(`${method} propagates interruption and runs finalizers`, () =>
463 Effect.gen(function*() {
464 const started = yield* Deferred.make<void>()
465 const finalized = yield* Ref.make(false)
466 const check = SchemaGetter.checkEffect<string>(() =>
467 Effect.andThen(Deferred.succeed(started, undefined), Effect.never).pipe(
468 Effect.ensuring(Ref.set(finalized, true))
469 )
470 )
471 const schema = Schema.String.pipe(Schema.decode({ decode: check, encode: check }))
472 const asserts = new TestSchema.Asserts(schema)
473 const effect = method === "roundTrip"
474 ? asserts.verifyRoundTripEffect({ runs: 1, seed: 1 })
475 : asserts[method]().failEffect("value", "should not pass")
476 const fiber = yield* Effect.forkChild(effect)
477 yield* Deferred.await(started)
478 yield* Fiber.interrupt(fiber)
479 assert.isTrue(yield* Ref.get(finalized))
480 const exit = yield* Fiber.await(fiber)
481 assert.isTrue(Exit.isFailure(exit))
482 if (Exit.isFailure(exit)) {
483 assert.isTrue(Cause.hasInterruptsOnly(exit.cause))
484 }
485 }))
486 }
487
488 it.effect("round trips use both decoding and encoding services lazily", () =>
489 Effect.gen(function*() {
490 class Decoding extends Context.Service<Decoding, Ref.Ref<number>>()("Decoding") {}
491 class Encoding extends Context.Service<Encoding, Ref.Ref<number>>()("Encoding") {}
492 const schema = Schema.String.pipe(Schema.decode({
493 decode: SchemaGetter.checkEffect(() =>
494 Effect.flatMap(Decoding, (ref) => Ref.update(ref, (n) => n + 1)).pipe(Effect.as(true))
495 ),
496 encode: SchemaGetter.checkEffect(() =>
497 Effect.flatMap(Encoding, (ref) => Ref.update(ref, (n) => n + 1)).pipe(Effect.as(true))
498 )
499 }))
500 const decoding = yield* Ref.make(0)
501 const encoding = yield* Ref.make(0)
502 const assertion = new TestSchema.Asserts(schema).verifyRoundTripEffect({ runs: 3, seed: 1 })
503 assert.strictEqual(yield* Ref.get(decoding), 0)
504 assert.strictEqual(yield* Ref.get(encoding), 0)
505 yield* assertion.pipe(Effect.provide(Layer.mergeAll(
506 Layer.succeed(Decoding, decoding),
507 Layer.succeed(Encoding, encoding)
508 )))
509 assert.strictEqual(yield* Ref.get(decoding), 3)
510 assert.strictEqual(yield* Ref.get(encoding), 3)
511 }))
512
513 it.effect("round-trip assertion defects report shrunk inputs and replay", () =>
514 Effect.gen(function*() {
515 const schema = Schema.Number.pipe(Schema.decodeTo(
516 Schema.Number,
517 SchemaTransformation.transform({ decode: (value) => value, encode: () => 0 })
518 ))
519 const exit = yield* Effect.exit(
520 new TestSchema.Asserts(schema).verifyRoundTripEffect({ runs: 20, seed: "lossy" })
521 )
522 assert.isTrue(Exit.isFailure(exit))
523 if (Exit.isFailure(exit)) {
524 const error = Cause.squash(exit.cause)
525 assert.instanceOf(error, AssertionError)
526 assert.match(error.message, /Property falsified/)
527 assert.match(error.message, /Shrunk input:/)
528 assert.match(error.message, /Replay:/)
529 }
530 }))
531
532 it.effect("round trips preserve schema defects", () =>
533 Effect.gen(function*() {
534 const defect = new Error("round-trip defect")
535 const schema = Schema.String.pipe(Schema.decode({
536 decode: SchemaGetter.passthrough(),
537 encode: SchemaGetter.checkEffect(() => Effect.die(defect))
538 }))
539 const assertion = new TestSchema.Asserts(schema).verifyRoundTripEffect({ runs: 1, seed: 1 })
540 assert.deepStrictEqual(yield* Effect.exit(assertion), Exit.die(defect))
541 }))
542})