🔊 feat!: make the three silent failures loud
Every change here answers one question: where does cereale currently fail
without saying so?
**Vite 8 / Vitest 4 drop decorators silently.** Both transform with oxc,
which does not implement the standard decorator transform and does not
report that. `vitest` prints "0 test" beside a bare SyntaxError, and
`vite build` reports success while emitting a bundle that throws on first
import. Ship the plugin that fixes it as `cereale/vite`, transforming with
esbuild and falling back to tsc — cereale depends on neither. The library's
own suite now runs through it, so it is exercised by every test.
**Legacy decorators died opaquely.** With `experimentalDecorators: true`,
still the default in most existing TypeScript projects, decorators are
invoked as (prototype, "name") and cereale raised "TypeError: Cannot convert
undefined or null to object". All decorators now resolve metadata through
one checkpoint that names the tsconfig setting instead, and reject
application to a method, getter or accessor field.
**Values JSON cannot carry were emptied.** A populated Map serialized to
{}, a Uint8Array to index-keyed noise, a bigint straight through so the
caller's own JSON.stringify threw somewhere unrelated. All now raise
JsonMappingError naming the property path and both ways out. Covers what a
@JsonSerialize serializer returns, sync or async. Circular-reference and
depth errors name the path too.
Also fixed: defineRule on a subclass with no decorators of its own wrote
the rule into its base class, because the base's metadata object is
inherited through the static prototype chain and `??=` found it non-nullish.
The README's toolchain table (tsc, esbuild, swc ✅, oxc ❌) is now executed
by a test rather than asserted, and the positioning leads with
class-validator + class-transformer, the stack cereale actually replaces,
rather than Zod, which it deliberately is not.
193 -> 249 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
IsString, JsonIgnore, JsonSerialize, JsonSerializer, JsonMappingError,
|
||||
toPlain, toPlainSync, defineRule, modelOf, validateSync,
|
||||
} from './index.js';
|
||||
|
||||
/**
|
||||
* Before 0.3.0 every case in this file produced `{}` (or index-keyed noise, or a bigint that
|
||||
* made the caller's own `JSON.stringify` throw somewhere unrelated) with nothing logged and
|
||||
* no error raised. A mapping layer that loses data quietly is worse than one that stops.
|
||||
*/
|
||||
describe('values JSON cannot carry', () => {
|
||||
class Basket {
|
||||
// Typed loosely on purpose: the point is what happens at runtime, and the decorators are
|
||||
// deliberately absent so nothing is claiming to handle these.
|
||||
items: any;
|
||||
}
|
||||
|
||||
const withItems = (items: unknown) => Object.assign(new Basket(), { items });
|
||||
|
||||
const cases: [string, unknown, RegExp][] = [
|
||||
['a Map', new Map([['a', 1]]), /is a Map/],
|
||||
['a Set', new Set([1, 2]), /is a Set/],
|
||||
['a WeakMap', new WeakMap(), /is a WeakMap/],
|
||||
['a WeakSet', new WeakSet(), /is a WeakSet/],
|
||||
['a Promise', Promise.resolve(1), /is a Promise/],
|
||||
['a RegExp', /abc/g, /is a RegExp/],
|
||||
['an Error', new Error('boom'), /is an Error/],
|
||||
['a TypeError', new TypeError('boom'), /is an Error/],
|
||||
['an ArrayBuffer', new ArrayBuffer(8), /is an ArrayBuffer/],
|
||||
['a DataView', new DataView(new ArrayBuffer(8)), /is a DataView/],
|
||||
['a Uint8Array', new Uint8Array([1, 2, 3]), /is a Uint8Array/],
|
||||
['a Float64Array', new Float64Array([1.5]), /is a Float64Array/],
|
||||
['a bigint', 10n, /is a bigint/],
|
||||
['a symbol', Symbol('x'), /is a symbol/],
|
||||
['a function', () => 1, /is a function/],
|
||||
];
|
||||
|
||||
for (const [label, value, expected] of cases) {
|
||||
it(`refuses ${label}`, () => {
|
||||
expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(JsonMappingError);
|
||||
expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('names the property in the message and points at the way out', () => {
|
||||
expect(() => toPlainSync(withItems(new Map()), { validate: false }))
|
||||
.toThrow(/items is a Map.*@JsonSerialize\(\).*@JsonIgnore\(\)/s);
|
||||
});
|
||||
|
||||
it('names the full path through nested objects and arrays', () => {
|
||||
class Line { tags: any }
|
||||
class Order { lines: any }
|
||||
const order = Object.assign(new Order(), {
|
||||
lines: [Object.assign(new Line(), { tags: [] }), Object.assign(new Line(), { tags: [new Set(['a'])] })],
|
||||
});
|
||||
|
||||
expect(() => toPlainSync(order, { validate: false })).toThrow(/lines\[1\]\.tags\[0\] is a Set/);
|
||||
});
|
||||
|
||||
it('reports the root when the offending value is the argument itself', () => {
|
||||
expect(() => toPlainSync(new Map(), { validate: false })).toThrow(/the value passed in is a Map/);
|
||||
});
|
||||
|
||||
it('still allows the built-ins that do map cleanly', () => {
|
||||
class Fine {
|
||||
when = new Date('2024-01-01T00:00:00.000Z');
|
||||
list = [1, 'two', true, null];
|
||||
nested = { deep: { deeper: [{ ok: true }] } };
|
||||
empty = {};
|
||||
}
|
||||
|
||||
expect(toPlainSync(new Fine(), { validate: false })).toEqual({
|
||||
when: '2024-01-01T00:00:00.000Z',
|
||||
list: [1, 'two', true, null],
|
||||
nested: { deep: { deeper: [{ ok: true }] } },
|
||||
empty: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a Map once a serializer converts it', () => {
|
||||
class TagsSerializer implements JsonSerializer<Map<string, number>, Record<string, number>> {
|
||||
serialize(value: Map<string, number>) { return Object.fromEntries(value); }
|
||||
}
|
||||
|
||||
class Post {
|
||||
@JsonSerialize(TagsSerializer)
|
||||
tags!: Map<string, number>;
|
||||
}
|
||||
|
||||
const post = new Post();
|
||||
post.tags = new Map([['a', 1], ['b', 2]]);
|
||||
expect(toPlainSync(post, { validate: false })).toEqual({ tags: { a: 1, b: 2 } });
|
||||
});
|
||||
|
||||
it('accepts a Map once the property is ignored', () => {
|
||||
class Cache {
|
||||
@IsString() name = 'x';
|
||||
@JsonIgnore() entries = new Map([['a', 1]]);
|
||||
}
|
||||
|
||||
expect(toPlainSync(new Cache(), { validate: false })).toEqual({ name: 'x' });
|
||||
});
|
||||
|
||||
it('refuses a serializer that hands back something unrepresentable', () => {
|
||||
class BadSerializer implements JsonSerializer<string, unknown> {
|
||||
serialize() { return new Set(['still a Set']); }
|
||||
}
|
||||
|
||||
class Thing {
|
||||
@JsonSerialize(BadSerializer)
|
||||
label!: string;
|
||||
}
|
||||
|
||||
const thing = new Thing();
|
||||
thing.label = 'x';
|
||||
expect(() => toPlainSync(thing, { validate: false })).toThrow(/label is a Set/);
|
||||
});
|
||||
|
||||
it('refuses an async serializer that resolves to something unrepresentable', async () => {
|
||||
class SlowBadSerializer implements JsonSerializer<string, unknown> {
|
||||
async serialize() { return new Map([['a', 1]]); }
|
||||
}
|
||||
|
||||
class Thing {
|
||||
@JsonSerialize(SlowBadSerializer)
|
||||
label!: string;
|
||||
}
|
||||
|
||||
const thing = new Thing();
|
||||
thing.label = 'x';
|
||||
await expect(toPlain(thing, { validate: false })).rejects.toThrow(/label is a Map/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization error paths', () => {
|
||||
it('names where the cycle was found', () => {
|
||||
class Node { name = 'root'; child: any = null; parent: any = null }
|
||||
const root = new Node();
|
||||
const child = new Node();
|
||||
child.name = 'child';
|
||||
child.parent = root;
|
||||
root.child = child;
|
||||
|
||||
expect(() => toPlainSync(root, { validate: false })).toThrow(/at child\.parent/);
|
||||
});
|
||||
|
||||
it('names where the depth limit was hit', () => {
|
||||
class Deep { next: any = null }
|
||||
const root = new Deep();
|
||||
let tip = root;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
tip.next = new Deep();
|
||||
tip = tip.next;
|
||||
}
|
||||
|
||||
expect(() => toPlainSync(root, { validate: false, maxDepth: 3 }))
|
||||
.toThrow(/exceeded while serializing at next\.next\.next/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineRule', () => {
|
||||
// `??=` on an inherited static symbol property finds the base class's metadata object and
|
||||
// never creates an own one, so the rule lands on the base and every sibling inherits it.
|
||||
it('does not write a subclass rule into its base class', () => {
|
||||
class Base {
|
||||
@IsString() name!: string;
|
||||
}
|
||||
class Sub extends Base { extra!: string }
|
||||
class Sibling extends Base { }
|
||||
|
||||
defineRule(Sub, 'extra', {
|
||||
name: 'isShouty',
|
||||
validate: (v: any) => typeof v === 'string' && v === v.toUpperCase(),
|
||||
message: 'extra must be upper case',
|
||||
});
|
||||
|
||||
expect(Object.keys(modelOf(Sub)).sort()).toEqual(['extra', 'name']);
|
||||
expect(Object.keys(modelOf(Base))).toEqual(['name']);
|
||||
expect(Object.keys(modelOf(Sibling))).toEqual(['name']);
|
||||
|
||||
const sibling = Object.assign(new Sibling(), { name: 'ok' });
|
||||
expect(validateSync(sibling)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user