diff --git a/package.json b/package.json index 9d9a564..69160a4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "TypeSpec-based IDL for ATProto Lexicons", "scripts": { "build": "pnpm -r build", - "test": "pnpm --filter @typelex/emitter test", + "test": "pnpm -r test", "test:watch": "pnpm --filter @typelex/emitter test:watch", "example": "pnpm --filter @typelex/example build", "playground": "pnpm --filter @typelex/playground dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index 8fe26a9..e28ec66 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,6 +14,8 @@ "build": "tsc", "clean": "rm -rf dist", "watch": "tsc --watch", + "test": "npm run build && vitest run", + "test:watch": "npm run build && vitest watch", "prepublishOnly": "npm run build" }, "keywords": [ @@ -26,13 +28,16 @@ "license": "MIT", "dependencies": { "@typespec/compiler": "^1.4.0", + "globby": "^14.0.0", "picocolors": "^1.1.1", "yargs": "^18.0.0" }, "devDependencies": { "@types/node": "^20.0.0", "@types/yargs": "^17.0.33", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^1.0.0", + "@typelex/emitter": "workspace:*" }, "peerDependencies": { "@typelex/emitter": "^0.2.0" diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5eb616a..211a7df 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -6,20 +6,25 @@ import pc from "picocolors"; function gradientText(text: string): string { const colors = [ - '\x1b[38;5;33m', - '\x1b[38;5;69m', - '\x1b[38;5;99m', - '\x1b[38;5;133m', - '\x1b[38;5;170m', - '\x1b[38;5;170m', - '\x1b[38;5;133m', + "\x1b[38;5;33m", + "\x1b[38;5;69m", + "\x1b[38;5;99m", + "\x1b[38;5;133m", + "\x1b[38;5;170m", + "\x1b[38;5;170m", + "\x1b[38;5;133m", ]; - const reset = '\x1b[0m'; - - return text.split('').map((char, i) => { - const colorIndex = Math.floor((i / text.length) * colors.length); - return colors[colorIndex] + char; - }).join('') + reset; + const reset = "\x1b[0m"; + + return ( + text + .split("") + .map((char, i) => { + const colorIndex = Math.floor((i / text.length) * colors.length); + return colors[colorIndex] + char; + }) + .join("") + reset + ); } function createMainTemplate(namespace: string): string { @@ -49,14 +54,20 @@ async function promptNamespace(): Promise { }); return new Promise((resolve) => { - rl.question(`Enter your app's root namespace (e.g. ${pc.cyan("com.example.*")}): `, (answer) => { - rl.close(); - resolve(answer.trim()); - }); + rl.question( + `Enter your app's root namespace (e.g. ${pc.cyan("com.example.*")}): `, + (answer) => { + rl.close(); + resolve(answer.trim()); + }, + ); }); } -export async function initCommand(isSetup: boolean = false, flags: string[] = []): Promise { +export async function initCommand( + isSetup: boolean = false, + flags: string[] = [], +): Promise { const originalCwd = process.cwd(); // Find nearest package.json upward @@ -101,9 +112,15 @@ export async function initCommand(isSetup: boolean = false, flags: string[] = [] // Install dependencies await new Promise((resolvePromise, reject) => { - const args = packageManager === "npm" - ? ["install", "--save-dev", "@typelex/cli@latest", "@typelex/emitter@latest"] - : ["add", "-D", "@typelex/cli@latest", "@typelex/emitter@latest"]; + const args = + packageManager === "npm" + ? [ + "install", + "--save-dev", + "@typelex/cli@latest", + "@typelex/emitter@latest", + ] + : ["add", "-D", "@typelex/cli@latest", "@typelex/emitter@latest"]; // Add any additional flags args.push(...flags); @@ -115,7 +132,9 @@ export async function initCommand(isSetup: boolean = false, flags: string[] = [] install.on("close", (code) => { if (code === 0) { - console.log(`\n${pc.green("✓")} Installed ${pc.dim("@typelex/cli")} and ${pc.dim("@typelex/emitter")}\n`); + console.log( + `\n${pc.green("✓")} Installed ${pc.dim("@typelex/cli")} and ${pc.dim("@typelex/emitter")}\n`, + ); resolvePromise(); } else { console.error(pc.red("✗ Failed to install dependencies")); @@ -217,8 +236,12 @@ export async function initSetup(): Promise { : lexiconsDir || "./lexicons"; // Inform about external lexicons - console.log(`\nLexicons other than ${pc.cyan(namespace)} will be considered external.`); - console.log(`Put them into the ${pc.cyan(displayLexiconsPath)} folder as JSON.\n`); + console.log( + `\nLexicons other than ${pc.cyan(namespace)} will be considered external.`, + ); + console.log( + `Put them into the ${pc.cyan(displayLexiconsPath)} folder as JSON.\n`, + ); // Create typelex directory await mkdir(typelexDir, { recursive: true }); @@ -229,7 +252,9 @@ export async function initSetup(): Promise { await access(mainTspPath); const content = await readFile(mainTspPath, "utf-8"); if (content.trim().length > 0) { - console.log(`${pc.green("✓")} ${pc.cyan("typelex/main.tsp")} already exists, skipping`); + console.log( + `${pc.green("✓")} ${pc.cyan("typelex/main.tsp")} already exists, skipping`, + ); shouldCreateMain = false; } } catch { @@ -254,24 +279,50 @@ export async function initSetup(): Promise { } if (!packageJson.scripts["build:typelex"]) { const outFlag = lexiconsDir ? ` --out ${lexiconsDir}` : ""; - packageJson.scripts["build:typelex"] = `typelex compile ${namespace}${outFlag}`; - await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf-8"); - console.log(`${pc.green("✓")} Added ${pc.cyan("build:typelex")} script to ${pc.cyan("package.json")}`); + packageJson.scripts["build:typelex"] = + `typelex compile ${namespace}${outFlag}`; + await writeFile( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + "\n", + "utf-8", + ); + console.log( + `${pc.green("✓")} Added ${pc.cyan("build:typelex")} script to ${pc.cyan("package.json")}`, + ); if (hasLocalLexicons) { - console.log(pc.dim(` Using existing lexicons directory: ${pc.cyan("./lexicons")}`)); + console.log( + pc.dim( + ` Using existing lexicons directory: ${pc.cyan("./lexicons")}`, + ), + ); } else if (lexiconsDir) { - console.log(pc.dim(` Using existing lexicons directory: ${pc.cyan(lexiconsDir)}`)); + console.log( + pc.dim( + ` Using existing lexicons directory: ${pc.cyan(lexiconsDir)}`, + ), + ); } } else { - console.log(`${pc.green("✓")} ${pc.cyan("build:typelex")} script already exists in ${pc.cyan("package.json")}`); + console.log( + `${pc.green("✓")} ${pc.cyan("build:typelex")} script already exists in ${pc.cyan("package.json")}`, + ); } } catch (err) { - console.warn(pc.yellow(`⚠ Could not update ${pc.cyan("package.json")}:`), (err as Error).message); + console.warn( + pc.yellow(`⚠ Could not update ${pc.cyan("package.json")}:`), + (err as Error).message, + ); } console.log(`\n${pc.green("✓")} ${pc.bold("All set!")}`); console.log(`\n${pc.bold("Next steps:")}`); - console.log(` ${pc.dim("1.")} Edit ${pc.cyan("typelex/main.tsp")} to define your lexicons`); - console.log(` ${pc.dim("2.")} Keep putting external lexicons into ${pc.cyan(displayLexiconsPath)}`); - console.log(` ${pc.dim("3.")} Run ${pc.cyan("npm run build:typelex")} to compile to JSON`); + console.log( + ` ${pc.dim("1.")} Edit ${pc.cyan("typelex/main.tsp")} to define your lexicons`, + ); + console.log( + ` ${pc.dim("2.")} Keep putting external lexicons into ${pc.cyan(displayLexiconsPath)}`, + ); + console.log( + ` ${pc.dim("3.")} Run ${pc.cyan("npm run build:typelex")} to compile to JSON`, + ); } diff --git a/packages/cli/test/helpers/test-project.ts b/packages/cli/test/helpers/test-project.ts new file mode 100644 index 0000000..e7cee21 --- /dev/null +++ b/packages/cli/test/helpers/test-project.ts @@ -0,0 +1,291 @@ +import { mkdtemp, rm, mkdir, writeFile, readFile, readdir, stat } from "fs/promises"; +import { join, resolve, dirname } from "path"; +import { tmpdir } from "os"; +import { spawn } from "child_process"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export interface TestProjectOptions { + packageManager?: "npm" | "pnpm"; +} + +export class TestProject { + public readonly path: string; + public scenarioPath?: string; + private cleanupHandlers: Array<() => Promise> = []; + + constructor(path: string) { + this.path = path; + } + + static async create(options: TestProjectOptions = {}): Promise { + const tmpDir = await mkdtemp(join(tmpdir(), "typelex-test-")); + const project = new TestProject(tmpDir); + + // Create lock file based on package manager (scenarios provide their own package.json and lexicons) + if (options.packageManager === "pnpm") { + await writeFile(join(tmpDir, "pnpm-lock.yaml"), "lockfileVersion: '6.0'\n"); + } else if (options.packageManager === "npm") { + // npm is default, no lock file needed for detection + } + + return project; + } + + async cleanup(): Promise { + for (const handler of this.cleanupHandlers) { + await handler(); + } + await rm(this.path, { recursive: true, force: true }); + } + + async writeFile(relativePath: string, content: string): Promise { + const fullPath = join(this.path, relativePath); + await mkdir(join(fullPath, ".."), { recursive: true }); + await writeFile(fullPath, content); + } + + async readFile(relativePath: string): Promise { + return readFile(join(this.path, relativePath), "utf-8"); + } + + async fileExists(relativePath: string): Promise { + try { + await stat(join(this.path, relativePath)); + return true; + } catch { + return false; + } + } + + async readJson(relativePath: string): Promise { + const content = await this.readFile(relativePath); + return JSON.parse(content); + } + + async getDirectoryContents(relativePath: string = ""): Promise { + const fullPath = join(this.path, relativePath); + try { + return await readdir(fullPath); + } catch { + return []; + } + } + + async runCommand( + command: string, + args: string[], + options: { input?: string; env?: Record; cwd?: string } = {} + ): Promise<{ stdout: string; stderr: string; exitCode: number; output: string }> { + return new Promise((promiseResolve, promiseReject) => { + // Add monorepo node_modules/.bin to PATH for tsp and other tools + const monorepoRoot = resolve(__dirname, "../../../.."); + const tspBinPath = join(monorepoRoot, "node_modules/.bin"); + const envPath = options.env?.PATH || process.env.PATH || ""; + const newPath = `${tspBinPath}:${envPath}`; + + const child = spawn(command, args, { + cwd: options.cwd || this.path, + env: { ...process.env, ...options.env, PATH: newPath }, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + if (options.input) { + child.stdin?.write(options.input); + child.stdin?.end(); + } + + child.on("close", (exitCode) => { + promiseResolve({ + stdout, + stderr, + exitCode: exitCode ?? 0, + output: stdout + stderr // Combined output for easier testing + }); + }); + + child.on("error", promiseReject); + }); + } + + async runTypelex(args: string[], options?: { input?: string; cwd?: string }): Promise<{ + stdout: string; + stderr: string; + exitCode: number; + output: string; // Combined stdout + stderr + }> { + // Use the local CLI from the monorepo + const cliPath = resolve(__dirname, "../../dist/cli.js"); + const result = await this.runCommand("node", [cliPath, ...args], options); + return { + ...result, + output: result.stdout + result.stderr, + }; + } + + async compile(namespace: string, outDir: string = "./lexicons", options?: { cwd?: string }): Promise { + const result = await this.runTypelex(["compile", namespace, "--out", outDir], options); + if (result.exitCode !== 0) { + throw new Error(`Compilation failed: ${result.output}`); + } + } + + async init(namespace: string, options?: { cwd?: string }): Promise { + const result = await this.runTypelex(["init", "--setup"], { + input: `${namespace}\n`, + ...options, + }); + if (result.exitCode !== 0) { + throw new Error(`Init failed: ${result.output}`); + } + } + + async runBuildScript(options?: { cwd?: string }): Promise<{stdout: string; stderr: string}> { + const result = await this.runCommand("npm", ["run", "build:typelex"], options); + if (result.exitCode !== 0) { + throw new Error(`Build failed with exit code ${result.exitCode}:\n${result.output}`); + } + return { stdout: result.stdout, stderr: result.stderr }; + } + + async expectBuildToFail(options?: { cwd?: string }): Promise<{stdout: string; stderr: string; output: string}> { + const result = await this.runCommand("npm", ["run", "build:typelex"], options); + if (result.exitCode === 0) { + throw new Error(`Expected build to fail but it succeeded`); + } + return { stdout: result.stdout, stderr: result.stderr, output: result.output }; + } + + /** + * Compare files in the project against an expected directory + * Only checks files that exist in expectedDir + */ + async compareTo(expectedSubdir: string = "expected"): Promise { + const { readdir } = await import("fs/promises"); + + if (!this.scenarioPath) { + throw new Error("scenarioPath not set on TestProject"); + } + + const expectedDir = join(this.scenarioPath, expectedSubdir); + + // Helper to recursively list all files in a directory + async function listAllFiles(dir: string, prefix: string = ""): Promise { + const files: string[] = []; + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const relPath = prefix ? join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...await listAllFiles(fullPath, relPath)); + } else { + files.push(relPath); + } + } + } catch { + // Directory doesn't exist + } + return files.sort(); + } + + async function compareRecursive(relPath: string = "") { + const expectedPath = join(expectedDir, relPath); + const actualPath = join(this.path, relPath); + + const entries = await readdir(expectedPath, { withFileTypes: true }); + + for (const entry of entries) { + const entryRelPath = join(relPath, entry.name); + + if (entry.isDirectory()) { + await compareRecursive.call(this, entryRelPath); + } else { + const expected = await readFile(join(expectedDir, entryRelPath), "utf-8"); + + let actual: string; + try { + actual = await readFile(join(this.path, entryRelPath), "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + // File is missing - show what files actually exist + const actualFiles = await listAllFiles(this.path); + throw new Error( + `Expected file not found: ${entryRelPath}\n\n` + + `Actual files in project:\n${actualFiles.map(f => ` ${f}`).join("\n") || " (none)"}` + ); + } + throw err; + } + + if (expected !== actual) { + throw new Error( + `File mismatch: ${entryRelPath}\n\nExpected:\n${expected}\n\nActual:\n${actual}` + ); + } + } + } + } + + await compareRecursive.call(this); + } + + /** + * Mock npm/pnpm install by creating node_modules structure + * Links to the real packages from the monorepo + */ + async mockInstall(): Promise { + const nodeModulesPath = join(this.path, "node_modules"); + await mkdir(nodeModulesPath, { recursive: true }); + await mkdir(join(nodeModulesPath, ".bin"), { recursive: true }); + await mkdir(join(nodeModulesPath, "@typelex"), { recursive: true }); + await mkdir(join(nodeModulesPath, "@typespec"), { recursive: true }); + + // Get paths to real packages in monorepo + const monorepoRoot = resolve(__dirname, "../../../.."); + const cliPackagePath = resolve(monorepoRoot, "packages/cli"); + const emitterPackagePath = resolve(monorepoRoot, "packages/emitter"); + const typespecCompilerPath = resolve(monorepoRoot, "node_modules/@typespec/compiler"); + + // Create symlinks to real packages + const { symlink } = await import("fs/promises"); + + try { + await symlink(cliPackagePath, join(nodeModulesPath, "@typelex/cli"), "dir"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + + try { + await symlink(emitterPackagePath, join(nodeModulesPath, "@typelex/emitter"), "dir"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + + try { + await symlink(typespecCompilerPath, join(nodeModulesPath, "@typespec/compiler"), "dir"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + + // Create bin symlink for typelex CLI + const cliPath = resolve(cliPackagePath, "dist/cli.js"); + try { + await symlink(cliPath, join(nodeModulesPath, ".bin/typelex"), "file"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + } +} diff --git a/packages/cli/test/scenarios.test.ts b/packages/cli/test/scenarios.test.ts new file mode 100644 index 0000000..6686a62 --- /dev/null +++ b/packages/cli/test/scenarios.test.ts @@ -0,0 +1,77 @@ +import { describe, it, afterEach } from "vitest"; +import { readdirSync, statSync, existsSync } from "fs"; +import { readFile, readdir } from "fs/promises"; +import { join, dirname, relative } from "path"; +import { fileURLToPath } from "url"; +import { TestProject } from "./helpers/test-project.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const SCENARIOS_DIR = join(__dirname, "scenarios"); + +async function copyDirRecursive(src: string, dest: string, project: TestProject) { + const { mkdir } = await import("fs/promises"); + const entries = await readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + + if (entry.isDirectory()) { + // Create the directory in destination (even if empty) + const relativePath = relative(project.path, destPath); + await mkdir(join(project.path, relativePath), { recursive: true }); + await copyDirRecursive(srcPath, destPath, project); + } else { + const content = await readFile(srcPath, "utf-8"); + const relativePath = relative(project.path, destPath); + await project.writeFile(relativePath, content); + } + } +} + +describe("CLI scenarios", () => { + let project: TestProject; + + afterEach(async () => { + if (project) { + await project.cleanup(); + } + }); + + // Auto-discover scenario directories + const scenarios = readdirSync(SCENARIOS_DIR) + .map((name) => join(SCENARIOS_DIR, name)) + .filter((path) => statSync(path).isDirectory()) + .filter((path) => existsSync(join(path, "test.ts"))); + + for (const scenarioPath of scenarios) { + const scenarioName = scenarioPath.split("/").pop()!; + + it(scenarioName, async () => { + // Load test module to get config + const testModule = await import(join(scenarioPath, "test.ts")); + if (typeof testModule.run !== "function") { + throw new Error(`${scenarioName}/test.ts must export a run() function`); + } + + // Create project + project = await TestProject.create({ + packageManager: testModule.packageManager || "npm", + }); + project.scenarioPath = scenarioPath; + + // Copy project files + const projectDir = join(scenarioPath, "project"); + if (existsSync(projectDir)) { + await copyDirRecursive(projectDir, project.path, project); + } + + await project.mockInstall(); + + // Run the scenario + await testModule.run(project); + }); + } +}); diff --git a/packages/cli/test/scenarios/README.md b/packages/cli/test/scenarios/README.md new file mode 100644 index 0000000..635f41b --- /dev/null +++ b/packages/cli/test/scenarios/README.md @@ -0,0 +1,123 @@ +# Test Scenarios + +This directory contains declarative test scenarios for the typelex CLI. + +## Philosophy + +**These tests focus on CLI workflows, NOT language features.** + +The CLI's job is to: +1. Find/create lexicons directories (`./lexicons`, `../lexicons`) +2. Read existing JSON lexicons from disk +3. Generate `externals.tsp` from those JSON files +4. Run compilation while preserving external lexicons +5. Manage paths and directory structures correctly + +Language features (syntax, types, decorators) are tested in the emitter package. + +## Test Coverage + +All non-trivial branches in the CLI code are tested. Each test was verified by: +1. Breaking the code (commenting out the condition) +2. Verifying the test fails +3. Fixing the code and verifying the test passes + +### Current Scenarios (8 total) + +**External Lexicon Workflows** (The Core CLI Functionality): +- `compile-with-external-atproto` - Real JSON→TSP→JSON cycle, externals preserved +- `compile-to-parent-lexicons` - Compile with `../lexicons` directory +- `compile-idempotent` - Deterministic output across runs + +**Init Workflows** (Directory Detection & File Management): +- `init-finds-current-lexicons` - Detects `./lexicons`, no `--out` flag +- `init-finds-parent-lexicons` - Detects `../lexicons`, adds `--out ../lexicons` +- `init-overwrites-empty-main` - Empty `main.tsp` gets overwritten +- `init-preserves-build-script` - Existing `build:typelex` not overwritten + +**Validation** (Error Handling): +- `validation-errors` - Namespace format, path validation, file structure + +### Branch Coverage Matrix + +| File | Line | Branch | Tested By | +|------|------|--------|-----------| +| compile.ts | 21 | Path validation | validation-errors | +| ensure-imports.ts | 20 | First line check | validation-errors | +| ensure-imports.ts | 26 | Second line check | validation-errors | +| ensure-imports.ts | 32 | File not found | validation-errors | +| externals-generator.ts | 87 | No externals case | All compile scenarios | +| init.ts | 194 | Local lexicons dir | init-finds-current-lexicons | +| init.ts | 203 | Parent lexicons dir | init-finds-parent-lexicons | +| init.ts | 231 | Empty main.tsp | init-overwrites-empty-main | +| init.ts | 252 | No scripts object | All init scenarios (crashes without) | +| init.ts | 255 | Script exists | init-preserves-build-script | + +## Structure + +Each scenario directory contains: + +``` +scenario-name/ + project/ # Realistic project structure + package.json + typelex/ + main.tsp # Input TypeSpec + externals.tsp # Boilerplate or generated + lexicons/ # REAL JSON FILES (not mocked!) + com/atproto/... # Checked-in external lexicons + expected/ # Expected outputs (optional) + lexicons/ + com/myapp/... + test.ts # Test logic with run() function +``` + +## Writing Tests + +The `test.ts` exports a `run()` function that performs assertions: + +```typescript +import { expect } from "vitest"; + +export const namespace = "com.myapp.*"; + +export async function run(project, scenarioPath) { + // Compile + await project.compile(namespace); + + // Assert on behavior + const externals = await project.readFile("typelex/externals.tsp"); + expect(externals).toContain("namespace com.atproto.label.defs"); + + // Verify files match expected + await verifyExpectedFiles(join(scenarioPath, "expected"), project); +} +``` + +Available exports: +- `namespace` - Default namespace +- `packageManager` - "npm" or "pnpm" +- `lexiconsDirLocation` - "current", "parent" +- `run(project, scenarioPath)` - Test logic + +Available helpers: +- `project.compile(namespace, outDir?)` - Compile (throws on error) +- `project.init(namespace)` - Run init (throws on error) +- `project.runTypelex(args, options?)` - Run any command +- `project.writeFile/readFile/readJson/fileExists` +- `verifyExpectedFiles(expectedDir, project)` - Match expected outputs + +## Key Insight + +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. + +Don't test language features here - test file I/O, directory management, and the JSON↔TSP↔JSON workflow. + +## Adding New Tests + +When adding a new scenario, verify it catches bugs: +1. Write the test +2. Break the corresponding code +3. Run tests - should FAIL +4. Fix the code +5. Run tests - should PASS diff --git a/packages/cli/test/scenarios/basic/expected/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/basic/expected/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/basic/expected/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/basic/expected/lexicons/com/test/post.json b/packages/cli/test/scenarios/basic/expected/lexicons/com/test/post.json new file mode 100644 index 0000000..c923458 --- /dev/null +++ b/packages/cli/test/scenarios/basic/expected/lexicons/com/test/post.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "com.test.post", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + }, + "required": [ + "text", + "createdAt" + ] + } + } + } +} diff --git a/packages/cli/test/scenarios/basic/expected/package.json b/packages/cli/test/scenarios/basic/expected/package.json new file mode 100644 index 0000000..c76ab7d --- /dev/null +++ b/packages/cli/test/scenarios/basic/expected/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-idempotent", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.test.*" + } +} diff --git a/packages/cli/test/scenarios/basic/expected/typelex/externals.tsp b/packages/cli/test/scenarios/basic/expected/typelex/externals.tsp new file mode 100644 index 0000000..657d5a8 --- /dev/null +++ b/packages/cli/test/scenarios/basic/expected/typelex/externals.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; + +// Generated by typelex from ./lexicons (excluding com.test.*) +// This file is auto-generated. Do not edit manually. + +@external +namespace com.atproto.label.defs { + model SelfLabel { } + model SelfLabels { } +} diff --git a/packages/cli/test/scenarios/basic/expected/typelex/main.tsp b/packages/cli/test/scenarios/basic/expected/typelex/main.tsp new file mode 100644 index 0000000..f004e39 --- /dev/null +++ b/packages/cli/test/scenarios/basic/expected/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.test.post { + @rec("tid") + model Main { + @required text: string; + @required createdAt: datetime; + } +} diff --git a/packages/cli/test/scenarios/basic/project/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/basic/project/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/basic/project/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/basic/project/package.json b/packages/cli/test/scenarios/basic/project/package.json new file mode 100644 index 0000000..30fb75f --- /dev/null +++ b/packages/cli/test/scenarios/basic/project/package.json @@ -0,0 +1 @@ +{"name":"test-idempotent","version":"1.0.0","type":"module"} diff --git a/packages/cli/test/scenarios/basic/test.ts b/packages/cli/test/scenarios/basic/test.ts new file mode 100644 index 0000000..0fcd1f8 --- /dev/null +++ b/packages/cli/test/scenarios/basic/test.ts @@ -0,0 +1,10 @@ +export async function run(project) { + await project.init("com.test.*"); + + await project.runBuildScript(); + await project.compareTo("expected"); + + // Second build - verify idempotency + await project.runBuildScript(); + await project.compareTo("expected"); +} diff --git a/packages/cli/test/scenarios/init-preserves-main/expected/lexicons/com/example/custom.json b/packages/cli/test/scenarios/init-preserves-main/expected/lexicons/com/example/custom.json new file mode 100644 index 0000000..ded12ca --- /dev/null +++ b/packages/cli/test/scenarios/init-preserves-main/expected/lexicons/com/example/custom.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "com.example.custom", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "foo": { + "type": "string" + }, + "bar": { + "type": "integer" + } + }, + "required": [ + "foo", + "bar" + ] + } + } + } +} diff --git a/packages/cli/test/scenarios/init-preserves-main/expected/package.json b/packages/cli/test/scenarios/init-preserves-main/expected/package.json new file mode 100644 index 0000000..eadebf0 --- /dev/null +++ b/packages/cli/test/scenarios/init-preserves-main/expected/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-init-preserves-main", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.example.*" + } +} diff --git a/packages/cli/test/scenarios/init-preserves-main/expected/typelex/externals.tsp b/packages/cli/test/scenarios/init-preserves-main/expected/typelex/externals.tsp new file mode 100644 index 0000000..6483b7c --- /dev/null +++ b/packages/cli/test/scenarios/init-preserves-main/expected/typelex/externals.tsp @@ -0,0 +1,4 @@ +import "@typelex/emitter"; + +// Generated by typelex from ./lexicons (excluding com.example.*) +// No external lexicons found diff --git a/packages/cli/test/scenarios/init-preserves-main/expected/typelex/main.tsp b/packages/cli/test/scenarios/init-preserves-main/expected/typelex/main.tsp new file mode 100644 index 0000000..b578fa6 --- /dev/null +++ b/packages/cli/test/scenarios/init-preserves-main/expected/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.example.custom { + @rec("tid") + model Main { + @required foo: string; + @required bar: integer; + } +} diff --git a/packages/cli/test/scenarios/init-preserves-main/project/package.json b/packages/cli/test/scenarios/init-preserves-main/project/package.json new file mode 100644 index 0000000..3d7508d --- /dev/null +++ b/packages/cli/test/scenarios/init-preserves-main/project/package.json @@ -0,0 +1 @@ +{"name":"test-init-preserves-main","version":"1.0.0","type":"module"} diff --git a/packages/cli/test/scenarios/init-preserves-main/project/typelex/main.tsp b/packages/cli/test/scenarios/init-preserves-main/project/typelex/main.tsp new file mode 100644 index 0000000..b578fa6 --- /dev/null +++ b/packages/cli/test/scenarios/init-preserves-main/project/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.example.custom { + @rec("tid") + model Main { + @required foo: string; + @required bar: integer; + } +} diff --git a/packages/cli/test/scenarios/init-preserves-main/test.ts b/packages/cli/test/scenarios/init-preserves-main/test.ts new file mode 100644 index 0000000..ac7da94 --- /dev/null +++ b/packages/cli/test/scenarios/init-preserves-main/test.ts @@ -0,0 +1,9 @@ +export async function run(project) { + await project.init("com.example.*"); + await project.runBuildScript(); + await project.compareTo("expected"); + + // Second build - verify idempotency + await project.runBuildScript(); + await project.compareTo("expected"); +} diff --git a/packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/external/media/defs.json b/packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/external/media/defs.json new file mode 100644 index 0000000..cbd939d --- /dev/null +++ b/packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/external/media/defs.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "com.external.media.defs", + "defs": { + "video": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + } +} diff --git a/packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/myapp/post.json b/packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/myapp/post.json new file mode 100644 index 0000000..7199c18 --- /dev/null +++ b/packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/myapp/post.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "com.myapp.post", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "video": { + "type": "ref", + "ref": "com.external.media.defs#video" + } + }, + "required": [ + "text" + ] + } + } + } +} diff --git a/packages/cli/test/scenarios/missing-dependency/expected/package.json b/packages/cli/test/scenarios/missing-dependency/expected/package.json new file mode 100644 index 0000000..38c9684 --- /dev/null +++ b/packages/cli/test/scenarios/missing-dependency/expected/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-missing-dependency", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.myapp.*" + } +} diff --git a/packages/cli/test/scenarios/missing-dependency/expected/typelex/externals.tsp b/packages/cli/test/scenarios/missing-dependency/expected/typelex/externals.tsp new file mode 100644 index 0000000..d1dbf94 --- /dev/null +++ b/packages/cli/test/scenarios/missing-dependency/expected/typelex/externals.tsp @@ -0,0 +1,9 @@ +import "@typelex/emitter"; + +// Generated by typelex from ./lexicons (excluding com.myapp.*) +// This file is auto-generated. Do not edit manually. + +@external +namespace com.external.media.defs { + model Video { } +} diff --git a/packages/cli/test/scenarios/missing-dependency/expected/typelex/main.tsp b/packages/cli/test/scenarios/missing-dependency/expected/typelex/main.tsp new file mode 100644 index 0000000..ede5208 --- /dev/null +++ b/packages/cli/test/scenarios/missing-dependency/expected/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.post { + @rec("tid") + model Main { + @required text: string; + video?: com.external.media.defs.Video; + } +} diff --git a/packages/cli/test/scenarios/missing-dependency/project/package.json b/packages/cli/test/scenarios/missing-dependency/project/package.json new file mode 100644 index 0000000..72b19c6 --- /dev/null +++ b/packages/cli/test/scenarios/missing-dependency/project/package.json @@ -0,0 +1 @@ +{"name":"test-missing-dependency","version":"1.0.0","type":"module"} diff --git a/packages/cli/test/scenarios/missing-dependency/test.ts b/packages/cli/test/scenarios/missing-dependency/test.ts new file mode 100644 index 0000000..f41f4f7 --- /dev/null +++ b/packages/cli/test/scenarios/missing-dependency/test.ts @@ -0,0 +1,51 @@ +export async function run(project) { + await project.init("com.myapp.*"); + + // Edit main.tsp to reference a missing external lexicon + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.post { + @rec("tid") + model Main { + @required text: string; + video?: com.external.media.defs.Video; + } +} +`); + + // Build should fail because com.external.media.defs doesn't exist + const failure = await project.expectBuildToFail(); + if (!failure.output.includes("com.external.media.defs")) { + throw new Error(`Expected error about missing com.external.media.defs, got: ${failure.output}`); + } + + // Add the missing external lexicon + await project.writeFile("lexicons/com/external/media/defs.json", JSON.stringify({ + "lexicon": 1, + "id": "com.external.media.defs", + "defs": { + "video": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "mimeType": { + "type": "string" + } + }, + "required": ["url"] + } + } + }, null, 2) + "\n"); + + // Now build should succeed + await project.runBuildScript(); + await project.compareTo("expected"); + + // Verify idempotency + await project.runBuildScript(); + await project.compareTo("expected"); +} diff --git a/packages/cli/test/scenarios/nested-init/expected/lexicons/com/myservice/post.json b/packages/cli/test/scenarios/nested-init/expected/lexicons/com/myservice/post.json new file mode 100644 index 0000000..1475d2c --- /dev/null +++ b/packages/cli/test/scenarios/nested-init/expected/lexicons/com/myservice/post.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "com.myservice.post", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + }, + "required": [ + "text", + "createdAt" + ] + } + } + } +} diff --git a/packages/cli/test/scenarios/nested-init/expected/package.json b/packages/cli/test/scenarios/nested-init/expected/package.json new file mode 100644 index 0000000..9830fd3 --- /dev/null +++ b/packages/cli/test/scenarios/nested-init/expected/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-nested-init", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.myservice.*" + } +} diff --git a/packages/cli/test/scenarios/nested-init/expected/typelex/externals.tsp b/packages/cli/test/scenarios/nested-init/expected/typelex/externals.tsp new file mode 100644 index 0000000..bec8c73 --- /dev/null +++ b/packages/cli/test/scenarios/nested-init/expected/typelex/externals.tsp @@ -0,0 +1,4 @@ +import "@typelex/emitter"; + +// Generated by typelex from ./lexicons (excluding com.myservice.*) +// No external lexicons found diff --git a/packages/cli/test/scenarios/nested-init/expected/typelex/main.tsp b/packages/cli/test/scenarios/nested-init/expected/typelex/main.tsp new file mode 100644 index 0000000..75fe571 --- /dev/null +++ b/packages/cli/test/scenarios/nested-init/expected/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myservice.post { + @rec("tid") + model Main { + @required text: string; + @required createdAt: datetime; + } +} diff --git a/packages/cli/test/scenarios/nested-init/project/package.json b/packages/cli/test/scenarios/nested-init/project/package.json new file mode 100644 index 0000000..370946a --- /dev/null +++ b/packages/cli/test/scenarios/nested-init/project/package.json @@ -0,0 +1 @@ +{"name":"test-nested-init","version":"1.0.0","type":"module"} diff --git a/packages/cli/test/scenarios/nested-init/test.ts b/packages/cli/test/scenarios/nested-init/test.ts new file mode 100644 index 0000000..b6d2702 --- /dev/null +++ b/packages/cli/test/scenarios/nested-init/test.ts @@ -0,0 +1,16 @@ +import { join } from "path"; + +export async function run(project) { + const apiDir = join(project.path, "src/api"); + + // Init at root (where package.json is) + await project.init("com.myservice.*"); + + // Build from nested directory should work (this is what we're testing) + await project.runBuildScript({ cwd: apiDir }); + await project.compareTo("expected"); + + // Verify idempotency + await project.runBuildScript({ cwd: apiDir }); + await project.compareTo("expected"); +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected1/app/package.json b/packages/cli/test/scenarios/parent-lexicons/expected1/app/package.json new file mode 100644 index 0000000..828b2dc --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected1/app/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-parent-lexicons", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.myapp.* --out ../lexicons" + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/externals.tsp b/packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/externals.tsp new file mode 100644 index 0000000..1ae10cf --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/externals.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; + +// Generated by typelex from ../lexicons (excluding com.myapp.*) +// This file is auto-generated. Do not edit manually. + +@external +namespace com.atproto.label.defs { + model SelfLabel { } + model SelfLabels { } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/main.tsp b/packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/main.tsp new file mode 100644 index 0000000..204e86d --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.post { + @rec("tid") + model Main { + @required text: string; + @required createdAt: datetime; + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/myapp/post.json b/packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/myapp/post.json new file mode 100644 index 0000000..8277df3 --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/myapp/post.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "com.myapp.post", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + }, + "required": [ + "text", + "createdAt" + ] + } + } + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected2/app/package.json b/packages/cli/test/scenarios/parent-lexicons/expected2/app/package.json new file mode 100644 index 0000000..828b2dc --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected2/app/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-parent-lexicons", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.myapp.* --out ../lexicons" + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/externals.tsp b/packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/externals.tsp new file mode 100644 index 0000000..1ae10cf --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/externals.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; + +// Generated by typelex from ../lexicons (excluding com.myapp.*) +// This file is auto-generated. Do not edit manually. + +@external +namespace com.atproto.label.defs { + model SelfLabel { } + model SelfLabels { } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/main.tsp b/packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/main.tsp new file mode 100644 index 0000000..63ab2b1 --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.post { + @rec("tid") + model Main { + @required text: string; + labels?: com.atproto.label.defs.SelfLabels; + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/myapp/post.json b/packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/myapp/post.json new file mode 100644 index 0000000..19d593a --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/myapp/post.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "com.myapp.post", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels" + } + }, + "required": [ + "text" + ] + } + } + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/project/app/package.json b/packages/cli/test/scenarios/parent-lexicons/project/app/package.json new file mode 100644 index 0000000..986f05e --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/project/app/package.json @@ -0,0 +1 @@ +{"name":"test-parent-lexicons","version":"1.0.0","type":"module"} diff --git a/packages/cli/test/scenarios/parent-lexicons/project/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/parent-lexicons/project/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/project/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/parent-lexicons/test.ts b/packages/cli/test/scenarios/parent-lexicons/test.ts new file mode 100644 index 0000000..18d44e3 --- /dev/null +++ b/packages/cli/test/scenarios/parent-lexicons/test.ts @@ -0,0 +1,31 @@ +import { join } from "path"; + +export async function run(project) { + const appDir = join(project.path, "app"); + + await project.init("com.myapp.*", { cwd: appDir }); + + // Verify init created a working project with default main.tsp + await project.runBuildScript({ cwd: appDir }); + await project.compareTo("expected1"); + + // Edit main.tsp to add a post schema with labels (simulates user editing the file) + await project.writeFile("app/typelex/main.tsp", `import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.post { + @rec("tid") + model Main { + @required text: string; + labels?: com.atproto.label.defs.SelfLabels; + } +} +`); + + await project.runBuildScript({ cwd: appDir }); + await project.compareTo("expected2"); + + // Third build - verify idempotency + await project.runBuildScript({ cwd: appDir }); + await project.compareTo("expected2"); +} diff --git a/packages/cli/test/scenarios/validation-errors/project/package.json b/packages/cli/test/scenarios/validation-errors/project/package.json new file mode 100644 index 0000000..b9deaac --- /dev/null +++ b/packages/cli/test/scenarios/validation-errors/project/package.json @@ -0,0 +1 @@ +{"name":"test-validation","version":"1.0.0","type":"module"} diff --git a/packages/cli/test/scenarios/validation-errors/test.ts b/packages/cli/test/scenarios/validation-errors/test.ts new file mode 100644 index 0000000..d261591 --- /dev/null +++ b/packages/cli/test/scenarios/validation-errors/test.ts @@ -0,0 +1,35 @@ +import { expect } from "vitest"; + +export async function run(project) { + // Test: Namespace must end with .* + let result = await project.runTypelex(["compile", "com.example"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("namespace must end with .*"); + + // Test: Output path must end with 'lexicons' + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter";\nimport "./externals.tsp";\n`); + await project.writeFile("typelex/externals.tsp", `import "@typelex/emitter";\n`); + + result = await project.runTypelex(["compile", "com.test.*", "--out", "./output"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("Output directory must end with 'lexicons'"); + + // Test: main.tsp must exist + await project.runCommand("rm", ["-rf", "typelex"]); + result = await project.runTypelex(["compile", "com.test.*"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("main.tsp not found"); + + // Test: main.tsp first line must be import "@typelex/emitter" + await project.writeFile("typelex/main.tsp", `// wrong first line\nimport "./externals.tsp";\n`); + await project.writeFile("typelex/externals.tsp", `import "@typelex/emitter";\n`); + result = await project.runTypelex(["compile", "com.test.*"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('main.tsp must start with: import "@typelex/emitter"'); + + // Test: main.tsp second line must be import "./externals.tsp" + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter";\n// wrong second line\n`); + result = await project.runTypelex(["compile", "com.test.*"]); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('Line 2 of main.tsp must be: import "./externals.tsp"'); +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/myapp/post.json b/packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/myapp/post.json new file mode 100644 index 0000000..8277df3 --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/myapp/post.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "com.myapp.post", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + }, + "required": [ + "text", + "createdAt" + ] + } + } + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected1/package.json b/packages/cli/test/scenarios/with-external-lexicons/expected1/package.json new file mode 100644 index 0000000..c9ee28e --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected1/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-external-lexicons", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.myapp.*" + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/externals.tsp b/packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/externals.tsp new file mode 100644 index 0000000..0840539 --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/externals.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; + +// Generated by typelex from ./lexicons (excluding com.myapp.*) +// This file is auto-generated. Do not edit manually. + +@external +namespace com.atproto.label.defs { + model SelfLabel { } + model SelfLabels { } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/main.tsp b/packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/main.tsp new file mode 100644 index 0000000..204e86d --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/main.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.post { + @rec("tid") + model Main { + @required text: string; + @required createdAt: datetime; + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/myapp/profile.json b/packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/myapp/profile.json new file mode 100644 index 0000000..1d6eb12 --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/myapp/profile.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "com.myapp.profile", + "defs": { + "main": { + "type": "object", + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string", + "format": "handle" + }, + "displayName": { + "type": "string" + }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels" + } + }, + "required": [ + "did", + "handle" + ] + } + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected2/package.json b/packages/cli/test/scenarios/with-external-lexicons/expected2/package.json new file mode 100644 index 0000000..c9ee28e --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected2/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-external-lexicons", + "version": "1.0.0", + "type": "module", + "scripts": { + "build:typelex": "typelex compile com.myapp.*" + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/externals.tsp b/packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/externals.tsp new file mode 100644 index 0000000..0840539 --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/externals.tsp @@ -0,0 +1,10 @@ +import "@typelex/emitter"; + +// Generated by typelex from ./lexicons (excluding com.myapp.*) +// This file is auto-generated. Do not edit manually. + +@external +namespace com.atproto.label.defs { + model SelfLabel { } + model SelfLabels { } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/main.tsp b/packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/main.tsp new file mode 100644 index 0000000..d41a34e --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/main.tsp @@ -0,0 +1,13 @@ +import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.profile { + model Main { + @required did: did; + @required handle: handle; + displayName?: string; + + // Reference to external lexicon + labels?: com.atproto.label.defs.SelfLabels; + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/project/lexicons/com/atproto/label/defs.json b/packages/cli/test/scenarios/with-external-lexicons/project/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..b3e6049 --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/project/lexicons/com/atproto/label/defs.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "selfLabels": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + }, + "required": ["values"] + }, + "selfLabel": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 128 + } + }, + "required": ["val"] + } + } +} diff --git a/packages/cli/test/scenarios/with-external-lexicons/project/package.json b/packages/cli/test/scenarios/with-external-lexicons/project/package.json new file mode 100644 index 0000000..d0df3ea --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/project/package.json @@ -0,0 +1 @@ +{"name":"test-external-lexicons","version":"1.0.0","type":"module"} diff --git a/packages/cli/test/scenarios/with-external-lexicons/test.ts b/packages/cli/test/scenarios/with-external-lexicons/test.ts new file mode 100644 index 0000000..4d5972f --- /dev/null +++ b/packages/cli/test/scenarios/with-external-lexicons/test.ts @@ -0,0 +1,30 @@ +export async function run(project) { + await project.init("com.myapp.*"); + + // Verify init created a working project with default main.tsp + await project.runBuildScript(); + await project.compareTo("expected1"); + + // Edit main.tsp to add a profile schema (simulates user editing the file) + await project.writeFile("typelex/main.tsp", `import "@typelex/emitter"; +import "./externals.tsp"; + +namespace com.myapp.profile { + model Main { + @required did: did; + @required handle: handle; + displayName?: string; + + // Reference to external lexicon + labels?: com.atproto.label.defs.SelfLabels; + } +} +`); + + await project.runBuildScript(); + await project.compareTo("expected2"); + + // Third build - verify idempotency + await project.runBuildScript(); + await project.compareTo("expected2"); +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..c1a6968 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: 60000, // CLI operations can take time + hookTimeout: 60000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7580839..e7a70cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,12 @@ importers: packages/cli: dependencies: - '@typelex/emitter': - specifier: ^0.2.0 - version: 0.2.0(@typespec/compiler@1.4.0(@types/node@20.19.19)) '@typespec/compiler': specifier: ^1.4.0 version: 1.4.0(@types/node@20.19.19) + globby: + specifier: ^14.0.0 + version: 14.1.0 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -27,6 +27,9 @@ importers: specifier: ^18.0.0 version: 18.0.0 devDependencies: + '@typelex/emitter': + specifier: workspace:* + version: link:../emitter '@types/node': specifier: ^20.0.0 version: 20.19.19 @@ -36,6 +39,9 @@ importers: typescript: specifier: ^5.0.0 version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@20.19.19) packages/emitter: dependencies: @@ -1670,11 +1676,6 @@ packages: '@ts-morph/common@0.25.0': resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} - '@typelex/emitter@0.2.0': - resolution: {integrity: sha512-4Iw6VAnd9nCFGOkJcu9utWdmu9ZyPeAb1QX/B7KerGBmfc2FuIDqgZZ/mZ6c56atcZd62pb2oYF/3RgSFhEsoQ==} - peerDependencies: - '@typespec/compiler': ^1.4.0 - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -7447,10 +7448,6 @@ snapshots: path-browserify: 1.0.1 tinyglobby: 0.2.15 - '@typelex/emitter@0.2.0(@typespec/compiler@1.4.0(@types/node@20.19.19))': - dependencies: - '@typespec/compiler': 1.4.0(@types/node@20.19.19) - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.4