1import { EnvironmentId } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import { 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.
7const branding = vi.hoisted(() => ({ APP_VERSION: "0.0.34" }));
8vi.mock("./branding", () => branding);
9
10import { APP_VERSION } from "./branding";
11import {
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
26const MISMATCH_HINT =
27 "Version mismatch. Try syncing the client and server to the same version of The Knife.";
28
29describe("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});