1import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
5
6const testState = vi.hoisted(() => ({
7 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
8 lastTarget: null as unknown,
9}));
10
11const hooks = vi.hoisted(() => {
12 let cursor = 0;
13 let slots: unknown[] = [];
14 const nextIndex = () => cursor++;
15
16 return {
17 beginRender() {
18 cursor = 0;
19 },
20 reset() {
21 cursor = 0;
22 slots = [];
23 },
24 useMemoCache(size: number): unknown[] {
25 const index = nextIndex();
26 if (!slots[index]) {
27 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
28 }
29 return slots[index] as unknown[];
30 },
31 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
32 const index = nextIndex();
33 if (index >= slots.length) {
34 slots[index] =
35 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
36 }
37 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
38 const previous = slots[index] as T;
39 slots[index] =
40 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
41 };
42 return [slots[index] as T, setValue];
43 },
44 };
45});
46
47vi.mock("react", async (importOriginal) => {
48 const actual = await importOriginal<typeof import("react")>();
49 return {
50 ...actual,
51 useState: hooks.useState,
52 };
53});
54
55vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
56vi.mock("lucide-react/dynamic", () => ({
57 DynamicIcon: "dynamic-icon",
58 iconNames: ["alarm-clock", "folder-code"],
59}));
60vi.mock("@effect/atom-react", () => ({
61 useAtomValue: () => testState.faviconUrl,
62}));
63vi.mock("../state/assets", () => ({
64 projectFaviconUrlAtom: (input: unknown) => {
65 testState.lastTarget = input;
66 },
67}));
68
69import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
70
71function makeProject(
72 overrides: Partial<ProjectFaviconProject> &
73 Pick<ProjectFaviconProject, "workspaceRoot" | "title">,
74): ProjectFaviconProject {
75 return { environmentId: "environment-test" as EnvironmentId, ...overrides };
76}
77
78type ProjectFaviconImageProps = {
79 readonly cacheKey: string;
80 readonly src: string;
81 readonly className?: string | undefined;
82 readonly fallbackIcon: ComponentType<{ className?: string }>;
83};
84
85type ImageElement = ReactElement<{
86 readonly src: string;
87 readonly onLoad?: () => void;
88 readonly onError?: () => void;
89}>;
90
91type ProjectFaviconImageElement = ReactElement<{
92 readonly children: [ReactElement | null, ImageElement | null, ImageElement | null];
93}>;
94
95function resolveImageComponent(): {
96 readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement;
97 readonly props: ProjectFaviconImageProps;
98} {
99 hooks.beginRender();
100 const element = ProjectFavicon({
101 project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }),
102 }) as ReactElement<ProjectFaviconImageProps>;
103 hooks.reset();
104
105 return {
106 Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement,
107 props: element.props,
108 };
109}
110
111function renderImage(
112 Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement,
113 props: ProjectFaviconImageProps,
114): ProjectFaviconImageElement {
115 hooks.beginRender();
116 return Component(props);
117}
118
119describe("ProjectFavicon", () => {
120 beforeEach(() => {
121 hooks.reset();
122 testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg";
123 });
124
125 it("shows the project monogram when no favicon exists", () => {
126 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
127
128 const element = ProjectFavicon({
129 project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }),
130 }) as ReactElement<{
131 readonly projectName?: string;
132 }>;
133
134 expect(element.props.projectName).toBe("analytics-db");
135 });
136
137 it("uses the same monogram fallback for every project category", () => {
138 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
139
140 const element = ProjectFavicon({
141 project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }),
142 }) as ReactElement<{
143 readonly projectName?: string;
144 }>;
145
146 expect(element.props.projectName).toBe("agent-runtime");
147 });
148
149 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {
150 const element = ProjectFavicon({
151 project: makeProject({
152 workspaceRoot: "/workspace/test",
153 title: "test",
154 faviconPath: "brand/icon.svg",
155 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
156 }),
157 }) as ReactElement<{
158 readonly children: ReactElement<{
159 readonly children: ReactElement<{ readonly name: string; readonly className: string }>;
160 }>;
161 readonly className: string;
162 }>;
163
164 expect(element.props.children.props.children.props.name).toBe("alarm-clock");
165 expect(element.props.className).toContain("text-violet-600");
166 expect(element.props.children.props.children.props.className).toContain("text-violet-600");
167 });
168
169 it("renders a saved emoji ahead of an uploaded favicon", () => {
170 const element = ProjectFavicon({
171 project: makeProject({
172 workspaceRoot: "/workspace/test",
173 title: "test",
174 faviconPath: "brand/icon.svg",
175 projectIcon: { kind: "emoji", emoji: "🦄" },
176 }),
177 }) as ReactElement<{ readonly emoji: string }>;
178
179 expect(element.props.emoji).toBe("🦄");
180 });
181
182 it("falls back when the displayed favicon fails without discarding a valid older image early", () => {
183 const { Component, props } = resolveImageComponent();
184 const initialLoadingImage = renderImage(Component, props).props.children[2];
185 initialLoadingImage?.props.onLoad?.();
186
187 const refreshedProps = {
188 ...props,
189 src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg",
190 };
191 const refreshing = renderImage(Component, refreshedProps).props.children;
192 expect(refreshing[1]?.props.src).toBe(props.src);
193 refreshing[2]?.props.onError?.();
194
195 const afterRefreshError = renderImage(Component, refreshedProps).props.children;
196 expect(afterRefreshError[1]?.props.src).toBe(props.src);
197 afterRefreshError[1]?.props.onError?.();
198
199 const afterDisplayedError = renderImage(Component, refreshedProps).props.children;
200 expect(afterDisplayedError[0]).not.toBeNull();
201 expect(afterDisplayedError[1]).toBeNull();
202 });
203
204 it("requests a saved favicon path when one is set", () => {
205 ProjectFavicon({
206 project: makeProject({
207 workspaceRoot: "/workspace-test",
208 title: "workspace-test",
209 faviconPath: "brand/icon.svg",
210 }),
211 });
212
213 expect(testState.lastTarget).toMatchObject({
214 environmentId: "environment-test",
215 cwd: "/workspace-test",
216 faviconPath: "brand/icon.svg",
217 });
218 });
219});