1// @vitest-environment jsdom
2import type { DesktopUpdateState } from "@t3tools/contracts";
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const test = vi.hoisted(() => ({
8 state: null as DesktopUpdateState | null,
9 run: vi.fn().mockResolvedValue(undefined),
10 hasUpdates: true,
11 restarts: [] as Array<{ environmentId: string; continuesThreads: boolean }>,
12 toast: vi.fn(),
13}));
14
15vi.mock("../../env", () => ({ isElectron: true }));
16vi.mock("../../hooks/useMediaQuery", () => ({ useMediaQuery: () => true }));
17vi.mock("../../state/desktopUpdate", () => ({ useDesktopUpdateState: () => test.state }));
18vi.mock("../../state/server", () => ({ serverEnvironment: {} }));
19vi.mock("~/state/threads", () => ({ environmentThreadShells: {} }));
20vi.mock("@effect/atom-react", () => ({ useAtomValue: () => 0 }));
21vi.mock("./useUpdateEverything", () => ({
22 useUpdateEverything: () => ({
23 hasUpdates: test.hasUpdates,
24 servers: [],
25 summary: "Update providers",
26 lines: ["Home: Codex"],
27 skipped: [],
28 restarts: test.restarts,
29 run: test.run,
30 }),
31}));
32vi.mock("./updateEverythingRun", () => ({ useUpdateEverythingRun: () => null }));
33vi.mock("../ui/toast", () => ({
34 toastManager: { add: test.toast },
35 stackedThreadToast: (toast: unknown) => toast,
36}));
37vi.mock("../desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: vi.fn() }));
38vi.mock("../../localApi", () => ({ ensureLocalApi: vi.fn() }));
39
40import { ensureLocalApi } from "../../localApi";
41import { AppMenuUpdates } from "./SidebarUpdatePill";
42
43const baseState: DesktopUpdateState = {
44 enabled: true,
45 status: "idle",
46 channel: "latest",
47 currentVersion: "1.0.0",
48 hostArch: "arm64",
49 appArch: "arm64",
50 runningUnderArm64Translation: false,
51 availableVersion: null,
52 downloadedVersion: null,
53 releaseNotes: [],
54 omittedReleaseCount: 0,
55 downloadPercent: null,
56 checkedAt: null,
57 message: null,
58 errorContext: null,
59 canRetry: false,
60};
61
62let root: Root;
63let container: HTMLDivElement;
64
65async function click(label: string) {
66 const button = Array.from(document.querySelectorAll("button")).find(
67 (element) => element.textContent?.trim() === label,
68 );
69 expect(button, `Button ${label}`).toBeDefined();
70 await act(async () => button?.click());
71}
72
73beforeEach(() => {
74 test.state = baseState;
75 test.hasUpdates = true;
76 test.restarts = [];
77 test.run.mockReset().mockResolvedValue(undefined);
78 test.toast.mockReset();
79 vi.mocked(ensureLocalApi).mockReset();
80 container = document.createElement("div");
81 document.body.append(container);
82 root = createRoot(container);
83 Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
84});
85
86afterEach(async () => {
87 await act(async () => root.unmount());
88 container.remove();
89 Reflect.deleteProperty(window, "desktopBridge");
90 vi.unstubAllGlobals();
91});
92
93describe("app menu update execution", () => {
94 it("updates providers directly when no app or server will restart", async () => {
95 await act(async () => root.render(<AppMenuUpdates />));
96 await click("Update all");
97
98 expect(test.run).toHaveBeenCalledExactlyOnceWith({});
99 expect(document.querySelector('[role="alertdialog"]')).toBeNull();
100 });
101
102 it("waits for confirmation when a remote server will restart", async () => {
103 test.restarts = [{ environmentId: "home", continuesThreads: false }];
104 await act(async () => root.render(<AppMenuUpdates />));
105 await click("Update all");
106
107 expect(test.run).not.toHaveBeenCalled();
108 expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
109 await click("Update");
110 expect(test.run).toHaveBeenCalledOnce();
111 });
112
113 it("keeps an app update behind restart confirmation even without connected servers", async () => {
114 test.state = { ...baseState, status: "available", availableVersion: "1.1.0" };
115 test.hasUpdates = false;
116 await act(async () => root.render(<AppMenuUpdates />));
117 await click("Update all");
118
119 expect(test.run).not.toHaveBeenCalled();
120 expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
121 await click("Cancel");
122 expect(test.run).not.toHaveBeenCalled();
123 });
124
125 it("reports a failed update run and allows retry", async () => {
126 test.run.mockRejectedValueOnce(new Error("Disconnected"));
127 await act(async () => root.render(<AppMenuUpdates />));
128 await click("Update all");
129
130 expect(test.toast).toHaveBeenCalledWith({
131 type: "error",
132 title: "Could not update everything",
133 description: "Disconnected",
134 });
135 await click("Update all");
136 expect(test.run).toHaveBeenCalledTimes(2);
137 });
138 it("retries a failed app download without restarting", async () => {
139 test.state = {
140 ...baseState,
141 status: "error",
142 errorContext: "download",
143 availableVersion: "1.1.0",
144 message: "Offline",
145 canRetry: true,
146 };
147 const downloadUpdate = vi.fn().mockResolvedValue({
148 accepted: true,
149 completed: true,
150 state: { ...test.state, status: "downloaded", downloadedVersion: "1.1.0" },
151 });
152 const installUpdate = vi.fn();
153 Object.defineProperty(window, "desktopBridge", {
154 configurable: true,
155 value: { downloadUpdate, installUpdate },
156 });
157 await act(async () => root.render(<AppMenuUpdates />));
158 await click("Retry download");
159
160 expect(downloadUpdate).toHaveBeenCalledOnce();
161 expect(installUpdate).not.toHaveBeenCalled();
162 });
163
164 it("reloads a compatible UI update without asking to interrupt work", async () => {
165 test.state = {
166 ...baseState,
167 status: "downloaded",
168 downloadedVersion: "1.1.0",
169 installKind: "ui",
170 };
171 const installUpdate = vi
172 .fn()
173 .mockResolvedValue({ accepted: true, completed: true, state: { ...baseState } });
174 Object.defineProperty(window, "desktopBridge", {
175 configurable: true,
176 value: { installUpdate },
177 });
178 await act(async () => root.render(<AppMenuUpdates />));
179 await click("Reload");
180 expect(installUpdate).toHaveBeenCalledOnce();
181 expect(ensureLocalApi).not.toHaveBeenCalled();
182 });
183
184 it.each([false, true])(
185 "requires confirmation before a full-app restart (confirm=%s)",
186 async (accepted) => {
187 test.state = {
188 ...baseState,
189 status: "downloaded",
190 downloadedVersion: "1.1.0",
191 installKind: "app",
192 };
193 const confirm = vi.fn().mockResolvedValue(accepted);
194 const { createLocalApi } =
195 await vi.importActual<typeof import("../../localApi")>("../../localApi");
196 const api = createLocalApi();
197 vi.mocked(ensureLocalApi).mockReturnValue({ ...api, dialogs: { ...api.dialogs, confirm } });
198 const installUpdate = vi
199 .fn()
200 .mockResolvedValue({ accepted: true, completed: false, state: test.state });
201 Object.defineProperty(window, "desktopBridge", {
202 configurable: true,
203 value: { installUpdate },
204 });
205 await act(async () => root.render(<AppMenuUpdates />));
206 await click("Restart");
207 expect(confirm).toHaveBeenCalledOnce();
208 expect(installUpdate).toHaveBeenCalledTimes(accepted ? 1 : 0);
209 },
210 );
211
212 it("reports an unavailable app update check", async () => {
213 const checkForUpdate = vi.fn().mockResolvedValue({
214 checked: false,
215 state: { ...baseState, message: "Updates disabled" },
216 });
217 Object.defineProperty(window, "desktopBridge", {
218 configurable: true,
219 value: { checkForUpdate },
220 });
221 await act(async () => root.render(<AppMenuUpdates />));
222 await click("Check");
223
224 expect(test.toast).toHaveBeenCalledWith({
225 type: "error",
226 title: "Could not check for updates",
227 description: "Updates disabled",
228 });
229 });
230});