diff --git a/ui/src/ui/chat/chat-avatar.test.ts b/ui/src/ui/chat/chat-avatar.test.ts new file mode 100644 index 00000000000..183a615f938 --- /dev/null +++ b/ui/src/ui/chat/chat-avatar.test.ts @@ -0,0 +1,87 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderChatAvatar } from "./chat-avatar.ts"; + +vi.mock("../views/agents-utils.ts", () => ({ + isRenderableControlUiAvatarUrl: (value: string) => + /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")), + assistantAvatarFallbackUrl: () => "apple-touch-icon.png", + resolveAssistantTextAvatar: (value: string | null | undefined) => { + if (!value) { + return null; + } + return value.length <= 3 ? value : null; + }, + resolveChatAvatarRenderUrl: ( + candidate: string | null | undefined, + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + ) => { + const isRenderableControlUiAvatarUrl = (value: string) => + /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")); + if (typeof candidate === "string" && candidate.startsWith("blob:")) { + return candidate; + } + for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) { + if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) { + return value; + } + } + return null; + }, +})); + +function renderAvatar(params: Parameters) { + const container = document.createElement("div"); + render(renderChatAvatar(...params), container); + return container.querySelector(".chat-avatar"); +} + +describe("renderChatAvatar", () => { + it("uses the assistant fallback when no assistant avatar is configured", () => { + const avatar = renderAvatar(["assistant"]); + + expect(avatar).not.toBeNull(); + expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png"); + }); + + it("renders assistant fallback, blob image, and text avatars", () => { + const remoteAvatar = renderAvatar([ + "assistant", + { avatar: "https://example.com/avatar.png", name: "Val" }, + ]); + expect(remoteAvatar?.getAttribute("src")).toBe("apple-touch-icon.png"); + + const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]); + expect(blobAvatar?.tagName).toBe("IMG"); + expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image"); + + const textAvatar = renderAvatar(["assistant", { avatar: "VC", name: "Val" }]); + expect(textAvatar?.tagName).toBe("DIV"); + expect(textAvatar?.textContent).toContain("VC"); + expect(textAvatar?.getAttribute("aria-label")).toBe("Val"); + }); + + it("uses the assistant fallback while authenticated avatar routes are loading", () => { + const avatar = renderAvatar([ + "assistant", + { avatar: "/avatar/main", name: "OpenClaw" }, + undefined, + "", + "session-token", + ]); + + expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png"); + }); + + it("renders local user image and text avatars", () => { + const imageAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "/avatar/user" }]); + expect(imageAvatar?.getAttribute("src")).toBe("/avatar/user"); + expect(imageAvatar?.getAttribute("alt")).toBe("Buns"); + + const textAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "AB" }]); + expect(textAvatar?.tagName).toBe("DIV"); + expect(textAvatar?.textContent).toContain("AB"); + }); +}); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index e774b3c6108..fe99d05413d 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -8,7 +8,6 @@ import { createSessionsListResult, DEFAULT_CHAT_MODEL_CATALOG, } from "../chat-model.test-helpers.ts"; -import { renderChatAvatar } from "../chat/chat-avatar.ts"; import { renderChatQueue } from "../chat/chat-queue.ts"; import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts"; import { renderWelcomeState } from "../chat/chat-welcome.ts"; @@ -80,12 +79,6 @@ vi.mock("./agents-utils.ts", () => ({ }, })); -function renderAvatar(params: Parameters) { - const container = document.createElement("div"); - render(renderChatAvatar(...params), container); - return container.querySelector(".chat-avatar"); -} - function renderQueue(params: { queue: ChatQueueItem[]; canAbort?: boolean; @@ -264,54 +257,6 @@ describe("chat queue", () => { }); }); -describe("renderChatAvatar", () => { - it("uses the assistant fallback when no assistant avatar is configured", () => { - const avatar = renderAvatar(["assistant"]); - - expect(avatar).not.toBeNull(); - expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png"); - }); - - it("renders assistant fallback, blob image, and text avatars", () => { - const remoteAvatar = renderAvatar([ - "assistant", - { avatar: "https://example.com/avatar.png", name: "Val" }, - ]); - expect(remoteAvatar?.getAttribute("src")).toBe("apple-touch-icon.png"); - - const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]); - expect(blobAvatar?.tagName).toBe("IMG"); - expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image"); - - const textAvatar = renderAvatar(["assistant", { avatar: "VC", name: "Val" }]); - expect(textAvatar?.tagName).toBe("DIV"); - expect(textAvatar?.textContent).toContain("VC"); - expect(textAvatar?.getAttribute("aria-label")).toBe("Val"); - }); - - it("uses the assistant fallback while authenticated avatar routes are loading", () => { - const avatar = renderAvatar([ - "assistant", - { avatar: "/avatar/main", name: "OpenClaw" }, - undefined, - "", - "session-token", - ]); - - expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png"); - }); - - it("renders local user image and text avatars", () => { - const imageAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "/avatar/user" }]); - expect(imageAvatar?.getAttribute("src")).toBe("/avatar/user"); - expect(imageAvatar?.getAttribute("alt")).toBe("Buns"); - - const textAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "AB" }]); - expect(textAvatar?.tagName).toBe("DIV"); - expect(textAvatar?.textContent).toContain("AB"); - }); -}); - describe("chat sidebar raw content", () => { it("keeps markdown raw text toggles idempotent", () => { const rawMarkdown = "```ts\nconst value = 1;\n```";