From 168c75022ce3816fccd1435c35036d63525d2bb5 Mon Sep 17 00:00:00 2001 From: David East Date: Tue, 5 May 2026 21:11:15 -0400 Subject: [PATCH] feat: graduate upload capability to unified Project.upload() method surface and bump version to 0.3.0 stable --- packages/sdk/generated/domain-map.json | 2 +- packages/sdk/package.json | 2 +- packages/sdk/src/index.ts | 6 +- packages/sdk/src/project-ext.ts | 33 ++--- packages/sdk/src/spec/upload.ts | 26 ++-- packages/sdk/src/upload-handler.ts | 49 ++++---- packages/sdk/src/version.ts | 2 +- .../test/unit/extension-resolution.test.ts | 4 +- packages/sdk/test/unit/sdk.test.ts | 1 + packages/sdk/test/unit/upload.test.ts | 119 ++++++------------ 10 files changed, 100 insertions(+), 144 deletions(-) diff --git a/packages/sdk/generated/domain-map.json b/packages/sdk/generated/domain-map.json index 773d439..349e15e 100644 --- a/packages/sdk/generated/domain-map.json +++ b/packages/sdk/generated/domain-map.json @@ -36,7 +36,7 @@ ], "sideEffects": [ { - "method": "uploadImage", + "method": "upload", "reason": "private_rest", "specPath": "src/spec/upload.ts" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index bae31c2..caed68b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/stitch-sdk", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "private": false, "description": "Generate UI screens from text prompts and extract their HTML and screenshots programmatically.", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a2f7ea7..5f86325 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -63,9 +63,9 @@ export type { // Upload types export type { - UploadImageInput, - UploadImageResult, - UploadImageErrorCode, + UploadInput, + UploadResult, + UploadErrorCode, } from "./spec/upload.js"; // Download types diff --git a/packages/sdk/src/project-ext.ts b/packages/sdk/src/project-ext.ts index f3e103e..ac2c8fb 100644 --- a/packages/sdk/src/project-ext.ts +++ b/packages/sdk/src/project-ext.ts @@ -31,35 +31,26 @@ import { DownloadAssetsHandler } from './download-handler.js'; import { DownloadAssetsInputSchema } from './spec/download.js'; import type { DownloadAssetsOutput } from './spec/download.js'; import { - UploadImageInputSchema, - type UploadImageInput, + UploadInputSchema, + type UploadInput, } from './spec/upload.js'; -import { UploadImageHandler } from './upload-handler.js'; +import { UploadHandler } from './upload-handler.js'; export class Project extends GeneratedProject { + /** - * Upload an image file to the project and create a new Screen from it. - * - * WHY THIS IS NOT GENERATED: - * BatchCreateScreens is a private REST endpoint — it has no MCP tool - * in tools-manifest.json. It also requires reading a file from disk and - * base64-encoding it, which the codegen arg-routing model cannot express. - * - * @param filePath - Absolute or relative path to the image (PNG, JPG, JPEG, WEBP). - * @param opts - Optional screen title and createScreenInstances flag. - * @returns An array of Screen objects created from the upload. - * @throws {StitchError} on file not found, unsupported format, or upload failure. + * Upload any supported design or document file asset (PNG, JPG, WEBP, HTML) into the project. + * Creates a new screen canvas entity from the file contents. * - * @example - * const [screen] = await project.uploadImage('./mockup.png', { title: 'Home Screen' }); - * const html = await screen.getHtml(); + * @param filePath - Absolute or relative path to the asset file. + * @param opts - Optional parameter overrides. */ - async uploadImage( + async upload( filePath: string, - opts?: Partial>, + opts?: Partial>, ): Promise { - const input = UploadImageInputSchema.parse({ filePath, ...opts }); - const handler = new UploadImageHandler(this.client); + const input = UploadInputSchema.parse({ filePath, ...opts }); + const handler = new UploadHandler(this.client); const result = await handler.execute(this.projectId, input); if (!result.success) { diff --git a/packages/sdk/src/spec/upload.ts b/packages/sdk/src/spec/upload.ts index 48cf51a..48a7ee0 100644 --- a/packages/sdk/src/spec/upload.ts +++ b/packages/sdk/src/spec/upload.ts @@ -27,14 +27,16 @@ export const SUPPORTED_MIME_TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', + '.html': 'text/html', + '.htm': 'text/html', } as const; export type SupportedExtension = keyof typeof SUPPORTED_MIME_TYPES; // ── Input ────────────────────────────────────────────────────────────────────── -export const UploadImageInputSchema = z.object({ - /** Absolute or relative path to the image file on disk. */ +export const UploadInputSchema = z.object({ + /** Absolute or relative path to the asset file on disk. */ filePath: z.string().min(1), /** Optional display title for the created screen. */ title: z.string().optional(), @@ -42,11 +44,11 @@ export const UploadImageInputSchema = z.object({ createScreenInstances: z.boolean().default(true), }); -export type UploadImageInput = z.infer; +export type UploadInput = z.infer; // ── Error Codes ──────────────────────────────────────────────────────────────── -export const UploadImageErrorCode = z.enum([ +export const UploadErrorCode = z.enum([ 'FILE_NOT_FOUND', 'UNSUPPORTED_FORMAT', 'UPLOAD_FAILED', @@ -54,16 +56,16 @@ export const UploadImageErrorCode = z.enum([ 'UNKNOWN_ERROR', ]); -export type UploadImageErrorCode = z.infer; +export type UploadErrorCode = z.infer; // ── Result ───────────────────────────────────────────────────────────────────── -export type UploadImageResult = +export type UploadResult = | { success: true; screens: Screen[] } | { success: false; error: { - code: UploadImageErrorCode; + code: UploadErrorCode; message: string; recoverable: boolean; }; @@ -72,9 +74,11 @@ export type UploadImageResult = // ── Interface ────────────────────────────────────────────────────────────────── /** - * Contract for the uploadImage operation. - * Implementations must never throw — all failures return UploadImageResult. + * Contract for the upload operation. + * Implementations must never throw — all failures return UploadResult. */ -export interface UploadImageSpec { - execute(projectId: string, input: UploadImageInput): Promise; +export interface UploadSpec { + execute(projectId: string, input: UploadInput): Promise; } + + diff --git a/packages/sdk/src/upload-handler.ts b/packages/sdk/src/upload-handler.ts index 8be0822..c96fd6e 100644 --- a/packages/sdk/src/upload-handler.ts +++ b/packages/sdk/src/upload-handler.ts @@ -41,10 +41,10 @@ import type { StitchToolClientSpec } from './spec/client.js'; import { SUPPORTED_MIME_TYPES, type SupportedExtension, - type UploadImageInput, - type UploadImageResult, - type UploadImageErrorCode, - type UploadImageSpec, + type UploadInput, + type UploadResult, + type UploadErrorCode, + type UploadSpec, } from './spec/upload.js'; import { Screen } from '../generated/src/screen.js'; @@ -53,17 +53,25 @@ function buildBatchCreateScreensBody( projectId: string, fileContentBase64: string, mimeType: string, - input: UploadImageInput, + input: UploadInput, ) { + const fileObj = { + fileContentBase64, + mimeType, + }; + + const isHtml = mimeType === 'text/html'; const screen: Record = { - screenshot: { - fileContentBase64, - mimeType, - }, - screenType: 'IMAGE', + screenType: isHtml ? 'DOCUMENT' : 'IMAGE', isCreatedByClient: true, }; + if (isHtml) { + screen['htmlCode'] = fileObj; + } else { + screen['screenshot'] = fileObj; + } + if (input.title) { screen['title'] = input.title; } @@ -76,16 +84,14 @@ function buildBatchCreateScreensBody( } /** - * Handler for uploadImage — implements UploadImageSpec. - * - * Never throws. All failures are returned as UploadImageResult with a typed - * error code. The caller (Project.uploadImage) surfaces failures as StitchError. + * Handler for the upload capability — implements UploadSpec. + * Never throws. All failures return an UploadResult value with a classified error code. */ -export class UploadImageHandler implements UploadImageSpec { +export class UploadHandler implements UploadSpec { constructor(private readonly client: StitchToolClientSpec) {} - async execute(projectId: string, input: UploadImageInput): Promise { - // ── Step 1: Validate extension → typed error code ──────────────────────── + async execute(projectId: string, input: UploadInput): Promise { + // ── Step 1: Validate extension ─────────────────────────────────────────── const ext = path.extname(input.filePath).toLowerCase(); const mimeType = SUPPORTED_MIME_TYPES[ext as SupportedExtension]; if (!mimeType) { @@ -117,12 +123,10 @@ export class UploadImageHandler implements UploadImageSpec { body, ); - // ── Step 3: Project the response into Screen[] ─────────────────────── - // BatchCreateScreens returns { results: [{ screen: { ... } }] } + // ── Step 3: Project the response into Screen[] ───────────────────────── const results: Array<{ screen: any }> = raw?.results ?? []; const screens: Screen[] = results.map((r) => { const screenData = { ...r.screen, projectId }; - // If API didn't return an ID but returned a file name, extract ID from it if (!screenData.id && screenData.screenshot?.name) { const parts = screenData.screenshot.name.split('/files/'); if (parts.length === 2) { @@ -132,7 +136,6 @@ export class UploadImageHandler implements UploadImageSpec { return new Screen(this.client as any, screenData); }); - return { success: true, screens }; } catch (err) { if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { @@ -146,7 +149,7 @@ export class UploadImageHandler implements UploadImageSpec { }; } const msg = err instanceof Error ? err.message : String(err); - const code: UploadImageErrorCode = + const code: UploadErrorCode = msg.includes('401') || msg.includes('403') || msg.toLowerCase().includes('auth') ? 'AUTH_FAILED' : 'UPLOAD_FAILED'; @@ -158,3 +161,5 @@ export class UploadImageHandler implements UploadImageSpec { } } } + + diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts index 6d83c55..a5eaf9e 100644 --- a/packages/sdk/src/version.ts +++ b/packages/sdk/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/inject-version.ts — do not edit. -export const SDK_VERSION = '0.2.0'; +export const SDK_VERSION = '0.3.0'; diff --git a/packages/sdk/test/unit/extension-resolution.test.ts b/packages/sdk/test/unit/extension-resolution.test.ts index 18f3e0f..8fd9bc1 100644 --- a/packages/sdk/test/unit/extension-resolution.test.ts +++ b/packages/sdk/test/unit/extension-resolution.test.ts @@ -18,8 +18,8 @@ describe('SDK Extension Resolution', () => { const project = projects[0]; // 4. Assert that the returned object is actually the extended subclass - // By verifying the existence of the handwritten uploadImage method + // By verifying the existence of the handwritten upload method expect(project).toBeDefined(); - expect(typeof project!.uploadImage).toBe('function'); + expect(typeof project!.upload).toBe('function'); }); }); diff --git a/packages/sdk/test/unit/sdk.test.ts b/packages/sdk/test/unit/sdk.test.ts index 8f6baff..890dd34 100644 --- a/packages/sdk/test/unit/sdk.test.ts +++ b/packages/sdk/test/unit/sdk.test.ts @@ -379,6 +379,7 @@ describe("SDK Unit Tests", () => { expect(result).toEqual([]); }); + it("generate should throw StitchError on failure", async () => { const project = new Project(mockClient, projectId); diff --git a/packages/sdk/test/unit/upload.test.ts b/packages/sdk/test/unit/upload.test.ts index 1a7cbb7..3d64073 100644 --- a/packages/sdk/test/unit/upload.test.ts +++ b/packages/sdk/test/unit/upload.test.ts @@ -13,8 +13,11 @@ // limitations under the License. import { describe, it, expect, vi } from "vitest"; -import { UploadImageInputSchema } from "../../src/spec/upload.js"; -import { UploadImageHandler } from "../../src/upload-handler.js"; +import { Project } from "../../src/project-ext.js"; +import { StitchError } from "../../src/spec/errors.js"; +import { StitchToolClient } from "../../src/client.js"; +import { UploadInputSchema } from "../../src/spec/upload.js"; +import { UploadHandler } from "../../src/upload-handler.js"; import type { StitchToolClientSpec } from "../../src/spec/client.js"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -34,18 +37,14 @@ function createMockClient( }; } -// ─── Slice 1: Contract Tests ────────────────────────────────────────────────── - -describe("UploadImageInputSchema", () => { - // Test 1: rejects empty filePath +describe("UploadInputSchema", () => { it("rejects an empty filePath", () => { - const result = UploadImageInputSchema.safeParse({ filePath: "" }); + const result = UploadInputSchema.safeParse({ filePath: "" }); expect(result.success).toBe(false); }); - // Test 2: parses valid input, defaults createScreenInstances to true it("parses valid input with createScreenInstances defaulting to true", () => { - const result = UploadImageInputSchema.safeParse({ filePath: "/img/a.png" }); + const result = UploadInputSchema.safeParse({ filePath: "/img/a.png" }); expect(result.success).toBe(true); if (result.success) { expect(result.data.createScreenInstances).toBe(true); @@ -53,9 +52,8 @@ describe("UploadImageInputSchema", () => { } }); - // Test 3: title is optional it("allows input without a title", () => { - const result = UploadImageInputSchema.safeParse({ + const result = UploadInputSchema.safeParse({ filePath: "/img/b.webp", createScreenInstances: false, }); @@ -69,10 +67,17 @@ describe("UploadImageInputSchema", () => { // ─── Slice 2: Handler Tests ─────────────────────────────────────────────────── -describe("UploadImageHandler", () => { - // Test 4: UNSUPPORTED_FORMAT for .gif +describe("UploadHandler (TDD RED)", () => { + it("should exist as a valid handler class constructor and be executable", async () => { + expect(UploadHandler).toBeDefined(); + const handler = new UploadHandler(createMockClient()); + expect(handler.execute).toBeDefined(); + }); +}); + +describe("UploadHandler", () => { it("returns UNSUPPORTED_FORMAT for a .gif file", async () => { - const handler = new UploadImageHandler(createMockClient()); + const handler = new UploadHandler(createMockClient()); const result = await handler.execute("proj-1", { filePath: "/images/animation.gif", createScreenInstances: true, @@ -84,15 +89,12 @@ describe("UploadImageHandler", () => { } }); - // Test 5: FILE_NOT_FOUND for a path that doesn't exist - // Note: vi.mock at module level stubs fs.access globally, so we need to - // temporarily restore the real behavior for this test. it("returns FILE_NOT_FOUND for a nonexistent .png path", async () => { const fs = await import("node:fs/promises"); const realReadFile = (await vi.importActual("node:fs/promises")).readFile; vi.mocked(fs.readFile).mockImplementationOnce(realReadFile as any); - const handler = new UploadImageHandler(createMockClient()); + const handler = new UploadHandler(createMockClient()); const result = await handler.execute("proj-1", { filePath: "/absolutely/nonexistent/photo.png", createScreenInstances: true, @@ -104,30 +106,8 @@ describe("UploadImageHandler", () => { } }); - // Test 6: successful upload → httpPost called with correct path → Screen[] - it("calls httpPost with the correct REST path and returns Screen[]", async () => { - const httpPost = vi.fn().mockResolvedValue({ - screens: [{ name: "projects/proj-1/screens/s-123", title: "Uploaded" }], - }); - - // Use the package.json as a stand-in "image" — it exists on disk. - // We rename it conceptually by reading the real path with a .png extension alias - // via symlinking would be complex; instead we swap fs.access/readFile via vi.mock. - // Since we can't dynamically mock fs here without vi.mock at module level, - // we use the vitest.config.ts file (which exists) and override the extension - // check by using a .png path that references a real file. - // - // Practical approach: mock the entire 'node:fs/promises' module. - // This test is deferred to the vi.mock block below. - expect(httpPost).toBeDefined(); // placeholder — covered by mocked block below - }); - - // Test 7: UPLOAD_FAILED when httpPost throws generic error it("returns UPLOAD_FAILED when httpPost throws a generic server error", async () => { - // To reach httpPost the file must pass ext + access checks. - // We'll use a real file path with .png extension — handled via the mock block. - // Placeholder: extension guard verified here using .gif - const handler = new UploadImageHandler( + const handler = new UploadHandler( createMockClient({ httpPost: vi.fn().mockRejectedValue(new Error("Internal Server Error")), }), @@ -136,17 +116,14 @@ describe("UploadImageHandler", () => { filePath: "/tmp/missing.gif", createScreenInstances: true, }); - // .gif hits UNSUPPORTED_FORMAT before httpPost expect(result.success).toBe(false); if (!result.success) { expect(result.error.code).toBe("UNSUPPORTED_FORMAT"); } }); - // Test 8: AUTH_FAILED when httpPost throws with 401 in message it("returns AUTH_FAILED when httpPost throws with 401 in message", async () => { - // Deferred to fs-mocked block below. Verify format guard works for .gif here. - const handler = new UploadImageHandler( + const handler = new UploadHandler( createMockClient({ httpPost: vi.fn().mockRejectedValue(new Error("HTTP 401")), }), @@ -160,12 +137,6 @@ describe("UploadImageHandler", () => { expect(result.error.code).toBe("UNSUPPORTED_FORMAT"); } }); - - // Test 9: correct REST path is passed to httpPost - it("passes the right REST path to httpPost", async () => { - // Covered by the vi.mock block below — placeholder here - expect(true).toBe(true); - }); }); // ─── Slice 2b: Handler Tests with mocked fs ─────────────────────────────────── @@ -179,13 +150,12 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); -describe("UploadImageHandler (fs mocked)", () => { - // Test 6 (real): successful upload returns Screen[] +describe("UploadHandler (fs mocked)", () => { it("returns Screen[] on a successful upload", async () => { const httpPost = vi.fn().mockResolvedValue({ results: [{ screen: { name: "projects/proj-1/screens/s-abc", title: "Test" } }], }); - const handler = new UploadImageHandler(createMockClient({ httpPost })); + const handler = new UploadHandler(createMockClient({ httpPost })); const result = await handler.execute("proj-1", { filePath: "/fake/design.png", createScreenInstances: true, @@ -201,7 +171,7 @@ describe("UploadImageHandler (fs mocked)", () => { vi.mocked(fs.access).mockClear(); const httpPost = vi.fn().mockResolvedValue({ results: [] }); - const handler = new UploadImageHandler(createMockClient({ httpPost })); + const handler = new UploadHandler(createMockClient({ httpPost })); await handler.execute("proj-1", { filePath: "/fake/design.png", @@ -211,10 +181,9 @@ describe("UploadImageHandler (fs mocked)", () => { expect(fs.access).not.toHaveBeenCalled(); }); - // Test 7 (real): UPLOAD_FAILED when httpPost throws it("returns UPLOAD_FAILED when httpPost throws a generic server error", async () => { const httpPost = vi.fn().mockRejectedValue(new Error("Internal Server Error")); - const handler = new UploadImageHandler(createMockClient({ httpPost })); + const handler = new UploadHandler(createMockClient({ httpPost })); const result = await handler.execute("proj-1", { filePath: "/fake/design.png", createScreenInstances: true, @@ -225,10 +194,9 @@ describe("UploadImageHandler (fs mocked)", () => { } }); - // Test 8 (real): AUTH_FAILED when httpPost throws 401 it("returns AUTH_FAILED when httpPost throws with 401 in message", async () => { const httpPost = vi.fn().mockRejectedValue(new Error("HTTP 401: Unauthorized")); - const handler = new UploadImageHandler(createMockClient({ httpPost })); + const handler = new UploadHandler(createMockClient({ httpPost })); const result = await handler.execute("proj-1", { filePath: "/fake/design.png", createScreenInstances: true, @@ -239,10 +207,9 @@ describe("UploadImageHandler (fs mocked)", () => { } }); - // Test 9: correct REST path is passed to httpPost it("calls httpPost with the correct REST path", async () => { const httpPost = vi.fn().mockResolvedValue({ screens: [] }); - const handler = new UploadImageHandler(createMockClient({ httpPost })); + const handler = new UploadHandler(createMockClient({ httpPost })); await handler.execute("my-proj-id", { filePath: "/fake/design.webp", createScreenInstances: true, @@ -254,42 +221,30 @@ describe("UploadImageHandler (fs mocked)", () => { }); }); -// ─── Slice 4: Integration Tests (Project.uploadImage) ──────────────────────── +// ─── Slice 4: Integration Tests (Project.upload) ───────────────────────────── -import { Project } from "../../src/project-ext.js"; -import { StitchError } from "../../src/spec/errors.js"; -import { StitchToolClient } from "../../src/client.js"; -vi.mock( - "../../src/client.js", - async (importOriginal) => { - const real = await importOriginal(); - return real; - }, -); - -describe("Project.uploadImage (integration)", () => { +describe("Project.upload (generic integration)", () => { function createProjectWithMockedClient(httpPostMock: ReturnType) { - // Create a real Project with a mock client that satisfies StitchToolClientSpec const mockClient = createMockClient({ httpPost: httpPostMock as unknown as StitchToolClientSpec['httpPost'] }); return new Project(mockClient as unknown as StitchToolClient, "test-project-id"); } - // Test 12: throws StitchError when handler returns failure (UNSUPPORTED_FORMAT) - it("throws StitchError when the image format is unsupported", async () => { + it("throws StitchError when the asset format is unsupported", async () => { const proj = createProjectWithMockedClient(vi.fn()); await expect( - proj.uploadImage("/path/to/animation.gif"), + proj.upload("/path/to/animation.gif"), ).rejects.toThrow(StitchError); }); - // Test 13: returns Screen[] on success - it("returns Screen[] when the upload succeeds", async () => { + it("should surface a valid generic upload method capability", async () => { const httpPost = vi.fn().mockResolvedValue({ - results: [{ screen: { name: "projects/test-project-id/screens/s-xyz", title: "Uploaded" } }], + results: [{ screen: { name: "projects/test-project-id/screens/s-abc", title: "Generic" } }], }); const proj = createProjectWithMockedClient(httpPost); - const screens = await proj.uploadImage("/fake/design.png"); + expect(proj.upload).toBeDefined(); + const screens = await proj.upload("/fake/document.html"); expect(screens).toHaveLength(1); }); }); +