|
1 | | -import { describe, it, expect } from 'vitest'; |
| 1 | +import { describe, it, expect, vi } from 'vitest'; |
| 2 | +import fs from 'node:fs/promises'; |
| 3 | +import os from 'node:os'; |
| 4 | +import path from 'node:path'; |
| 5 | +import { NodeMetadataManager } from '../node-metadata-manager.js'; |
2 | 6 | import { JSONSerializer } from '../serializers/json-serializer.js'; |
3 | 7 | import { YAMLSerializer } from '../serializers/yaml-serializer.js'; |
4 | | -import { TypeScriptSerializer } from '../serializers/typescript-serializer.js'; |
| 8 | +import { TypeScriptSerializer, serializeTypeScriptForMetadataType } from '../serializers/typescript-serializer.js'; |
| 9 | +import type { MetadataFormat } from '@objectstack/spec/system'; |
| 10 | +import type { MetadataSerializer, SerializeOptions } from '../serializers/serializer-interface.js'; |
| 11 | +import { FilesystemLoader } from '../loaders/filesystem-loader.js'; |
5 | 12 |
|
6 | 13 | describe('Serializers', () => { |
7 | 14 | describe('JSONSerializer', () => { |
@@ -55,14 +62,143 @@ describe('Serializers', () => { |
55 | 62 | describe('TypeScriptSerializer', () => { |
56 | 63 | const serializer = new TypeScriptSerializer('typescript'); |
57 | 64 |
|
| 65 | + // The saved-view repro: a view used to be annotated `ServiceObject`, which |
| 66 | + // has no top-level `type` or `columns` key, so `tsc` refused the file (TS2353). |
| 67 | + const view = { name: 'all_accounts', type: 'grid', object: 'account', columns: ['name'] }; |
| 68 | + const object = { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }; |
| 69 | + const plain = (item: unknown) => |
| 70 | + `export const metadata = ${JSON.stringify(item, null, 2)};\n\nexport default metadata;\n`; |
| 71 | + // Exactly what `FilesystemLoader.save('object', …)` wrote before this fix, and still writes. |
| 72 | + const annotatedObject = |
| 73 | + `import type { ServiceObject } from '@objectstack/spec/data';\n\n` + |
| 74 | + `export const metadata: ServiceObject = ${JSON.stringify(object, null, 2)};\n\n` + |
| 75 | + `export default metadata;\n`; |
| 76 | + |
58 | 77 | it('should serialize to TypeScript module', () => { |
59 | 78 | const data = { name: 'test', value: 42 }; |
60 | 79 | const result = serializer.serialize(data); |
61 | | - expect(result).toContain('import type'); |
62 | 80 | expect(result).toContain('export const metadata'); |
63 | 81 | expect(result).toContain('export default metadata'); |
64 | 82 | }); |
65 | 83 |
|
| 84 | + it('the public serialize() writes no annotation, even for an object: it does not know the metadata type', () => { |
| 85 | + expect(serializer.serialize(object)).toBe(plain(object)); |
| 86 | + expect(serializer.serialize(view)).toBe(plain(view)); |
| 87 | + }); |
| 88 | + |
| 89 | + it('writes a view with no annotation: no ServiceObject, no import type', () => { |
| 90 | + const result = serializeTypeScriptForMetadataType(view, 'view'); |
| 91 | + expect(result).not.toContain('ServiceObject'); |
| 92 | + expect(result).toBe(plain(view)); |
| 93 | + }); |
| 94 | + |
| 95 | + it('still annotates an object ServiceObject, byte-identical to the earlier output', () => { |
| 96 | + expect(serializeTypeScriptForMetadataType(object, 'object')).toBe(annotatedObject); |
| 97 | + }); |
| 98 | + |
| 99 | + it.each([ |
| 100 | + ['a metadata type the spec has no type for', 'external_catalog'], |
| 101 | + ['a metadata type whose spec type is narrower than its schema', 'book'], |
| 102 | + ['a plugin-registered metadata type', 'acme_widget'], |
| 103 | + ['a plural spelling', 'objects'], |
| 104 | + ])('writes no annotation for %s', (_label, metadataType) => { |
| 105 | + expect(serializeTypeScriptForMetadataType({ name: 'x' }, metadataType)).toBe(plain({ name: 'x' })); |
| 106 | + }); |
| 107 | + |
| 108 | + it('the javascript format never annotates', () => { |
| 109 | + expect(new TypeScriptSerializer('javascript').serialize(object)).toBe(plain(object)); |
| 110 | + }); |
| 111 | + |
| 112 | + it('reads back a file written before this fix: a view annotated ServiceObject', () => { |
| 113 | + const legacy = |
| 114 | + `import type { ServiceObject } from '@objectstack/spec/data';\n\n` + |
| 115 | + `export const metadata: ServiceObject = ${JSON.stringify(view, null, 2)};\n\n` + |
| 116 | + `export default metadata;\n`; |
| 117 | + expect(serializer.deserialize(legacy)).toEqual(view); |
| 118 | + }); |
| 119 | + |
| 120 | + it('round-trips a view and an object through serialize and deserialize', () => { |
| 121 | + for (const [metadataType, item] of [['view', view], ['object', object]] as const) { |
| 122 | + expect(serializer.deserialize(serializeTypeScriptForMetadataType(item, metadataType))).toEqual(item); |
| 123 | + expect(serializer.deserialize(serializer.serialize(item))).toEqual(item); |
| 124 | + } |
| 125 | + }); |
| 126 | + |
| 127 | + it('FilesystemLoader.save() annotates by metadata type: the view file is unannotated, the object file is ServiceObject', async () => { |
| 128 | + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-')); |
| 129 | + try { |
| 130 | + const manager = new NodeMetadataManager({ rootDir, watch: false }); |
| 131 | + await manager.save('view', 'all_accounts', view); |
| 132 | + await manager.save('object', 'account', object); |
| 133 | + const viewFile = await fs.readFile(path.join(rootDir, 'view', 'all_accounts.ts'), 'utf-8'); |
| 134 | + const objectFile = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8'); |
| 135 | + expect(viewFile).not.toContain('ServiceObject'); |
| 136 | + expect(viewFile).toBe(plain(view)); |
| 137 | + expect(objectFile).toBe(annotatedObject); |
| 138 | + } finally { |
| 139 | + await fs.rm(rootDir, { recursive: true, force: true }); |
| 140 | + } |
| 141 | + }); |
| 142 | + |
| 143 | + it('FilesystemLoader.save() calls a custom serializer registered for typescript as it always did', async () => { |
| 144 | + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-custom-')); |
| 145 | + try { |
| 146 | + const seen: unknown[] = []; |
| 147 | + const custom: MetadataSerializer = { |
| 148 | + serialize: (item, options) => { |
| 149 | + seen.push(options); |
| 150 | + return `// custom\nexport const metadata = ${JSON.stringify(item)};\n`; |
| 151 | + }, |
| 152 | + deserialize: (content) => serializer.deserialize(content), |
| 153 | + getExtension: () => '.ts', |
| 154 | + canHandle: (format) => format === 'typescript', |
| 155 | + getFormat: () => 'typescript', |
| 156 | + }; |
| 157 | + const loader = new FilesystemLoader(rootDir, new Map<MetadataFormat, MetadataSerializer>([['typescript', custom]])); |
| 158 | + await loader.save('object', 'account', object); |
| 159 | + const file = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8'); |
| 160 | + expect(file).toBe(`// custom\nexport const metadata = ${JSON.stringify(object)};\n`); |
| 161 | + expect(seen).toEqual([{ prettify: true, indent: 2, sortKeys: false }]); |
| 162 | + } finally { |
| 163 | + await fs.rm(rootDir, { recursive: true, force: true }); |
| 164 | + } |
| 165 | + }); |
| 166 | + |
| 167 | + // A FilesystemLoader wired by hand with one `typescript` serializer, saving `object`. |
| 168 | + const saveObjectWith = async (tsSerializer: MetadataSerializer): Promise<string> => { |
| 169 | + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-wired-')); |
| 170 | + try { |
| 171 | + const loader = new FilesystemLoader(rootDir, new Map<MetadataFormat, MetadataSerializer>([['typescript', tsSerializer]])); |
| 172 | + await loader.save('object', 'account', object); |
| 173 | + return await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8'); |
| 174 | + } finally { |
| 175 | + await fs.rm(rootDir, { recursive: true, force: true }); |
| 176 | + } |
| 177 | + }; |
| 178 | + |
| 179 | + it('FilesystemLoader.save() calls a subclass that overrides serialize(), as it always did', async () => { |
| 180 | + class HeaderSerializer extends TypeScriptSerializer { |
| 181 | + override serialize<T>(item: T, options?: SerializeOptions): string { |
| 182 | + return '// header\n' + super.serialize(item, options); |
| 183 | + } |
| 184 | + } |
| 185 | + expect(await saveObjectWith(new HeaderSerializer('typescript'))).toBe('// header\n' + plain(object)); |
| 186 | + }); |
| 187 | + |
| 188 | + it('FilesystemLoader.save() annotates through the built-in serialize(), inherited by a subclass or not', async () => { |
| 189 | + class KeepsSerialize extends TypeScriptSerializer {} |
| 190 | + expect(await saveObjectWith(new TypeScriptSerializer('typescript'))).toBe(annotatedObject); |
| 191 | + expect(await saveObjectWith(new KeepsSerialize('typescript'))).toBe(annotatedObject); |
| 192 | + }); |
| 193 | + |
| 194 | + it('FilesystemLoader.save() calls a TypeScriptSerializer from another module copy through its own serialize(): no annotation', async () => { |
| 195 | + // The published `.` and `./node` entries are separate bundles, each with its own class copy. |
| 196 | + vi.resetModules(); |
| 197 | + const other = await import('../serializers/typescript-serializer.js'); |
| 198 | + expect(other.TypeScriptSerializer).not.toBe(TypeScriptSerializer); |
| 199 | + expect(await saveObjectWith(new other.TypeScriptSerializer('typescript'))).toBe(plain(object)); |
| 200 | + }); |
| 201 | + |
66 | 202 | it('should get correct extension', () => { |
67 | 203 | const ts = new TypeScriptSerializer('typescript'); |
68 | 204 | expect(ts.getExtension()).toBe('.ts'); |
|
0 commit comments