| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | |
| 4 | import { |
| 5 | canCheckForUpdate, |
| 6 | getArm64IntelBuildWarningDescription, |
| 7 | getDesktopUpdateActionError, |
| 8 | getDesktopUpdateButtonTooltip, |
| 9 | getDesktopUpdateInstallConfirmationMessage, |
| 10 | getDesktopUpdateReleaseHistoryUrl, |
| 11 | getDesktopUpdateReleaseUrl, |
| 12 | isDesktopUpdateButtonDisabled, |
| 13 | resolveDesktopUpdateButtonAction, |
| 14 | shouldShowArm64IntelBuildWarning, |
| 15 | shouldToastDesktopUpdateActionResult, |
| 16 | } from "./desktopUpdate.logic"; |
| 17 | |
| 18 | const baseState: DesktopUpdateState = { |
| 19 | enabled: true, |
| 20 | status: "idle", |
| 21 | channel: "latest", |
| 22 | currentVersion: "1.0.0", |
| 23 | hostArch: "x64", |
| 24 | appArch: "x64", |
| 25 | runningUnderArm64Translation: false, |
| 26 | availableVersion: null, |
| 27 | downloadedVersion: null, |
| 28 | releaseNotes: [], |
| 29 | omittedReleaseCount: 0, |
| 30 | downloadPercent: null, |
| 31 | checkedAt: null, |
| 32 | message: null, |
| 33 | errorContext: null, |
| 34 | canRetry: false, |
| 35 | }; |
| 36 | |
| 37 | describe("desktop update button state", () => { |
| 38 | it("shows a download action when an update is available", () => { |
| 39 | const state: DesktopUpdateState = { |
| 40 | ...baseState, |
| 41 | status: "available", |
| 42 | availableVersion: "1.1.0", |
| 43 | }; |
| 44 | expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); |
| 45 | }); |
| 46 | |
| 47 | it("keeps retry action available after a download error", () => { |
| 48 | const state: DesktopUpdateState = { |
| 49 | ...baseState, |
| 50 | status: "error", |
| 51 | availableVersion: "1.1.0", |
| 52 | message: "network timeout", |
| 53 | errorContext: "download", |
| 54 | canRetry: true, |
| 55 | }; |
| 56 | expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); |
| 57 | expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); |
| 58 | }); |
| 59 | |
| 60 | it("says when the same version did not apply before", () => { |
| 61 | const state: DesktopUpdateState = { |
| 62 | ...baseState, |
| 63 | status: "downloaded", |
| 64 | availableVersion: "1.1.0", |
| 65 | downloadedVersion: "1.1.0", |
| 66 | notAppliedVersion: "1.1.0", |
| 67 | }; |
| 68 | expect(getDesktopUpdateButtonTooltip(state)).toBe( |
| 69 | "Update 1.1.0 did not apply. Click to retry.", |
| 70 | ); |
| 71 | expect( |
| 72 | getDesktopUpdateButtonTooltip({ |
| 73 | ...state, |
| 74 | availableVersion: "1.2.0", |
| 75 | downloadedVersion: "1.2.0", |
| 76 | }), |
| 77 | ).toContain("1.2.0 downloaded"); |
| 78 | }); |
| 79 | |
| 80 | it("keeps install action available after an install error", () => { |
| 81 | const state: DesktopUpdateState = { |
| 82 | ...baseState, |
| 83 | status: "error", |
| 84 | downloadedVersion: "1.1.0", |
| 85 | availableVersion: "1.1.0", |
| 86 | message: "shutdown timeout", |
| 87 | errorContext: "install", |
| 88 | canRetry: true, |
| 89 | }; |
| 90 | expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); |
| 91 | expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); |
| 92 | }); |
| 93 | |
| 94 | it("keeps install action available after a background updater error", () => { |
| 95 | const state: DesktopUpdateState = { |
| 96 | ...baseState, |
| 97 | status: "error", |
| 98 | downloadedVersion: "1.1.0", |
| 99 | availableVersion: "1.1.0", |
| 100 | message: "background updater error", |
| 101 | errorContext: null, |
| 102 | canRetry: true, |
| 103 | }; |
| 104 | expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); |
| 105 | expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to restart and install"); |
| 106 | }); |
| 107 | |
| 108 | it("prefers a newly available release over a stale downloaded version", () => { |
| 109 | const state: DesktopUpdateState = { |
| 110 | ...baseState, |
| 111 | status: "available", |
| 112 | availableVersion: "1.2.0", |
| 113 | downloadedVersion: "1.1.0", |
| 114 | }; |
| 115 | expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); |
| 116 | }); |
| 117 | |
| 118 | it("hides the install action while checking for a newer release", () => { |
| 119 | const state: DesktopUpdateState = { |
| 120 | ...baseState, |
| 121 | status: "checking", |
| 122 | availableVersion: "1.1.0", |
| 123 | downloadedVersion: "1.1.0", |
| 124 | downloadPercent: 100, |
| 125 | }; |
| 126 | expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); |
| 127 | }); |
| 128 | |
| 129 | it("has no action for non-actionable check errors", () => { |
| 130 | const state: DesktopUpdateState = { |
| 131 | ...baseState, |
| 132 | status: "error", |
| 133 | message: "network unavailable", |
| 134 | errorContext: "check", |
| 135 | canRetry: true, |
| 136 | }; |
| 137 | expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); |
| 138 | }); |
| 139 | |
| 140 | it("disables the button while downloading", () => { |
| 141 | const state: DesktopUpdateState = { |
| 142 | ...baseState, |
| 143 | status: "downloading", |
| 144 | availableVersion: "1.1.0", |
| 145 | downloadPercent: 42.5, |
| 146 | }; |
| 147 | expect(isDesktopUpdateButtonDisabled(state)).toBe(true); |
| 148 | expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); |
| 149 | }); |
| 150 | }); |
| 151 | |
| 152 | describe("getDesktopUpdateActionError", () => { |
| 153 | it("returns user-visible message for accepted failed attempts", () => { |
| 154 | const result: DesktopUpdateActionResult = { |
| 155 | accepted: true, |
| 156 | completed: false, |
| 157 | state: { |
| 158 | ...baseState, |
| 159 | status: "available", |
| 160 | availableVersion: "1.1.0", |
| 161 | message: "checksum mismatch", |
| 162 | errorContext: "download", |
| 163 | canRetry: true, |
| 164 | }, |
| 165 | }; |
| 166 | expect(getDesktopUpdateActionError(result)).toBe("checksum mismatch"); |
| 167 | }); |
| 168 | |
| 169 | it("ignores messages for non-accepted attempts", () => { |
| 170 | const result: DesktopUpdateActionResult = { |
| 171 | accepted: false, |
| 172 | completed: false, |
| 173 | state: { |
| 174 | ...baseState, |
| 175 | status: "error", |
| 176 | message: "background failure", |
| 177 | errorContext: "check", |
| 178 | canRetry: false, |
| 179 | }, |
| 180 | }; |
| 181 | expect(getDesktopUpdateActionError(result)).toBeNull(); |
| 182 | }); |
| 183 | |
| 184 | it("ignores messages for successful attempts", () => { |
| 185 | const result: DesktopUpdateActionResult = { |
| 186 | accepted: true, |
| 187 | completed: true, |
| 188 | state: { |
| 189 | ...baseState, |
| 190 | status: "downloaded", |
| 191 | downloadedVersion: "1.1.0", |
| 192 | availableVersion: "1.1.0", |
| 193 | message: null, |
| 194 | errorContext: null, |
| 195 | canRetry: true, |
| 196 | }, |
| 197 | }; |
| 198 | expect(getDesktopUpdateActionError(result)).toBeNull(); |
| 199 | }); |
| 200 | }); |
| 201 | |
| 202 | describe("desktop update UI helpers", () => { |
| 203 | it("builds the stable release URL for a downloaded version", () => { |
| 204 | expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( |
| 205 | "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", |
| 206 | ); |
| 207 | }); |
| 208 | |
| 209 | it("builds the nightly release URL without dropping its version suffix", () => { |
| 210 | expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( |
| 211 | "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", |
| 212 | ); |
| 213 | }); |
| 214 | |
| 215 | it("omits the release URL when the updater does not report a version", () => { |
| 216 | expect(getDesktopUpdateReleaseUrl(null)).toBeNull(); |
| 217 | expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); |
| 218 | }); |
| 219 | |
| 220 | it("builds the release history URL", () => { |
| 221 | expect(getDesktopUpdateReleaseHistoryUrl()).toBe( |
| 222 | "https://github.com/pingdotgg/t3code/releases", |
| 223 | ); |
| 224 | }); |
| 225 | |
| 226 | it("toasts only for actionable updater errors", () => { |
| 227 | expect( |
| 228 | shouldToastDesktopUpdateActionResult({ |
| 229 | accepted: true, |
| 230 | completed: false, |
| 231 | state: { ...baseState, message: "checksum mismatch" }, |
| 232 | }), |
| 233 | ).toBe(true); |
| 234 | expect( |
| 235 | shouldToastDesktopUpdateActionResult({ |
| 236 | accepted: true, |
| 237 | completed: false, |
| 238 | state: { ...baseState, message: null }, |
| 239 | }), |
| 240 | ).toBe(false); |
| 241 | expect( |
| 242 | shouldToastDesktopUpdateActionResult({ |
| 243 | accepted: true, |
| 244 | completed: true, |
| 245 | state: { ...baseState, message: "checksum mismatch" }, |
| 246 | }), |
| 247 | ).toBe(false); |
| 248 | }); |
| 249 | |
| 250 | it("shows an Apple Silicon warning for Intel builds under Rosetta", () => { |
| 251 | const state: DesktopUpdateState = { |
| 252 | ...baseState, |
| 253 | hostArch: "arm64", |
| 254 | appArch: "x64", |
| 255 | runningUnderArm64Translation: true, |
| 256 | }; |
| 257 | |
| 258 | expect(shouldShowArm64IntelBuildWarning(state)).toBe(true); |
| 259 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Apple Silicon"); |
| 260 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Intel build"); |
| 261 | }); |
| 262 | |
| 263 | it("changes the warning copy when a native build update is ready to download", () => { |
| 264 | const state: DesktopUpdateState = { |
| 265 | ...baseState, |
| 266 | hostArch: "arm64", |
| 267 | appArch: "x64", |
| 268 | runningUnderArm64Translation: true, |
| 269 | status: "available", |
| 270 | availableVersion: "1.1.0", |
| 271 | }; |
| 272 | |
| 273 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); |
| 274 | }); |
| 275 | |
| 276 | it("includes the downloaded version in the install confirmation copy", () => { |
| 277 | expect( |
| 278 | getDesktopUpdateInstallConfirmationMessage({ |
| 279 | availableVersion: "1.1.0", |
| 280 | downloadedVersion: "1.1.1", |
| 281 | }), |
| 282 | ).toContain("Install update 1.1.1 and restart The Knife?"); |
| 283 | }); |
| 284 | |
| 285 | it("falls back to generic install confirmation copy when no version is available", () => { |
| 286 | expect( |
| 287 | getDesktopUpdateInstallConfirmationMessage({ |
| 288 | availableVersion: null, |
| 289 | downloadedVersion: null, |
| 290 | }), |
| 291 | ).toContain("Install update and restart The Knife?"); |
| 292 | }); |
| 293 | |
| 294 | it("keeps the same install confirmation copy across desktop platforms", () => { |
| 295 | expect( |
| 296 | getDesktopUpdateInstallConfirmationMessage({ |
| 297 | availableVersion: "1.1.0", |
| 298 | downloadedVersion: "1.1.0", |
| 299 | }), |
| 300 | ).toBe( |
| 301 | "Install update 1.1.0 and restart The Knife?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.", |
| 302 | ); |
| 303 | }); |
| 304 | }); |
| 305 | |
| 306 | describe("canCheckForUpdate", () => { |
| 307 | it("returns false for null state", () => { |
| 308 | expect(canCheckForUpdate(null)).toBe(false); |
| 309 | }); |
| 310 | |
| 311 | it("returns false when updates are disabled", () => { |
| 312 | expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false); |
| 313 | }); |
| 314 | |
| 315 | it("returns false while checking", () => { |
| 316 | expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false); |
| 317 | }); |
| 318 | |
| 319 | it("returns false while downloading", () => { |
| 320 | expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe( |
| 321 | false, |
| 322 | ); |
| 323 | }); |
| 324 | |
| 325 | it("returns true once an update has been downloaded so newer releases can be found", () => { |
| 326 | expect( |
| 327 | canCheckForUpdate({ |
| 328 | ...baseState, |
| 329 | status: "downloaded", |
| 330 | availableVersion: "1.1.0", |
| 331 | downloadedVersion: "1.1.0", |
| 332 | }), |
| 333 | ).toBe(true); |
| 334 | }); |
| 335 | |
| 336 | it("returns true when idle", () => { |
| 337 | expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); |
| 338 | }); |
| 339 | |
| 340 | it("returns true when up-to-date", () => { |
| 341 | expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true); |
| 342 | }); |
| 343 | |
| 344 | it("returns true when an update is available", () => { |
| 345 | expect( |
| 346 | canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }), |
| 347 | ).toBe(true); |
| 348 | }); |
| 349 | |
| 350 | it("returns true on error so the user can retry", () => { |
| 351 | expect( |
| 352 | canCheckForUpdate({ |
| 353 | ...baseState, |
| 354 | status: "error", |
| 355 | errorContext: "check", |
| 356 | message: "network", |
| 357 | }), |
| 358 | ).toBe(true); |
| 359 | }); |
| 360 | }); |
| 361 | |
| 362 | describe("getDesktopUpdateButtonTooltip", () => { |
| 363 | it("returns 'Up to date' for non-actionable states", () => { |
| 364 | expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date"); |
| 365 | expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe( |
| 366 | "Up to date", |
| 367 | ); |
| 368 | }); |
| 369 | }); |