fix: update fallback (#5115)
* fix: update fallback * test: introduce Vitest and add semver helper tests * chore: merge vitest config into vite
This commit is contained in:
140
src/services/update.test.ts
Normal file
140
src/services/update.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compareVersions,
|
||||
ensureSemver,
|
||||
extractSemver,
|
||||
normalizeVersion,
|
||||
resolveRemoteVersion,
|
||||
splitVersion,
|
||||
} from "@/services/update";
|
||||
import type { VersionParts } from "@/services/update";
|
||||
|
||||
const makeUpdate = (data: {
|
||||
version?: string | null;
|
||||
rawJson?: Record<string, unknown> | null;
|
||||
}): Update =>
|
||||
({
|
||||
version: data.version ?? "",
|
||||
rawJson: data.rawJson ?? {},
|
||||
}) as unknown as Update;
|
||||
|
||||
describe("normalizeVersion", () => {
|
||||
it("strips optional v prefix and trims whitespace", () => {
|
||||
expect(normalizeVersion(" v1.2.3 ")).toBe("1.2.3");
|
||||
expect(normalizeVersion("V2.0.0-beta")).toBe("2.0.0-beta");
|
||||
});
|
||||
|
||||
it("returns null for empty or non-string input", () => {
|
||||
expect(normalizeVersion(null)).toBeNull();
|
||||
expect(normalizeVersion(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureSemver", () => {
|
||||
it("returns normalized semver when input is valid", () => {
|
||||
expect(ensureSemver("1.2.3")).toBe("1.2.3");
|
||||
expect(ensureSemver("v3.4.5-alpha.1+build.7")).toBe(
|
||||
"3.4.5-alpha.1+build.7",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for invalid versions", () => {
|
||||
expect(ensureSemver("1")).toBeNull();
|
||||
expect(ensureSemver("1.2.3.4")).toBeNull();
|
||||
expect(ensureSemver("release-candidate")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSemver", () => {
|
||||
it("finds the first semver-like string and normalizes it", () => {
|
||||
expect(extractSemver("Release v1.2.3 (latest)")).toBe("1.2.3");
|
||||
expect(extractSemver("tag:V2.0.0-beta+exp.sha")).toBe("2.0.0-beta+exp.sha");
|
||||
});
|
||||
|
||||
it("returns null when no semver-like string is present", () => {
|
||||
expect(extractSemver("no version available")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitVersion", () => {
|
||||
it("splits version into numeric main and typed prerelease parts", () => {
|
||||
const parts = splitVersion("1.2.3-alpha.4.beta") as VersionParts;
|
||||
expect(parts.main).toEqual([1, 2, 3]);
|
||||
expect(parts.pre).toEqual(["alpha", 4, "beta"]);
|
||||
});
|
||||
|
||||
it("returns null when version is missing", () => {
|
||||
expect(splitVersion(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareVersions", () => {
|
||||
it("orders versions by numeric components", () => {
|
||||
expect(compareVersions("1.2.3", "1.2.4")).toBe(-1);
|
||||
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
||||
});
|
||||
|
||||
it("treats release versions as newer than prereleases", () => {
|
||||
expect(compareVersions("1.0.0", "1.0.0-beta")).toBe(1);
|
||||
expect(compareVersions("1.0.0-beta", "1.0.0")).toBe(-1);
|
||||
});
|
||||
|
||||
it("resolves prerelease precedence correctly", () => {
|
||||
expect(compareVersions("1.0.0-beta", "1.0.0-alpha")).toBe(1);
|
||||
expect(compareVersions("1.0.0-alpha.1", "1.0.0-alpha.beta")).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns null when comparison cannot be made", () => {
|
||||
expect(compareVersions(null, "1.0.0")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRemoteVersion", () => {
|
||||
it("prefers direct semver value on the update object", () => {
|
||||
const update = makeUpdate({ version: "v1.2.3" });
|
||||
expect(resolveRemoteVersion(update)).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("falls back through rawJson fields when primary version is missing", () => {
|
||||
const update = makeUpdate({
|
||||
version: "See release notes",
|
||||
rawJson: {
|
||||
version: "v2.3.4",
|
||||
tag_name: "ignore-me",
|
||||
name: "v0.0.1",
|
||||
},
|
||||
});
|
||||
expect(resolveRemoteVersion(update)).toBe("2.3.4");
|
||||
});
|
||||
|
||||
it("rescues version from tag_name or name when needed", () => {
|
||||
const update = makeUpdate({
|
||||
version: "no version here",
|
||||
rawJson: {
|
||||
tag_name: "release-v3.1.0",
|
||||
name: "build-should-not-override",
|
||||
},
|
||||
});
|
||||
expect(resolveRemoteVersion(update)).toBe("3.1.0");
|
||||
|
||||
const nameOnly = makeUpdate({
|
||||
version: "invalid",
|
||||
rawJson: {
|
||||
name: "release v4.0.0-beta.1",
|
||||
},
|
||||
});
|
||||
expect(resolveRemoteVersion(nameOnly)).toBe("4.0.0-beta.1");
|
||||
});
|
||||
|
||||
it("returns null when no semver-like data is present", () => {
|
||||
const update = makeUpdate({
|
||||
version: "not-a-version",
|
||||
rawJson: {
|
||||
name: "nope",
|
||||
},
|
||||
});
|
||||
expect(resolveRemoteVersion(update)).toBeNull();
|
||||
});
|
||||
});
|
||||
155
src/services/update.ts
Normal file
155
src/services/update.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
check,
|
||||
type CheckOptions,
|
||||
type Update,
|
||||
} from "@tauri-apps/plugin-updater";
|
||||
|
||||
import { version as appVersion } from "@root/package.json";
|
||||
|
||||
export type VersionParts = {
|
||||
main: number[];
|
||||
pre: (number | string)[];
|
||||
};
|
||||
|
||||
const SEMVER_FULL_REGEX =
|
||||
/^\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const SEMVER_SEARCH_REGEX =
|
||||
/v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/i;
|
||||
|
||||
export const normalizeVersion = (
|
||||
input: string | null | undefined,
|
||||
): string | null => {
|
||||
if (typeof input !== "string") return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/^v/i, "");
|
||||
};
|
||||
|
||||
export const ensureSemver = (
|
||||
input: string | null | undefined,
|
||||
): string | null => {
|
||||
const normalized = normalizeVersion(input);
|
||||
if (!normalized) return null;
|
||||
return SEMVER_FULL_REGEX.test(normalized) ? normalized : null;
|
||||
};
|
||||
|
||||
export const extractSemver = (
|
||||
input: string | null | undefined,
|
||||
): string | null => {
|
||||
if (typeof input !== "string") return null;
|
||||
const match = input.match(SEMVER_SEARCH_REGEX);
|
||||
if (!match) return null;
|
||||
return normalizeVersion(match[0]);
|
||||
};
|
||||
|
||||
export const splitVersion = (version: string | null): VersionParts | null => {
|
||||
if (!version) return null;
|
||||
const [mainPart, preRelease] = version.split("-");
|
||||
const main = mainPart
|
||||
.split(".")
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.map((num) => (Number.isNaN(num) ? 0 : num));
|
||||
|
||||
const pre =
|
||||
preRelease?.split(".").map((token) => {
|
||||
const numeric = Number.parseInt(token, 10);
|
||||
return Number.isNaN(numeric) ? token : numeric;
|
||||
}) ?? [];
|
||||
|
||||
return { main, pre };
|
||||
};
|
||||
|
||||
const compareVersionParts = (a: VersionParts, b: VersionParts): number => {
|
||||
const length = Math.max(a.main.length, b.main.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const diff = (a.main[i] ?? 0) - (b.main[i] ?? 0);
|
||||
if (diff !== 0) return diff > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
if (a.pre.length === 0 && b.pre.length === 0) return 0;
|
||||
if (a.pre.length === 0) return 1;
|
||||
if (b.pre.length === 0) return -1;
|
||||
|
||||
const preLen = Math.max(a.pre.length, b.pre.length);
|
||||
for (let i = 0; i < preLen; i += 1) {
|
||||
const aToken = a.pre[i];
|
||||
const bToken = b.pre[i];
|
||||
if (aToken === undefined) return -1;
|
||||
if (bToken === undefined) return 1;
|
||||
|
||||
if (typeof aToken === "number" && typeof bToken === "number") {
|
||||
if (aToken > bToken) return 1;
|
||||
if (aToken < bToken) return -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof aToken === "number") return -1;
|
||||
if (typeof bToken === "number") return 1;
|
||||
|
||||
if (aToken > bToken) return 1;
|
||||
if (aToken < bToken) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const compareVersions = (
|
||||
a: string | null,
|
||||
b: string | null,
|
||||
): number | null => {
|
||||
const partsA = splitVersion(a);
|
||||
const partsB = splitVersion(b);
|
||||
if (!partsA || !partsB) return null;
|
||||
return compareVersionParts(partsA, partsB);
|
||||
};
|
||||
|
||||
export const resolveRemoteVersion = (update: Update): string | null => {
|
||||
const primary = ensureSemver(update.version);
|
||||
if (primary) return primary;
|
||||
|
||||
const fallbackPrimary = extractSemver(update.version);
|
||||
if (fallbackPrimary) return fallbackPrimary;
|
||||
|
||||
const raw = update.rawJson ?? {};
|
||||
const rawVersion = ensureSemver(
|
||||
typeof raw.version === "string" ? raw.version : null,
|
||||
);
|
||||
if (rawVersion) return rawVersion;
|
||||
|
||||
const tagVersion = extractSemver(
|
||||
typeof raw.tag_name === "string" ? raw.tag_name : null,
|
||||
);
|
||||
if (tagVersion) return tagVersion;
|
||||
|
||||
const nameVersion = extractSemver(
|
||||
typeof raw.name === "string" ? raw.name : null,
|
||||
);
|
||||
if (nameVersion) return nameVersion;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const localVersionNormalized = normalizeVersion(appVersion);
|
||||
|
||||
export const checkUpdateSafe = async (
|
||||
options?: CheckOptions,
|
||||
): Promise<Update | null> => {
|
||||
const result = await check({ ...(options ?? {}), allowDowngrades: false });
|
||||
if (!result) return null;
|
||||
|
||||
const remoteVersion = resolveRemoteVersion(result);
|
||||
const comparison = compareVersions(remoteVersion, localVersionNormalized);
|
||||
|
||||
if (comparison !== null && comparison <= 0) {
|
||||
try {
|
||||
await result.close();
|
||||
} catch (err) {
|
||||
console.warn("[updater] failed to close stale update resource", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export type { CheckOptions };
|
||||
Reference in New Issue
Block a user