1import { RegistryContext } from "@effect/atom-react";
2import { EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts";
3import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings";
4import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
5import { act, useEffect } from "react";
6import { create, type ReactTestRenderer } from "react-test-renderer";
7import { afterEach, expect, it, vi } from "vite-plus/test";
8
9const mocks = vi.hoisted(() => ({
10 persist: vi.fn<(settings: ClientSettings) => Promise<void>>().mockResolvedValue(undefined),
11 load: vi.fn<() => Promise<ClientSettings | null>>().mockResolvedValue(null),
12 rpc: vi.fn(),
13 changes: null as Atom.Writable<AsyncResult.AsyncResult<UiSettingsProfile>> | null,
14 supported: false,
15 read: Symbol("read"),
16 patch: Symbol("patch"),
17}));
18vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
19vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: undefined }));
20vi.mock("../localApi", () => ({
21 ensureLocalApi: () => ({
22 persistence: { getClientSettings: mocks.load, setClientSettings: mocks.persist },
23 }),
24}));
25vi.mock("./session", () => ({
26 environmentSession: {},
27 readEnvironmentScope: () => false,
28 useEnvironmentScope: () => false,
29}));
30vi.mock("./server", () => ({ serverEnvironment: {}, primaryServerSettingsAtom: undefined }));
31vi.mock("./environments", () => ({
32 useEnvironments: () => ({
33 environments: [
34 {
35 environmentId: "personal",
36 label: "Personal",
37 connection: { phase: "connected" },
38 serverConfig: { environment: { capabilities: { uiSettingsSync: mocks.supported } } },
39 },
40 ],
41 }),
42}));
43vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({
44 ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()),
45 runAtomCommand: mocks.rpc,
46}));
47vi.mock("@t3tools/client-runtime/state/ui-settings-sync", async (original) => ({
48 ...(await original<typeof import("@t3tools/client-runtime/state/ui-settings-sync")>()),
49 createUiSettingsSyncAtoms: () => ({
50 changes: () => mocks.changes,
51 patch: mocks.patch,
52 read: mocks.read,
53 }),
54}));
55
56import {
57 ensureClientSettingsHydrated,
58 getClientSettings,
59 persistClientSettingsPatch,
60 subscribeClientSettingsEdits,
61 useClientSettings,
62 __resetClientSettingsPersistenceForTests,
63} from "../hooks/useSettings";
64import { readUiThemePreferences, useTheme } from "../hooks/useTheme";
65import { getStandardThemeColors, getCustomThemes } from "../themePalette";
66import {
67 enableUiSettingsSync,
68 disableUiSettingsSync,
69 usePersonalUiSettingsSync,
70} from "./uiSettingsSync";
71
72let renderer: ReactTestRenderer | undefined;
73const registry = AtomRegistry.make();
74afterEach(async () => {
75 await act(async () => renderer?.unmount());
76 renderer = undefined;
77 await disableUiSettingsSync();
78 __resetClientSettingsPersistenceForTests();
79 vi.unstubAllGlobals();
80});
81
82it("adopts and follows a second client's theme and fonts through the existing stores without echoing", async () => {
83 const storage = new Map<string, string>();
84 const desktopTheme = vi.fn().mockResolvedValue(undefined);
85 vi.stubGlobal("window", {
86 localStorage: {
87 getItem: (key: string) => storage.get(key) ?? null,
88 setItem: (key: string, value: string) => storage.set(key, value),
89 removeItem: (key: string) => storage.delete(key),
90 },
91 matchMedia: () => ({
92 matches: false,
93 addEventListener: () => {},
94 removeEventListener: () => {},
95 }),
96 addEventListener: () => {},
97 removeEventListener: () => {},
98 desktopBridge: { setTheme: desktopTheme },
99 });
100 const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
101 const root = {
102 style,
103 dataset: {} as Record<string, string>,
104 classList: { add: vi.fn(), toggle: vi.fn(), remove: vi.fn() },
105 };
106 vi.stubGlobal("document", { documentElement: root });
107 vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
108 callback();
109 return 1;
110 });
111 mocks.changes = Atom.make<AsyncResult.AsyncResult<UiSettingsProfile>>(AsyncResult.initial());
112 mocks.supported = false;
113 mocks.rpc.mockReset();
114 await ensureClientSettingsHydrated();
115 let observed = DEFAULT_CLIENT_SETTINGS;
116 function Client() {
117 usePersonalUiSettingsSync();
118 const settings = useClientSettings();
119 useEffect(() => {
120 observed = settings;
121 }, [settings]);
122 useTheme();
123 return null;
124 }
125 await act(async () => {
126 renderer = create(
127 <RegistryContext.Provider value={registry}>
128 <Client />
129 </RegistryContext.Provider>,
130 );
131 });
132 const theme = {
133 selection: "personal-dark",
134 mode: "dark" as const,
135 halves: {},
136 customThemes: [
137 {
138 id: "personal-dark",
139 name: "Personal dark",
140 appearance: "dark" as const,
141 colors: getStandardThemeColors("dark"),
142 },
143 ],
144 };
145 const saved: UiSettingsProfile = {
146 revision: 1,
147 values: { theme, fontFamilyCode: "Saved Mono", fontSizeCode: 17, fontSizeTerminal: 15 },
148 fieldRevisions: { theme: 1, fontFamilyCode: 1, fontSizeCode: 1, fontSizeTerminal: 1 },
149 };
150 const edit = vi.fn();
151 const unsubscribe = subscribeClientSettingsEdits(edit);
152 await act(async () => {
153 await enableUiSettingsSync(EnvironmentId.make("personal"), saved, "adopt");
154 });
155 expect(observed.fontFamilyCode).toBe("Saved Mono");
156 expect(getClientSettings().fontSizeTerminal).toBe(15);
157 expect(readUiThemePreferences().selection).toBe("personal-dark");
158 expect(getCustomThemes()[0]?.id).toBe("personal-dark");
159 expect(root.dataset.themeId).toBe("personal-dark");
160 expect(desktopTheme).toHaveBeenCalledWith("dark");
161 expect(style.setProperty).toHaveBeenCalled();
162 expect(edit).not.toHaveBeenCalled();
163 expect(mocks.rpc).not.toHaveBeenCalled();
164
165 mocks.supported = true;
166 const remote = {
167 ...saved,
168 revision: 2,
169 values: { ...saved.values, fontFamilyCode: "Second Mono", fontSizeTerminal: 18 },
170 fieldRevisions: { ...saved.fieldRevisions, fontFamilyCode: 2, fontSizeTerminal: 2 },
171 };
172 mocks.rpc.mockResolvedValue(AsyncResult.success(remote));
173 await act(async () => {
174 registry.set(mocks.changes!, AsyncResult.success(remote));
175 renderer?.update(
176 <RegistryContext.Provider value={registry}>
177 <Client />
178 </RegistryContext.Provider>,
179 );
180 });
181 expect(observed.fontFamilyCode).toBe("Second Mono");
182 expect(observed.fontSizeTerminal).toBe(18);
183 expect(edit).not.toHaveBeenCalled();
184 expect(mocks.rpc).toHaveBeenCalledTimes(1);
185 await act(async () => {
186 registry.set(
187 mocks.changes!,
188 AsyncResult.waiting(
189 AsyncResult.success({
190 ...remote,
191 revision: 3,
192 values: { ...remote.values, fontFamilyCode: "Live Mono", fontSizeTerminal: 20 },
193 fieldRevisions: { ...remote.fieldRevisions, fontFamilyCode: 3, fontSizeTerminal: 3 },
194 }),
195 ),
196 );
197 });
198 expect(observed.fontFamilyCode).toBe("Live Mono");
199 expect(observed.fontSizeTerminal).toBe(20);
200 expect(edit).not.toHaveBeenCalled();
201 expect(mocks.rpc).toHaveBeenCalledTimes(1);
202 await act(async () => {
203 await disableUiSettingsSync();
204 await persistClientSettingsPatch({ fontSizeCode: 19 });
205 });
206 expect(edit).toHaveBeenCalledWith({ fontSizeCode: 19 });
207 expect(getClientSettings().fontSizeCode).toBe(19);
208 expect(mocks.rpc).toHaveBeenCalledTimes(1);
209 unsubscribe();
210});