diff --git a/examples/minimal/src/index.ts b/examples/minimal/src/index.ts index 6106fce2..09835073 100644 --- a/examples/minimal/src/index.ts +++ b/examples/minimal/src/index.ts @@ -8,7 +8,13 @@ * - Each version exposed at /v1/mcp and /v2/mcp */ -import { createApp, defineTool, tool, type ClientToolsFromCore } from "@mcp-apps-kit/core"; +import { + createApp, + defineTool, + tool, + type ClientToolsFromCore, + iconFromFile, +} from "@mcp-apps-kit/core"; import { defineReactUI } from "@mcp-apps-kit/ui-react-builder"; import { GreetingWidgetV1 } from "./ui/GreetingWidgetV1"; import { GreetingWidgetV2 } from "./ui/GreetingWidgetV2"; @@ -171,6 +177,10 @@ const echoToolV3 = defineTool({ const app = createApp({ name: "minimal-app", + // Server icon - displayed in MCP client UIs + // Can be a URL or base64 data URI + icon: iconFromFile("./src/logo.png").src, + // Shared config across all versions config: { cors: { diff --git a/examples/minimal/src/logo.png b/examples/minimal/src/logo.png new file mode 100644 index 00000000..2c8f5df5 Binary files /dev/null and b/examples/minimal/src/logo.png differ diff --git a/packages/core/src/createApp.ts b/packages/core/src/createApp.ts index 3e459f6c..f803c3e4 100644 --- a/packages/core/src/createApp.ts +++ b/packages/core/src/createApp.ts @@ -19,6 +19,7 @@ import type { VersionConfig, GlobalConfig, VersionSpecificConfig, + Icon, } from "./types/config"; import type { UIDef, UIDefs } from "./types/ui"; import type { Middleware } from "./middleware/types"; @@ -436,7 +437,9 @@ function deepMerge>( function mergeVersionConfig( globalConfig: GlobalConfig | undefined, versionConfig: VersionConfig, - globalPlugins: Plugin[] | undefined + globalPlugins: Plugin[] | undefined, + globalIcon?: string, + globalIcons?: Icon[] ): AppConfig & { ui?: UIDefs } { // Handle primitive config properties (null means remove, undefined means inherit) const serverRoute = @@ -488,6 +491,9 @@ function mergeVersionConfig( ui: versionConfig.ui, config: mergedConfig, plugins: mergedPlugins.length > 0 ? mergedPlugins : undefined, + // Propagate global icons to each version + icon: globalIcon, + icons: globalIcons, }; } @@ -820,8 +826,14 @@ function createMultiVersionApp(config: VersionsConfig): A // Create app instance for each version for (const [versionKey, versionConfig] of Object.entries(config.versions)) { - // Merge global and version-specific configs - const mergedConfig = mergeVersionConfig(config.config, versionConfig, config.plugins); + // Merge global and version-specific configs (including icons) + const mergedConfig = mergeVersionConfig( + config.config, + versionConfig, + config.plugins, + config.icon, + config.icons + ); mergedConfig.name = config.name; // Set app name from global config // Extract colocated UIs from tool definitions diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc0d6966..d5365d6f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -62,6 +62,8 @@ export type { ServerConfig, VersionSpecificConfig, DeepPartialWithNull, + Icon, + IconTheme, } from "./types/config"; // OAuth types @@ -203,6 +205,10 @@ export type { OpenAIUIResourceMetadata, } from "./utils/csp"; +// Icon utilities +export { iconFromFile } from "./utils/icons"; +export type { IconFromFileOptions } from "./utils/icons"; + // ============================================================================= // MAIN ENTRY POINTS // ============================================================================= diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 45e7cc67..14db093b 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -24,7 +24,7 @@ import type { ToolContext, UserLocation, } from "../types/tools"; -import type { AppConfig, CORSConfig, DebugConfig } from "../types/config"; +import type { AppConfig, CORSConfig, DebugConfig, Icon } from "../types/config"; import type { UIDefs, UIDef } from "../types/ui"; import type { MiddlewareContext } from "../middleware/types"; import type { EventMap } from "../events/types"; @@ -38,6 +38,74 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; +// ============================================================================= +// ICON HELPERS +// ============================================================================= + +/** + * Valid sizes format pattern: "WxH" (e.g., "48x48") or "any" for scalable formats. + */ +const SIZES_PATTERN = /^\d+x\d+$/; + +/** + * Validate icon sizes array format. + */ +function validateIconSizes(sizes: string[] | undefined, index: number): void { + if (!sizes) return; + + for (const size of sizes) { + if (size !== "any" && !SIZES_PATTERN.test(size)) { + throw new Error( + `Invalid icon size "${size}" at index ${index}: ` + + `Sizes must be in "WxH" format (e.g., "48x48") or "any" for scalable formats.` + ); + } + } +} + +/** + * Validate an icon object has required fields. + */ +function validateIcon(icon: Icon, index: number): void { + if (!icon.src || typeof icon.src !== "string" || icon.src.trim() === "") { + throw new Error(`Invalid icon at index ${index}: 'src' must be a non-empty string`); + } + validateIconSizes(icon.sizes, index); +} + +/** + * Normalize icon configuration into an icons array. + * + * Handles both the shorthand `icon` string and the full `icons` array. + * If both are provided, `icons` takes precedence. + * + * @internal This function is used internally by createApp. Do not use directly. + * @throws Error if any icon has an invalid or empty src + */ +export function normalizeIcons( + icon: string | undefined, + icons: Icon[] | undefined +): Icon[] | undefined { + // icons array takes precedence + if (icons && icons.length > 0) { + icons.forEach((ic, i) => { + validateIcon(ic, i); + }); + return icons; + } + + // Convert shorthand icon string to icons array + // Validate before falsy check for consistency (empty string should also throw) + if (icon !== undefined) { + if (typeof icon !== "string" || icon.trim() === "") { + throw new Error("Icon must be a non-empty string URL or data URI"); + } + return [{ src: icon }]; + } + + return undefined; +} + // ============================================================================= // SERVER WRAPPER // ============================================================================= @@ -83,10 +151,14 @@ export function createServerInstance( // Create protocol adapter const adapter = createAdapter(config.config?.protocol ?? "mcp"); - // Create MCP server + // Normalize icons from shorthand or array format + const icons = normalizeIcons(config.icon, config.icons); + + // Create MCP server with icons if provided const mcpServer = new McpServer({ name: config.name, version: config.version, + ...(icons && { icons }), }); // Compute UI resource URIs with content hashes for cache busting diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index a9fbd763..4a7f895a 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -7,6 +7,81 @@ import type { Plugin } from "../plugins/types"; import type { OAuthConfig } from "../server/oauth/types.js"; import type { UIDefs } from "./ui"; +// ============================================================================= +// ICON CONFIGURATION +// ============================================================================= + +/** + * Icon theme for light/dark mode support + */ +export type IconTheme = "light" | "dark"; + +/** + * Icon definition following the MCP specification. + * + * Supports both URL references and inline base64 data URIs. + * + * @see https://modelcontextprotocol.io/specification/2025-11-25/schema#implementation + * + * @example URL reference + * ```typescript + * { src: "https://example.com/icon.png", mimeType: "image/png", sizes: ["48x48"] } + * ``` + * + * @example Base64 data URI + * ```typescript + * { src: "data:image/svg+xml;base64,PHN2Zy...", mimeType: "image/svg+xml", sizes: ["any"] } + * ``` + * + * @example Theme-specific icon + * ```typescript + * { src: "https://example.com/icon-dark.png", theme: "dark" } + * ``` + */ +export interface Icon { + /** + * Icon source URI. + * + * Can be: + * - HTTP/HTTPS URL (e.g., "https://example.com/icon.png") + * - Data URI with base64 encoding (e.g., "data:image/png;base64,...") + */ + src: string; + + /** + * MIME type of the icon. + * + * Required MIME types that clients must support: + * - `"image/png"` + * - `"image/jpeg"` + * + * Optional MIME types: + * - `"image/svg+xml"` + * - `"image/webp"` + * + * @example "image/png" + */ + mimeType?: string; + + /** + * Icon sizes in "WxH" format. + * + * Use `["any"]` for scalable formats like SVG. + * + * @example ["48x48", "96x96"] + * @example ["any"] + */ + sizes?: string[]; + + /** + * Theme this icon is designed for. + * + * When specified, clients can select the appropriate icon + * based on their current light/dark mode setting. + */ + theme?: IconTheme; +} + // ============================================================================= // PROTOCOL CONFIGURATION // ============================================================================= @@ -463,6 +538,27 @@ export interface VersionsConfig { * Merged with each version's plugins. */ plugins?: Plugin[]; + + /** + * Server icon URL or data URI (shorthand for single icon). + * + * Applied to all versions. For multiple icons or advanced configuration, use `icons` instead. + * + * @example + * ```typescript + * icon: "https://example.com/icon.png" + * ``` + */ + icon?: string; + + /** + * Server icons for MCP client display. + * + * Applied to all versions. Follows the MCP specification for Implementation icons. + * + * @see https://modelcontextprotocol.io/specification/2025-11-25/schema#implementation + */ + icons?: Icon[]; } /** @@ -533,6 +629,42 @@ export interface AppConfig { * ``` */ plugins?: Plugin[]; + + /** + * Server icon URL or data URI (shorthand for single icon). + * + * For multiple icons or advanced configuration, use `icons` instead. + * + * @example URL + * ```typescript + * icon: "https://example.com/icon.png" + * ``` + * + * @example Data URI + * ```typescript + * icon: "data:image/svg+xml;base64,PHN2Zy..." + * ``` + */ + icon?: string; + + /** + * Server icons for MCP client display. + * + * Allows specifying multiple icons with different sizes, formats, and themes. + * Follows the MCP specification for Implementation icons. + * + * @see https://modelcontextprotocol.io/specification/2025-11-25/schema#implementation + * + * @example + * ```typescript + * icons: [ + * { src: "https://example.com/icon-48.png", mimeType: "image/png", sizes: ["48x48"] }, + * { src: "https://example.com/icon-96.png", mimeType: "image/png", sizes: ["96x96"] }, + * { src: "https://example.com/icon-dark.png", theme: "dark" } + * ] + * ``` + */ + icons?: Icon[]; } /** diff --git a/packages/core/src/utils/icons.ts b/packages/core/src/utils/icons.ts new file mode 100644 index 00000000..a607b401 --- /dev/null +++ b/packages/core/src/utils/icons.ts @@ -0,0 +1,156 @@ +/** + * Icon utility functions for MCP server configuration + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { Icon, IconTheme } from "../types/config"; + +/** + * Maximum allowed icon file size (1MB). + * Base64 encoding increases size by ~33%, so this limits data URIs to ~1.33MB. + */ +const MAX_ICON_SIZE = 1024 * 1024; + +/** + * MIME type mappings for common image formats + */ +const MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".gif": "image/gif", + ".ico": "image/x-icon", +}; + +/** + * Options for creating an icon from a file + */ +export interface IconFromFileOptions { + /** + * Icon sizes in "WxH" format. + * Use `["any"]` for scalable formats like SVG. + * + * @example ["48x48", "96x96"] + */ + sizes?: string[]; + + /** + * Theme this icon is designed for. + */ + theme?: IconTheme; + + /** + * Override the auto-detected MIME type. + */ + mimeType?: string; +} + +/** + * Create an Icon object from a local image file. + * + * Reads the file, converts it to a base64 data URI, and returns + * an Icon object ready for use in createApp configuration. + * + * **Size limit:** Files must be under 1MB. For larger images, host them + * externally and use a URL instead. Consider using SVG for logos (smaller + * size, scalable). + * + * @param filePath - Path to the image file (absolute or relative to cwd) + * @param options - Optional icon configuration (sizes, theme, mimeType override) + * @returns Icon object with base64 data URI + * + * @example Basic usage + * ```typescript + * import { createApp, iconFromFile } from "@mcp-apps-kit/core"; + * + * const app = createApp({ + * name: "my-app", + * version: "1.0.0", + * icons: [iconFromFile("./assets/icon.png")], + * tools: { ... } + * }); + * ``` + * + * @example With options + * ```typescript + * const app = createApp({ + * name: "my-app", + * version: "1.0.0", + * icons: [ + * iconFromFile("./assets/icon-48.png", { sizes: ["48x48"] }), + * iconFromFile("./assets/icon-dark.png", { theme: "dark" }), + * iconFromFile("./assets/icon.svg", { sizes: ["any"] }), + * ], + * tools: { ... } + * }); + * ``` + * + * @throws Error if the file cannot be read, exceeds size limit, or has an unsupported extension + */ +export function iconFromFile(filePath: string, options: IconFromFileOptions = {}): Icon { + // Resolve to absolute path + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath); + + // Read file with error handling + let fileBuffer: Buffer; + try { + fileBuffer = fs.readFileSync(absolutePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read icon file "${filePath}": ${message}`); + } + + // Check file size + if (fileBuffer.length > MAX_ICON_SIZE) { + const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2); + throw new Error( + `Icon file too large: ${sizeMB}MB (max: 1MB). ` + + `Consider using a smaller image, SVG format, or hosting it externally with a URL.` + ); + } + + // Detect MIME type from extension + const ext = path.extname(absolutePath).toLowerCase(); + const detectedMimeType = MIME_TYPES[ext]; + + if (!detectedMimeType && !options.mimeType) { + throw new Error( + `Unsupported image format: ${ext}. ` + + `Supported formats: ${Object.keys(MIME_TYPES).join(", ")}. ` + + `You can override this by providing a mimeType option.` + ); + } + + // mimeType is guaranteed to be defined after validation above + const mimeType = options.mimeType ?? detectedMimeType ?? ""; + + // Validate MIME type is an image type + if (!mimeType.startsWith("image/")) { + throw new Error( + `Invalid MIME type: "${mimeType}". Must be an image MIME type (e.g., "image/png").` + ); + } + + // Convert to base64 data URI + const base64 = fileBuffer.toString("base64"); + const dataUri = `data:${mimeType};base64,${base64}`; + + // Build Icon object + const icon: Icon = { + src: dataUri, + mimeType, + }; + + if (options.sizes) { + icon.sizes = options.sizes; + } + + if (options.theme) { + icon.theme = options.theme; + } + + return icon; +} diff --git a/packages/core/tests/unit/icons.test.ts b/packages/core/tests/unit/icons.test.ts new file mode 100644 index 00000000..751f9748 --- /dev/null +++ b/packages/core/tests/unit/icons.test.ts @@ -0,0 +1,446 @@ +/** + * Unit tests for server icon configuration + * + * Tests the icon types and configuration for MCP server icons. + */ + +import { describe, it, expect, expectTypeOf, vi, beforeEach } from "vitest"; +import { z } from "zod"; +import type { Icon, IconTheme, AppConfig } from "../../src/types/config"; +import type { ToolDefs } from "../../src/types/tools"; + +// Mock node:fs before importing iconFromFile +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), +})); + +import { iconFromFile } from "../../src/utils/icons"; +import { normalizeIcons } from "../../src/server/index"; +import { readFileSync } from "node:fs"; + +describe("Icon types", () => { + describe("Icon interface", () => { + it("should accept a minimal icon with just src", () => { + const icon: Icon = { + src: "https://example.com/icon.png", + }; + + expect(icon.src).toBe("https://example.com/icon.png"); + expect(icon.mimeType).toBeUndefined(); + expect(icon.sizes).toBeUndefined(); + expect(icon.theme).toBeUndefined(); + }); + + it("should accept a fully specified icon", () => { + const icon: Icon = { + src: "https://example.com/icon.png", + mimeType: "image/png", + sizes: ["48x48", "96x96"], + theme: "light", + }; + + expect(icon.src).toBe("https://example.com/icon.png"); + expect(icon.mimeType).toBe("image/png"); + expect(icon.sizes).toEqual(["48x48", "96x96"]); + expect(icon.theme).toBe("light"); + }); + + it("should accept data URI for src", () => { + const icon: Icon = { + src: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==", + mimeType: "image/svg+xml", + sizes: ["any"], + }; + + expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("should accept theme values", () => { + const lightIcon: Icon = { src: "https://example.com/light.png", theme: "light" }; + const darkIcon: Icon = { src: "https://example.com/dark.png", theme: "dark" }; + + expectTypeOf().toEqualTypeOf<"light" | "dark">(); + expect(lightIcon.theme).toBe("light"); + expect(darkIcon.theme).toBe("dark"); + }); + }); + + describe("AppConfig with icons", () => { + const baseTools: ToolDefs = { + test: { + description: "Test tool", + input: z.object({}), + output: z.object({}), + handler: async () => ({}), + }, + }; + + it("should accept icon shorthand string", () => { + const config: AppConfig = { + name: "test-app", + version: "1.0.0", + tools: baseTools, + icon: "https://example.com/icon.png", + }; + + expect(config.icon).toBe("https://example.com/icon.png"); + }); + + it("should accept icons array", () => { + const config: AppConfig = { + name: "test-app", + version: "1.0.0", + tools: baseTools, + icons: [ + { src: "https://example.com/icon-48.png", sizes: ["48x48"] }, + { src: "https://example.com/icon-96.png", sizes: ["96x96"] }, + ], + }; + + expect(config.icons).toHaveLength(2); + expect(config.icons?.[0].sizes).toEqual(["48x48"]); + }); + + it("should accept both icon and icons (icons takes precedence)", () => { + const config: AppConfig = { + name: "test-app", + version: "1.0.0", + tools: baseTools, + icon: "https://example.com/fallback.png", + icons: [{ src: "https://example.com/primary.png" }], + }; + + expect(config.icon).toBe("https://example.com/fallback.png"); + expect(config.icons).toHaveLength(1); + }); + + it("should accept theme-specific icons", () => { + const config: AppConfig = { + name: "test-app", + version: "1.0.0", + tools: baseTools, + icons: [ + { src: "https://example.com/light.png", theme: "light" }, + { src: "https://example.com/dark.png", theme: "dark" }, + ], + }; + + expect(config.icons?.[0].theme).toBe("light"); + expect(config.icons?.[1].theme).toBe("dark"); + }); + + it("should accept data URI icons", () => { + const svgBase64 = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg=="; + const config: AppConfig = { + name: "test-app", + version: "1.0.0", + tools: baseTools, + icon: `data:image/svg+xml;base64,${svgBase64}`, + }; + + expect(config.icon).toMatch(/^data:image\/svg\+xml;base64,/); + }); + }); +}); + +describe("iconFromFile", () => { + const mockReadFileSync = vi.mocked(readFileSync); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should convert PNG file to data URI", () => { + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes + mockReadFileSync.mockReturnValue(pngData); + + const icon = iconFromFile("/path/to/icon.png"); + + expect(icon.src).toMatch(/^data:image\/png;base64,/); + expect(icon.mimeType).toBe("image/png"); + expect(icon.sizes).toBeUndefined(); + expect(icon.theme).toBeUndefined(); + }); + + it("should convert JPEG file to data URI", () => { + const jpegData = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); // JPEG magic bytes + mockReadFileSync.mockReturnValue(jpegData); + + const icon = iconFromFile("/path/to/photo.jpg"); + + expect(icon.src).toMatch(/^data:image\/jpeg;base64,/); + expect(icon.mimeType).toBe("image/jpeg"); + }); + + it("should convert SVG file to data URI", () => { + const svgContent = Buffer.from(''); + mockReadFileSync.mockReturnValue(svgContent); + + const icon = iconFromFile("/path/to/icon.svg"); + + expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/); + expect(icon.mimeType).toBe("image/svg+xml"); + }); + + it("should handle .jpeg extension", () => { + const jpegData = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + mockReadFileSync.mockReturnValue(jpegData); + + const icon = iconFromFile("/path/to/photo.jpeg"); + + expect(icon.mimeType).toBe("image/jpeg"); + }); + + it("should handle WebP files", () => { + const webpData = Buffer.from("RIFF....WEBP"); + mockReadFileSync.mockReturnValue(webpData); + + const icon = iconFromFile("/path/to/icon.webp"); + + expect(icon.mimeType).toBe("image/webp"); + }); + + it("should include sizes option when provided", () => { + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + mockReadFileSync.mockReturnValue(pngData); + + const icon = iconFromFile("/path/to/icon.png", { sizes: ["48x48", "96x96"] }); + + expect(icon.sizes).toEqual(["48x48", "96x96"]); + }); + + it("should include theme option when provided", () => { + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + mockReadFileSync.mockReturnValue(pngData); + + const icon = iconFromFile("/path/to/icon-dark.png", { theme: "dark" }); + + expect(icon.theme).toBe("dark"); + }); + + it("should allow mimeType override", () => { + const customData = Buffer.from("custom format"); + mockReadFileSync.mockReturnValue(customData); + + const icon = iconFromFile("/path/to/icon.custom", { mimeType: "image/x-custom" }); + + expect(icon.src).toMatch(/^data:image\/x-custom;base64,/); + expect(icon.mimeType).toBe("image/x-custom"); + }); + + it("should throw for unsupported extension without mimeType override", () => { + const unknownData = Buffer.from("unknown format"); + mockReadFileSync.mockReturnValue(unknownData); + + expect(() => iconFromFile("/path/to/icon.xyz")).toThrow("Unsupported image format: .xyz"); + }); + + it("should throw for non-image MIME type override", () => { + const data = Buffer.from("test data"); + mockReadFileSync.mockReturnValue(data); + + expect(() => iconFromFile("/path/to/file.txt", { mimeType: "text/plain" })).toThrow( + 'Invalid MIME type: "text/plain". Must be an image MIME type' + ); + }); + + it("should accept custom image MIME types", () => { + const data = Buffer.from("custom image data"); + mockReadFileSync.mockReturnValue(data); + + const icon = iconFromFile("/path/to/file.custom", { mimeType: "image/x-custom" }); + + expect(icon.mimeType).toBe("image/x-custom"); + expect(icon.src).toMatch(/^data:image\/x-custom;base64,/); + }); + + it("should throw for files exceeding size limit", () => { + // Create buffer larger than 1MB + const largeBuffer = Buffer.alloc(1024 * 1024 + 1); + mockReadFileSync.mockReturnValue(largeBuffer); + + expect(() => iconFromFile("/path/to/large.png")).toThrow(/Icon file too large/); + }); + + it("should accept files at exactly 1MB", () => { + const exactlyOneMB = Buffer.alloc(1024 * 1024); + mockReadFileSync.mockReturnValue(exactlyOneMB); + + const icon = iconFromFile("/path/to/exact.png"); + expect(icon.src).toMatch(/^data:image\/png;base64,/); + }); + + it("should wrap file read errors with helpful message", () => { + mockReadFileSync.mockImplementation(() => { + throw new Error("ENOENT: no such file or directory"); + }); + + expect(() => iconFromFile("./missing.png")).toThrow( + 'Failed to read icon file "./missing.png": ENOENT: no such file or directory' + ); + }); + + it("should correctly encode file content as base64", () => { + const testContent = Buffer.from("Hello, World!"); + mockReadFileSync.mockReturnValue(testContent); + + const icon = iconFromFile("/path/to/test.png"); + + // "Hello, World!" in base64 is "SGVsbG8sIFdvcmxkIQ==" + expect(icon.src).toBe("data:image/png;base64,SGVsbG8sIFdvcmxkIQ=="); + }); + + it("should handle all options together", () => { + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + mockReadFileSync.mockReturnValue(pngData); + + const icon = iconFromFile("/path/to/icon.png", { + sizes: ["48x48"], + theme: "light", + }); + + expect(icon.src).toMatch(/^data:image\/png;base64,/); + expect(icon.mimeType).toBe("image/png"); + expect(icon.sizes).toEqual(["48x48"]); + expect(icon.theme).toBe("light"); + }); +}); + +describe("normalizeIcons", () => { + describe("with icon shorthand", () => { + it("should convert icon string to icons array", () => { + const result = normalizeIcons("https://example.com/icon.png", undefined); + + expect(result).toEqual([{ src: "https://example.com/icon.png" }]); + }); + + it("should convert data URI icon to icons array", () => { + const dataUri = "data:image/svg+xml;base64,PHN2Zw=="; + const result = normalizeIcons(dataUri, undefined); + + expect(result).toEqual([{ src: dataUri }]); + }); + + it("should throw for empty string icon", () => { + expect(() => normalizeIcons("", undefined)).toThrow( + "Icon must be a non-empty string URL or data URI" + ); + }); + + it("should throw for whitespace-only icon", () => { + expect(() => normalizeIcons(" ", undefined)).toThrow( + "Icon must be a non-empty string URL or data URI" + ); + }); + }); + + describe("with icons array", () => { + it("should return icons array as-is", () => { + const icons = [ + { src: "https://example.com/icon-48.png", sizes: ["48x48"] as string[] }, + { src: "https://example.com/icon-96.png", sizes: ["96x96"] as string[] }, + ]; + + const result = normalizeIcons(undefined, icons); + + expect(result).toBe(icons); + }); + + it("should validate icon src is non-empty", () => { + expect(() => normalizeIcons(undefined, [{ src: "" }])).toThrow( + "Invalid icon at index 0: 'src' must be a non-empty string" + ); + }); + + it("should validate icon src is not whitespace only", () => { + expect(() => normalizeIcons(undefined, [{ src: " " }])).toThrow( + "Invalid icon at index 0: 'src' must be a non-empty string" + ); + }); + + it("should report correct index for invalid icon", () => { + const icons = [{ src: "https://example.com/valid.png" }, { src: "" }]; + + expect(() => normalizeIcons(undefined, icons)).toThrow( + "Invalid icon at index 1: 'src' must be a non-empty string" + ); + }); + + it("should accept theme-specific icons", () => { + const icons = [ + { src: "https://example.com/light.png", theme: "light" as const }, + { src: "https://example.com/dark.png", theme: "dark" as const }, + ]; + + const result = normalizeIcons(undefined, icons); + + expect(result).toBe(icons); + expect(result?.[0].theme).toBe("light"); + expect(result?.[1].theme).toBe("dark"); + }); + }); + + describe("precedence", () => { + it("should prefer icons array over icon shorthand", () => { + const icons = [{ src: "https://example.com/primary.png" }]; + + const result = normalizeIcons("https://example.com/fallback.png", icons); + + expect(result).toBe(icons); + expect(result).toHaveLength(1); + expect(result?.[0].src).toBe("https://example.com/primary.png"); + }); + + it("should ignore icon shorthand when icons array is provided", () => { + const icons = [{ src: "https://example.com/primary.png" }]; + const result = normalizeIcons("https://example.com/ignored.png", icons); + + expect(result).not.toContainEqual({ src: "https://example.com/ignored.png" }); + }); + }); + + describe("with no icons", () => { + it("should return undefined when no icon or icons provided", () => { + const result = normalizeIcons(undefined, undefined); + + expect(result).toBeUndefined(); + }); + + it("should return undefined for empty icons array", () => { + const result = normalizeIcons(undefined, []); + + expect(result).toBeUndefined(); + }); + }); + + describe("sizes format validation", () => { + it("should accept valid WxH sizes format", () => { + const icons = [{ src: "https://example.com/icon.png", sizes: ["48x48", "96x96"] }]; + const result = normalizeIcons(undefined, icons); + expect(result).toBe(icons); + }); + + it("should accept 'any' as valid size for scalable formats", () => { + const icons = [{ src: "https://example.com/icon.svg", sizes: ["any"] }]; + const result = normalizeIcons(undefined, icons); + expect(result).toBe(icons); + }); + + it("should throw for invalid sizes format", () => { + const icons = [{ src: "https://example.com/icon.png", sizes: ["48"] }]; + expect(() => normalizeIcons(undefined, icons)).toThrow('Invalid icon size "48" at index 0'); + }); + + it("should throw for malformed sizes like 48x", () => { + const icons = [{ src: "https://example.com/icon.png", sizes: ["48x"] }]; + expect(() => normalizeIcons(undefined, icons)).toThrow('Invalid icon size "48x" at index 0'); + }); + + it("should throw for text in sizes", () => { + const icons = [{ src: "https://example.com/icon.png", sizes: ["large"] }]; + expect(() => normalizeIcons(undefined, icons)).toThrow( + 'Invalid icon size "large" at index 0' + ); + }); + }); +}); diff --git a/packages/core/tests/unit/versioning.test.ts b/packages/core/tests/unit/versioning.test.ts index 362f3b10..f964f6be 100644 --- a/packages/core/tests/unit/versioning.test.ts +++ b/packages/core/tests/unit/versioning.test.ts @@ -286,6 +286,45 @@ describe("createApp versioning", () => { expect(app).toBeDefined(); // Both plugins should be registered for v1 }); + + it("should propagate global icon to all versions", () => { + const app = createApp({ + name: "test-app", + icon: "https://example.com/icon.png", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + v2: { + version: "2.0.0", + tools: {}, + }, + }, + }); + + expect(app).toBeDefined(); + // Global icon should be applied to both versions + }); + + it("should propagate global icons array to all versions", () => { + const app = createApp({ + name: "test-app", + icons: [ + { src: "https://example.com/icon-48.png", sizes: ["48x48"] }, + { src: "https://example.com/icon-96.png", sizes: ["96x96"] }, + ], + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + }, + }); + + expect(app).toBeDefined(); + // Global icons should be applied to v1 + }); }); describe("deep config merging", () => { diff --git a/packages/testing/src/eval/property/generators.ts b/packages/testing/src/eval/property/generators.ts index 1efe5f10..1c4008cc 100644 --- a/packages/testing/src/eval/property/generators.ts +++ b/packages/testing/src/eval/property/generators.ts @@ -139,7 +139,7 @@ export const generators = { } else if (max !== undefined) { return fc.float({ max, noNaN: true }); } - return fc.float(); + return fc.float({ noNaN: true }); }); },