|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `TypeScriptSerializer` annotates a `typescript`-format file with the spec |
| 5 | + * type of the item's metadata type. This file holds that annotation to the one |
| 6 | + * property that makes it worth writing: it is never false. |
| 7 | + * |
| 8 | + * Every annotated metadata type is compiled here with `tsc`, twice: |
| 9 | + * - a spec-valid body must type-check with no diagnostic at all; |
| 10 | + * - the same body plus one undeclared key must fail with exactly TS2353. That |
| 11 | + * is what makes the annotation a check: a spec type that is `unknown` (as |
| 12 | + * `ViewMetadata` is) would pass the first file and the second. |
| 13 | + * |
| 14 | + * `@objectstack/spec` is resolved the way a consumer of a saved file resolves |
| 15 | + * it, through this package's `node_modules` and the `exports` map's `types`, |
| 16 | + * so this reads spec's BUILT `dist/*.d.ts`. `turbo test` builds it first |
| 17 | + * (`^build`); a bare `vitest run` needs `pnpm --filter @objectstack/spec build`. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect } from 'vitest'; |
| 21 | +import { dirname, join } from 'node:path'; |
| 22 | +import { fileURLToPath } from 'node:url'; |
| 23 | +import ts from 'typescript'; |
| 24 | +import { MetadataTypeSchema, getMetadataTypeSchema } from '@objectstack/spec/kernel'; |
| 25 | +import { TypeScriptSerializer } from './typescript-serializer.js'; |
| 26 | + |
| 27 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 28 | + |
| 29 | +/** One spec-valid body per metadata type the serializer annotates. */ |
| 30 | +const REPRESENTATIVE: Record<string, Record<string, unknown>> = { |
| 31 | + object: { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }, |
| 32 | + field: { name: 'title', type: 'text', label: 'Title' }, |
| 33 | + hook: { name: 'account_audit', object: 'account', events: ['beforeInsert'], handler: 'audit_account' }, |
| 34 | + seed: { object: 'account', records: [{ name: 'Acme' }] }, |
| 35 | + mapping: { name: 'account_import', targetObject: 'account', fieldMapping: [] }, |
| 36 | + datasource: { name: 'warehouse', driver: 'sqlite', config: {} }, |
| 37 | + analytics_cube: { name: 'account_cube', sql: 'account', measures: {}, dimensions: {} }, |
| 38 | + page: { name: 'account_home', label: 'Account Home', regions: [] }, |
| 39 | + dashboard: { name: 'ops', label: 'Ops', widgets: [] }, |
| 40 | + app: { name: 'sales', label: 'Sales' }, |
| 41 | + action: { name: 'close_account', label: 'Close', type: 'script', target: 'close_account' }, |
| 42 | + report: { name: 'hours_by_status', label: 'Hours by status', dataset: 'task_metrics', rows: ['status'], values: ['est_hours'] }, |
| 43 | + dataset: { name: 'account_ds', label: 'Accounts', object: 'account', dimensions: [], measures: [] }, |
| 44 | + flow: { name: 'notify_owner', label: 'Notify owner', type: 'autolaunched', nodes: [], edges: [] }, |
| 45 | + webhook: { name: 'account_hook', object: 'account', triggers: ['create'], url: 'https://example.com/hook' }, |
| 46 | + job: { name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 2 * * *' }, handler: 'sweep' }, |
| 47 | + translation: { locale: 'en', objects: { account: { label: 'Account' } } }, |
| 48 | + email_template: { name: 'welcome', label: 'Welcome', subject: 'Welcome', bodyHtml: 'Hello' }, |
| 49 | + doc: { name: 'getting_started', label: 'Getting Started', content: '# Hello' }, |
| 50 | + api: { name: 'ping', path: '/api/v1/apps/demo/ping', method: 'GET', type: 'object_operation', target: 'account' }, |
| 51 | + permission: { name: 'sales_user', label: 'Sales user', objects: {} }, |
| 52 | + sharing_rule: { |
| 53 | + name: 'share_accounts', object: 'account', type: 'criteria', condition: 'true', |
| 54 | + sharedWith: { type: 'team', value: 'sales' }, accessLevel: 'read', |
| 55 | + }, |
| 56 | + capability: { name: 'manage_accounts', label: 'Manage accounts' }, |
| 57 | + position: { name: 'sales_rep', label: 'Sales rep' }, |
| 58 | + agent: { name: 'support_agent', label: 'Support', role: 'Support', instructions: 'Help users.', skills: ['case_management'] }, |
| 59 | + tool: { name: 'list_records', label: 'List records', description: 'Lists records', parameters: {} }, |
| 60 | + skill: { name: 'case_management', label: 'Case management', tools: [] }, |
| 61 | + connector: { name: 'status_api', label: 'Status API', type: 'api', authentication: { type: 'none' } }, |
| 62 | +}; |
| 63 | + |
| 64 | +/** Stack collections `getMetadataTypeSchema()` binds that are not `MetadataTypeSchema` members. */ |
| 65 | +const NON_MEMBER_TYPES = ['webhook', 'connector', 'sharing_rule', 'analytics_cube']; |
| 66 | + |
| 67 | +const serializer = new TypeScriptSerializer('typescript'); |
| 68 | +const annotates = (metadataType: string): boolean => |
| 69 | + serializer.serialize({ name: 'x' }, { metadataType }).startsWith('import type {'); |
| 70 | + |
| 71 | +/** Type-check in-memory files as if they sat in this directory. */ |
| 72 | +function typeCheck(files: ReadonlyMap<string, string>): readonly ts.Diagnostic[] { |
| 73 | + const options: ts.CompilerOptions = { |
| 74 | + strict: true, |
| 75 | + noEmit: true, |
| 76 | + skipLibCheck: true, |
| 77 | + types: [], |
| 78 | + target: ts.ScriptTarget.ES2022, |
| 79 | + module: ts.ModuleKind.ESNext, |
| 80 | + moduleResolution: ts.ModuleResolutionKind.Bundler, |
| 81 | + }; |
| 82 | + const host = ts.createCompilerHost(options, true); |
| 83 | + const { getSourceFile, fileExists, readFile } = host; |
| 84 | + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { |
| 85 | + const text = files.get(fileName); |
| 86 | + return text === undefined |
| 87 | + ? getSourceFile.call(host, fileName, languageVersion, onError, shouldCreate) |
| 88 | + : ts.createSourceFile(fileName, text, languageVersion, true); |
| 89 | + }; |
| 90 | + host.fileExists = (fileName) => files.has(fileName) || fileExists.call(host, fileName); |
| 91 | + host.readFile = (fileName) => files.get(fileName) ?? readFile.call(host, fileName); |
| 92 | + return ts.getPreEmitDiagnostics(ts.createProgram([...files.keys()], options, host)); |
| 93 | +} |
| 94 | + |
| 95 | +describe('TypeScriptSerializer annotation, per metadata type', () => { |
| 96 | + it('every representative body is valid for its metadata type', () => { |
| 97 | + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { |
| 98 | + const result = getMetadataTypeSchema(metadataType)?.safeParse(item); |
| 99 | + expect(result?.success, metadataType).toBe(true); |
| 100 | + } |
| 101 | + }); |
| 102 | + |
| 103 | + it('annotates exactly the representative metadata types, and never view, book or external_catalog', () => { |
| 104 | + const universe = new Set([...MetadataTypeSchema.options, ...NON_MEMBER_TYPES, ...Object.keys(REPRESENTATIVE)]); |
| 105 | + const annotated = [...universe].filter(annotates).sort(); |
| 106 | + expect(annotated).toEqual(Object.keys(REPRESENTATIVE).sort()); |
| 107 | + for (const metadataType of ['view', 'book', 'external_catalog']) { |
| 108 | + expect(annotates(metadataType), metadataType).toBe(false); |
| 109 | + } |
| 110 | + }); |
| 111 | + |
| 112 | + it('round-trips every annotated body through serialize and deserialize', () => { |
| 113 | + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { |
| 114 | + expect(serializer.deserialize(serializer.serialize(item, { metadataType })), metadataType).toEqual(item); |
| 115 | + } |
| 116 | + }); |
| 117 | + |
| 118 | + it('tsc: a valid body type-checks and an undeclared key is refused (TS2353), for every annotation', () => { |
| 119 | + const dir = join(HERE, '__annotation_type_check__'); |
| 120 | + const files = new Map<string, string>(); |
| 121 | + for (const [metadataType, item] of Object.entries(REPRESENTATIVE)) { |
| 122 | + const valid = serializer.serialize(item, { metadataType }); |
| 123 | + files.set(join(dir, `${metadataType}.ts`), valid); |
| 124 | + files.set(join(dir, `${metadataType}.undeclared-key.ts`), valid.replace('= {', '= {\n "undeclared_key": 1,')); |
| 125 | + } |
| 126 | + const diagnostics = typeCheck(files); |
| 127 | + const byFile = new Map<string, number[]>(); |
| 128 | + const unattributed: string[] = []; |
| 129 | + for (const d of diagnostics) { |
| 130 | + if (!d.file) { |
| 131 | + unattributed.push(ts.flattenDiagnosticMessageText(d.messageText, '\n')); |
| 132 | + continue; |
| 133 | + } |
| 134 | + byFile.set(d.file.fileName, [...(byFile.get(d.file.fileName) ?? []), d.code]); |
| 135 | + } |
| 136 | + const report = ts.formatDiagnostics(diagnostics, { |
| 137 | + getCanonicalFileName: (f) => f, |
| 138 | + getCurrentDirectory: () => dir, |
| 139 | + getNewLine: () => '\n', |
| 140 | + }); |
| 141 | + expect(unattributed, report).toEqual([]); |
| 142 | + expect([...byFile.keys()].filter((f) => !files.has(f)), report).toEqual([]); |
| 143 | + for (const metadataType of Object.keys(REPRESENTATIVE)) { |
| 144 | + expect(byFile.get(join(dir, `${metadataType}.ts`)) ?? [], report).toEqual([]); |
| 145 | + expect(byFile.get(join(dir, `${metadataType}.undeclared-key.ts`)) ?? [], report).toEqual([2353]); |
| 146 | + } |
| 147 | + }, 60_000); |
| 148 | +}); |
0 commit comments