The old page had been quietly broken for some time. It loaded
@babel/standalone from an **unpinned** CDN URL, which rolled over to Babel
8 and dropped the `proposal-class-properties` plugin the page asked for, so
Babel.transform threw before it ever reached the decorators — and the
decorator config it passed was `{ legacy: true }`, which 0.2.0 had already
made wrong. Nothing on the page said so. The copy was still selling the
0.1.0 pitch ("Spring-like"), listed about half the decorators, showed
`npm install cereale` for a package the registry returns 404 for, and
claimed "Zero overhead" against a README that publishes the real
microsecond costs.
The rebuild is one self-contained page: hand-written CSS, no Tailwind CDN,
no CodeMirror, and a vendored compiler pinned by package.json. It loads
nothing from the network. The playground runs the real bundled library
across six examples, all verified in a headless browser. The reference
covers all 68 decorators and the full API, counted from the bundle at
runtime so it cannot drift.
The hero's compiler error is not typed into the HTML. scripts/build-docs.mjs
compiles the snippets with the real tsc and writes the verbatim diagnostics
into docs/diagnostics.js, failing the build if a snippet the page calls a
compile error ever compiles — and two snippets that must compile guard
against the harness passing vacuously.
Three guards keep it honest, all wired into CI:
- check:docs fails on any remote subresource
- build:docs + git diff fails if docs/ is stale against src/
- check:types compiles a consumer against dist/ with no DOM lib, no
@types/node and no skipLibCheck
That last one found a real packaging defect: `fromRequest` was declared as
taking the global `Request`, so cereale's own published .d.ts raised
"Cannot find name 'Request'" in any project whose lib and types did not
happen to supply it — inside a dependency, in code they may never call, and
unfixable from the outside. It now takes a structural JsonBody, which a
Request still satisfies. The library's own type tests had been hiding it by
enabling both DOM and skipLibCheck.
An adversarial review of the finished page caught four more: the lede
claimed *every* rule is type-checked (@IsDefined and @IsNotIn deliberately
are not), the guarantee section was wrong about the mechanism (a legacy
decorator does get design:type under emitDecoratorMetadata — the real claim
is about its type signature), one sample called a Movie method on a Media[]
and did not compile, and "nested objects come back as real classes" omitted
that you have to declare them. WCAG contrast was measured rather than
eyeballed: seven real failures fixed in the two themes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
177 lines
6.7 KiB
TypeScript
177 lines
6.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
|
|
/**
|
|
* The headline guarantee of v2 is that a rule cannot be attached to a field it does not fit.
|
|
* That is a *compile-time* claim, so asserting it needs the compiler: each case below is
|
|
* type-checked in isolation and must fail.
|
|
*
|
|
* These run the real `tsc`, so they are slower than the rest of the suite — but a guarantee
|
|
* nobody checks is a guarantee that quietly stops holding.
|
|
*/
|
|
const TSC = resolve('node_modules/.bin/tsc');
|
|
const SRC = resolve('src/index.js').replace(/\.js$/, '');
|
|
|
|
function typeCheck(body: string): { ok: boolean; output: string } {
|
|
const dir = mkdtempSync(join(tmpdir(), 'cereale-types-'));
|
|
try {
|
|
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
|
compilerOptions: {
|
|
target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext',
|
|
// These cases compile the library's *source*, whose implementations call `new URL()`.
|
|
// Nothing in the published signatures needs DOM — scripts/check-types.mjs compiles a
|
|
// consumer against dist/ with no DOM lib and no @types/node to keep it that way.
|
|
lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true,
|
|
strictPropertyInitialization: false, noEmit: true, skipLibCheck: true,
|
|
},
|
|
include: ['case.ts'],
|
|
}));
|
|
writeFileSync(join(dir, 'case.ts'), `import {\n IsString, IsInt, Min, MinLength, IsArray, ArrayMinSize, ArrayUnique,\n IsDate, MinDate, IsBoolean, IsIn, IsEnum, JsonType, JsonSerialize,\n JsonDeserialize, JsonSerializer, JsonDeserializer,\n} from ${JSON.stringify(SRC + '.js')};\n\n${body}\n`);
|
|
try {
|
|
execFileSync(process.execPath, [TSC, '-p', dir], { stdio: 'pipe' });
|
|
return { ok: true, output: '' };
|
|
} catch (error: any) {
|
|
return { ok: false, output: String(error.stdout ?? '') + String(error.stderr ?? '') };
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const compiles = (body: string) => {
|
|
const result = typeCheck(body);
|
|
if (!result.ok) throw new Error(`expected this to compile but it did not:\n${result.output}`);
|
|
};
|
|
|
|
const rejects = (body: string) => {
|
|
const result = typeCheck(body);
|
|
expect(result.ok, 'expected a compile error, but it compiled').toBe(false);
|
|
return result.output;
|
|
};
|
|
|
|
describe('rules are checked against the field type', () => {
|
|
it('accepts rules that match the field', () => {
|
|
compiles(`
|
|
class Ok {
|
|
@IsString() @MinLength(2) name!: string;
|
|
@IsInt() @Min(0) age!: number;
|
|
@IsBoolean() active!: boolean;
|
|
@IsDate() @MinDate(new Date(0)) when!: Date;
|
|
@IsArray() @ArrayMinSize(1) tags!: string[];
|
|
@IsString() nickname?: string;
|
|
@IsString() maybe!: string | null;
|
|
}
|
|
void Ok;
|
|
`);
|
|
}, 60_000);
|
|
|
|
it('rejects a string rule on a number field', () => {
|
|
expect(rejects(`class Bad { @IsString() age!: number } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects a number rule on a string field', () => {
|
|
expect(rejects(`class Bad { @Min(0) label!: string } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects an array rule on a non-array field', () => {
|
|
expect(rejects(`class Bad { @ArrayMinSize(1) count!: number } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects a date rule on a string field', () => {
|
|
expect(rejects(`class Bad { @MinDate(new Date(0)) when!: string } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('each: true moves the rule onto the elements', () => {
|
|
it('accepts a matching array field', () => {
|
|
compiles(`class Ok { @IsString({ each: true }) tags!: string[] } void Ok;`);
|
|
}, 60_000);
|
|
|
|
it('rejects each:true on a scalar field', () => {
|
|
expect(rejects(`class Bad { @IsString({ each: true }) tag!: string } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects a bare rule on an array field', () => {
|
|
expect(rejects(`class Bad { @IsString() tags!: string[] } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects an element-type mismatch', () => {
|
|
expect(rejects(`class Bad { @IsString({ each: true }) nums!: number[] } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('nested types and converters are checked', () => {
|
|
const shapes = `
|
|
class Address { street!: string }
|
|
class Money { amount!: number }
|
|
`;
|
|
|
|
it('accepts the matching class', () => {
|
|
compiles(`${shapes}
|
|
class Ok {
|
|
@JsonType(() => Address) ship!: Address;
|
|
@JsonType(() => Address) history!: Address[];
|
|
}
|
|
void Ok;`);
|
|
}, 60_000);
|
|
|
|
it('rejects an unrelated class', () => {
|
|
expect(rejects(`${shapes}
|
|
class Bad { @JsonType(() => Money) ship!: Address }
|
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects a serializer whose input does not match the field', () => {
|
|
expect(rejects(`
|
|
class DateToString implements JsonSerializer<Date, string> {
|
|
serialize(v: Date) { return v.toISOString(); }
|
|
}
|
|
class Bad { @JsonSerialize(DateToString) name!: string }
|
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects a deserializer whose output does not match the field', () => {
|
|
expect(rejects(`
|
|
class StringToDate implements JsonDeserializer<string, Date> {
|
|
deserialize(v: string) { return new Date(v); }
|
|
}
|
|
class Bad { @JsonDeserialize(StringToDate) name!: string }
|
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('membership rules narrow the field', () => {
|
|
it('accepts a field typed as the allowed union', () => {
|
|
compiles(`class Ok { @IsIn(['a', 'b']) choice!: 'a' | 'b' } void Ok;`);
|
|
}, 60_000);
|
|
|
|
it('rejects a field that cannot hold the allowed values', () => {
|
|
expect(rejects(`class Bad { @IsIn(['a', 'b']) choice!: number } void Bad;`))
|
|
.toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('rejects an enum rule on a mismatched field', () => {
|
|
expect(rejects(`
|
|
enum Role { Admin = 'admin' }
|
|
class Bad { @IsEnum(Role) role!: number }
|
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
|
}, 60_000);
|
|
|
|
it('accepts an enum rule on the enum field', () => {
|
|
compiles(`
|
|
enum Role { Admin = 'admin', User = 'user' }
|
|
class Ok { @IsEnum(Role) role!: Role }
|
|
void Ok;`);
|
|
}, 60_000);
|
|
});
|