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

inferred gen tests

Tyler cc75989b 46f917c9

+480
+360
packages/cli/tests/commands/gen-inferred.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 { genInferred } from "../../src/commands/gen-inferred.ts"; 5 + import { tmpdir } from "node:os"; 6 + 7 + describe("genInferred", () => { 8 + let testDir: string; 9 + let outDir: string; 10 + let schemasDir: string; 11 + 12 + beforeEach(async () => { 13 + // Create a temporary directory for test files 14 + testDir = join(tmpdir(), `prototypey-inferred-test-${Date.now()}`); 15 + outDir = join(testDir, "output"); 16 + schemasDir = join(testDir, "schemas"); 17 + await mkdir(testDir, { recursive: true }); 18 + await mkdir(outDir, { recursive: true }); 19 + await mkdir(schemasDir, { recursive: true }); 20 + }); 21 + 22 + afterEach(async () => { 23 + // Clean up test directory 24 + await rm(testDir, { recursive: true, force: true }); 25 + }); 26 + 27 + test("generates inferred types from a simple schema", async () => { 28 + // Create a test schema file 29 + const schemaFile = join(schemasDir, "app.bsky.actor.profile.json"); 30 + await writeFile( 31 + schemaFile, 32 + JSON.stringify( 33 + { 34 + lexicon: 1, 35 + id: "app.bsky.actor.profile", 36 + defs: { 37 + main: { 38 + type: "record", 39 + key: "self", 40 + record: { 41 + type: "object", 42 + properties: { 43 + displayName: { 44 + type: "string", 45 + maxLength: 64, 46 + maxGraphemes: 64, 47 + }, 48 + description: { 49 + type: "string", 50 + maxLength: 256, 51 + maxGraphemes: 256, 52 + }, 53 + }, 54 + }, 55 + }, 56 + }, 57 + }, 58 + null, 59 + "\t", 60 + ), 61 + ); 62 + 63 + // Run the inferred command 64 + await genInferred(outDir, schemaFile); 65 + 66 + // Read the generated TypeScript file 67 + const outputFile = join(outDir, "app/bsky/actor/profile.ts"); 68 + const content = await readFile(outputFile, "utf-8"); 69 + 70 + // Verify the generated code structure 71 + expect(content).toContain("// Generated by prototypey - DO NOT EDIT"); 72 + expect(content).toContain("// Source: app.bsky.actor.profile"); 73 + expect(content).toContain('import type { Infer } from "prototypey"'); 74 + expect(content).toContain('with { type: "json" }'); 75 + expect(content).toContain("export type Profile = Infer<typeof schema>"); 76 + expect(content).toContain("export const ProfileSchema = schema"); 77 + expect(content).toContain("export function isProfile(v: unknown): v is Profile"); 78 + expect(content).toContain('v.$type === "app.bsky.actor.profile"'); 79 + }); 80 + 81 + test("generates correct directory structure from NSID", async () => { 82 + // Create a test schema with nested NSID 83 + const schemaFile = join(schemasDir, "app.bsky.feed.post.json"); 84 + await writeFile( 85 + schemaFile, 86 + JSON.stringify({ 87 + lexicon: 1, 88 + id: "app.bsky.feed.post", 89 + defs: { 90 + main: { 91 + type: "record", 92 + key: "tid", 93 + record: { 94 + type: "object", 95 + properties: { 96 + text: { type: "string" }, 97 + }, 98 + }, 99 + }, 100 + }, 101 + }), 102 + ); 103 + 104 + await genInferred(outDir, schemaFile); 105 + 106 + // Verify the directory structure matches NSID 107 + const outputFile = join(outDir, "app/bsky/feed/post.ts"); 108 + const content = await readFile(outputFile, "utf-8"); 109 + 110 + expect(content).toBeTruthy(); 111 + expect(content).toContain("export type Post = Infer<typeof schema>"); 112 + }); 113 + 114 + test("handles multiple schema files with glob patterns", async () => { 115 + // Create multiple schema files 116 + await writeFile( 117 + join(schemasDir, "app.bsky.actor.profile.json"), 118 + JSON.stringify({ 119 + lexicon: 1, 120 + id: "app.bsky.actor.profile", 121 + defs: { main: { type: "record" } }, 122 + }), 123 + ); 124 + 125 + await writeFile( 126 + join(schemasDir, "app.bsky.feed.post.json"), 127 + JSON.stringify({ 128 + lexicon: 1, 129 + id: "app.bsky.feed.post", 130 + defs: { main: { type: "record" } }, 131 + }), 132 + ); 133 + 134 + // Run with glob pattern 135 + await genInferred(outDir, `${schemasDir}/*.json`); 136 + 137 + // Verify both files were created 138 + const profileContent = await readFile( 139 + join(outDir, "app/bsky/actor/profile.ts"), 140 + "utf-8", 141 + ); 142 + const postContent = await readFile( 143 + join(outDir, "app/bsky/feed/post.ts"), 144 + "utf-8", 145 + ); 146 + 147 + expect(profileContent).toContain("export type Profile"); 148 + expect(postContent).toContain("export type Post"); 149 + }); 150 + 151 + test("generates correct relative import path", async () => { 152 + // Create a deeply nested schema 153 + const schemaFile = join(schemasDir, "com.atproto.repo.createRecord.json"); 154 + await writeFile( 155 + schemaFile, 156 + JSON.stringify({ 157 + lexicon: 1, 158 + id: "com.atproto.repo.createRecord", 159 + defs: { 160 + main: { 161 + type: "procedure", 162 + input: { encoding: "application/json" }, 163 + }, 164 + }, 165 + }), 166 + ); 167 + 168 + await genInferred(outDir, schemaFile); 169 + 170 + // Read generated file and check the import path is relative 171 + const outputFile = join(outDir, "com/atproto/repo/createRecord.ts"); 172 + const content = await readFile(outputFile, "utf-8"); 173 + 174 + // The import should be relative to the generated file location 175 + expect(content).toContain('import schema from "'); 176 + expect(content).toContain('.json" with { type: "json" }'); 177 + // Should navigate up from com/atproto/repo/ to schemas/ 178 + expect(content).toMatch(/import schema from ".*createRecord\.json"/); 179 + }); 180 + 181 + test("generates proper type name from NSID", async () => { 182 + // Test various NSID formats 183 + const testCases = [ 184 + { id: "app.bsky.feed.post", expectedType: "Post" }, 185 + { id: "com.atproto.repo.createRecord", expectedType: "CreateRecord" }, 186 + { id: "app.bsky.actor.profile", expectedType: "Profile" }, 187 + { 188 + id: "app.bsky.feed.searchPosts", 189 + expectedType: "SearchPosts", 190 + }, 191 + ]; 192 + 193 + for (const { id, expectedType } of testCases) { 194 + const schemaFile = join(schemasDir, `${id}.json`); 195 + await writeFile( 196 + schemaFile, 197 + JSON.stringify({ 198 + lexicon: 1, 199 + id, 200 + defs: { main: { type: "record" } }, 201 + }), 202 + ); 203 + 204 + const testOutDir = join(testDir, `out-${id}`); 205 + await mkdir(testOutDir, { recursive: true }); 206 + await genInferred(testOutDir, schemaFile); 207 + 208 + const nsidParts = id.split("."); 209 + const outputFile = join(testOutDir, ...nsidParts) + ".ts"; 210 + const content = await readFile(outputFile, "utf-8"); 211 + 212 + expect(content).toContain(`export type ${expectedType}`); 213 + expect(content).toContain(`export const ${expectedType}Schema`); 214 + expect(content).toContain(`export function is${expectedType}`); 215 + } 216 + }); 217 + 218 + test("handles schema without id gracefully", async () => { 219 + // Create an invalid schema without id 220 + const schemaFile = join(schemasDir, "invalid.json"); 221 + await writeFile( 222 + schemaFile, 223 + JSON.stringify({ 224 + lexicon: 1, 225 + defs: { main: { type: "record" } }, 226 + }), 227 + ); 228 + 229 + // Should not throw, but should skip the file 230 + await expect(genInferred(outDir, schemaFile)).resolves.not.toThrow(); 231 + 232 + // Output directory should be empty or not contain generated files 233 + const files = await readFile(outDir, "utf-8").catch(() => null); 234 + expect(files).toBeNull(); 235 + }); 236 + 237 + test("handles schema without defs gracefully", async () => { 238 + // Create an invalid schema without defs 239 + const schemaFile = join(schemasDir, "invalid2.json"); 240 + await writeFile( 241 + schemaFile, 242 + JSON.stringify({ 243 + lexicon: 1, 244 + id: "app.test.invalid", 245 + }), 246 + ); 247 + 248 + // Should not throw, but should skip the file 249 + await expect(genInferred(outDir, schemaFile)).resolves.not.toThrow(); 250 + }); 251 + 252 + test("processes array of schema patterns", async () => { 253 + // Create schemas in different directories 254 + const schemasDir1 = join(testDir, "schemas1"); 255 + const schemasDir2 = join(testDir, "schemas2"); 256 + await mkdir(schemasDir1, { recursive: true }); 257 + await mkdir(schemasDir2, { recursive: true }); 258 + 259 + await writeFile( 260 + join(schemasDir1, "app.one.json"), 261 + JSON.stringify({ 262 + lexicon: 1, 263 + id: "app.one", 264 + defs: { main: { type: "record" } }, 265 + }), 266 + ); 267 + 268 + await writeFile( 269 + join(schemasDir2, "app.two.json"), 270 + JSON.stringify({ 271 + lexicon: 1, 272 + id: "app.two", 273 + defs: { main: { type: "record" } }, 274 + }), 275 + ); 276 + 277 + // Run with array of patterns 278 + await genInferred(outDir, [`${schemasDir1}/*.json`, `${schemasDir2}/*.json`]); 279 + 280 + // Verify both were generated 281 + const oneContent = await readFile(join(outDir, "app/one.ts"), "utf-8"); 282 + const twoContent = await readFile(join(outDir, "app/two.ts"), "utf-8"); 283 + 284 + expect(oneContent).toContain("export type One"); 285 + expect(twoContent).toContain("export type Two"); 286 + }); 287 + 288 + test("generates code with all required components", async () => { 289 + // Create a comprehensive schema 290 + const schemaFile = join(schemasDir, "app.test.complete.json"); 291 + await writeFile( 292 + schemaFile, 293 + JSON.stringify({ 294 + lexicon: 1, 295 + id: "app.test.complete", 296 + defs: { 297 + main: { 298 + type: "record", 299 + key: "tid", 300 + record: { 301 + type: "object", 302 + required: ["text"], 303 + properties: { 304 + text: { type: "string", maxLength: 300 }, 305 + tags: { type: "array", items: { type: "string" } }, 306 + }, 307 + }, 308 + }, 309 + }, 310 + }), 311 + ); 312 + 313 + await genInferred(outDir, schemaFile); 314 + 315 + const outputFile = join(outDir, "app/test/complete.ts"); 316 + const content = await readFile(outputFile, "utf-8"); 317 + 318 + // Check all required exports 319 + expect(content).toContain('import type { Infer } from "prototypey"'); 320 + expect(content).toContain("export type Complete = Infer<typeof schema>"); 321 + expect(content).toContain("export const CompleteSchema = schema"); 322 + expect(content).toContain("export function isComplete(v: unknown): v is Complete"); 323 + 324 + // Check type guard implementation 325 + expect(content).toContain('typeof v === "object"'); 326 + expect(content).toContain("v !== null"); 327 + expect(content).toContain('"$type" in v'); 328 + expect(content).toContain('v.$type === "app.test.complete"'); 329 + 330 + // Check comments 331 + expect(content).toContain("// Generated by prototypey - DO NOT EDIT"); 332 + expect(content).toContain("// Source: app.test.complete"); 333 + expect(content).toContain("* Type-inferred from lexicon schema: app.test.complete"); 334 + expect(content).toContain("* The lexicon schema object"); 335 + expect(content).toContain("* Type guard to check if a value is a Complete"); 336 + }); 337 + 338 + test("handles kebab-case and mixed-case NSID parts", async () => { 339 + // Test NSID with different casing 340 + const schemaFile = join(schemasDir, "app.test.myCustomType.json"); 341 + await writeFile( 342 + schemaFile, 343 + JSON.stringify({ 344 + lexicon: 1, 345 + id: "app.test.myCustomType", 346 + defs: { main: { type: "record" } }, 347 + }), 348 + ); 349 + 350 + await genInferred(outDir, schemaFile); 351 + 352 + const outputFile = join(outDir, "app/test/myCustomType.ts"); 353 + const content = await readFile(outputFile, "utf-8"); 354 + 355 + // Should convert to PascalCase 356 + expect(content).toContain("export type MyCustomType"); 357 + expect(content).toContain("export const MyCustomTypeSchema"); 358 + expect(content).toContain("export function isMyCustomType"); 359 + }); 360 + });
+30
packages/cli/tests/fixtures/schemas/app.bsky.actor.profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "displayName": { 12 + "type": "string", 13 + "maxLength": 64, 14 + "maxGraphemes": 64 15 + }, 16 + "description": { 17 + "type": "string", 18 + "maxLength": 256, 19 + "maxGraphemes": 256 20 + }, 21 + "avatar": { 22 + "type": "blob", 23 + "accept": ["image/png", "image/jpeg"], 24 + "maxSize": 1000000 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+43
packages/cli/tests/fixtures/schemas/app.bsky.feed.post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["text", "createdAt"], 11 + "properties": { 12 + "text": { 13 + "type": "string", 14 + "maxLength": 300, 15 + "maxGraphemes": 300 16 + }, 17 + "createdAt": { 18 + "type": "string", 19 + "format": "datetime" 20 + }, 21 + "reply": { 22 + "type": "ref", 23 + "ref": "app.bsky.feed.post#replyRef" 24 + } 25 + } 26 + } 27 + }, 28 + "replyRef": { 29 + "type": "object", 30 + "required": ["root", "parent"], 31 + "properties": { 32 + "root": { 33 + "type": "ref", 34 + "ref": "com.atproto.repo.strongRef" 35 + }, 36 + "parent": { 37 + "type": "ref", 38 + "ref": "com.atproto.repo.strongRef" 39 + } 40 + } 41 + } 42 + } 43 + }
+47
packages/cli/tests/fixtures/schemas/app.bsky.feed.searchPosts.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.searchPosts", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Find posts matching search criteria", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["q"], 11 + "properties": { 12 + "q": { 13 + "type": "string" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "minimum": 1, 18 + "maximum": 100, 19 + "default": 25 20 + }, 21 + "cursor": { 22 + "type": "string" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["posts"], 31 + "properties": { 32 + "cursor": { 33 + "type": "string" 34 + }, 35 + "posts": { 36 + "type": "array", 37 + "items": { 38 + "type": "ref", 39 + "ref": "app.bsky.feed.defs#postView" 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + } 47 + }