An experimental TypeSpec syntax for Lexicon

add tests for cli

Changed files
+1461 -48
packages
cli
src
commands
test
helpers
scenarios
basic
expected
lexicons
com
atproto
label
test
typelex
project
lexicons
com
atproto
label
init-preserves-main
expected
lexicons
com
example
typelex
project
missing-dependency
expected
lexicons
com
external
media
myapp
typelex
project
nested-init
expected
lexicons
com
myservice
typelex
project
parent-lexicons
expected1
app
lexicons
com
atproto
label
myapp
expected2
app
lexicons
com
atproto
label
myapp
project
app
lexicons
com
atproto
label
validation-errors
with-external-lexicons
expected1
lexicons
com
atproto
label
myapp
typelex
expected2
lexicons
com
atproto
label
myapp
typelex
project
lexicons
com
atproto
label
+1 -1
package.json
··· 5 5 "description": "TypeSpec-based IDL for ATProto Lexicons", 6 6 "scripts": { 7 7 "build": "pnpm -r build", 8 - "test": "pnpm --filter @typelex/emitter test", 8 + "test": "pnpm -r test", 9 9 "test:watch": "pnpm --filter @typelex/emitter test:watch", 10 10 "example": "pnpm --filter @typelex/example build", 11 11 "playground": "pnpm --filter @typelex/playground dev",
+6 -1
packages/cli/package.json
··· 14 14 "build": "tsc", 15 15 "clean": "rm -rf dist", 16 16 "watch": "tsc --watch", 17 + "test": "npm run build && vitest run", 18 + "test:watch": "npm run build && vitest watch", 17 19 "prepublishOnly": "npm run build" 18 20 }, 19 21 "keywords": [ ··· 26 28 "license": "MIT", 27 29 "dependencies": { 28 30 "@typespec/compiler": "^1.4.0", 31 + "globby": "^14.0.0", 29 32 "picocolors": "^1.1.1", 30 33 "yargs": "^18.0.0" 31 34 }, 32 35 "devDependencies": { 33 36 "@types/node": "^20.0.0", 34 37 "@types/yargs": "^17.0.33", 35 - "typescript": "^5.0.0" 38 + "typescript": "^5.0.0", 39 + "vitest": "^1.0.0", 40 + "@typelex/emitter": "workspace:*" 36 41 }, 37 42 "peerDependencies": { 38 43 "@typelex/emitter": "^0.2.0"
+85 -34
packages/cli/src/commands/init.ts
··· 6 6 7 7 function gradientText(text: string): string { 8 8 const colors = [ 9 - '\x1b[38;5;33m', 10 - '\x1b[38;5;69m', 11 - '\x1b[38;5;99m', 12 - '\x1b[38;5;133m', 13 - '\x1b[38;5;170m', 14 - '\x1b[38;5;170m', 15 - '\x1b[38;5;133m', 9 + "\x1b[38;5;33m", 10 + "\x1b[38;5;69m", 11 + "\x1b[38;5;99m", 12 + "\x1b[38;5;133m", 13 + "\x1b[38;5;170m", 14 + "\x1b[38;5;170m", 15 + "\x1b[38;5;133m", 16 16 ]; 17 - const reset = '\x1b[0m'; 17 + const reset = "\x1b[0m"; 18 18 19 - return text.split('').map((char, i) => { 20 - const colorIndex = Math.floor((i / text.length) * colors.length); 21 - return colors[colorIndex] + char; 22 - }).join('') + reset; 19 + return ( 20 + text 21 + .split("") 22 + .map((char, i) => { 23 + const colorIndex = Math.floor((i / text.length) * colors.length); 24 + return colors[colorIndex] + char; 25 + }) 26 + .join("") + reset 27 + ); 23 28 } 24 29 25 30 function createMainTemplate(namespace: string): string { ··· 49 54 }); 50 55 51 56 return new Promise((resolve) => { 52 - rl.question(`Enter your app's root namespace (e.g. ${pc.cyan("com.example.*")}): `, (answer) => { 53 - rl.close(); 54 - resolve(answer.trim()); 55 - }); 57 + rl.question( 58 + `Enter your app's root namespace (e.g. ${pc.cyan("com.example.*")}): `, 59 + (answer) => { 60 + rl.close(); 61 + resolve(answer.trim()); 62 + }, 63 + ); 56 64 }); 57 65 } 58 66 59 - export async function initCommand(isSetup: boolean = false, flags: string[] = []): Promise<void> { 67 + export async function initCommand( 68 + isSetup: boolean = false, 69 + flags: string[] = [], 70 + ): Promise<void> { 60 71 const originalCwd = process.cwd(); 61 72 62 73 // Find nearest package.json upward ··· 101 112 102 113 // Install dependencies 103 114 await new Promise<void>((resolvePromise, reject) => { 104 - const args = packageManager === "npm" 105 - ? ["install", "--save-dev", "@typelex/cli@latest", "@typelex/emitter@latest"] 106 - : ["add", "-D", "@typelex/cli@latest", "@typelex/emitter@latest"]; 115 + const args = 116 + packageManager === "npm" 117 + ? [ 118 + "install", 119 + "--save-dev", 120 + "@typelex/cli@latest", 121 + "@typelex/emitter@latest", 122 + ] 123 + : ["add", "-D", "@typelex/cli@latest", "@typelex/emitter@latest"]; 107 124 108 125 // Add any additional flags 109 126 args.push(...flags); ··· 115 132 116 133 install.on("close", (code) => { 117 134 if (code === 0) { 118 - console.log(`\n${pc.green("✓")} Installed ${pc.dim("@typelex/cli")} and ${pc.dim("@typelex/emitter")}\n`); 135 + console.log( 136 + `\n${pc.green("✓")} Installed ${pc.dim("@typelex/cli")} and ${pc.dim("@typelex/emitter")}\n`, 137 + ); 119 138 resolvePromise(); 120 139 } else { 121 140 console.error(pc.red("✗ Failed to install dependencies")); ··· 217 236 : lexiconsDir || "./lexicons"; 218 237 219 238 // Inform about external lexicons 220 - console.log(`\nLexicons other than ${pc.cyan(namespace)} will be considered external.`); 221 - console.log(`Put them into the ${pc.cyan(displayLexiconsPath)} folder as JSON.\n`); 239 + console.log( 240 + `\nLexicons other than ${pc.cyan(namespace)} will be considered external.`, 241 + ); 242 + console.log( 243 + `Put them into the ${pc.cyan(displayLexiconsPath)} folder as JSON.\n`, 244 + ); 222 245 223 246 // Create typelex directory 224 247 await mkdir(typelexDir, { recursive: true }); ··· 229 252 await access(mainTspPath); 230 253 const content = await readFile(mainTspPath, "utf-8"); 231 254 if (content.trim().length > 0) { 232 - console.log(`${pc.green("✓")} ${pc.cyan("typelex/main.tsp")} already exists, skipping`); 255 + console.log( 256 + `${pc.green("✓")} ${pc.cyan("typelex/main.tsp")} already exists, skipping`, 257 + ); 233 258 shouldCreateMain = false; 234 259 } 235 260 } catch { ··· 254 279 } 255 280 if (!packageJson.scripts["build:typelex"]) { 256 281 const outFlag = lexiconsDir ? ` --out ${lexiconsDir}` : ""; 257 - packageJson.scripts["build:typelex"] = `typelex compile ${namespace}${outFlag}`; 258 - await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf-8"); 259 - console.log(`${pc.green("✓")} Added ${pc.cyan("build:typelex")} script to ${pc.cyan("package.json")}`); 282 + packageJson.scripts["build:typelex"] = 283 + `typelex compile ${namespace}${outFlag}`; 284 + await writeFile( 285 + packageJsonPath, 286 + JSON.stringify(packageJson, null, 2) + "\n", 287 + "utf-8", 288 + ); 289 + console.log( 290 + `${pc.green("✓")} Added ${pc.cyan("build:typelex")} script to ${pc.cyan("package.json")}`, 291 + ); 260 292 if (hasLocalLexicons) { 261 - console.log(pc.dim(` Using existing lexicons directory: ${pc.cyan("./lexicons")}`)); 293 + console.log( 294 + pc.dim( 295 + ` Using existing lexicons directory: ${pc.cyan("./lexicons")}`, 296 + ), 297 + ); 262 298 } else if (lexiconsDir) { 263 - console.log(pc.dim(` Using existing lexicons directory: ${pc.cyan(lexiconsDir)}`)); 299 + console.log( 300 + pc.dim( 301 + ` Using existing lexicons directory: ${pc.cyan(lexiconsDir)}`, 302 + ), 303 + ); 264 304 } 265 305 } else { 266 - console.log(`${pc.green("✓")} ${pc.cyan("build:typelex")} script already exists in ${pc.cyan("package.json")}`); 306 + console.log( 307 + `${pc.green("✓")} ${pc.cyan("build:typelex")} script already exists in ${pc.cyan("package.json")}`, 308 + ); 267 309 } 268 310 } catch (err) { 269 - console.warn(pc.yellow(`⚠ Could not update ${pc.cyan("package.json")}:`), (err as Error).message); 311 + console.warn( 312 + pc.yellow(`⚠ Could not update ${pc.cyan("package.json")}:`), 313 + (err as Error).message, 314 + ); 270 315 } 271 316 272 317 console.log(`\n${pc.green("✓")} ${pc.bold("All set!")}`); 273 318 console.log(`\n${pc.bold("Next steps:")}`); 274 - console.log(` ${pc.dim("1.")} Edit ${pc.cyan("typelex/main.tsp")} to define your lexicons`); 275 - console.log(` ${pc.dim("2.")} Keep putting external lexicons into ${pc.cyan(displayLexiconsPath)}`); 276 - console.log(` ${pc.dim("3.")} Run ${pc.cyan("npm run build:typelex")} to compile to JSON`); 319 + console.log( 320 + ` ${pc.dim("1.")} Edit ${pc.cyan("typelex/main.tsp")} to define your lexicons`, 321 + ); 322 + console.log( 323 + ` ${pc.dim("2.")} Keep putting external lexicons into ${pc.cyan(displayLexiconsPath)}`, 324 + ); 325 + console.log( 326 + ` ${pc.dim("3.")} Run ${pc.cyan("npm run build:typelex")} to compile to JSON`, 327 + ); 277 328 }
+291
packages/cli/test/helpers/test-project.ts
··· 1 + import { mkdtemp, rm, mkdir, writeFile, readFile, readdir, stat } from "fs/promises"; 2 + import { join, resolve, dirname } from "path"; 3 + import { tmpdir } from "os"; 4 + import { spawn } from "child_process"; 5 + import { fileURLToPath } from "url"; 6 + 7 + const __filename = fileURLToPath(import.meta.url); 8 + const __dirname = dirname(__filename); 9 + 10 + export interface TestProjectOptions { 11 + packageManager?: "npm" | "pnpm"; 12 + } 13 + 14 + export class TestProject { 15 + public readonly path: string; 16 + public scenarioPath?: string; 17 + private cleanupHandlers: Array<() => Promise<void>> = []; 18 + 19 + constructor(path: string) { 20 + this.path = path; 21 + } 22 + 23 + static async create(options: TestProjectOptions = {}): Promise<TestProject> { 24 + const tmpDir = await mkdtemp(join(tmpdir(), "typelex-test-")); 25 + const project = new TestProject(tmpDir); 26 + 27 + // Create lock file based on package manager (scenarios provide their own package.json and lexicons) 28 + if (options.packageManager === "pnpm") { 29 + await writeFile(join(tmpDir, "pnpm-lock.yaml"), "lockfileVersion: '6.0'\n"); 30 + } else if (options.packageManager === "npm") { 31 + // npm is default, no lock file needed for detection 32 + } 33 + 34 + return project; 35 + } 36 + 37 + async cleanup(): Promise<void> { 38 + for (const handler of this.cleanupHandlers) { 39 + await handler(); 40 + } 41 + await rm(this.path, { recursive: true, force: true }); 42 + } 43 + 44 + async writeFile(relativePath: string, content: string): Promise<void> { 45 + const fullPath = join(this.path, relativePath); 46 + await mkdir(join(fullPath, ".."), { recursive: true }); 47 + await writeFile(fullPath, content); 48 + } 49 + 50 + async readFile(relativePath: string): Promise<string> { 51 + return readFile(join(this.path, relativePath), "utf-8"); 52 + } 53 + 54 + async fileExists(relativePath: string): Promise<boolean> { 55 + try { 56 + await stat(join(this.path, relativePath)); 57 + return true; 58 + } catch { 59 + return false; 60 + } 61 + } 62 + 63 + async readJson(relativePath: string): Promise<unknown> { 64 + const content = await this.readFile(relativePath); 65 + return JSON.parse(content); 66 + } 67 + 68 + async getDirectoryContents(relativePath: string = ""): Promise<string[]> { 69 + const fullPath = join(this.path, relativePath); 70 + try { 71 + return await readdir(fullPath); 72 + } catch { 73 + return []; 74 + } 75 + } 76 + 77 + async runCommand( 78 + command: string, 79 + args: string[], 80 + options: { input?: string; env?: Record<string, string>; cwd?: string } = {} 81 + ): Promise<{ stdout: string; stderr: string; exitCode: number; output: string }> { 82 + return new Promise((promiseResolve, promiseReject) => { 83 + // Add monorepo node_modules/.bin to PATH for tsp and other tools 84 + const monorepoRoot = resolve(__dirname, "../../../.."); 85 + const tspBinPath = join(monorepoRoot, "node_modules/.bin"); 86 + const envPath = options.env?.PATH || process.env.PATH || ""; 87 + const newPath = `${tspBinPath}:${envPath}`; 88 + 89 + const child = spawn(command, args, { 90 + cwd: options.cwd || this.path, 91 + env: { ...process.env, ...options.env, PATH: newPath }, 92 + }); 93 + 94 + let stdout = ""; 95 + let stderr = ""; 96 + 97 + child.stdout?.on("data", (data) => { 98 + stdout += data.toString(); 99 + }); 100 + 101 + child.stderr?.on("data", (data) => { 102 + stderr += data.toString(); 103 + }); 104 + 105 + if (options.input) { 106 + child.stdin?.write(options.input); 107 + child.stdin?.end(); 108 + } 109 + 110 + child.on("close", (exitCode) => { 111 + promiseResolve({ 112 + stdout, 113 + stderr, 114 + exitCode: exitCode ?? 0, 115 + output: stdout + stderr // Combined output for easier testing 116 + }); 117 + }); 118 + 119 + child.on("error", promiseReject); 120 + }); 121 + } 122 + 123 + async runTypelex(args: string[], options?: { input?: string; cwd?: string }): Promise<{ 124 + stdout: string; 125 + stderr: string; 126 + exitCode: number; 127 + output: string; // Combined stdout + stderr 128 + }> { 129 + // Use the local CLI from the monorepo 130 + const cliPath = resolve(__dirname, "../../dist/cli.js"); 131 + const result = await this.runCommand("node", [cliPath, ...args], options); 132 + return { 133 + ...result, 134 + output: result.stdout + result.stderr, 135 + }; 136 + } 137 + 138 + async compile(namespace: string, outDir: string = "./lexicons", options?: { cwd?: string }): Promise<void> { 139 + const result = await this.runTypelex(["compile", namespace, "--out", outDir], options); 140 + if (result.exitCode !== 0) { 141 + throw new Error(`Compilation failed: ${result.output}`); 142 + } 143 + } 144 + 145 + async init(namespace: string, options?: { cwd?: string }): Promise<void> { 146 + const result = await this.runTypelex(["init", "--setup"], { 147 + input: `${namespace}\n`, 148 + ...options, 149 + }); 150 + if (result.exitCode !== 0) { 151 + throw new Error(`Init failed: ${result.output}`); 152 + } 153 + } 154 + 155 + async runBuildScript(options?: { cwd?: string }): Promise<{stdout: string; stderr: string}> { 156 + const result = await this.runCommand("npm", ["run", "build:typelex"], options); 157 + if (result.exitCode !== 0) { 158 + throw new Error(`Build failed with exit code ${result.exitCode}:\n${result.output}`); 159 + } 160 + return { stdout: result.stdout, stderr: result.stderr }; 161 + } 162 + 163 + async expectBuildToFail(options?: { cwd?: string }): Promise<{stdout: string; stderr: string; output: string}> { 164 + const result = await this.runCommand("npm", ["run", "build:typelex"], options); 165 + if (result.exitCode === 0) { 166 + throw new Error(`Expected build to fail but it succeeded`); 167 + } 168 + return { stdout: result.stdout, stderr: result.stderr, output: result.output }; 169 + } 170 + 171 + /** 172 + * Compare files in the project against an expected directory 173 + * Only checks files that exist in expectedDir 174 + */ 175 + async compareTo(expectedSubdir: string = "expected"): Promise<void> { 176 + const { readdir } = await import("fs/promises"); 177 + 178 + if (!this.scenarioPath) { 179 + throw new Error("scenarioPath not set on TestProject"); 180 + } 181 + 182 + const expectedDir = join(this.scenarioPath, expectedSubdir); 183 + 184 + // Helper to recursively list all files in a directory 185 + async function listAllFiles(dir: string, prefix: string = ""): Promise<string[]> { 186 + const files: string[] = []; 187 + try { 188 + const entries = await readdir(dir, { withFileTypes: true }); 189 + for (const entry of entries) { 190 + const fullPath = join(dir, entry.name); 191 + const relPath = prefix ? join(prefix, entry.name) : entry.name; 192 + if (entry.isDirectory()) { 193 + files.push(...await listAllFiles(fullPath, relPath)); 194 + } else { 195 + files.push(relPath); 196 + } 197 + } 198 + } catch { 199 + // Directory doesn't exist 200 + } 201 + return files.sort(); 202 + } 203 + 204 + async function compareRecursive(relPath: string = "") { 205 + const expectedPath = join(expectedDir, relPath); 206 + const actualPath = join(this.path, relPath); 207 + 208 + const entries = await readdir(expectedPath, { withFileTypes: true }); 209 + 210 + for (const entry of entries) { 211 + const entryRelPath = join(relPath, entry.name); 212 + 213 + if (entry.isDirectory()) { 214 + await compareRecursive.call(this, entryRelPath); 215 + } else { 216 + const expected = await readFile(join(expectedDir, entryRelPath), "utf-8"); 217 + 218 + let actual: string; 219 + try { 220 + actual = await readFile(join(this.path, entryRelPath), "utf-8"); 221 + } catch (err) { 222 + if ((err as NodeJS.ErrnoException).code === "ENOENT") { 223 + // File is missing - show what files actually exist 224 + const actualFiles = await listAllFiles(this.path); 225 + throw new Error( 226 + `Expected file not found: ${entryRelPath}\n\n` + 227 + `Actual files in project:\n${actualFiles.map(f => ` ${f}`).join("\n") || " (none)"}` 228 + ); 229 + } 230 + throw err; 231 + } 232 + 233 + if (expected !== actual) { 234 + throw new Error( 235 + `File mismatch: ${entryRelPath}\n\nExpected:\n${expected}\n\nActual:\n${actual}` 236 + ); 237 + } 238 + } 239 + } 240 + } 241 + 242 + await compareRecursive.call(this); 243 + } 244 + 245 + /** 246 + * Mock npm/pnpm install by creating node_modules structure 247 + * Links to the real packages from the monorepo 248 + */ 249 + async mockInstall(): Promise<void> { 250 + const nodeModulesPath = join(this.path, "node_modules"); 251 + await mkdir(nodeModulesPath, { recursive: true }); 252 + await mkdir(join(nodeModulesPath, ".bin"), { recursive: true }); 253 + await mkdir(join(nodeModulesPath, "@typelex"), { recursive: true }); 254 + await mkdir(join(nodeModulesPath, "@typespec"), { recursive: true }); 255 + 256 + // Get paths to real packages in monorepo 257 + const monorepoRoot = resolve(__dirname, "../../../.."); 258 + const cliPackagePath = resolve(monorepoRoot, "packages/cli"); 259 + const emitterPackagePath = resolve(monorepoRoot, "packages/emitter"); 260 + const typespecCompilerPath = resolve(monorepoRoot, "node_modules/@typespec/compiler"); 261 + 262 + // Create symlinks to real packages 263 + const { symlink } = await import("fs/promises"); 264 + 265 + try { 266 + await symlink(cliPackagePath, join(nodeModulesPath, "@typelex/cli"), "dir"); 267 + } catch (err) { 268 + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; 269 + } 270 + 271 + try { 272 + await symlink(emitterPackagePath, join(nodeModulesPath, "@typelex/emitter"), "dir"); 273 + } catch (err) { 274 + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; 275 + } 276 + 277 + try { 278 + await symlink(typespecCompilerPath, join(nodeModulesPath, "@typespec/compiler"), "dir"); 279 + } catch (err) { 280 + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; 281 + } 282 + 283 + // Create bin symlink for typelex CLI 284 + const cliPath = resolve(cliPackagePath, "dist/cli.js"); 285 + try { 286 + await symlink(cliPath, join(nodeModulesPath, ".bin/typelex"), "file"); 287 + } catch (err) { 288 + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; 289 + } 290 + } 291 + }
+77
packages/cli/test/scenarios.test.ts
··· 1 + import { describe, it, afterEach } from "vitest"; 2 + import { readdirSync, statSync, existsSync } from "fs"; 3 + import { readFile, readdir } from "fs/promises"; 4 + import { join, dirname, relative } from "path"; 5 + import { fileURLToPath } from "url"; 6 + import { TestProject } from "./helpers/test-project.js"; 7 + 8 + const __filename = fileURLToPath(import.meta.url); 9 + const __dirname = dirname(__filename); 10 + 11 + const SCENARIOS_DIR = join(__dirname, "scenarios"); 12 + 13 + async function copyDirRecursive(src: string, dest: string, project: TestProject) { 14 + const { mkdir } = await import("fs/promises"); 15 + const entries = await readdir(src, { withFileTypes: true }); 16 + 17 + for (const entry of entries) { 18 + const srcPath = join(src, entry.name); 19 + const destPath = join(dest, entry.name); 20 + 21 + if (entry.isDirectory()) { 22 + // Create the directory in destination (even if empty) 23 + const relativePath = relative(project.path, destPath); 24 + await mkdir(join(project.path, relativePath), { recursive: true }); 25 + await copyDirRecursive(srcPath, destPath, project); 26 + } else { 27 + const content = await readFile(srcPath, "utf-8"); 28 + const relativePath = relative(project.path, destPath); 29 + await project.writeFile(relativePath, content); 30 + } 31 + } 32 + } 33 + 34 + describe("CLI scenarios", () => { 35 + let project: TestProject; 36 + 37 + afterEach(async () => { 38 + if (project) { 39 + await project.cleanup(); 40 + } 41 + }); 42 + 43 + // Auto-discover scenario directories 44 + const scenarios = readdirSync(SCENARIOS_DIR) 45 + .map((name) => join(SCENARIOS_DIR, name)) 46 + .filter((path) => statSync(path).isDirectory()) 47 + .filter((path) => existsSync(join(path, "test.ts"))); 48 + 49 + for (const scenarioPath of scenarios) { 50 + const scenarioName = scenarioPath.split("/").pop()!; 51 + 52 + it(scenarioName, async () => { 53 + // Load test module to get config 54 + const testModule = await import(join(scenarioPath, "test.ts")); 55 + if (typeof testModule.run !== "function") { 56 + throw new Error(`${scenarioName}/test.ts must export a run() function`); 57 + } 58 + 59 + // Create project 60 + project = await TestProject.create({ 61 + packageManager: testModule.packageManager || "npm", 62 + }); 63 + project.scenarioPath = scenarioPath; 64 + 65 + // Copy project files 66 + const projectDir = join(scenarioPath, "project"); 67 + if (existsSync(projectDir)) { 68 + await copyDirRecursive(projectDir, project.path, project); 69 + } 70 + 71 + await project.mockInstall(); 72 + 73 + // Run the scenario 74 + await testModule.run(project); 75 + }); 76 + } 77 + });
+123
packages/cli/test/scenarios/README.md
··· 1 + # Test Scenarios 2 + 3 + This directory contains declarative test scenarios for the typelex CLI. 4 + 5 + ## Philosophy 6 + 7 + **These tests focus on CLI workflows, NOT language features.** 8 + 9 + The CLI's job is to: 10 + 1. Find/create lexicons directories (`./lexicons`, `../lexicons`) 11 + 2. Read existing JSON lexicons from disk 12 + 3. Generate `externals.tsp` from those JSON files 13 + 4. Run compilation while preserving external lexicons 14 + 5. Manage paths and directory structures correctly 15 + 16 + Language features (syntax, types, decorators) are tested in the emitter package. 17 + 18 + ## Test Coverage 19 + 20 + All non-trivial branches in the CLI code are tested. Each test was verified by: 21 + 1. Breaking the code (commenting out the condition) 22 + 2. Verifying the test fails 23 + 3. Fixing the code and verifying the test passes 24 + 25 + ### Current Scenarios (8 total) 26 + 27 + **External Lexicon Workflows** (The Core CLI Functionality): 28 + - `compile-with-external-atproto` - Real JSON→TSP→JSON cycle, externals preserved 29 + - `compile-to-parent-lexicons` - Compile with `../lexicons` directory 30 + - `compile-idempotent` - Deterministic output across runs 31 + 32 + **Init Workflows** (Directory Detection & File Management): 33 + - `init-finds-current-lexicons` - Detects `./lexicons`, no `--out` flag 34 + - `init-finds-parent-lexicons` - Detects `../lexicons`, adds `--out ../lexicons` 35 + - `init-overwrites-empty-main` - Empty `main.tsp` gets overwritten 36 + - `init-preserves-build-script` - Existing `build:typelex` not overwritten 37 + 38 + **Validation** (Error Handling): 39 + - `validation-errors` - Namespace format, path validation, file structure 40 + 41 + ### Branch Coverage Matrix 42 + 43 + | File | Line | Branch | Tested By | 44 + |------|------|--------|-----------| 45 + | compile.ts | 21 | Path validation | validation-errors | 46 + | ensure-imports.ts | 20 | First line check | validation-errors | 47 + | ensure-imports.ts | 26 | Second line check | validation-errors | 48 + | ensure-imports.ts | 32 | File not found | validation-errors | 49 + | externals-generator.ts | 87 | No externals case | All compile scenarios | 50 + | init.ts | 194 | Local lexicons dir | init-finds-current-lexicons | 51 + | init.ts | 203 | Parent lexicons dir | init-finds-parent-lexicons | 52 + | init.ts | 231 | Empty main.tsp | init-overwrites-empty-main | 53 + | init.ts | 252 | No scripts object | All init scenarios (crashes without) | 54 + | init.ts | 255 | Script exists | init-preserves-build-script | 55 + 56 + ## Structure 57 + 58 + Each scenario directory contains: 59 + 60 + ``` 61 + scenario-name/ 62 + project/ # Realistic project structure 63 + package.json 64 + typelex/ 65 + main.tsp # Input TypeSpec 66 + externals.tsp # Boilerplate or generated 67 + lexicons/ # REAL JSON FILES (not mocked!) 68 + com/atproto/... # Checked-in external lexicons 69 + expected/ # Expected outputs (optional) 70 + lexicons/ 71 + com/myapp/... 72 + test.ts # Test logic with run() function 73 + ``` 74 + 75 + ## Writing Tests 76 + 77 + The `test.ts` exports a `run()` function that performs assertions: 78 + 79 + ```typescript 80 + import { expect } from "vitest"; 81 + 82 + export const namespace = "com.myapp.*"; 83 + 84 + export async function run(project, scenarioPath) { 85 + // Compile 86 + await project.compile(namespace); 87 + 88 + // Assert on behavior 89 + const externals = await project.readFile("typelex/externals.tsp"); 90 + expect(externals).toContain("namespace com.atproto.label.defs"); 91 + 92 + // Verify files match expected 93 + await verifyExpectedFiles(join(scenarioPath, "expected"), project); 94 + } 95 + ``` 96 + 97 + Available exports: 98 + - `namespace` - Default namespace 99 + - `packageManager` - "npm" or "pnpm" 100 + - `lexiconsDirLocation` - "current", "parent" 101 + - `run(project, scenarioPath)` - Test logic 102 + 103 + Available helpers: 104 + - `project.compile(namespace, outDir?)` - Compile (throws on error) 105 + - `project.init(namespace)` - Run init (throws on error) 106 + - `project.runTypelex(args, options?)` - Run any command 107 + - `project.writeFile/readFile/readJson/fileExists` 108 + - `verifyExpectedFiles(expectedDir, project)` - Match expected outputs 109 + 110 + ## Key Insight 111 + 112 + Most tests should have **real lexicons/ folders with JSON files**. This tests the actual CLI behavior: reading JSON from disk, generating externals.tsp, and emitting new JSON that correctly references external lexicons. 113 + 114 + Don't test language features here - test file I/O, directory management, and the JSON↔TSP↔JSON workflow. 115 + 116 + ## Adding New Tests 117 + 118 + When adding a new scenario, verify it catches bugs: 119 + 1. Write the test 120 + 2. Break the corresponding code 121 + 3. Run tests - should FAIL 122 + 4. Fix the code 123 + 5. Run tests - should PASS
+27
packages/cli/test/scenarios/basic/expected/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+26
packages/cli/test/scenarios/basic/expected/lexicons/com/test/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.test.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "text": { 12 + "type": "string" 13 + }, 14 + "createdAt": { 15 + "type": "string", 16 + "format": "datetime" 17 + } 18 + }, 19 + "required": [ 20 + "text", 21 + "createdAt" 22 + ] 23 + } 24 + } 25 + } 26 + }
+8
packages/cli/test/scenarios/basic/expected/package.json
··· 1 + { 2 + "name": "test-idempotent", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.test.*" 7 + } 8 + }
+10
packages/cli/test/scenarios/basic/expected/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ./lexicons (excluding com.test.*) 4 + // This file is auto-generated. Do not edit manually. 5 + 6 + @external 7 + namespace com.atproto.label.defs { 8 + model SelfLabel { } 9 + model SelfLabels { } 10 + }
+10
packages/cli/test/scenarios/basic/expected/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.test.post { 5 + @rec("tid") 6 + model Main { 7 + @required text: string; 8 + @required createdAt: datetime; 9 + } 10 + }
+27
packages/cli/test/scenarios/basic/project/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+1
packages/cli/test/scenarios/basic/project/package.json
··· 1 + {"name":"test-idempotent","version":"1.0.0","type":"module"}
+10
packages/cli/test/scenarios/basic/test.ts
··· 1 + export async function run(project) { 2 + await project.init("com.test.*"); 3 + 4 + await project.runBuildScript(); 5 + await project.compareTo("expected"); 6 + 7 + // Second build - verify idempotency 8 + await project.runBuildScript(); 9 + await project.compareTo("expected"); 10 + }
+25
packages/cli/test/scenarios/init-preserves-main/expected/lexicons/com/example/custom.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.custom", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "foo": { 12 + "type": "string" 13 + }, 14 + "bar": { 15 + "type": "integer" 16 + } 17 + }, 18 + "required": [ 19 + "foo", 20 + "bar" 21 + ] 22 + } 23 + } 24 + } 25 + }
+8
packages/cli/test/scenarios/init-preserves-main/expected/package.json
··· 1 + { 2 + "name": "test-init-preserves-main", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.example.*" 7 + } 8 + }
+4
packages/cli/test/scenarios/init-preserves-main/expected/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ./lexicons (excluding com.example.*) 4 + // No external lexicons found
+10
packages/cli/test/scenarios/init-preserves-main/expected/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.example.custom { 5 + @rec("tid") 6 + model Main { 7 + @required foo: string; 8 + @required bar: integer; 9 + } 10 + }
+1
packages/cli/test/scenarios/init-preserves-main/project/package.json
··· 1 + {"name":"test-init-preserves-main","version":"1.0.0","type":"module"}
+10
packages/cli/test/scenarios/init-preserves-main/project/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.example.custom { 5 + @rec("tid") 6 + model Main { 7 + @required foo: string; 8 + @required bar: integer; 9 + } 10 + }
+9
packages/cli/test/scenarios/init-preserves-main/test.ts
··· 1 + export async function run(project) { 2 + await project.init("com.example.*"); 3 + await project.runBuildScript(); 4 + await project.compareTo("expected"); 5 + 6 + // Second build - verify idempotency 7 + await project.runBuildScript(); 8 + await project.compareTo("expected"); 9 + }
+21
packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/external/media/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.external.media.defs", 4 + "defs": { 5 + "video": { 6 + "type": "object", 7 + "properties": { 8 + "url": { 9 + "type": "string", 10 + "format": "uri" 11 + }, 12 + "mimeType": { 13 + "type": "string" 14 + } 15 + }, 16 + "required": [ 17 + "url" 18 + ] 19 + } 20 + } 21 + }
+25
packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/myapp/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "text": { 12 + "type": "string" 13 + }, 14 + "video": { 15 + "type": "ref", 16 + "ref": "com.external.media.defs#video" 17 + } 18 + }, 19 + "required": [ 20 + "text" 21 + ] 22 + } 23 + } 24 + } 25 + }
+8
packages/cli/test/scenarios/missing-dependency/expected/package.json
··· 1 + { 2 + "name": "test-missing-dependency", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.myapp.*" 7 + } 8 + }
+9
packages/cli/test/scenarios/missing-dependency/expected/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ./lexicons (excluding com.myapp.*) 4 + // This file is auto-generated. Do not edit manually. 5 + 6 + @external 7 + namespace com.external.media.defs { 8 + model Video { } 9 + }
+10
packages/cli/test/scenarios/missing-dependency/expected/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.myapp.post { 5 + @rec("tid") 6 + model Main { 7 + @required text: string; 8 + video?: com.external.media.defs.Video; 9 + } 10 + }
+1
packages/cli/test/scenarios/missing-dependency/project/package.json
··· 1 + {"name":"test-missing-dependency","version":"1.0.0","type":"module"}
+51
packages/cli/test/scenarios/missing-dependency/test.ts
··· 1 + export async function run(project) { 2 + await project.init("com.myapp.*"); 3 + 4 + // Edit main.tsp to reference a missing external lexicon 5 + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter"; 6 + import "./externals.tsp"; 7 + 8 + namespace com.myapp.post { 9 + @rec("tid") 10 + model Main { 11 + @required text: string; 12 + video?: com.external.media.defs.Video; 13 + } 14 + } 15 + `); 16 + 17 + // Build should fail because com.external.media.defs doesn't exist 18 + const failure = await project.expectBuildToFail(); 19 + if (!failure.output.includes("com.external.media.defs")) { 20 + throw new Error(`Expected error about missing com.external.media.defs, got: ${failure.output}`); 21 + } 22 + 23 + // Add the missing external lexicon 24 + await project.writeFile("lexicons/com/external/media/defs.json", JSON.stringify({ 25 + "lexicon": 1, 26 + "id": "com.external.media.defs", 27 + "defs": { 28 + "video": { 29 + "type": "object", 30 + "properties": { 31 + "url": { 32 + "type": "string", 33 + "format": "uri" 34 + }, 35 + "mimeType": { 36 + "type": "string" 37 + } 38 + }, 39 + "required": ["url"] 40 + } 41 + } 42 + }, null, 2) + "\n"); 43 + 44 + // Now build should succeed 45 + await project.runBuildScript(); 46 + await project.compareTo("expected"); 47 + 48 + // Verify idempotency 49 + await project.runBuildScript(); 50 + await project.compareTo("expected"); 51 + }
+26
packages/cli/test/scenarios/nested-init/expected/lexicons/com/myservice/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myservice.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "text": { 12 + "type": "string" 13 + }, 14 + "createdAt": { 15 + "type": "string", 16 + "format": "datetime" 17 + } 18 + }, 19 + "required": [ 20 + "text", 21 + "createdAt" 22 + ] 23 + } 24 + } 25 + } 26 + }
+8
packages/cli/test/scenarios/nested-init/expected/package.json
··· 1 + { 2 + "name": "test-nested-init", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.myservice.*" 7 + } 8 + }
+4
packages/cli/test/scenarios/nested-init/expected/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ./lexicons (excluding com.myservice.*) 4 + // No external lexicons found
+10
packages/cli/test/scenarios/nested-init/expected/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.myservice.post { 5 + @rec("tid") 6 + model Main { 7 + @required text: string; 8 + @required createdAt: datetime; 9 + } 10 + }
+1
packages/cli/test/scenarios/nested-init/project/package.json
··· 1 + {"name":"test-nested-init","version":"1.0.0","type":"module"}
+16
packages/cli/test/scenarios/nested-init/test.ts
··· 1 + import { join } from "path"; 2 + 3 + export async function run(project) { 4 + const apiDir = join(project.path, "src/api"); 5 + 6 + // Init at root (where package.json is) 7 + await project.init("com.myservice.*"); 8 + 9 + // Build from nested directory should work (this is what we're testing) 10 + await project.runBuildScript({ cwd: apiDir }); 11 + await project.compareTo("expected"); 12 + 13 + // Verify idempotency 14 + await project.runBuildScript({ cwd: apiDir }); 15 + await project.compareTo("expected"); 16 + }
+8
packages/cli/test/scenarios/parent-lexicons/expected1/app/package.json
··· 1 + { 2 + "name": "test-parent-lexicons", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.myapp.* --out ../lexicons" 7 + } 8 + }
+10
packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ../lexicons (excluding com.myapp.*) 4 + // This file is auto-generated. Do not edit manually. 5 + 6 + @external 7 + namespace com.atproto.label.defs { 8 + model SelfLabel { } 9 + model SelfLabels { } 10 + }
+10
packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.myapp.post { 5 + @rec("tid") 6 + model Main { 7 + @required text: string; 8 + @required createdAt: datetime; 9 + } 10 + }
+27
packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+26
packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/myapp/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "text": { 12 + "type": "string" 13 + }, 14 + "createdAt": { 15 + "type": "string", 16 + "format": "datetime" 17 + } 18 + }, 19 + "required": [ 20 + "text", 21 + "createdAt" 22 + ] 23 + } 24 + } 25 + } 26 + }
+8
packages/cli/test/scenarios/parent-lexicons/expected2/app/package.json
··· 1 + { 2 + "name": "test-parent-lexicons", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.myapp.* --out ../lexicons" 7 + } 8 + }
+10
packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ../lexicons (excluding com.myapp.*) 4 + // This file is auto-generated. Do not edit manually. 5 + 6 + @external 7 + namespace com.atproto.label.defs { 8 + model SelfLabel { } 9 + model SelfLabels { } 10 + }
+10
packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.myapp.post { 5 + @rec("tid") 6 + model Main { 7 + @required text: string; 8 + labels?: com.atproto.label.defs.SelfLabels; 9 + } 10 + }
+27
packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+25
packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/myapp/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "text": { 12 + "type": "string" 13 + }, 14 + "labels": { 15 + "type": "ref", 16 + "ref": "com.atproto.label.defs#selfLabels" 17 + } 18 + }, 19 + "required": [ 20 + "text" 21 + ] 22 + } 23 + } 24 + } 25 + }
+1
packages/cli/test/scenarios/parent-lexicons/project/app/package.json
··· 1 + {"name":"test-parent-lexicons","version":"1.0.0","type":"module"}
+27
packages/cli/test/scenarios/parent-lexicons/project/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+31
packages/cli/test/scenarios/parent-lexicons/test.ts
··· 1 + import { join } from "path"; 2 + 3 + export async function run(project) { 4 + const appDir = join(project.path, "app"); 5 + 6 + await project.init("com.myapp.*", { cwd: appDir }); 7 + 8 + // Verify init created a working project with default main.tsp 9 + await project.runBuildScript({ cwd: appDir }); 10 + await project.compareTo("expected1"); 11 + 12 + // Edit main.tsp to add a post schema with labels (simulates user editing the file) 13 + await project.writeFile("app/typelex/main.tsp", `import "@typelex/emitter"; 14 + import "./externals.tsp"; 15 + 16 + namespace com.myapp.post { 17 + @rec("tid") 18 + model Main { 19 + @required text: string; 20 + labels?: com.atproto.label.defs.SelfLabels; 21 + } 22 + } 23 + `); 24 + 25 + await project.runBuildScript({ cwd: appDir }); 26 + await project.compareTo("expected2"); 27 + 28 + // Third build - verify idempotency 29 + await project.runBuildScript({ cwd: appDir }); 30 + await project.compareTo("expected2"); 31 + }
+1
packages/cli/test/scenarios/validation-errors/project/package.json
··· 1 + {"name":"test-validation","version":"1.0.0","type":"module"}
+35
packages/cli/test/scenarios/validation-errors/test.ts
··· 1 + import { expect } from "vitest"; 2 + 3 + export async function run(project) { 4 + // Test: Namespace must end with .* 5 + let result = await project.runTypelex(["compile", "com.example"]); 6 + expect(result.exitCode).not.toBe(0); 7 + expect(result.output).toContain("namespace must end with .*"); 8 + 9 + // Test: Output path must end with 'lexicons' 10 + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter";\nimport "./externals.tsp";\n`); 11 + await project.writeFile("typelex/externals.tsp", `import "@typelex/emitter";\n`); 12 + 13 + result = await project.runTypelex(["compile", "com.test.*", "--out", "./output"]); 14 + expect(result.exitCode).not.toBe(0); 15 + expect(result.output).toContain("Output directory must end with 'lexicons'"); 16 + 17 + // Test: main.tsp must exist 18 + await project.runCommand("rm", ["-rf", "typelex"]); 19 + result = await project.runTypelex(["compile", "com.test.*"]); 20 + expect(result.exitCode).not.toBe(0); 21 + expect(result.output).toContain("main.tsp not found"); 22 + 23 + // Test: main.tsp first line must be import "@typelex/emitter" 24 + await project.writeFile("typelex/main.tsp", `// wrong first line\nimport "./externals.tsp";\n`); 25 + await project.writeFile("typelex/externals.tsp", `import "@typelex/emitter";\n`); 26 + result = await project.runTypelex(["compile", "com.test.*"]); 27 + expect(result.exitCode).not.toBe(0); 28 + expect(result.output).toContain('main.tsp must start with: import "@typelex/emitter"'); 29 + 30 + // Test: main.tsp second line must be import "./externals.tsp" 31 + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter";\n// wrong second line\n`); 32 + result = await project.runTypelex(["compile", "com.test.*"]); 33 + expect(result.exitCode).not.toBe(0); 34 + expect(result.output).toContain('Line 2 of main.tsp must be: import "./externals.tsp"'); 35 + }
+27
packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+26
packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/myapp/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "text": { 12 + "type": "string" 13 + }, 14 + "createdAt": { 15 + "type": "string", 16 + "format": "datetime" 17 + } 18 + }, 19 + "required": [ 20 + "text", 21 + "createdAt" 22 + ] 23 + } 24 + } 25 + } 26 + }
+8
packages/cli/test/scenarios/with-external-lexicons/expected1/package.json
··· 1 + { 2 + "name": "test-external-lexicons", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.myapp.*" 7 + } 8 + }
+10
packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ./lexicons (excluding com.myapp.*) 4 + // This file is auto-generated. Do not edit manually. 5 + 6 + @external 7 + namespace com.atproto.label.defs { 8 + model SelfLabel { } 9 + model SelfLabels { } 10 + }
+10
packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.myapp.post { 5 + @rec("tid") 6 + model Main { 7 + @required text: string; 8 + @required createdAt: datetime; 9 + } 10 + }
+27
packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+30
packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/myapp/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.profile", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "did": { 9 + "type": "string", 10 + "format": "did" 11 + }, 12 + "handle": { 13 + "type": "string", 14 + "format": "handle" 15 + }, 16 + "displayName": { 17 + "type": "string" 18 + }, 19 + "labels": { 20 + "type": "ref", 21 + "ref": "com.atproto.label.defs#selfLabels" 22 + } 23 + }, 24 + "required": [ 25 + "did", 26 + "handle" 27 + ] 28 + } 29 + } 30 + }
+8
packages/cli/test/scenarios/with-external-lexicons/expected2/package.json
··· 1 + { 2 + "name": "test-external-lexicons", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "build:typelex": "typelex compile com.myapp.*" 7 + } 8 + }
+10
packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ./lexicons (excluding com.myapp.*) 4 + // This file is auto-generated. Do not edit manually. 5 + 6 + @external 7 + namespace com.atproto.label.defs { 8 + model SelfLabel { } 9 + model SelfLabels { } 10 + }
+13
packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace com.myapp.profile { 5 + model Main { 6 + @required did: did; 7 + @required handle: handle; 8 + displayName?: string; 9 + 10 + // Reference to external lexicon 11 + labels?: com.atproto.label.defs.SelfLabels; 12 + } 13 + }
+27
packages/cli/test/scenarios/with-external-lexicons/project/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "selfLabels": { 6 + "type": "object", 7 + "properties": { 8 + "values": { 9 + "type": "array", 10 + "items": { "type": "ref", "ref": "#selfLabel" }, 11 + "maxLength": 10 12 + } 13 + }, 14 + "required": ["values"] 15 + }, 16 + "selfLabel": { 17 + "type": "object", 18 + "properties": { 19 + "val": { 20 + "type": "string", 21 + "maxLength": 128 22 + } 23 + }, 24 + "required": ["val"] 25 + } 26 + } 27 + }
+1
packages/cli/test/scenarios/with-external-lexicons/project/package.json
··· 1 + {"name":"test-external-lexicons","version":"1.0.0","type":"module"}
+30
packages/cli/test/scenarios/with-external-lexicons/test.ts
··· 1 + export async function run(project) { 2 + await project.init("com.myapp.*"); 3 + 4 + // Verify init created a working project with default main.tsp 5 + await project.runBuildScript(); 6 + await project.compareTo("expected1"); 7 + 8 + // Edit main.tsp to add a profile schema (simulates user editing the file) 9 + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter"; 10 + import "./externals.tsp"; 11 + 12 + namespace com.myapp.profile { 13 + model Main { 14 + @required did: did; 15 + @required handle: handle; 16 + displayName?: string; 17 + 18 + // Reference to external lexicon 19 + labels?: com.atproto.label.defs.SelfLabels; 20 + } 21 + } 22 + `); 23 + 24 + await project.runBuildScript(); 25 + await project.compareTo("expected2"); 26 + 27 + // Third build - verify idempotency 28 + await project.runBuildScript(); 29 + await project.compareTo("expected2"); 30 + }
+10
packages/cli/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: true, 6 + environment: 'node', 7 + testTimeout: 60000, // CLI operations can take time 8 + hookTimeout: 60000, 9 + }, 10 + });
+9 -12
pnpm-lock.yaml
··· 14 14 15 15 packages/cli: 16 16 dependencies: 17 - '@typelex/emitter': 18 - specifier: ^0.2.0 19 - version: 0.2.0(@typespec/compiler@1.4.0(@types/node@20.19.19)) 20 17 '@typespec/compiler': 21 18 specifier: ^1.4.0 22 19 version: 1.4.0(@types/node@20.19.19) 20 + globby: 21 + specifier: ^14.0.0 22 + version: 14.1.0 23 23 picocolors: 24 24 specifier: ^1.1.1 25 25 version: 1.1.1 ··· 27 27 specifier: ^18.0.0 28 28 version: 18.0.0 29 29 devDependencies: 30 + '@typelex/emitter': 31 + specifier: workspace:* 32 + version: link:../emitter 30 33 '@types/node': 31 34 specifier: ^20.0.0 32 35 version: 20.19.19 ··· 36 39 typescript: 37 40 specifier: ^5.0.0 38 41 version: 5.9.3 42 + vitest: 43 + specifier: ^1.0.0 44 + version: 1.6.1(@types/node@20.19.19) 39 45 40 46 packages/emitter: 41 47 dependencies: ··· 1669 1675 1670 1676 '@ts-morph/common@0.25.0': 1671 1677 resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} 1672 - 1673 - '@typelex/emitter@0.2.0': 1674 - resolution: {integrity: sha512-4Iw6VAnd9nCFGOkJcu9utWdmu9ZyPeAb1QX/B7KerGBmfc2FuIDqgZZ/mZ6c56atcZd62pb2oYF/3RgSFhEsoQ==} 1675 - peerDependencies: 1676 - '@typespec/compiler': ^1.4.0 1677 1678 1678 1679 '@types/babel__core@7.20.5': 1679 1680 resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} ··· 7446 7447 minimatch: 9.0.5 7447 7448 path-browserify: 1.0.1 7448 7449 tinyglobby: 0.2.15 7449 - 7450 - '@typelex/emitter@0.2.0(@typespec/compiler@1.4.0(@types/node@20.19.19))': 7451 - dependencies: 7452 - '@typespec/compiler': 1.4.0(@types/node@20.19.19) 7453 7450 7454 7451 '@types/babel__core@7.20.5': 7455 7452 dependencies: