prototypey.org - atproto lexicon typescript toolkit - mirror https://github.com/tylersayshi/prototypey

added tests for json emit

Tyler 46f917c9 e9acbecd

+660 -2
+5 -2
packages/cli/package.json
··· 21 21 "README.md" 22 22 ], 23 23 "scripts": { 24 - "build": "tsdown --entry src/index.ts --format esm --dts false" 24 + "build": "tsdown --entry src/index.ts --format esm --dts false", 25 + "test": "vitest run", 26 + "tsc": "tsc" 25 27 }, 26 28 "dependencies": { 27 29 "prototypey": "workspace:*", ··· 31 33 "devDependencies": { 32 34 "@types/node": "24.0.4", 33 35 "tsdown": "0.12.7", 34 - "typescript": "5.8.3" 36 + "typescript": "5.8.3", 37 + "vitest": "^3.2.4" 35 38 }, 36 39 "engines": { 37 40 "node": ">=20.19.0"
+102
packages/cli/src/commands/gen-emit.ts
··· 1 + import { glob } from "tinyglobby"; 2 + import { mkdir, writeFile } from "node:fs/promises"; 3 + import { join } from "node:path"; 4 + import { pathToFileURL } from "node:url"; 5 + 6 + interface LexiconNamespace { 7 + json: { 8 + lexicon: number; 9 + id: string; 10 + defs: Record<string, unknown>; 11 + }; 12 + } 13 + 14 + export async function genEmit( 15 + outdir: string, 16 + sources: string | string[], 17 + ): Promise<void> { 18 + try { 19 + const sourcePatterns = Array.isArray(sources) ? sources : [sources]; 20 + 21 + // Find all source files matching the patterns 22 + const sourceFiles = await glob(sourcePatterns, { 23 + absolute: true, 24 + onlyFiles: true, 25 + }); 26 + 27 + if (sourceFiles.length === 0) { 28 + console.log("No source files found matching patterns:", sourcePatterns); 29 + return; 30 + } 31 + 32 + console.log(`Found ${sourceFiles.length} source file(s)`); 33 + 34 + // Ensure output directory exists 35 + await mkdir(outdir, { recursive: true }); 36 + 37 + // Process each source file 38 + for (const sourcePath of sourceFiles) { 39 + await processSourceFile(sourcePath, outdir); 40 + } 41 + 42 + console.log(`\nEmitted lexicon schemas to ${outdir}`); 43 + } catch (error) { 44 + console.error("Error emitting lexicon schemas:", error); 45 + process.exit(1); 46 + } 47 + } 48 + 49 + async function processSourceFile( 50 + sourcePath: string, 51 + outdir: string, 52 + ): Promise<void> { 53 + try { 54 + // Convert file path to file URL for dynamic import 55 + const fileUrl = pathToFileURL(sourcePath).href; 56 + 57 + // Dynamically import the module 58 + const module = await import(fileUrl); 59 + 60 + // Find all exported namespaces 61 + const namespaces: LexiconNamespace[] = []; 62 + for (const key of Object.keys(module)) { 63 + const exported = module[key]; 64 + // Check if it's a namespace with a json property 65 + if ( 66 + exported && 67 + typeof exported === "object" && 68 + "json" in exported && 69 + exported.json && 70 + typeof exported.json === "object" && 71 + "lexicon" in exported.json && 72 + "id" in exported.json && 73 + "defs" in exported.json 74 + ) { 75 + namespaces.push(exported as LexiconNamespace); 76 + } 77 + } 78 + 79 + if (namespaces.length === 0) { 80 + console.warn(` ⚠ ${sourcePath}: No lexicon namespaces found`); 81 + return; 82 + } 83 + 84 + // Emit JSON for each namespace 85 + for (const namespace of namespaces) { 86 + const { id } = namespace.json; 87 + const outputPath = join(outdir, `${id}.json`); 88 + 89 + // Write the JSON file 90 + await writeFile( 91 + outputPath, 92 + JSON.stringify(namespace.json, null, "\t"), 93 + "utf-8", 94 + ); 95 + 96 + console.log(` ✓ ${id} -> ${id}.json`); 97 + } 98 + } catch (error) { 99 + console.error(` ✗ Error processing ${sourcePath}:`, error); 100 + throw error; 101 + } 102 + }
+7
packages/cli/src/index.ts
··· 1 1 #!/usr/bin/env node 2 2 import sade from "sade"; 3 3 import { genInferred } from "./commands/gen-inferred.ts"; 4 + import { genEmit } from "./commands/gen-emit.ts"; 4 5 5 6 const prog = sade("prototypey"); 6 7 ··· 13 14 .describe("Generate type-inferred code from lexicon schemas") 14 15 .example("gen-inferred ./generated/inferred ./lexicons/**/*.json") 15 16 .action(genInferred); 17 + 18 + prog 19 + .command("gen-emit <outdir> <sources...>") 20 + .describe("Emit JSON lexicon schemas from authored TypeScript") 21 + .example("gen-emit ./lexicons ./src/lexicons/**/*.ts") 22 + .action(genEmit); 16 23 17 24 prog.parse(process.argv);
+525
packages/cli/tests/commands/gen-emit.test.ts
··· 1 + import { expect, test, describe, beforeEach, afterEach } from "vitest"; 2 + import { mkdir, writeFile, rm, readFile } from "node:fs/promises"; 3 + import { join } from "node:path"; 4 + import { genEmit } from "../../src/commands/gen-emit.ts"; 5 + import { tmpdir } from "node:os"; 6 + 7 + describe("genEmit", () => { 8 + let testDir: string; 9 + let outDir: string; 10 + 11 + beforeEach(async () => { 12 + // Create a temporary directory for test files 13 + testDir = join(tmpdir(), `prototypey-test-${Date.now()}`); 14 + outDir = join(testDir, "output"); 15 + await mkdir(testDir, { recursive: true }); 16 + await mkdir(outDir, { recursive: true }); 17 + }); 18 + 19 + afterEach(async () => { 20 + // Clean up test directory 21 + await rm(testDir, { recursive: true, force: true }); 22 + }); 23 + 24 + test("emits JSON from a simple lexicon file", async () => { 25 + // Create a test lexicon file 26 + const lexiconFile = join(testDir, "profile.ts"); 27 + await writeFile( 28 + lexiconFile, 29 + ` 30 + import { lx } from "prototypey"; 31 + 32 + export const profileNamespace = lx.namespace("app.bsky.actor.profile", { 33 + main: lx.record({ 34 + key: "self", 35 + record: lx.object({ 36 + displayName: lx.string({ maxLength: 64, maxGraphemes: 64 }), 37 + description: lx.string({ maxLength: 256, maxGraphemes: 256 }), 38 + }), 39 + }), 40 + }); 41 + `, 42 + ); 43 + 44 + // Run the emit command 45 + await genEmit(outDir, lexiconFile); 46 + 47 + // Read the emitted JSON file 48 + const outputFile = join(outDir, "app.bsky.actor.profile.json"); 49 + const content = await readFile(outputFile, "utf-8"); 50 + const json = JSON.parse(content); 51 + 52 + // Verify the structure 53 + expect(json).toEqual({ 54 + lexicon: 1, 55 + id: "app.bsky.actor.profile", 56 + defs: { 57 + main: { 58 + type: "record", 59 + key: "self", 60 + record: { 61 + type: "object", 62 + properties: { 63 + displayName: { 64 + type: "string", 65 + maxLength: 64, 66 + maxGraphemes: 64, 67 + }, 68 + description: { 69 + type: "string", 70 + maxLength: 256, 71 + maxGraphemes: 256, 72 + }, 73 + }, 74 + }, 75 + }, 76 + }, 77 + }); 78 + }); 79 + 80 + test("emits JSON from multiple lexicon exports in one file", async () => { 81 + // Create a test file with multiple exports 82 + const lexiconFile = join(testDir, "multiple.ts"); 83 + await writeFile( 84 + lexiconFile, 85 + ` 86 + import { lx } from "prototypey"; 87 + 88 + export const profile = lx.namespace("app.bsky.actor.profile", { 89 + main: lx.record({ 90 + key: "self", 91 + record: lx.object({ 92 + displayName: lx.string({ maxLength: 64 }), 93 + }), 94 + }), 95 + }); 96 + 97 + export const post = lx.namespace("app.bsky.feed.post", { 98 + main: lx.record({ 99 + key: "tid", 100 + record: lx.object({ 101 + text: lx.string({ maxLength: 300 }), 102 + }), 103 + }), 104 + }); 105 + `, 106 + ); 107 + 108 + // Run the emit command 109 + await genEmit(outDir, lexiconFile); 110 + 111 + // Verify both files were created 112 + const profileJson = JSON.parse( 113 + await readFile(join(outDir, "app.bsky.actor.profile.json"), "utf-8"), 114 + ); 115 + const postJson = JSON.parse( 116 + await readFile(join(outDir, "app.bsky.feed.post.json"), "utf-8"), 117 + ); 118 + 119 + expect(profileJson.id).toBe("app.bsky.actor.profile"); 120 + expect(postJson.id).toBe("app.bsky.feed.post"); 121 + }); 122 + 123 + test("handles glob patterns for multiple files", async () => { 124 + // Create multiple test files 125 + const lexicons = join(testDir, "lexicons"); 126 + await mkdir(lexicons, { recursive: true }); 127 + 128 + await writeFile( 129 + join(lexicons, "profile.ts"), 130 + ` 131 + import { lx } from "prototypey"; 132 + export const schema = lx.namespace("app.bsky.actor.profile", { 133 + main: lx.record({ key: "self", record: lx.object({}) }), 134 + }); 135 + `, 136 + ); 137 + 138 + await writeFile( 139 + join(lexicons, "post.ts"), 140 + ` 141 + import { lx } from "prototypey"; 142 + export const schema = lx.namespace("app.bsky.feed.post", { 143 + main: lx.record({ key: "tid", record: lx.object({}) }), 144 + }); 145 + `, 146 + ); 147 + 148 + // Run with glob pattern 149 + await genEmit(outDir, `${lexicons}/*.ts`); 150 + 151 + // Verify both files were created 152 + const profileExists = await readFile( 153 + join(outDir, "app.bsky.actor.profile.json"), 154 + "utf-8", 155 + ); 156 + const postExists = await readFile( 157 + join(outDir, "app.bsky.feed.post.json"), 158 + "utf-8", 159 + ); 160 + 161 + expect(profileExists).toBeTruthy(); 162 + expect(postExists).toBeTruthy(); 163 + }); 164 + 165 + test("emits query endpoint with parameters and output", async () => { 166 + const lexiconFile = join(testDir, "search.ts"); 167 + await writeFile( 168 + lexiconFile, 169 + ` 170 + import { lx } from "prototypey"; 171 + 172 + export const searchPosts = lx.namespace("app.bsky.feed.searchPosts", { 173 + main: lx.query({ 174 + description: "Find posts matching search criteria", 175 + parameters: lx.params({ 176 + q: lx.string({ required: true }), 177 + limit: lx.integer({ minimum: 1, maximum: 100, default: 25 }), 178 + cursor: lx.string(), 179 + }), 180 + output: { 181 + encoding: "application/json", 182 + schema: lx.object({ 183 + cursor: lx.string(), 184 + posts: lx.array(lx.ref("app.bsky.feed.defs#postView"), { required: true }), 185 + }), 186 + }, 187 + }), 188 + }); 189 + `, 190 + ); 191 + 192 + await genEmit(outDir, lexiconFile); 193 + 194 + const outputFile = join(outDir, "app.bsky.feed.searchPosts.json"); 195 + const content = await readFile(outputFile, "utf-8"); 196 + const json = JSON.parse(content); 197 + 198 + expect(json).toEqual({ 199 + lexicon: 1, 200 + id: "app.bsky.feed.searchPosts", 201 + defs: { 202 + main: { 203 + type: "query", 204 + description: "Find posts matching search criteria", 205 + parameters: { 206 + type: "params", 207 + properties: { 208 + q: { type: "string", required: true }, 209 + limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 210 + cursor: { type: "string" }, 211 + }, 212 + required: ["q"], 213 + }, 214 + output: { 215 + encoding: "application/json", 216 + schema: { 217 + type: "object", 218 + properties: { 219 + cursor: { type: "string" }, 220 + posts: { 221 + type: "array", 222 + items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 223 + required: true, 224 + }, 225 + }, 226 + required: ["posts"], 227 + }, 228 + }, 229 + }, 230 + }, 231 + }); 232 + }); 233 + 234 + test("emits procedure endpoint with input and output", async () => { 235 + const lexiconFile = join(testDir, "create-post.ts"); 236 + await writeFile( 237 + lexiconFile, 238 + ` 239 + import { lx } from "prototypey"; 240 + 241 + export const createPost = lx.namespace("com.atproto.repo.createRecord", { 242 + main: lx.procedure({ 243 + description: "Create a record", 244 + input: { 245 + encoding: "application/json", 246 + schema: lx.object({ 247 + repo: lx.string({ required: true }), 248 + collection: lx.string({ required: true }), 249 + record: lx.unknown({ required: true }), 250 + }), 251 + }, 252 + output: { 253 + encoding: "application/json", 254 + schema: lx.object({ 255 + uri: lx.string({ required: true }), 256 + cid: lx.string({ required: true }), 257 + }), 258 + }, 259 + }), 260 + }); 261 + `, 262 + ); 263 + 264 + await genEmit(outDir, lexiconFile); 265 + 266 + const outputFile = join(outDir, "com.atproto.repo.createRecord.json"); 267 + const content = await readFile(outputFile, "utf-8"); 268 + const json = JSON.parse(content); 269 + 270 + expect(json).toEqual({ 271 + lexicon: 1, 272 + id: "com.atproto.repo.createRecord", 273 + defs: { 274 + main: { 275 + type: "procedure", 276 + description: "Create a record", 277 + input: { 278 + encoding: "application/json", 279 + schema: { 280 + type: "object", 281 + properties: { 282 + repo: { type: "string", required: true }, 283 + collection: { type: "string", required: true }, 284 + record: { type: "unknown", required: true }, 285 + }, 286 + required: ["repo", "collection", "record"], 287 + }, 288 + }, 289 + output: { 290 + encoding: "application/json", 291 + schema: { 292 + type: "object", 293 + properties: { 294 + uri: { type: "string", required: true }, 295 + cid: { type: "string", required: true }, 296 + }, 297 + required: ["uri", "cid"], 298 + }, 299 + }, 300 + }, 301 + }, 302 + }); 303 + }); 304 + 305 + test("emits subscription endpoint with message union", async () => { 306 + const lexiconFile = join(testDir, "subscription.ts"); 307 + await writeFile( 308 + lexiconFile, 309 + ` 310 + import { lx } from "prototypey"; 311 + 312 + export const subscribeRepos = lx.namespace("com.atproto.sync.subscribeRepos", { 313 + main: lx.subscription({ 314 + description: "Repository event stream", 315 + parameters: lx.params({ 316 + cursor: lx.integer(), 317 + }), 318 + message: { 319 + schema: lx.union(["#commit", "#identity", "#account"]), 320 + }, 321 + }), 322 + commit: lx.object({ 323 + seq: lx.integer({ required: true }), 324 + rebase: lx.boolean({ required: true }), 325 + }), 326 + identity: lx.object({ 327 + seq: lx.integer({ required: true }), 328 + did: lx.string({ required: true, format: "did" }), 329 + }), 330 + account: lx.object({ 331 + seq: lx.integer({ required: true }), 332 + active: lx.boolean({ required: true }), 333 + }), 334 + }); 335 + `, 336 + ); 337 + 338 + await genEmit(outDir, lexiconFile); 339 + 340 + const outputFile = join(outDir, "com.atproto.sync.subscribeRepos.json"); 341 + const content = await readFile(outputFile, "utf-8"); 342 + const json = JSON.parse(content); 343 + 344 + expect(json).toEqual({ 345 + lexicon: 1, 346 + id: "com.atproto.sync.subscribeRepos", 347 + defs: { 348 + main: { 349 + type: "subscription", 350 + description: "Repository event stream", 351 + parameters: { 352 + type: "params", 353 + properties: { 354 + cursor: { type: "integer" }, 355 + }, 356 + }, 357 + message: { 358 + schema: { 359 + type: "union", 360 + refs: ["#commit", "#identity", "#account"], 361 + }, 362 + }, 363 + }, 364 + commit: { 365 + type: "object", 366 + properties: { 367 + seq: { type: "integer", required: true }, 368 + rebase: { type: "boolean", required: true }, 369 + }, 370 + required: ["seq", "rebase"], 371 + }, 372 + identity: { 373 + type: "object", 374 + properties: { 375 + seq: { type: "integer", required: true }, 376 + did: { type: "string", format: "did", required: true }, 377 + }, 378 + required: ["seq", "did"], 379 + }, 380 + account: { 381 + type: "object", 382 + properties: { 383 + seq: { type: "integer", required: true }, 384 + active: { type: "boolean", required: true }, 385 + }, 386 + required: ["seq", "active"], 387 + }, 388 + }, 389 + }); 390 + }); 391 + 392 + test("emits complex namespace with tokens, refs, and unions", async () => { 393 + const lexiconFile = join(testDir, "complex.ts"); 394 + await writeFile( 395 + lexiconFile, 396 + ` 397 + import { lx } from "prototypey"; 398 + 399 + export const feedDefs = lx.namespace("app.bsky.feed.defs", { 400 + postView: lx.object({ 401 + uri: lx.string({ required: true, format: "at-uri" }), 402 + cid: lx.string({ required: true, format: "cid" }), 403 + author: lx.ref("app.bsky.actor.defs#profileViewBasic", { required: true }), 404 + embed: lx.union([ 405 + "app.bsky.embed.images#view", 406 + "app.bsky.embed.video#view", 407 + ]), 408 + likeCount: lx.integer({ minimum: 0 }), 409 + }), 410 + requestLess: lx.token("Request less content like this"), 411 + requestMore: lx.token("Request more content like this"), 412 + }); 413 + `, 414 + ); 415 + 416 + await genEmit(outDir, lexiconFile); 417 + 418 + const outputFile = join(outDir, "app.bsky.feed.defs.json"); 419 + const content = await readFile(outputFile, "utf-8"); 420 + const json = JSON.parse(content); 421 + 422 + expect(json).toEqual({ 423 + lexicon: 1, 424 + id: "app.bsky.feed.defs", 425 + defs: { 426 + postView: { 427 + type: "object", 428 + properties: { 429 + uri: { type: "string", format: "at-uri", required: true }, 430 + cid: { type: "string", format: "cid", required: true }, 431 + author: { 432 + type: "ref", 433 + ref: "app.bsky.actor.defs#profileViewBasic", 434 + required: true, 435 + }, 436 + embed: { 437 + type: "union", 438 + refs: ["app.bsky.embed.images#view", "app.bsky.embed.video#view"], 439 + }, 440 + likeCount: { type: "integer", minimum: 0 }, 441 + }, 442 + required: ["uri", "cid", "author"], 443 + }, 444 + requestLess: { 445 + type: "token", 446 + description: "Request less content like this", 447 + }, 448 + requestMore: { 449 + type: "token", 450 + description: "Request more content like this", 451 + }, 452 + }, 453 + }); 454 + }); 455 + 456 + test("emits lexicon with arrays, blobs, and string formats", async () => { 457 + const lexiconFile = join(testDir, "primitives.ts"); 458 + await writeFile( 459 + lexiconFile, 460 + ` 461 + import { lx } from "prototypey"; 462 + 463 + export const imagePost = lx.namespace("app.example.imagePost", { 464 + main: lx.record({ 465 + key: "tid", 466 + record: lx.object({ 467 + text: lx.string({ maxLength: 300, maxGraphemes: 300, required: true }), 468 + createdAt: lx.string({ format: "datetime", required: true }), 469 + images: lx.array(lx.blob({ accept: ["image/png", "image/jpeg"], maxSize: 1000000 }), { maxLength: 4 }), 470 + tags: lx.array(lx.string({ maxLength: 64 })), 471 + langs: lx.array(lx.string()), 472 + }), 473 + }), 474 + }); 475 + `, 476 + ); 477 + 478 + await genEmit(outDir, lexiconFile); 479 + 480 + const outputFile = join(outDir, "app.example.imagePost.json"); 481 + const content = await readFile(outputFile, "utf-8"); 482 + const json = JSON.parse(content); 483 + 484 + expect(json).toEqual({ 485 + lexicon: 1, 486 + id: "app.example.imagePost", 487 + defs: { 488 + main: { 489 + type: "record", 490 + key: "tid", 491 + record: { 492 + type: "object", 493 + properties: { 494 + text: { 495 + type: "string", 496 + maxLength: 300, 497 + maxGraphemes: 300, 498 + required: true, 499 + }, 500 + createdAt: { type: "string", format: "datetime", required: true }, 501 + images: { 502 + type: "array", 503 + items: { 504 + type: "blob", 505 + accept: ["image/png", "image/jpeg"], 506 + maxSize: 1000000, 507 + }, 508 + maxLength: 4, 509 + }, 510 + tags: { 511 + type: "array", 512 + items: { type: "string", maxLength: 64 }, 513 + }, 514 + langs: { 515 + type: "array", 516 + items: { type: "string" }, 517 + }, 518 + }, 519 + required: ["text", "createdAt"], 520 + }, 521 + }, 522 + }, 523 + }); 524 + }); 525 + });
+11
packages/cli/tests/fixtures/simple-lexicon.ts
··· 1 + import { lx } from "prototypey"; 2 + 3 + export const profileNamespace = lx.namespace("app.bsky.actor.profile", { 4 + main: lx.record({ 5 + key: "self", 6 + record: lx.object({ 7 + displayName: lx.string({ maxLength: 64, maxGraphemes: 64 }), 8 + description: lx.string({ maxLength: 256, maxGraphemes: 256 }), 9 + }), 10 + }), 11 + });
+7
packages/cli/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + include: ["tests/**/*.test.ts"], 6 + }, 7 + });
+3
pnpm-lock.yaml
··· 42 42 typescript: 43 43 specifier: 5.8.3 44 44 version: 5.8.3 45 + vitest: 46 + specifier: ^3.2.4 47 + version: 3.2.4(@types/node@24.0.4)(jiti@2.6.1) 45 48 46 49 packages/prototypey: 47 50 devDependencies: