From cf7782ddd61a6f16a18f969c27e58a69bc644d5e Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Fri, 20 Mar 2026 19:19:02 -0500 Subject: [PATCH] fix: add defensive null checks to generated projection paths (#135) project.generate() and screen.edit() crash with TypeError when the API returns an incomplete response (e.g. missing outputComponents or screens). The screen IS created server-side but the SDK fails to parse the response. Changes: - emitProjection() now uses optional chaining (?.) for simple projection chains - generateReturnExpression() adds a _projected guard with a descriptive StitchError for single-object returns with non-empty projection paths - Regenerated SDK classes with the new defensive patterns - Added 5 tests covering incomplete API responses for generate() and edit() Closes #135 --- packages/sdk/generated/src/index.ts | 2 +- packages/sdk/generated/src/project.ts | 8 ++- packages/sdk/generated/src/screen.ts | 10 +-- packages/sdk/generated/src/stitch.ts | 4 +- .../sdk/generated/src/tool-definitions.ts | 2 +- packages/sdk/generated/stitch-sdk.lock | 6 +- packages/sdk/test/unit/sdk.test.ts | 68 +++++++++++++++---- scripts/generate-sdk.ts | 27 ++++++-- 8 files changed, 95 insertions(+), 32 deletions(-) diff --git a/packages/sdk/generated/src/index.ts b/packages/sdk/generated/src/index.ts index ee67a99..c9e483d 100644 --- a/packages/sdk/generated/src/index.ts +++ b/packages/sdk/generated/src/index.ts @@ -4,7 +4,7 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:1f84b31604f9...) domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Generated: 2026-03-21T00:17:24.039Z */ export { Stitch } from "./stitch.js"; export { Project } from "./project.js"; diff --git a/packages/sdk/generated/src/project.ts b/packages/sdk/generated/src/project.ts index 12ef723..aeba3f3 100644 --- a/packages/sdk/generated/src/project.ts +++ b/packages/sdk/generated/src/project.ts @@ -4,7 +4,7 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:1f84b31604f9...) domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Generated: 2026-03-21T00:17:24.039Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; @@ -37,7 +37,9 @@ export class Project { async generate(prompt: string, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH"): Promise { try { const raw = await this.client.callTool("generate_screen_from_text", { projectId: this.projectId, prompt, deviceType, modelId }); - return new Screen(this.client, { ...raw.outputComponents[0].design.screens[0], projectId: this.projectId }); + const _projected = raw?.outputComponents?.[0]?.design?.screens?.[0]; + if (!_projected) throw new StitchError({ code: "UNKNOWN_ERROR", message: "Incomplete API response from generate_screen_from_text: expected object at projection path", recoverable: false }); + return new Screen(this.client, { ..._projected, projectId: this.projectId }) } catch (error) { throw StitchError.fromUnknown(error); } @@ -50,7 +52,7 @@ export class Project { async screens(): Promise { try { const raw = await this.client.callTool("list_screens", { projectId: this.projectId }); - return (raw.screens || []).map((item: any) => new Screen(this.client, { ...item, projectId: this.projectId })); + return (raw?.screens || []).map((item: any) => new Screen(this.client, { ...item, projectId: this.projectId })); } catch (error) { throw StitchError.fromUnknown(error); } diff --git a/packages/sdk/generated/src/screen.ts b/packages/sdk/generated/src/screen.ts index 5aca015..16bcc99 100644 --- a/packages/sdk/generated/src/screen.ts +++ b/packages/sdk/generated/src/screen.ts @@ -4,7 +4,7 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:1f84b31604f9...) domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Generated: 2026-03-21T00:17:24.039Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; @@ -38,7 +38,9 @@ export class Screen { async edit(prompt: string, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH"): Promise { try { const raw = await this.client.callTool("edit_screens", { projectId: this.projectId, selectedScreenIds: [this.screenId], prompt, deviceType, modelId }); - return new Screen(this.client, { ...raw.outputComponents[0].design.screens[0], projectId: this.projectId }); + const _projected = raw?.outputComponents?.[0]?.design?.screens?.[0]; + if (!_projected) throw new StitchError({ code: "UNKNOWN_ERROR", message: "Incomplete API response from edit_screens: expected object at projection path", recoverable: false }); + return new Screen(this.client, { ..._projected, projectId: this.projectId }) } catch (error) { throw StitchError.fromUnknown(error); } @@ -67,7 +69,7 @@ export class Screen { try { const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId: this.screenId, name: `projects/${this.projectId}/screens/${this.screenId}` }); - return raw.htmlCode.downloadUrl || ""; + return raw?.htmlCode?.downloadUrl || ""; } catch (error) { throw StitchError.fromUnknown(error); } @@ -83,7 +85,7 @@ export class Screen { try { const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId: this.screenId, name: `projects/${this.projectId}/screens/${this.screenId}` }); - return raw.screenshot.downloadUrl || ""; + return raw?.screenshot?.downloadUrl || ""; } catch (error) { throw StitchError.fromUnknown(error); } diff --git a/packages/sdk/generated/src/stitch.ts b/packages/sdk/generated/src/stitch.ts index 6aaf22d..8a82785 100644 --- a/packages/sdk/generated/src/stitch.ts +++ b/packages/sdk/generated/src/stitch.ts @@ -4,7 +4,7 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:1f84b31604f9...) domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Generated: 2026-03-21T00:17:24.039Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; @@ -22,7 +22,7 @@ export class Stitch { async projects(): Promise { try { const raw = await this.client.callTool("list_projects", { }); - return (raw.projects || []).map((item: any) => new Project(this.client, item)); + return (raw?.projects || []).map((item: any) => new Project(this.client, item)); } catch (error) { throw StitchError.fromUnknown(error); } diff --git a/packages/sdk/generated/src/tool-definitions.ts b/packages/sdk/generated/src/tool-definitions.ts index 15d17f3..eb5cca3 100644 --- a/packages/sdk/generated/src/tool-definitions.ts +++ b/packages/sdk/generated/src/tool-definitions.ts @@ -4,7 +4,7 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:1f84b31604f9...) domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Generated: 2026-03-21T00:17:24.039Z */ /** JSON Schema property descriptor for a tool parameter. */ export interface ToolPropertySchema { diff --git a/packages/sdk/generated/stitch-sdk.lock b/packages/sdk/generated/stitch-sdk.lock index fcacc2a..2bc26c5 100644 --- a/packages/sdk/generated/stitch-sdk.lock +++ b/packages/sdk/generated/stitch-sdk.lock @@ -7,14 +7,14 @@ "serverUrl": "https://stitch.googleapis.com/mcp" }, "generated": { - "generatedAt": "2026-03-19T18:56:19.351Z", - "sourceHash": "sha256:06c97f633a04942efd348aa1634356c9f5f0fd3e16d33802bffe7e8e9a650905", + "generatedAt": "2026-03-21T00:17:24.086Z", + "sourceHash": "sha256:8bae6b2e2940443b45a7dd8b5ee5ba73464cb213ccc22aa3bea98b1db3b66468", "manifestHash": "sha256:1f84b31604f95580325952f0c150a3e045543fad2596e9a4d8ed15c07e0cbf9b", "domainMapHash": "sha256:99b823ad930620c571a9443c7b956f90b092d626be9ce2319d7a7bfe3a2e3db4", "fileCount": 5 }, "domainMap": { - "generatedAt": "2026-03-19T18:56:19.351Z", + "generatedAt": "2026-03-21T00:17:24.086Z", "sourceHash": "sha256:99b823ad930620c571a9443c7b956f90b092d626be9ce2319d7a7bfe3a2e3db4", "manifestHash": "sha256:1f84b31604f95580325952f0c150a3e045543fad2596e9a4d8ed15c07e0cbf9b", "classCount": 3, diff --git a/packages/sdk/test/unit/sdk.test.ts b/packages/sdk/test/unit/sdk.test.ts index 4c50fab..87664bd 100644 --- a/packages/sdk/test/unit/sdk.test.ts +++ b/packages/sdk/test/unit/sdk.test.ts @@ -17,6 +17,7 @@ import { Screen } from "../../generated/src/screen.js"; import { Project } from "../../generated/src/project.js"; import { Stitch } from "../../generated/src/stitch.js"; import { StitchToolClient } from "../../src/client.js"; +import { StitchError } from "../../src/spec/errors.js"; // Mock the StitchToolClient class vi.mock("../../src/client"); @@ -135,6 +136,30 @@ describe("SDK Unit Tests", () => { expect(edited.id).toBe("edited-screen"); }); + it("edit should throw StitchError (not TypeError) when response has no screens", async () => { + const screen = new Screen(mockClient, screenData); + + (mockClient.callTool as Mock).mockResolvedValue({ + outputComponents: [{ design: {} }], + projectId, + }); + + const err = await screen.edit("Make it dark").catch((e: unknown) => e); + expect(err).toBeInstanceOf(StitchError); + expect((err as StitchError).code).toBe("UNKNOWN_ERROR"); + expect((err as StitchError).message).toContain("edit_screens"); + }); + + it("edit should throw StitchError when outputComponents is empty", async () => { + const screen = new Screen(mockClient, screenData); + + (mockClient.callTool as Mock).mockResolvedValue({ outputComponents: [] }); + + const err = await screen.edit("Make it dark").catch((e: unknown) => e); + expect(err).toBeInstanceOf(StitchError); + expect((err as StitchError).message).toContain("edit_screens"); + }); + it("variants should call generate_variants and return Screen[]", async () => { const screen = new Screen(mockClient, screenData); @@ -230,26 +255,41 @@ describe("SDK Unit Tests", () => { }); - it("generate should handle missing design.screens in outputComponents gracefully", async () => { + it("generate should throw StitchError (not TypeError) when response has no screens", async () => { const project = new Project(mockClient, projectId); - // Mock with missing screens array (mockClient.callTool as Mock).mockResolvedValue({ - outputComponents: [ - { - design: { - // screens is missing - }, - }, - ], + outputComponents: [{ design: { /* screens missing */ } }], projectId: projectId, - sessionId: "session-1", }); - // Based on the Screen class constructor, if it's passed undefined, it might throw - // or if it tries to access missing screens it might throw TypeError. - // The generated code in project.ts wraps the try block and throws StitchError. - await expect(project.generate("test")).rejects.toThrow(); + const err = await project.generate("test").catch((e: unknown) => e); + expect(err).toBeInstanceOf(StitchError); + expect((err as StitchError).code).toBe("UNKNOWN_ERROR"); + expect((err as StitchError).message).toContain("generate_screen_from_text"); + }); + + it("generate should throw StitchError when outputComponents is empty", async () => { + const project = new Project(mockClient, projectId); + + (mockClient.callTool as Mock).mockResolvedValue({ + outputComponents: [], + projectId: projectId, + }); + + const err = await project.generate("test").catch((e: unknown) => e); + expect(err).toBeInstanceOf(StitchError); + expect((err as StitchError).message).toContain("generate_screen_from_text"); + }); + + it("generate should throw StitchError when outputComponents is missing", async () => { + const project = new Project(mockClient, projectId); + + (mockClient.callTool as Mock).mockResolvedValue({ projectId: projectId }); + + const err = await project.generate("test").catch((e: unknown) => e); + expect(err).toBeInstanceOf(StitchError); + expect((err as StitchError).message).toContain("generate_screen_from_text"); }); it("screens should list screens and return Screen instances", async () => { diff --git a/scripts/generate-sdk.ts b/scripts/generate-sdk.ts index 4e0daff..c2fdb48 100644 --- a/scripts/generate-sdk.ts +++ b/scripts/generate-sdk.ts @@ -166,12 +166,12 @@ export function emitProjection(steps: ProjectionStep[], rawVar: string = "raw"): return emitFlatMapProjection(steps, rawVar); } - // Simple chain: raw.prop1[0].prop2.prop3 + // Simple chain with optional chaining: raw.outputComponents?.[0]?.design?.screens?.[0] let code = rawVar; for (const step of steps) { - code += `.${step.prop}`; + code += `?.${step.prop}`; if (step.index !== undefined) { - code += `[${step.index}]`; + code += `?.[${step.index}]`; } } return code; @@ -330,6 +330,19 @@ function generateReturnExpression( return `(${projectionExpr} || []).map((item: any) => new ${binding.returns.class}(this.client, ${itemExpr}))`; } + // Only emit guard when projection has actual steps (not just `raw`) + if (projection.length > 0) { + const guardVar = "_projected"; + const dataExpr = parentField + ? `{ ...${guardVar}, ${parentField}: this.${parentField} }` + : guardVar; + const toolName = binding.tool; + return `const ${guardVar} = ${projectionExpr};\n` + + ` if (!${guardVar}) throw new StitchError({ code: "UNKNOWN_ERROR", message: "Incomplete API response from ${toolName}: expected object at projection path", recoverable: false });\n` + + ` return new ${binding.returns.class}(this.client, ${dataExpr})`; + } + + // Direct return — projection is empty, raw is the result itself const dataExpr = parentField ? `{ ...${projectionExpr}, ${parentField}: this.${parentField} }` : projectionExpr; @@ -396,7 +409,13 @@ function buildMethodBody( statements.push(`try {`); statements.push(` const raw = await this.client.callTool("${binding.tool}", ${generateArgsObject(binding.args)});`); - statements.push(` return ${generateReturnExpression(binding, className, domainMap)};`); + const retExpr = generateReturnExpression(binding, className, domainMap); + // If retExpr contains newlines, it has guard statements — don't wrap in return + if (retExpr.includes("\n")) { + statements.push(` ${retExpr}`); + } else { + statements.push(` return ${retExpr};`); + } statements.push(`} catch (error) {`); statements.push(` throw StitchError.fromUnknown(error);`); statements.push(`}`);