/** * Round-trip test: every active module manifest must validate identically * under both the Zod schema (ModuleManifestSchema) and the exported JSON * Schema at /schemas/module-manifest.schema.json. * * This is the INDIRECT drift detector, not the primary guard. `bun run * check:schema` (a step in the CI validate job) regenerates the schema and * compares it byte-for-byte, so it fails the moment schema.ts changes without a * regenerate. This test only fails once some real manifest in the repo happens * to declare something a stale schema rejects, which can be days later and in * an unrelated PR. Do not mistake one for the other: if you add a field to * schema.ts, `check:schema` is what tells you to run `bun run export:schema`. * * What this test is actually for: * 1. Every manifest the repo ships really does validate under both * validators, so a stale schema cannot sit unnoticed once it starts * rejecting live manifests. * 2. zod-to-json-schema's translation hasn't lost fidelity in a way that * would let bad manifests pass the editor (red underlines) while still * failing at module import time. If a real manifest in the repo is * accepted by one validator but rejected by the other, that is a bug in * the export and it should be fixed at the source — not by hand-editing * the JSON Schema. */ import { describe, expect, test } from 'bun:test'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import Ajv from 'ajv'; import { parse as parseYaml } from 'yaml'; import { ModuleManifestSchema } from './schema'; const TEST_FILE_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(TEST_FILE_DIR, '..', '..', '..', '..'); const MODULES_DIR = resolve(REPO_ROOT, 'modules'); const SCHEMA_PATH = resolve(REPO_ROOT, 'schemas', 'module-manifest.schema.json'); /** * Returns active module manifest paths, excluding archived and temporary * extraction directories that shouldn't be schema-validated. */ function findActiveManifests(): string[] { const entries = readdirSync(MODULES_DIR); const manifests: string[] = []; for (const entry of entries) { if (entry === '__archive__' || entry.startsWith('.')) continue; const dir = join(MODULES_DIR, entry); if (!statSync(dir).isDirectory()) continue; const manifestPath = join(dir, 'manifest.yml'); try { if (statSync(manifestPath).isFile()) { manifests.push(manifestPath); } } catch { // No manifest.yml in this directory; skip. } } return manifests.sort(); } const manifestPaths = findActiveManifests(); const jsonSchema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')); const ajv = new Ajv({ strict: false, allErrors: true }); const validateJsonSchema = ajv.compile(jsonSchema); describe('JSON Schema round-trip', () => { test('finds at least one active manifest', () => { expect(manifestPaths.length).toBeGreaterThan(0); }); test('exported JSON Schema is valid JSON and compiles under ajv', () => { expect(jsonSchema).toBeTruthy(); expect(jsonSchema.title).toBe('Celilo Module Manifest'); expect(typeof validateJsonSchema).toBe('function'); }); describe.each(manifestPaths.map((path) => [path]))('%s', (manifestPath) => { const yamlText = readFileSync(manifestPath, 'utf-8'); const parsed = parseYaml(yamlText); const zodResult = ModuleManifestSchema.safeParse(parsed); const jsonSchemaValid = validateJsonSchema(parsed); test('Zod validation succeeds', () => { if (!zodResult.success) { throw new Error( `Zod rejected ${manifestPath}:\n${JSON.stringify(zodResult.error.format(), null, 2)}`, ); } }); test('JSON Schema validation succeeds', () => { if (!jsonSchemaValid) { throw new Error( `JSON Schema rejected ${manifestPath}:\n${JSON.stringify(validateJsonSchema.errors, null, 2)}`, ); } }); test('Zod and JSON Schema agree', () => { expect(jsonSchemaValid).toBe(zodResult.success); }); }); });