1import type { DesktopUpdateState } from "@t3tools/contracts";
2import * as AsyncResult from "effect/reactivity/AsyncResult";
3import { AtomRegistry } from "effect/reactivity";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { createDesktopUpdateStateAtom, DesktopUpdateStateReadError } from "./desktopUpdate";
7
8const baseState: DesktopUpdateState = {
9 enabled: true,
10 status: "idle",
11 channel: "latest",
12 currentVersion: "1.0.0",
13 hostArch: "x64",
14 appArch: "x64",
15 runningUnderArm64Translation: false,
16 availableVersion: null,
17 downloadedVersion: null,
18 releaseNotes: [],
19 omittedReleaseCount: 0,
20 downloadPercent: null,
21 checkedAt: null,
22 message: null,
23 errorContext: null,
24 canRetry: false,
25};
26
27afterEach(() => {
28 vi.restoreAllMocks();
29});
30
31describe("desktopUpdateStateAtom", () => {
32 it("loads once, retains state, and follows desktop update events", async () => {
33 let listener: ((state: DesktopUpdateState) => void) | undefined;
34 const unsubscribe = vi.fn();
35 const getUpdateState = vi.fn(async () => baseState);
36 const onUpdateState = vi.fn((nextListener: (state: DesktopUpdateState) => void) => {
37 listener = nextListener;
38 return unsubscribe;
39 });
40 const atom = createDesktopUpdateStateAtom(() => ({ getUpdateState, onUpdateState }));
41 const registry = AtomRegistry.make();
42
43 const unmount = registry.mount(atom);
44 await vi.waitFor(() => {
45 expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState);
46 });
47
48 const downloadedState: DesktopUpdateState = {
49 ...baseState,
50 status: "downloaded",
51 availableVersion: "1.1.0",
52 downloadedVersion: "1.1.0",
53 };
54 listener?.(downloadedState);
55
56 await vi.waitFor(() => {
57 expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState);
58 });
59 unmount();
60
61 const remount = registry.mount(atom);
62 expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState);
63 expect(getUpdateState).toHaveBeenCalledTimes(1);
64 expect(onUpdateState).toHaveBeenCalledTimes(1);
65
66 remount();
67 registry.dispose();
68 expect(unsubscribe).toHaveBeenCalledTimes(1);
69 });
70
71 it("does not let a slower initial read overwrite a newer update event", async () => {
72 let resolveInitial: ((state: DesktopUpdateState) => void) | undefined;
73 let listener: ((state: DesktopUpdateState) => void) | undefined;
74 const atom = createDesktopUpdateStateAtom(() => ({
75 getUpdateState: () =>
76 new Promise<DesktopUpdateState>((resolve) => {
77 resolveInitial = resolve;
78 }),
79 onUpdateState: (nextListener) => {
80 listener = nextListener;
81 return () => undefined;
82 },
83 }));
84 const registry = AtomRegistry.make();
85 registry.mount(atom);
86
87 await vi.waitFor(() => expect(listener).toBeDefined());
88 const newerState: DesktopUpdateState = { ...baseState, status: "checking" };
89 listener?.(newerState);
90 resolveInitial?.(baseState);
91
92 await vi.waitFor(() => {
93 expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(newerState);
94 });
95 registry.dispose();
96 });
97
98 it("keeps listening when the initial desktop state read fails", async () => {
99 let listener: ((state: DesktopUpdateState) => void) | undefined;
100 const cause = new Error("IPC unavailable");
101 const reportError = vi.spyOn(console, "log").mockImplementation(() => undefined);
102 const getUpdateState = vi.fn(async () => Promise.reject(cause));
103 const atom = createDesktopUpdateStateAtom(() => ({
104 getUpdateState,
105 onUpdateState: (nextListener) => {
106 listener = nextListener;
107 return () => undefined;
108 },
109 }));
110 const registry = AtomRegistry.make();
111 registry.mount(atom);
112
113 await vi.waitFor(() => expect(listener).toBeDefined());
114 await vi.waitFor(() => expect(reportError).toHaveBeenCalledOnce());
115 expect(getUpdateState).toHaveBeenCalledTimes(3);
116 const [, errorMessage, errorContext] = reportError.mock.calls[0] ?? [];
117 expect(errorMessage).toBe("Failed to read the initial desktop update state after 3 attempts.");
118 expect(errorContext).toMatchObject({
119 errorTag: "DesktopUpdateStateReadError",
120 attemptCount: 3,
121 });
122 const loggedError = (errorContext as { readonly error: unknown }).error;
123 expect(loggedError).toBeInstanceOf(DesktopUpdateStateReadError);
124 expect(loggedError).toMatchObject({
125 _tag: "DesktopUpdateStateReadError",
126 attemptCount: 3,
127 });
128 expect((loggedError as DesktopUpdateStateReadError).cause).toBe(cause);
129
130 listener?.(baseState);
131 await vi.waitFor(() => {
132 expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState);
133 });
134 registry.dispose();
135 });
136});