| 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | |
| 5 | // Pinned so the direction cases below read as fixed versions instead of |
| 6 | // arithmetic on whatever version this checkout happens to be at. |
| 7 | const branding = vi.hoisted(() => ({ APP_VERSION: "0.0.34" })); |
| 8 | vi.mock("./branding", () => branding); |
| 9 | |
| 10 | import { APP_VERSION } from "./branding"; |
| 11 | import { |
| 12 | appendVersionMismatchHint, |
| 13 | buildVersionMismatchDismissalKey, |
| 14 | dismissServerUpdateFailure, |
| 15 | dismissVersionMismatch, |
| 16 | isServerUpdateFailureDismissed, |
| 17 | isVersionMismatchDismissed, |
| 18 | manualServerUpdateCommand, |
| 19 | resolveServerConfigVersionMismatch, |
| 20 | resolveServerSelfUpdateCapability, |
| 21 | resolveVersionMismatch, |
| 22 | serverUpdateGuidance, |
| 23 | supportsDesktopAppUpdate, |
| 24 | } from "./versionSkew"; |
| 25 | |
| 26 | const MISMATCH_HINT = |
| 27 | "Version mismatch. Try syncing the client and server to the same version of The Knife."; |
| 28 | |
| 29 | describe("versionSkew", () => { |
| 30 | it("updates only the proven npm prefix and safely quotes its path", () => { |
| 31 | expect(manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/node" })).toBe( |
| 32 | "npm install --global --prefix '/opt/node' t3@0.0.45", |
| 33 | ); |
| 34 | expect( |
| 35 | manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/maria's node" }), |
| 36 | ).toBe("npm install --global --prefix '/opt/maria'\\''s node' t3@0.0.45"); |
| 37 | }); |
| 38 | |
| 39 | it("keeps runner and unknown commands as relaunches", () => { |
| 40 | expect(manualServerUpdateCommand("0.0.45")).toBe("npx t3@0.0.45"); |
| 41 | expect(manualServerUpdateCommand("0.0.45", { kind: "npx" })).toBe("npx t3@0.0.45"); |
| 42 | expect(manualServerUpdateCommand("0.0.45", { kind: "pnpm-dlx" })).toBe("pnpm dlx t3@0.0.45"); |
| 43 | expect(manualServerUpdateCommand("0.0.45", { kind: "bunx" })).toBe("bunx t3@0.0.45"); |
| 44 | }); |
| 45 | beforeEach(() => { |
| 46 | branding.APP_VERSION = "0.0.34"; |
| 47 | }); |
| 48 | |
| 49 | it("dismisses only the current failed attempt without clearing its retry state", () => { |
| 50 | const failure = { |
| 51 | status: "failed", |
| 52 | stage: "downloading", |
| 53 | fromVersion: "0.0.33", |
| 54 | targetVersion: "0.0.34", |
| 55 | message: "Download failed.", |
| 56 | } as const satisfies ServerUpdateState; |
| 57 | const retryFailure = { ...failure }; |
| 58 | const otherEnvironmentFailure = { ...failure }; |
| 59 | |
| 60 | dismissServerUpdateFailure(failure); |
| 61 | |
| 62 | expect(isServerUpdateFailureDismissed(failure)).toBe(true); |
| 63 | expect(failure.status).toBe("failed"); |
| 64 | expect(failure.message).toBe("Download failed."); |
| 65 | expect(isServerUpdateFailureDismissed(retryFailure)).toBe(false); |
| 66 | expect(isServerUpdateFailureDismissed(otherEnvironmentFailure)).toBe(false); |
| 67 | }); |
| 68 | |
| 69 | it("does not dismiss an update that is still running", () => { |
| 70 | const running = { |
| 71 | status: "running", |
| 72 | stage: "resuming", |
| 73 | fromVersion: "0.0.33", |
| 74 | targetVersion: "0.0.34", |
| 75 | } as const satisfies ServerUpdateState; |
| 76 | |
| 77 | dismissServerUpdateFailure(running); |
| 78 | |
| 79 | expect(isServerUpdateFailureDismissed(running)).toBe(false); |
| 80 | }); |
| 81 | |
| 82 | it("does not warn when versions match", () => { |
| 83 | expect(resolveVersionMismatch(APP_VERSION)).toBeNull(); |
| 84 | }); |
| 85 | |
| 86 | it("returns a mismatch when the server is behind the client", () => { |
| 87 | expect(resolveVersionMismatch("0.0.33")).toEqual({ |
| 88 | clientVersion: "0.0.34", |
| 89 | serverVersion: "0.0.33", |
| 90 | hint: MISMATCH_HINT, |
| 91 | }); |
| 92 | }); |
| 93 | |
| 94 | it("does not warn when the server is ahead of the client", () => { |
| 95 | expect(resolveVersionMismatch("0.0.35")).toBeNull(); |
| 96 | expect(resolveVersionMismatch("9.9.9")).toBeNull(); |
| 97 | }); |
| 98 | |
| 99 | it("does not warn when a nightly and a stable build share a core version", () => { |
| 100 | expect(resolveVersionMismatch("0.0.34-nightly.20260818.1124")).toBeNull(); |
| 101 | |
| 102 | branding.APP_VERSION = "0.0.34-nightly.20260818.1124"; |
| 103 | expect(resolveVersionMismatch("0.0.34")).toBeNull(); |
| 104 | }); |
| 105 | |
| 106 | it.each(["0.0.34-nightly.20260823.1124", "0.0.34-nightly.20260824.1124"])( |
| 107 | "warns when nightly server %s is behind a nightly client on the same release", |
| 108 | (serverVersion) => { |
| 109 | branding.APP_VERSION = "0.0.34-nightly.20260824.1125"; |
| 110 | |
| 111 | expect(resolveVersionMismatch(serverVersion)).toEqual({ |
| 112 | clientVersion: "0.0.34-nightly.20260824.1125", |
| 113 | serverVersion, |
| 114 | hint: MISMATCH_HINT, |
| 115 | }); |
| 116 | }, |
| 117 | ); |
| 118 | |
| 119 | it("does not warn when a nightly server is ahead on the same release", () => { |
| 120 | branding.APP_VERSION = "0.0.34-nightly.20260824.1125"; |
| 121 | |
| 122 | expect(resolveVersionMismatch("0.0.34-nightly.20260824.1126")).toBeNull(); |
| 123 | }); |
| 124 | |
| 125 | it("treats a nightly server built past the client as ahead, not skew", () => { |
| 126 | expect(resolveVersionMismatch("0.0.35-nightly.20260818.1124")).toBeNull(); |
| 127 | }); |
| 128 | |
| 129 | it("still warns when a nightly client outruns the server by a release", () => { |
| 130 | branding.APP_VERSION = "0.0.35-nightly.20260818.1124"; |
| 131 | |
| 132 | expect(resolveVersionMismatch("0.0.34")).toEqual({ |
| 133 | clientVersion: "0.0.35-nightly.20260818.1124", |
| 134 | serverVersion: "0.0.34", |
| 135 | hint: MISMATCH_HINT, |
| 136 | }); |
| 137 | }); |
| 138 | |
| 139 | it("falls back to string inequality when a version is not semver", () => { |
| 140 | expect(resolveVersionMismatch("dev")).toEqual({ |
| 141 | clientVersion: "0.0.34", |
| 142 | serverVersion: "dev", |
| 143 | hint: MISMATCH_HINT, |
| 144 | }); |
| 145 | |
| 146 | branding.APP_VERSION = "dev"; |
| 147 | expect(resolveVersionMismatch("dev")).toBeNull(); |
| 148 | expect(resolveVersionMismatch("0.0.34")).toMatchObject({ serverVersion: "0.0.34" }); |
| 149 | }); |
| 150 | |
| 151 | it("reads the server version from config descriptors", () => { |
| 152 | expect( |
| 153 | resolveServerConfigVersionMismatch({ |
| 154 | environment: { |
| 155 | environmentId: EnvironmentId.make("environment-1"), |
| 156 | label: "Remote", |
| 157 | platform: { |
| 158 | os: "darwin", |
| 159 | arch: "arm64", |
| 160 | }, |
| 161 | serverVersion: "0.0.33", |
| 162 | capabilities: { |
| 163 | repositoryIdentity: true, |
| 164 | }, |
| 165 | }, |
| 166 | }), |
| 167 | ).toMatchObject({ |
| 168 | serverVersion: "0.0.33", |
| 169 | }); |
| 170 | }); |
| 171 | |
| 172 | it("keys dismissals by environment, client version, and server version", () => { |
| 173 | const environmentId = EnvironmentId.make("environment-dismissal"); |
| 174 | const key = buildVersionMismatchDismissalKey(environmentId, { |
| 175 | clientVersion: APP_VERSION, |
| 176 | serverVersion: "9.9.9", |
| 177 | }); |
| 178 | |
| 179 | expect(key).toBe(`${environmentId}:${APP_VERSION}:9.9.9`); |
| 180 | expect(isVersionMismatchDismissed(key)).toBe(false); |
| 181 | |
| 182 | dismissVersionMismatch(key); |
| 183 | |
| 184 | expect(isVersionMismatchDismissed(key)).toBe(true); |
| 185 | expect( |
| 186 | isVersionMismatchDismissed( |
| 187 | buildVersionMismatchDismissalKey(environmentId, { |
| 188 | clientVersion: APP_VERSION, |
| 189 | serverVersion: "9.9.10", |
| 190 | }), |
| 191 | ), |
| 192 | ).toBe(false); |
| 193 | }); |
| 194 | |
| 195 | it("appends a hint to connection errors when the server is behind", () => { |
| 196 | const mismatch = resolveVersionMismatch("0.0.33"); |
| 197 | |
| 198 | expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe( |
| 199 | `Socket closed. Hint: ${MISMATCH_HINT}`, |
| 200 | ); |
| 201 | }); |
| 202 | |
| 203 | it("reads desktop-managed update capabilities from config descriptors", () => { |
| 204 | expect( |
| 205 | resolveServerSelfUpdateCapability({ |
| 206 | environment: { |
| 207 | environmentId: EnvironmentId.make("environment-desktop"), |
| 208 | label: "Desktop", |
| 209 | platform: { os: "darwin", arch: "arm64" }, |
| 210 | serverVersion: "9.9.9", |
| 211 | capabilities: { |
| 212 | repositoryIdentity: true, |
| 213 | serverSelfUpdate: "desktop-managed", |
| 214 | }, |
| 215 | }, |
| 216 | }), |
| 217 | ).toBe("desktop-managed"); |
| 218 | expect(resolveServerSelfUpdateCapability(null)).toBeNull(); |
| 219 | }); |
| 220 | |
| 221 | it("detects remote desktop-app update support from config descriptors", () => { |
| 222 | const descriptor = (desktopAppUpdate?: boolean) => ({ |
| 223 | environment: { |
| 224 | environmentId: EnvironmentId.make("environment-desktop"), |
| 225 | label: "Desktop", |
| 226 | platform: { os: "darwin", arch: "arm64" } as const, |
| 227 | serverVersion: "9.9.9", |
| 228 | capabilities: { |
| 229 | repositoryIdentity: true, |
| 230 | serverSelfUpdate: "desktop-managed" as const, |
| 231 | ...(desktopAppUpdate === undefined ? {} : { desktopAppUpdate }), |
| 232 | }, |
| 233 | }, |
| 234 | }); |
| 235 | |
| 236 | expect(supportsDesktopAppUpdate(descriptor(true))).toBe(true); |
| 237 | expect(supportsDesktopAppUpdate(descriptor(false))).toBe(false); |
| 238 | expect(supportsDesktopAppUpdate(descriptor())).toBe(false); |
| 239 | expect(supportsDesktopAppUpdate(null)).toBe(false); |
| 240 | }); |
| 241 | |
| 242 | it("matches version-drift guidance to the advertised update path", () => { |
| 243 | expect(serverUpdateGuidance("respawn")).toBe("Update to stay in sync"); |
| 244 | expect(serverUpdateGuidance("desktop-managed")).toBe("Update the desktop app"); |
| 245 | }); |
| 246 | }); |