From 6f5c951a494e6ed0111222959a4bd9c7c8af6c23 Mon Sep 17 00:00:00 2001 From: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:59:57 +0200 Subject: [PATCH] fix(web): compare client and server versions as semver, not strings (#7579) --- apps/web/src/versionSkew.test.ts | 72 +++++++++++++++++++++++++++----- apps/web/src/versionSkew.ts | 34 ++++++++++++--- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index 41a148bacd8cf28000f3aff843c9e27ed23d81df..949b242f0c6ec03fd51926c28cfba1ed755275ed 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -1,5 +1,10 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Pinned so the direction cases below read as fixed versions instead of +// arithmetic on whatever version this checkout happens to be at. +const branding = vi.hoisted(() => ({ APP_VERSION: "0.0.34" })); +vi.mock("./branding", () => branding); import { APP_VERSION } from "./branding"; import { @@ -13,19 +18,64 @@ import { serverUpdateGuidance, } from "./versionSkew"; +const MISMATCH_HINT = + "Version mismatch. Try syncing the client and server to the same T3 Code version."; + describe("versionSkew", () => { + beforeEach(() => { + branding.APP_VERSION = "0.0.34"; + }); + it("does not warn when versions match", () => { expect(resolveVersionMismatch(APP_VERSION)).toBeNull(); }); - it("returns a mismatch when the server version differs from the client", () => { - expect(resolveVersionMismatch("9.9.9")).toEqual({ - clientVersion: APP_VERSION, - serverVersion: "9.9.9", - hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.", + it("returns a mismatch when the server is behind the client", () => { + expect(resolveVersionMismatch("0.0.33")).toEqual({ + clientVersion: "0.0.34", + serverVersion: "0.0.33", + hint: MISMATCH_HINT, }); }); + it("does not warn when the server is ahead of the client", () => { + expect(resolveVersionMismatch("0.0.35")).toBeNull(); + expect(resolveVersionMismatch("9.9.9")).toBeNull(); + }); + + it("does not warn when a nightly and a stable build share a core version", () => { + expect(resolveVersionMismatch("0.0.34-nightly.20260818.1124")).toBeNull(); + + branding.APP_VERSION = "0.0.34-nightly.20260818.1124"; + expect(resolveVersionMismatch("0.0.34")).toBeNull(); + }); + + it("treats a nightly server built past the client as ahead, not skew", () => { + expect(resolveVersionMismatch("0.0.35-nightly.20260818.1124")).toBeNull(); + }); + + it("still warns when a nightly client outruns the server by a release", () => { + branding.APP_VERSION = "0.0.35-nightly.20260818.1124"; + + expect(resolveVersionMismatch("0.0.34")).toEqual({ + clientVersion: "0.0.35-nightly.20260818.1124", + serverVersion: "0.0.34", + hint: MISMATCH_HINT, + }); + }); + + it("falls back to string inequality when a version is not semver", () => { + expect(resolveVersionMismatch("dev")).toEqual({ + clientVersion: "0.0.34", + serverVersion: "dev", + hint: MISMATCH_HINT, + }); + + branding.APP_VERSION = "dev"; + expect(resolveVersionMismatch("dev")).toBeNull(); + expect(resolveVersionMismatch("0.0.34")).toMatchObject({ serverVersion: "0.0.34" }); + }); + it("reads the server version from config descriptors", () => { expect( resolveServerConfigVersionMismatch({ @@ -36,14 +86,14 @@ describe("versionSkew", () => { os: "darwin", arch: "arm64", }, - serverVersion: "9.9.9", + serverVersion: "0.0.33", capabilities: { repositoryIdentity: true, }, }, }), ).toMatchObject({ - serverVersion: "9.9.9", + serverVersion: "0.0.33", }); }); @@ -70,11 +120,11 @@ describe("versionSkew", () => { ).toBe(false); }); - it("appends a hint to connection errors when versions differ", () => { - const mismatch = resolveVersionMismatch("9.9.9"); + it("appends a hint to connection errors when the server is behind", () => { + const mismatch = resolveVersionMismatch("0.0.33"); expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe( - "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.", + `Socket closed. Hint: ${MISMATCH_HINT}`, ); }); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index f56f03ab7ad347a4493ad121ef2efa38e410e7c4..de40141fc8361f437f1cdb173920fd3b5441c183 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Schema from "effect/Schema"; import { APP_VERSION } from "./branding"; @@ -23,16 +24,39 @@ function normalizeVersion(version: string | null | undefined): string | null { return trimmed && trimmed.length > 0 ? trimmed : null; } +/** Core `major.minor.patch`, dropping any prerelease or build suffix. */ +function versionCore(version: string): string { + return version.replace(/[-+].*$/, ""); +} + +/** + * The skew a user can act on: the connected server runs an older T3 Code than + * this client, so the server is the side that needs updating. + * + * Versions compare as semver on their core `major.minor.patch` only. Nightlies + * are `-nightly..` builds of the release they precede, so a + * stable client on a nightly server (or the reverse, at the same core) shares a + * wire contract and is not skew. A server *ahead* of the client is not skew + * either: the client is the stale side, and every consumer of this result tells + * the user to update their server. Versions that do not parse as semver fall + * back to plain string inequality. + */ export function resolveVersionMismatch( serverVersion: string | null | undefined, ): VersionMismatch | null { const normalizedClientVersion = normalizeVersion(APP_VERSION); const normalizedServerVersion = normalizeVersion(serverVersion); - if ( - !normalizedClientVersion || - !normalizedServerVersion || - normalizedClientVersion === normalizedServerVersion - ) { + if (!normalizedClientVersion || !normalizedServerVersion) { + return null; + } + + const clientCore = versionCore(normalizedClientVersion); + const serverCore = versionCore(normalizedServerVersion); + const serverIsBehind = + parseSemver(clientCore) && parseSemver(serverCore) + ? compareSemverVersions(serverCore, clientCore) < 0 + : normalizedServerVersion !== normalizedClientVersion; + if (!serverIsBehind) { return null; } -- 2.54.0