An experimental TypeSpec syntax for Lexicon
at cli 210 lines 6.7 kB view raw
1import assert from "assert"; 2import path from "path"; 3import { describe, it } from "vitest"; 4import { 5 formatDiagnostic, 6 resolvePath, 7 type Diagnostic, 8} from "@typespec/compiler"; 9import { 10 TypeSpecTestLibrary, 11 createTestHost, 12 findTestPackageRoot, 13 resolveVirtualPath, 14} from "@typespec/compiler/testing"; 15import { readdirSync, statSync } from "fs"; 16import { readFile, readdir, stat } from "fs/promises"; 17 18const pkgRoot = await findTestPackageRoot(import.meta.url); 19const TESTS_DIR = resolvePath(pkgRoot, "test/spec"); 20 21const TypelexTestLibrary: TypeSpecTestLibrary = { 22 name: "@typelex/emitter", 23 packageRoot: await findTestPackageRoot(import.meta.url), 24 files: [ 25 { 26 realDir: "", 27 pattern: "package.json", 28 virtualPath: "./node_modules/@typelex/emitter", 29 }, 30 { 31 realDir: "dist", 32 pattern: "**/*.js", 33 virtualPath: "./node_modules/@typelex/emitter/dist", 34 }, 35 { 36 realDir: "lib/", 37 pattern: "*.tsp", 38 virtualPath: "./node_modules/@typelex/emitter/lib", 39 }, 40 ], 41}; 42 43describe("lexicon spec", function () { 44 const scenarios = readdirSync(TESTS_DIR) 45 .map((dn) => path.join(TESTS_DIR, dn)) 46 .filter((dn) => statSync(dn).isDirectory()); 47 48 for (const scenario of scenarios) { 49 const scenarioName = path.basename(scenario); 50 51 describe(scenarioName, async function () { 52 const inputFiles = await readdirRecursive(path.join(scenario, "input")); 53 const expectedFiles = await readdirRecursive( 54 path.join(scenario, "output"), 55 ); 56 57 // Compile all inputs together (for cross-references) 58 const tspFiles = Object.keys(inputFiles).filter((f) => 59 f.endsWith(".tsp"), 60 ); 61 let emitResult: EmitResult; 62 63 if (tspFiles.length > 0) { 64 // Create a virtual main.tsp that imports all other files 65 const mainContent = 66 'import "@typelex/emitter";\n' + 67 tspFiles.map((f) => `import "./${normalizePathToPosix(f)}";`).join("\n"); 68 const filesWithMain = { ...inputFiles, "main.tsp": mainContent }; 69 emitResult = await doEmit(filesWithMain, "main.tsp"); 70 } else { 71 emitResult = { files: {}, diagnostics: [], inputFiles: {} }; 72 } 73 74 // Generate a test for each expected output 75 for (const expectedPath of Object.keys(expectedFiles)) { 76 if (!expectedPath.endsWith(".json")) continue; 77 78 // Derive expected input path: output/app/bsky/feed/post.json -> input/app/bsky/feed/post.tsp 79 const inputPath = expectedPath.replace(/\.json$/, ".tsp"); 80 const hasInput = Object.keys(inputFiles).includes(inputPath); 81 82 if (hasInput) { 83 it(`should emit ${expectedPath}`, function () { 84 // Check for compilation errors 85 if (emitResult.diagnostics.length > 0) { 86 const formattedDiagnostics = emitResult.diagnostics.map((diag) => 87 formatDiagnostic(diag), 88 ); 89 assert.fail( 90 `Expected no diagnostics but got:\n${formattedDiagnostics.join("\n\n")}`, 91 ); 92 } 93 94 const normalizedExpectedPath = normalizePathToPosix(expectedPath); 95 96 assert.ok( 97 Object.prototype.hasOwnProperty.call( 98 emitResult.files, 99 normalizedExpectedPath, 100 ), 101 `Expected file ${expectedPath} was not produced`, 102 ); 103 104 const actual = JSON.parse(emitResult.files[normalizedExpectedPath]); 105 const expected = JSON.parse(expectedFiles[expectedPath]); 106 assert.deepStrictEqual(actual, expected); 107 }); 108 } else { 109 it(`should emit ${expectedPath}`, function () { 110 assert.fail( 111 `Expected output file ${expectedPath} has no corresponding input file ${inputPath}. ` + 112 `Either add the input file or remove the expected output.` 113 ); 114 }); 115 } 116 } 117 118 // Check for unexpected emitted files 119 it("should not emit unexpected files", function () { 120 const emittedFiles = Object.keys(emitResult.files).filter(f => f.endsWith(".json")); 121 const expectedPaths = Object.keys(expectedFiles) 122 .filter(f => f.endsWith(".json")) 123 .map(normalizePathToPosix); 124 125 const unexpected = emittedFiles.filter(f => !expectedPaths.includes(f)); 126 127 if (unexpected.length > 0) { 128 assert.fail( 129 `Unexpected files were emitted: ${unexpected.join(", ")}. ` + 130 `Either add expected output files or ensure these should not be emitted.` 131 ); 132 } 133 }); 134 }); 135 } 136}); 137 138interface EmitResult { 139 files: Record<string, string>; 140 inputFiles: Record<string, string>; 141 diagnostics: readonly Diagnostic[]; 142} 143 144async function doEmit( 145 files: Record<string, string>, 146 entryPoint: string, 147): Promise<EmitResult> { 148 const baseOutputPath = resolveVirtualPath("test-output/"); 149 150 const host = await createTestHost({ 151 libraries: [TypelexTestLibrary], 152 }); 153 154 for (const [fileName, content] of Object.entries(files)) { 155 host.addTypeSpecFile(fileName, content); 156 } 157 158 const [, diagnostics] = await host.compileAndDiagnose(entryPoint, { 159 outputDir: baseOutputPath, 160 noEmit: false, 161 emit: ["@typelex/emitter"], 162 }); 163 164 const outputFiles = Object.fromEntries( 165 [...host.fs.entries()] 166 .filter(([name]) => name.startsWith(baseOutputPath)) 167 .map(([name, value]) => { 168 let relativePath = name.replace(baseOutputPath, ""); 169 // Strip the @typelex/emitter/ prefix if present 170 if (relativePath.startsWith("@typelex/emitter/")) { 171 relativePath = relativePath.replace("@typelex/emitter/", ""); 172 } 173 return [relativePath, value]; 174 }), 175 ); 176 177 return { 178 files: outputFiles, 179 inputFiles: files, 180 diagnostics: diagnostics, 181 }; 182} 183 184async function readdirRecursive(dir: string): Promise<Record<string, string>> { 185 const result: Record<string, string> = {}; 186 187 async function walk(currentDir: string, relativePath: string) { 188 const entries = await readdir(currentDir); 189 190 for (const entry of entries) { 191 const fullPath = path.join(currentDir, entry); 192 const stats = await stat(fullPath); 193 194 if (stats.isDirectory()) { 195 await walk(fullPath, path.join(relativePath, entry)); 196 } else { 197 const content = await readFile(fullPath, "utf-8"); 198 const key = path.join(relativePath, entry); 199 result[key] = content; 200 } 201 } 202 } 203 204 await walk(dir, ""); 205 return result; 206} 207 208function normalizePathToPosix(thePath: string): string { 209 return thePath.replaceAll(path.sep, path.posix.sep); 210}