1import { assert, describe, it } from "@effect/vitest";
2import type { DesktopUpdateState } from "@t3tools/contracts";
3
4import {
5 MAX_REMOTE_UPDATE_CHECKS,
6 MAX_REMOTE_UPDATE_DOWNLOADS,
7 nextRemoteDesktopUpdateStep,
8 normalizeRemoteUpdateReason,
9 type RemoteDesktopUpdateAttempts,
10} from "./remoteUpdateFlow.ts";
11
12const NO_ATTEMPTS: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 };
13
14function makeState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
15 return {
16 enabled: true,
17 status: "idle",
18 channel: "latest",
19 currentVersion: "1.2.3",
20 hostArch: "arm64",
21 appArch: "arm64",
22 runningUnderArm64Translation: false,
23 availableVersion: null,
24 downloadedVersion: null,
25 releaseNotes: [],
26 downloadPercent: null,
27 checkedAt: null,
28 message: null,
29 errorContext: null,
30 canRetry: false,
31 omittedReleaseCount: 0,
32 ...overrides,
33 };
34}
35
36describe("nextRemoteDesktopUpdateStep", () => {
37 it("does not install again a version that did not apply on this machine", () => {
38 const step = nextRemoteDesktopUpdateStep(
39 makeState({ status: "available", availableVersion: "1.2.4", notAppliedVersion: "1.2.4" }),
40 NO_ATTEMPTS,
41 null,
42 );
43 assert.equal(step.action, "done");
44 assert.include(step.action === "done" ? step.reason : "", "1.2.4 did not apply");
45 assert.deepEqual(
46 nextRemoteDesktopUpdateStep(
47 makeState({ status: "available", availableVersion: "1.2.5", notAppliedVersion: "1.2.4" }),
48 NO_ATTEMPTS,
49 null,
50 ),
51 { action: "download" },
52 );
53 });
54
55 it("fails immediately when updates are disabled, preferring the known reason", () => {
56 const state = makeState({ enabled: false, status: "disabled" });
57 assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, "dev build"), {
58 action: "done",
59 outcome: "failed",
60 reason: "dev build",
61 });
62 assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, null), {
63 action: "done",
64 outcome: "failed",
65 reason: "Automatic updates are disabled on this machine.",
66 });
67 });
68
69 it("installs only from the downloaded status the updater will accept", () => {
70 assert.deepEqual(
71 nextRemoteDesktopUpdateStep(
72 makeState({ status: "downloaded", downloadedVersion: "1.2.4" }),
73 NO_ATTEMPTS,
74 null,
75 ),
76 { action: "install" },
77 );
78 // A previous install failure keeps status "downloaded", so a remote run
79 // retries the install.
80 assert.deepEqual(
81 nextRemoteDesktopUpdateStep(
82 makeState({
83 status: "downloaded",
84 downloadedVersion: "1.2.4",
85 errorContext: "install",
86 message: "quitAndInstall failed",
87 }),
88 NO_ATTEMPTS,
89 null,
90 ),
91 { action: "install" },
92 );
93 // A download survives an unrelated background updater error, so a run
94 // installs it instead of replaying that error.
95 assert.deepEqual(
96 nextRemoteDesktopUpdateStep(
97 makeState({
98 status: "error",
99 downloadedVersion: "1.2.4",
100 errorContext: null,
101 message: "background updater error",
102 }),
103 NO_ATTEMPTS,
104 null,
105 ),
106 { action: "install" },
107 );
108 // A leftover download behind a check error is not installable: fresh
109 // runs re-check, and post-check the error is terminal.
110 const staleError = makeState({
111 status: "error",
112 downloadedVersion: "1.2.4",
113 errorContext: "check",
114 message: "feed unreachable",
115 });
116 assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, NO_ATTEMPTS, null), {
117 action: "check",
118 });
119 assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, { checks: 1, downloads: 0 }, null), {
120 action: "done",
121 outcome: "failed",
122 reason: "feed unreachable",
123 });
124 });
125
126 it("rides along while a check or download is already in flight", () => {
127 assert.deepEqual(
128 nextRemoteDesktopUpdateStep(makeState({ status: "checking" }), NO_ATTEMPTS, null),
129 {
130 action: "wait",
131 },
132 );
133 assert.deepEqual(
134 nextRemoteDesktopUpdateStep(
135 makeState({ status: "downloading", availableVersion: "1.2.4", downloadPercent: 40 }),
136 NO_ATTEMPTS,
137 null,
138 ),
139 { action: "wait" },
140 );
141 });
142
143 it("downloads an available update until the attempt cap, then fails", () => {
144 const available = makeState({ status: "available", availableVersion: "1.2.4" });
145 assert.deepEqual(nextRemoteDesktopUpdateStep(available, NO_ATTEMPTS, null), {
146 action: "download",
147 });
148 assert.deepEqual(
149 nextRemoteDesktopUpdateStep(
150 available,
151 { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS },
152 null,
153 ),
154 {
155 action: "done",
156 outcome: "failed",
157 reason: "The desktop app failed to download the update.",
158 },
159 );
160 assert.deepEqual(
161 nextRemoteDesktopUpdateStep(
162 makeState({
163 status: "available",
164 availableVersion: "1.2.4",
165 message: "network blipped",
166 }),
167 { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS },
168 null,
169 ),
170 { action: "done", outcome: "failed", reason: "network blipped" },
171 );
172 });
173
174 it("re-checks stale up-to-date and error states before trusting them", () => {
175 // These states are retained from earlier/background checks; a remote
176 // request must look again instead of replaying them.
177 assert.deepEqual(
178 nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), NO_ATTEMPTS, null),
179 { action: "check" },
180 );
181 assert.deepEqual(
182 nextRemoteDesktopUpdateStep(
183 makeState({ status: "error", message: "feed unreachable" }),
184 NO_ATTEMPTS,
185 null,
186 ),
187 { action: "check" },
188 );
189 });
190
191 it("reports up-to-date and error states as terminal after this run's check", () => {
192 const checked = { checks: 1, downloads: 0 };
193 assert.deepEqual(
194 nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), checked, null),
195 { action: "done", outcome: "up-to-date" },
196 );
197 assert.deepEqual(
198 nextRemoteDesktopUpdateStep(
199 makeState({ status: "error", message: "feed unreachable" }),
200 checked,
201 null,
202 ),
203 { action: "done", outcome: "failed", reason: "feed unreachable" },
204 );
205 assert.deepEqual(nextRemoteDesktopUpdateStep(makeState({ status: "error" }), checked, null), {
206 action: "done",
207 outcome: "failed",
208 reason: "The desktop app update failed.",
209 });
210 });
211
212 it("checks from idle until the attempt cap, then fails", () => {
213 assert.deepEqual(nextRemoteDesktopUpdateStep(makeState(), NO_ATTEMPTS, null), {
214 action: "check",
215 });
216 assert.deepEqual(
217 nextRemoteDesktopUpdateStep(
218 makeState(),
219 { checks: MAX_REMOTE_UPDATE_CHECKS, downloads: 0 },
220 null,
221 ),
222 {
223 action: "done",
224 outcome: "failed",
225 reason: "The desktop app did not report an update result.",
226 },
227 );
228 });
229
230 it("drops blank reasons so the wire report still encodes", () => {
231 assert.equal(normalizeRemoteUpdateReason(undefined), undefined);
232 assert.equal(normalizeRemoteUpdateReason(""), undefined);
233 assert.equal(normalizeRemoteUpdateReason(" "), undefined);
234 assert.equal(normalizeRemoteUpdateReason(" feed unreachable "), "feed unreachable");
235 });
236});