import { describe, it, expect } from 'vitest'; import { build } from 'esbuild'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; /** * Tree-shakability is a property of the source that nothing else notices when it breaks. * * Every rule is declared as a top-level call — `export const IsString = rule(...)` — and rollup * can prove such a call pure by reading the factory, but esbuild and webpack will not. Without * the `/*#__PURE__*\/` annotations on those declarations, importing one decorator dragged in the * message and validator of all 68: 4909 bytes rather than 1837 through esbuild, 4823 rather * than 1818 through webpack. Nothing failed. The library simply got three times heavier in * every consumer's bundle, and the only way to notice was to go and measure. * * So these assertions are mostly about *content* rather than bytes: a byte ceiling tells you * something drifted, but naming the thing that should not be there says what. */ /** Markers that identify a chunk of the library in minified output. */ const MARKER = { isString: 'must be a string', minLength: 'must be longer than or equal to', isLatitude: 'must be a latitude', isSemVer: 'must be a valid semantic version', arraySize: 'must contain at least', serializer: 'Circular reference', representable: 'cannot be serialized to JSON', deserializer: 'Unknown property', validator: '[redacted]', naming: 'SCREAMING_SNAKE_CASE', } as const; const ENTRY = JSON.stringify(path.resolve('src/index.js')); async function bundle(source: string): Promise { const dir = await mkdtemp(path.join(tmpdir(), 'cereale-shake-')); try { const entry = path.join(dir, 'entry.ts'); await writeFile(entry, source); const result = await build({ entryPoints: [entry], bundle: true, format: 'esm', minify: true, target: 'es2022', write: false, tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } }, }); return result.outputFiles[0]!.text; } finally { await rm(dir, { recursive: true, force: true }); } } /** * Asserts what a bundle kept and what it dropped. * * `keeps` is not decoration. A bundle that failed to build, or that resolved the library as an * external and inlined none of it, contains none of the markers — so an "everything was shaken" * result and a broken harness look identical without it. */ function expectShaken(code: string, keeps: string[], drops: string[]) { expect(code.length, 'the bundle is empty — the harness is broken, not the tree-shaking').toBeGreaterThan(200); for (const marker of keeps) { expect(code, `expected the bundle to contain ${JSON.stringify(marker)}`).toContain(marker); } for (const marker of drops) { expect(code, `${JSON.stringify(marker)} should have been shaken out`).not.toContain(marker); } } describe('tree-shaking', () => { it('drops the 67 rules you did not import', async () => { const code = await bundle(` import { IsString } from ${ENTRY}; export const d = IsString(); `); expectShaken(code, [MARKER.isString], [ MARKER.isLatitude, MARKER.isSemVer, MARKER.arraySize, MARKER.minLength, MARKER.serializer, MARKER.deserializer, MARKER.naming, ]); // Generous ceiling: the measured figure is ~1.8 KB, and this is here to catch a regression // of the kind above (which trebled it), not to police every byte. expect(code.length).toBeLessThan(3000); }); it('keeps the deserializer and drops the serializer when only reading', async () => { const code = await bundle(` import { toInstanceSync } from ${ENTRY}; export const f = (C, p) => toInstanceSync(C, p, { validate: false }); `); // Validation is kept on purpose: `validate` defaults to true, so the entry point // references it whatever the call site passes. expectShaken(code, [MARKER.deserializer, MARKER.validator], [MARKER.serializer, MARKER.representable]); }); it('keeps the serializer and drops the deserializer when only writing', async () => { const code = await bundle(` import { toPlainSync } from ${ENTRY}; export const f = (o) => toPlainSync(o, { validate: false }); `); expectShaken(code, [MARKER.serializer, MARKER.representable, MARKER.validator], [MARKER.deserializer]); }); it('drops both engines when only validating', async () => { const code = await bundle(` import { validateSync } from ${ENTRY}; export const f = (o) => validateSync(o); `); expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]); }); /** * The `Symbol.metadata` install at the top of metadata.ts is a bare statement, not an export, * so it survives only because `sideEffects` names that module — and naming it is one hop short * of enough. The barrel that re-exports it is side-effect-free too, so a bundler prunes the * `export * from './metadata.js'` edge before metadata.js's own marking is ever consulted. * * Nothing notices, in the usual way. `import { configure } from 'cereale'` came out at 145 * bytes through esbuild, 143 through webpack and 144 through rollup with `Symbol.metadata` * absent from all three — and a tsc-compiled consumer on a runtime without the well-known * symbol is then decorated with `metadata: undefined`, which is to say with no rules at all. */ it('installs Symbol.metadata even when nothing model-shaped is imported', async () => { const code = await bundle(` import { configure } from ${ENTRY}; export const f = (o) => configure(o); `); expect(code, 'the Symbol.metadata install was pruned along with the barrel').toContain('Symbol.metadata'); expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer, MARKER.validator]); }); it('costs almost nothing to import only an error helper', async () => { const code = await bundle(` import { flattenErrors } from ${ENTRY}; export const f = (e) => flattenErrors(e); `); expectShaken(code, [], [MARKER.serializer, MARKER.deserializer, MARKER.validator, MARKER.isString]); expect(code.length).toBeLessThan(1500); }); it('still contains everything when everything is used', async () => { const code = await bundle(` import * as cereale from ${ENTRY}; export default cereale; `); // The counterweight to every assertion above: proves the markers are findable at all, so a // "shaken" result upstream means shaken rather than misspelled. expectShaken(code, Object.values(MARKER), []); }); /** * The cases above bundle `src/`, which is where the annotations are written — so they cannot * see what happens to them on the way into `dist/`. That is exactly where this broke: the * flat bundle behind `cereale/min` was built with esbuild's `minify: true`, whose * `minifyWhitespace` pass strips comments, annotations included. The published entry point * kept all 26 unrelated rules (5,066 bytes against 1,837) while every source-level check * stayed green. */ describe('the published artifacts', () => { const dist = path.resolve('dist'); const built = existsSync(path.join(dist, 'cereale.min.js')); it.runIf(built)('cereale/min tree-shakes as well as the per-module entry', async () => { const code = await bundle(` import { IsString } from ${JSON.stringify(path.join(dist, 'cereale.min.js'))}; export const d = IsString(); `); expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]); expect(code.length).toBeLessThan(3000); }); /** * The source-level case above proves the `sideEffects` mechanism works; this one proves the * two entries are spelled the way the published tree is laid out. `./src/index.ts` and * `./dist/esm/index.js` are separate paths in the manifest, and a typo in either is invisible * from the other side. */ it.runIf(built)('installs Symbol.metadata from the published barrel too', async () => { const code = await bundle(` import { configure } from ${JSON.stringify(path.join(dist, 'esm/index.js'))}; export const f = (o) => configure(o); `); expect(code, 'the Symbol.metadata install was pruned from dist/esm').toContain('Symbol.metadata'); expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer]); }); }); });