diff --git a/apps/ota/src/__tests__/version.test.ts b/apps/ota/src/__tests__/version.test.ts new file mode 100644 index 000000000..d165c6d10 --- /dev/null +++ b/apps/ota/src/__tests__/version.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" + +import type { OtaRelease } from "../lib/schema" +import { compareSemver, selectLatestCompatibleRelease } from "../lib/version" + +function createRelease(overrides: Partial = {}): OtaRelease { + return { + schemaVersion: 1, + product: "mobile", + channel: "production", + releaseVersion: "0.4.2", + releaseKind: "ota", + runtimeVersion: "0.4.1", + publishedAt: "2026-04-10T12:00:00Z", + git: { + tag: "mobile/v0.4.2", + commit: "abcdef1234567890", + }, + policy: { + storeRequired: false, + minSupportedBinaryVersion: "0.4.1", + message: null, + }, + platforms: { + ios: { + launchAsset: { + path: "bundles/ios-main.js", + sha256: "a".repeat(64), + contentType: "application/javascript", + }, + assets: [], + }, + }, + ...overrides, + } +} + +describe("compareSemver", () => { + it("orders plain x.y.z versions numerically", () => { + expect(compareSemver("0.4.10", "0.4.2")).toBeGreaterThan(0) + expect(compareSemver("1.0.0", "1.0.0")).toBe(0) + expect(compareSemver("0.4.2", "0.5.0")).toBeLessThan(0) + }) +}) + +describe("selectLatestCompatibleRelease", () => { + it("selects the highest OTA release for the same runtime", () => { + const result = selectLatestCompatibleRelease( + [ + createRelease({ + releaseVersion: "0.4.2", + git: { + tag: "mobile/v0.4.2", + commit: "abcdef1234567890", + }, + }), + createRelease({ + releaseVersion: "0.4.3", + git: { + tag: "mobile/v0.4.3", + commit: "bcdef1234567890a", + }, + }), + createRelease({ + releaseVersion: "0.4.4", + runtimeVersion: "0.4.3", + git: { + tag: "mobile/v0.4.4", + commit: "cdef1234567890ab", + }, + }), + ], + { + product: "mobile", + channel: "production", + runtimeVersion: "0.4.1", + platform: "ios", + }, + ) + + expect(result?.releaseVersion).toBe("0.4.3") + }) +}) diff --git a/apps/ota/src/lib/schema.ts b/apps/ota/src/lib/schema.ts index d17f66069..2cb342d19 100644 --- a/apps/ota/src/lib/schema.ts +++ b/apps/ota/src/lib/schema.ts @@ -2,6 +2,9 @@ import { z } from "zod" const semver = z.string().regex(/^\d+\.\d+\.\d+$/) const sha256 = z.string().regex(/^[a-f0-9]{64}$/) +const otaPlatforms = ["ios", "android", "macos", "windows", "linux"] as const + +export const otaPlatformSchema = z.enum(otaPlatforms) const assetSchema = z.object({ path: z.string().min(1), @@ -46,3 +49,6 @@ export const otaReleaseSchema = z.object({ }), platforms: platformsSchema, }) + +export type OtaPlatform = z.infer +export type OtaRelease = z.infer diff --git a/apps/ota/src/lib/version.ts b/apps/ota/src/lib/version.ts new file mode 100644 index 000000000..1aa07b264 --- /dev/null +++ b/apps/ota/src/lib/version.ts @@ -0,0 +1,35 @@ +import type { OtaPlatform, OtaRelease } from "./schema" + +export function compareSemver(left: string, right: string) { + const leftParts = left.split(".").map(Number) + const rightParts = right.split(".").map(Number) + + for (let index = 0; index < 3; index += 1) { + const diff = leftParts[index]! - rightParts[index]! + if (diff !== 0) { + return diff + } + } + + return 0 +} + +export function selectLatestCompatibleRelease( + releases: OtaRelease[], + input: { + product: OtaRelease["product"] + channel: OtaRelease["channel"] + runtimeVersion: OtaRelease["runtimeVersion"] + platform: OtaPlatform + }, +) { + return ( + releases + .filter((release) => release.product === input.product) + .filter((release) => release.channel === input.channel) + .filter((release) => release.releaseKind === "ota") + .filter((release) => release.runtimeVersion === input.runtimeVersion) + .filter((release) => Object.prototype.hasOwnProperty.call(release.platforms, input.platform)) + .sort((left, right) => compareSemver(right.releaseVersion, left.releaseVersion))[0] ?? null + ) +}