+1
-1
package.json
+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
+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
+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
+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
+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
+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
+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
+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
+8
packages/cli/test/scenarios/basic/expected/package.json
+10
packages/cli/test/scenarios/basic/expected/typelex/externals.tsp
+10
packages/cli/test/scenarios/basic/expected/typelex/externals.tsp
+10
packages/cli/test/scenarios/basic/expected/typelex/main.tsp
+10
packages/cli/test/scenarios/basic/expected/typelex/main.tsp
+27
packages/cli/test/scenarios/basic/project/lexicons/com/atproto/label/defs.json
+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
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
+10
packages/cli/test/scenarios/basic/test.ts
+25
packages/cli/test/scenarios/init-preserves-main/expected/lexicons/com/example/custom.json
+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
+8
packages/cli/test/scenarios/init-preserves-main/expected/package.json
+4
packages/cli/test/scenarios/init-preserves-main/expected/typelex/externals.tsp
+4
packages/cli/test/scenarios/init-preserves-main/expected/typelex/externals.tsp
+10
packages/cli/test/scenarios/init-preserves-main/expected/typelex/main.tsp
+10
packages/cli/test/scenarios/init-preserves-main/expected/typelex/main.tsp
+1
packages/cli/test/scenarios/init-preserves-main/project/package.json
+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
+10
packages/cli/test/scenarios/init-preserves-main/project/typelex/main.tsp
+9
packages/cli/test/scenarios/init-preserves-main/test.ts
+9
packages/cli/test/scenarios/init-preserves-main/test.ts
+21
packages/cli/test/scenarios/missing-dependency/expected/lexicons/com/external/media/defs.json
+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
+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
+8
packages/cli/test/scenarios/missing-dependency/expected/package.json
+9
packages/cli/test/scenarios/missing-dependency/expected/typelex/externals.tsp
+9
packages/cli/test/scenarios/missing-dependency/expected/typelex/externals.tsp
+10
packages/cli/test/scenarios/missing-dependency/expected/typelex/main.tsp
+10
packages/cli/test/scenarios/missing-dependency/expected/typelex/main.tsp
+1
packages/cli/test/scenarios/missing-dependency/project/package.json
+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
+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
+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
+8
packages/cli/test/scenarios/nested-init/expected/package.json
+4
packages/cli/test/scenarios/nested-init/expected/typelex/externals.tsp
+4
packages/cli/test/scenarios/nested-init/expected/typelex/externals.tsp
+10
packages/cli/test/scenarios/nested-init/expected/typelex/main.tsp
+10
packages/cli/test/scenarios/nested-init/expected/typelex/main.tsp
+1
packages/cli/test/scenarios/nested-init/project/package.json
+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
+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
+8
packages/cli/test/scenarios/parent-lexicons/expected1/app/package.json
+10
packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/externals.tsp
+10
packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/externals.tsp
+10
packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/main.tsp
+10
packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/main.tsp
+27
packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/atproto/label/defs.json
+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
+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
+8
packages/cli/test/scenarios/parent-lexicons/expected2/app/package.json
+10
packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/externals.tsp
+10
packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/externals.tsp
+10
packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/main.tsp
+10
packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/main.tsp
+27
packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/atproto/label/defs.json
+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
+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
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
+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
+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
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
+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
+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
+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
+8
packages/cli/test/scenarios/with-external-lexicons/expected1/package.json
+10
packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/externals.tsp
+10
packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/externals.tsp
+10
packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/main.tsp
+10
packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/main.tsp
+27
packages/cli/test/scenarios/with-external-lexicons/expected2/lexicons/com/atproto/label/defs.json
+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
+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
+8
packages/cli/test/scenarios/with-external-lexicons/expected2/package.json
+10
packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/externals.tsp
+10
packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/externals.tsp
+13
packages/cli/test/scenarios/with-external-lexicons/expected2/typelex/main.tsp
+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
+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
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
+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
+10
packages/cli/vitest.config.ts
+9
-12
pnpm-lock.yaml
+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: