import assert from "assert"; import path from "path"; import { describe, it } from "vitest"; import { formatDiagnostic, resolvePath, type Diagnostic, } from "@typespec/compiler"; import { TypeSpecTestLibrary, createTestHost, findTestPackageRoot, resolveVirtualPath, } from "@typespec/compiler/testing"; import { readdirSync, statSync } from "fs"; import { readFile, readdir, stat } from "fs/promises"; const pkgRoot = await findTestPackageRoot(import.meta.url); const TESTS_DIR = resolvePath(pkgRoot, "test/spec"); const TypelexTestLibrary: TypeSpecTestLibrary = { name: "@typelex/emitter", packageRoot: await findTestPackageRoot(import.meta.url), files: [ { realDir: "", pattern: "package.json", virtualPath: "./node_modules/@typelex/emitter", }, { realDir: "dist", pattern: "**/*.js", virtualPath: "./node_modules/@typelex/emitter/dist", }, { realDir: "lib/", pattern: "*.tsp", virtualPath: "./node_modules/@typelex/emitter/lib", }, ], }; describe("lexicon spec", function () { const scenarios = readdirSync(TESTS_DIR) .map((dn) => path.join(TESTS_DIR, dn)) .filter((dn) => statSync(dn).isDirectory()); for (const scenario of scenarios) { const scenarioName = path.basename(scenario); describe(scenarioName, async function () { const inputFiles = await readdirRecursive(path.join(scenario, "input")); const expectedFiles = await readdirRecursive( path.join(scenario, "output"), ); // Compile all inputs together (for cross-references) const tspFiles = Object.keys(inputFiles).filter((f) => f.endsWith(".tsp"), ); let emitResult: EmitResult; if (tspFiles.length > 0) { // Create a virtual main.tsp that imports all other files const mainContent = 'import "@typelex/emitter";\n' + tspFiles.map((f) => `import "./${normalizePathToPosix(f)}";`).join("\n"); const filesWithMain = { ...inputFiles, "main.tsp": mainContent }; emitResult = await doEmit(filesWithMain, "main.tsp"); } else { emitResult = { files: {}, diagnostics: [], inputFiles: {} }; } // Generate a test for each expected output for (const expectedPath of Object.keys(expectedFiles)) { if (!expectedPath.endsWith(".json")) continue; // Derive expected input path: output/app/bsky/feed/post.json -> input/app/bsky/feed/post.tsp const inputPath = expectedPath.replace(/\.json$/, ".tsp"); const hasInput = Object.keys(inputFiles).includes(inputPath); if (hasInput) { it(`should emit ${expectedPath}`, function () { // Check for compilation errors if (emitResult.diagnostics.length > 0) { const formattedDiagnostics = emitResult.diagnostics.map((diag) => formatDiagnostic(diag), ); assert.fail( `Expected no diagnostics but got:\n${formattedDiagnostics.join("\n\n")}`, ); } const normalizedExpectedPath = normalizePathToPosix(expectedPath); assert.ok( Object.prototype.hasOwnProperty.call( emitResult.files, normalizedExpectedPath, ), `Expected file ${expectedPath} was not produced`, ); const actual = JSON.parse(emitResult.files[normalizedExpectedPath]); const expected = JSON.parse(expectedFiles[expectedPath]); assert.deepStrictEqual(actual, expected); }); } else { it(`should emit ${expectedPath}`, function () { assert.fail( `Expected output file ${expectedPath} has no corresponding input file ${inputPath}. ` + `Either add the input file or remove the expected output.` ); }); } } // Check for unexpected emitted files it("should not emit unexpected files", function () { const emittedFiles = Object.keys(emitResult.files).filter(f => f.endsWith(".json")); const expectedPaths = Object.keys(expectedFiles) .filter(f => f.endsWith(".json")) .map(normalizePathToPosix); const unexpected = emittedFiles.filter(f => !expectedPaths.includes(f)); if (unexpected.length > 0) { assert.fail( `Unexpected files were emitted: ${unexpected.join(", ")}. ` + `Either add expected output files or ensure these should not be emitted.` ); } }); }); } }); interface EmitResult { files: Record; inputFiles: Record; diagnostics: readonly Diagnostic[]; } async function doEmit( files: Record, entryPoint: string, ): Promise { const baseOutputPath = resolveVirtualPath("test-output/"); const host = await createTestHost({ libraries: [TypelexTestLibrary], }); for (const [fileName, content] of Object.entries(files)) { host.addTypeSpecFile(fileName, content); } const [, diagnostics] = await host.compileAndDiagnose(entryPoint, { outputDir: baseOutputPath, noEmit: false, emit: ["@typelex/emitter"], }); const outputFiles = Object.fromEntries( [...host.fs.entries()] .filter(([name]) => name.startsWith(baseOutputPath)) .map(([name, value]) => { let relativePath = name.replace(baseOutputPath, ""); // Strip the @typelex/emitter/ prefix if present if (relativePath.startsWith("@typelex/emitter/")) { relativePath = relativePath.replace("@typelex/emitter/", ""); } return [relativePath, value]; }), ); return { files: outputFiles, inputFiles: files, diagnostics: diagnostics, }; } async function readdirRecursive(dir: string): Promise> { const result: Record = {}; async function walk(currentDir: string, relativePath: string) { const entries = await readdir(currentDir); for (const entry of entries) { const fullPath = path.join(currentDir, entry); const stats = await stat(fullPath); if (stats.isDirectory()) { await walk(fullPath, path.join(relativePath, entry)); } else { const content = await readFile(fullPath, "utf-8"); const key = path.join(relativePath, entry); result[key] = content; } } } await walk(dir, ""); return result; } function normalizePathToPosix(thePath: string): string { return thePath.replaceAll(path.sep, path.posix.sep); }