1import type { ReactElement } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { visitElements } from "../../test/reactElementTree";
5import { reactHookHarness as hooks } from "../../test/reactHookHarness";
6
7vi.mock("react", async (importOriginal) => {
8 const actual = await importOriginal<typeof import("react")>();
9 const { reactHookHarness } = await import("../../test/reactHookHarness");
10 return {
11 ...actual,
12 useEffect: (effect: () => void | (() => void)) => effect(),
13 useState: reactHookHarness.useState,
14 useId: () => "acp-icon-test-filter",
15 };
16});
17
18import {
19 AcpRegistryAgentIcon,
20 loadCachedAcpRegistryIcon,
21 officialAcpRegistryIconUrlForAgentId,
22 resolveOfficialAcpRegistryIconUrl,
23} from "./AcpRegistryIcon";
24
25describe("ACP Registry icon cache", () => {
26 const stored = new Map<string, Response>();
27 const match = vi.fn(async (url: string) => stored.get(url)?.clone());
28 const put = vi.fn(async (url: string, response: Response) => {
29 stored.set(url, response.clone());
30 });
31 const fetchIcon = vi.fn();
32
33 beforeEach(() => {
34 hooks.reset();
35 stored.clear();
36 match.mockClear();
37 put.mockClear();
38 fetchIcon.mockReset();
39 vi.stubGlobal("caches", { open: vi.fn(async () => ({ match, put })) });
40 vi.stubGlobal("fetch", fetchIcon);
41 });
42
43 it("single-flights the first fetch and serves later reads from persistent cache", async () => {
44 const url = "https://cdn.agentclientprotocol.com/registry/icons/cache-test.svg";
45 fetchIcon.mockResolvedValue(
46 new Response("<svg/>", {
47 status: 200,
48 headers: { "content-type": "image/svg+xml" },
49 }),
50 );
51
52 const [first, concurrent] = await Promise.all([
53 loadCachedAcpRegistryIcon(url),
54 loadCachedAcpRegistryIcon(url),
55 ]);
56 const cached = await loadCachedAcpRegistryIcon(url);
57
58 expect(first.type).toBe("image/svg+xml");
59 expect(concurrent.size).toBe(first.size);
60 expect(cached.size).toBe(first.size);
61 expect(fetchIcon).toHaveBeenCalledOnce();
62 expect(put).toHaveBeenCalledOnce();
63 });
64
65 it("rejects oversized image responses before caching", async () => {
66 const url = "https://cdn.agentclientprotocol.com/registry/icons/oversized.svg";
67 fetchIcon.mockResolvedValue(
68 new Response("too large", {
69 status: 200,
70 headers: {
71 "content-length": String(513 * 1_024),
72 "content-type": "image/svg+xml",
73 },
74 }),
75 );
76
77 await expect(loadCachedAcpRegistryIcon(url)).rejects.toThrow("too large");
78 expect(put).not.toHaveBeenCalled();
79 });
80
81 it("falls back to the network when CacheStorage cannot be opened", async () => {
82 const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg";
83 vi.stubGlobal("caches", {
84 open: vi.fn(async () => {
85 throw new Error("CacheStorage unavailable");
86 }),
87 });
88 fetchIcon.mockResolvedValue(
89 new Response("<svg/>", {
90 status: 200,
91 headers: { "content-type": "image/svg+xml" },
92 }),
93 );
94
95 await expect(loadCachedAcpRegistryIcon(url)).resolves.toMatchObject({
96 type: "image/svg+xml",
97 });
98 expect(fetchIcon).toHaveBeenCalledWith(url, {
99 credentials: "omit",
100 redirect: "error",
101 referrerPolicy: "no-referrer",
102 });
103 });
104
105 it("accepts only credential-free HTTPS URLs on the official CDN", () => {
106 expect(
107 resolveOfficialAcpRegistryIconUrl(
108 "https://cdn.agentclientprotocol.com/registry/icons/gemini.png",
109 ),
110 ).toBe("https://cdn.agentclientprotocol.com/registry/icons/gemini.png");
111 expect(
112 resolveOfficialAcpRegistryIconUrl("http://cdn.agentclientprotocol.com/icon.png"),
113 ).toBeNull();
114 expect(
115 resolveOfficialAcpRegistryIconUrl("https://cdn.agentclientprotocol.com.evil/icon.png"),
116 ).toBeNull();
117 expect(
118 resolveOfficialAcpRegistryIconUrl("https://user@cdn.agentclientprotocol.com/icon.png"),
119 ).toBeNull();
120 expect(
121 resolveOfficialAcpRegistryIconUrl("https://cdn.agentclientprotocol.com:8443/icon.png"),
122 ).toBeNull();
123 expect(officialAcpRegistryIconUrlForAgentId("kilo")).toBe(
124 "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg",
125 );
126 expect(officialAcpRegistryIconUrlForAgentId("../kilo")).toBeNull();
127 });
128
129 it("falls back to the raw allowlisted CDN URL when the validating fetch is blocked", async () => {
130 const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg";
131 // The official CDN serves no CORS headers, so the fetch can reject even
132 // though native <img> loading works.
133 fetchIcon.mockRejectedValue(new TypeError("Failed to fetch"));
134 hooks.beginRender();
135 AcpRegistryAgentIcon({ icon: url });
136
137 await new Promise((resolve) => setTimeout(resolve, 0));
138 hooks.beginRender();
139 const tree = AcpRegistryAgentIcon({ icon: url }) as ReactElement<Record<string, unknown>>;
140 const image = visitElements(tree, (element) => element.type === "img");
141 expect(image?.props).toMatchObject({ src: url, referrerPolicy: "no-referrer" });
142 expect(
143 visitElements(tree, (element) => element.props["data-slot"] === "acp-icon-fallback"),
144 ).not.toBeNull();
145 });
146
147 it("renders only the validated blob object URL and keeps the fallback until load", async () => {
148 const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg";
149 const objectUrl = "blob:t3/kilo-icon";
150 Object.assign(URL, {
151 createObjectURL: vi.fn(() => objectUrl),
152 revokeObjectURL: vi.fn(),
153 });
154 fetchIcon.mockResolvedValue(
155 new Response("<svg/>", {
156 status: 200,
157 headers: { "content-type": "image/svg+xml" },
158 }),
159 );
160 hooks.beginRender();
161 const validating = AcpRegistryAgentIcon({ icon: url }) as ReactElement<Record<string, unknown>>;
162
163 // The raw CDN URL is never rendered; only the fallback shows while the
164 // blob is fetched and validated.
165 expect(visitElements(validating, (element) => element.type === "img")).toBeNull();
166 expect(
167 visitElements(validating, (element) => element.props["data-slot"] === "acp-icon-fallback"),
168 ).not.toBeNull();
169
170 await new Promise((resolve) => setTimeout(resolve, 0));
171 hooks.beginRender();
172 const loading = AcpRegistryAgentIcon({ icon: url }) as ReactElement<Record<string, unknown>>;
173 const loadingImage = visitElements(loading, (element) => element.type === "img");
174
175 expect(loadingImage?.props).toMatchObject({
176 src: objectUrl,
177 alt: "",
178 decoding: "async",
179 referrerPolicy: "no-referrer",
180 });
181 expect(loadingImage?.props.className).not.toContain("dark:invert");
182 expect(loadingImage?.props.className).toContain("invisible");
183 expect(
184 visitElements(loading, (element) => element.props["data-slot"] === "acp-icon-fallback"),
185 ).not.toBeNull();
186
187 (loadingImage?.props.onLoad as (() => void) | undefined)?.();
188 hooks.beginRender();
189 const loaded = AcpRegistryAgentIcon({ icon: url }) as ReactElement<Record<string, unknown>>;
190 expect(
191 visitElements(loaded, (element) => element.type === "img")?.props.className,
192 ).not.toContain("invisible");
193 expect(
194 visitElements(loaded, (element) => element.props["data-slot"] === "acp-icon-fallback"),
195 ).toBeNull();
196
197 const loadedImage = visitElements(loaded, (element) => element.type === "img");
198 (loadedImage?.props.onError as (() => void) | undefined)?.();
199 hooks.beginRender();
200 const failed = AcpRegistryAgentIcon({ icon: url }) as ReactElement<Record<string, unknown>>;
201 expect(visitElements(failed, (element) => element.type === "img")).toBeNull();
202 expect(
203 visitElements(failed, (element) => element.props["data-slot"] === "acp-icon-fallback"),
204 ).not.toBeNull();
205 });
206});