🔧 fix: repair test, build and CI infrastructure

The vitest suite silently ran zero tests: vitest 4 transpiles with oxc, which
does not pick up `experimentalDecorators` from a tsconfig that excludes the
files being transformed, so every decorator-using suite failed to parse and was
reported as "0 test". A vitest.config.ts enabling legacy decorators brings all
40 existing tests back to life.

- Add vitest.config.ts (oxc legacy decorators + v8 coverage config)
- Type-check test files: move the test/demo exclusions from the base tsconfig
  onto the two build configs, and fix the strict-mode errors this surfaced
- Stop shipping src/example.ts in dist (it invokes runExample() at import time,
  a side effect in a package declaring "sideEffects": false)
- CI: run lint, tests with coverage, build and entry-point smoke checks; drop
  EOL Node 18, add Node 24; commit package-lock.json so `npm ci` works
- Add `verify`, `test:watch` and `build:docs` scripts, and an engines field;
  `build:docs` regenerates the previously hand-maintained docs/cereale.js

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-03 23:14:16 +00:00
parent fa75147e4e
commit 66e19f690f
12 changed files with 3327 additions and 25 deletions
+14 -2
View File
@@ -7,12 +7,14 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
build: verify:
name: Node ${{ matrix.node-version }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
node-version: [18.x, 20.x, 22.x] node-version: [20.x, 22.x, 24.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -25,5 +27,15 @@ jobs:
run: npm ci run: npm ci
- name: Type Check - name: Type Check
run: npm run type-check run: npm run type-check
- name: Lint
run: npm run lint
- name: Test
run: npm run test:coverage
- name: Build
run: npm run build
- name: Verify published entry points load
run: |
node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');"
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
- name: Run Demo - name: Run Demo
run: npm run demo run: npm run demo
+2 -1
View File
@@ -1,6 +1,7 @@
# Node modules and dependency files # Node modules and dependency files
# NOTE: package-lock.json is intentionally committed — CI installs with `npm ci`,
# which requires a lockfile to be present in the repository.
/node_modules/ /node_modules/
/package-lock.json
# Build outputs # Build outputs
/dist/ /dist/
+1 -1
View File
File diff suppressed because one or more lines are too long
+3258
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -19,13 +19,19 @@
], ],
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", "build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
"build:docs": "esbuild src/index.ts --bundle --format=iife --global-name=Cereale --minify --tsconfig=tsconfig.json --outfile=docs/cereale.js",
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts", "demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"prepublishOnly": "npm run build" "verify": "npm run type-check && npm run lint && npm run test && npm run build",
"prepublishOnly": "npm run verify"
},
"engines": {
"node": ">=20.0.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -49,6 +55,7 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@vitest/coverage-v8": "^4.1.4", "@vitest/coverage-v8": "^4.1.4",
"esbuild": "^0.25.0",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"globals": "^17.5.0", "globals": "^17.5.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
+7 -7
View File
@@ -238,7 +238,7 @@ describe('Additional Decorators', () => {
t.val = 'wrong'; t.val = 'wrong';
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['CustomValidator']).toBe('val must be correct'); expect(errors[0]!.constraints['CustomValidator']).toBe('val must be correct');
}); });
}); });
@@ -304,7 +304,7 @@ describe('Additional Decorators', () => {
t.val = 5; t.val = 5;
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be ten'); expect(errors[0]!.constraints['custom']).toBe('must be ten');
}); });
it('should handle options as second argument', async () => { it('should handle options as second argument', async () => {
@@ -318,7 +318,7 @@ describe('Additional Decorators', () => {
t.val = 2; t.val = 2;
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be one'); expect(errors[0]!.constraints['custom']).toBe('must be one');
}); });
}); });
@@ -339,10 +339,10 @@ describe('Additional Decorators', () => {
name: string; name: string;
} }
const json = '[{"name": "a"}, {"name": "b"}]'; const json = '[{"name": "a"}, {"name": "b"}]';
const items = await JsonMapper.fromJson(Item, json); const items = (await JsonMapper.fromJson(Item, json)) as unknown as Item[];
expect(Array.isArray(items)).toBe(true); expect(Array.isArray(items)).toBe(true);
expect(items[0]).toBeInstanceOf(Item); expect(items[0]).toBeInstanceOf(Item);
expect(items[0].name).toBe('a'); expect(items[0]!.name).toBe('a');
}); });
it('should handle single polymorphic object', async () => { it('should handle single polymorphic object', async () => {
@@ -350,7 +350,7 @@ describe('Additional Decorators', () => {
@IsString() type: string; @IsString() type: string;
} }
class Dog extends Animal { class Dog extends Animal {
type = 'dog'; override type = 'dog';
@IsString() breed: string; @IsString() breed: string;
} }
class Test { class Test {
@@ -376,7 +376,7 @@ describe('Additional Decorators', () => {
t.tags = ['a', 1 as any]; t.tags = ['a', 1 as any];
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['isString']).toContain('each element'); expect(errors[0]!.constraints['isString']).toContain('each element');
}); });
}); });
}); });
+9 -9
View File
@@ -202,8 +202,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('username'); expect(errors[0]!.property).toBe('username');
expect(errors[0].constraints).toHaveProperty('minLength'); expect(errors[0]!.constraints).toHaveProperty('minLength');
}); });
it('should fail on invalid email', async () => { it('should fail on invalid email', async () => {
@@ -215,8 +215,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('email'); expect(errors[0]!.property).toBe('email');
expect(errors[0].constraints).toHaveProperty('isEmail'); expect(errors[0]!.constraints).toHaveProperty('isEmail');
}); });
it('should fail on invalid role (IsIn)', async () => { it('should fail on invalid role (IsIn)', async () => {
@@ -228,8 +228,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('roles'); expect(errors[0]!.property).toBe('roles');
expect(errors[0].constraints).toHaveProperty('isIn'); expect(errors[0]!.constraints).toHaveProperty('isIn');
}); });
it('should skip validation for null optional field', async () => { it('should skip validation for null optional field', async () => {
@@ -238,7 +238,7 @@ describe('JsonMapper', () => {
user.email = 'john@example.com'; user.email = 'john@example.com';
user.active = true; user.active = true;
user.roles = ['user']; user.roles = ['user'];
user.age = undefined; // optional delete user.age; // optional
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
@@ -254,8 +254,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('age'); expect(errors[0]!.property).toBe('age');
expect(errors[0].constraints).toHaveProperty('min'); expect(errors[0]!.constraints).toHaveProperty('min');
}); });
}); });
}); });
+1 -1
View File
@@ -30,7 +30,7 @@ describe('Standalone Utility Functions', () => {
user.age = '30' as any; user.age = '30' as any;
const errors2 = await validate(user); const errors2 = await validate(user);
expect(errors2).toHaveLength(1); expect(errors2).toHaveLength(1);
expect(errors2[0].property).toBe('age'); expect(errors2[0]!.property).toBe('age');
}); });
it('should transform to plain object directly', async () => { it('should transform to plain object directly', async () => {
+2 -1
View File
@@ -5,5 +5,6 @@
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"outDir": "dist/cjs", "outDir": "dist/cjs",
"declaration": true "declaration": true
} },
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
} }
+2 -1
View File
@@ -4,5 +4,6 @@
"module": "NodeNext", "module": "NodeNext",
"outDir": "dist/esm", "outDir": "dist/esm",
"declaration": true "declaration": true
} },
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
} }
+3 -1
View File
@@ -35,6 +35,8 @@
"experimentalDecorators": true "experimentalDecorators": true
}, },
// NOTE: test files are deliberately included here so that `npm run type-check`
// covers them. The two build configs exclude them (and the demo) from `dist`.
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"] "exclude": ["node_modules", "dist"]
} }
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
// Vitest 4 transpiles with oxc, which does not read `experimentalDecorators`
// out of tsconfig.json for files the tsconfig does not `include`. Without this
// the decorator syntax in the test files fails to parse and every suite is
// silently reported as "0 test".
oxc: {
decorator: { legacy: true },
},
test: {
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/example.ts', 'src/index.ts'],
},
},
});