diff --git a/src/renderer/pages/flash/steps/firmware/CloudFirmwareReleasesPicker.tsx b/src/renderer/pages/flash/steps/firmware/CloudFirmwareReleasesPicker.tsx index 9507eff0..12ad59f4 100644 --- a/src/renderer/pages/flash/steps/firmware/CloudFirmwareReleasesPicker.tsx +++ b/src/renderer/pages/flash/steps/firmware/CloudFirmwareReleasesPicker.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import CloudVersionTargetForm from "renderer/components/CloudVersionTargetForm"; import { VersionFilters } from "renderer/components/VersionTargetForm"; import { SelectedFlags } from "shared/backend/types"; +import { compareFirmwareReleases } from "shared/firmwareVersions"; type Props = { onChanged: (values: { @@ -62,7 +63,7 @@ const CloudFirmwareReleasesPicker: React.FC = ({ const releases = targetsQuery.data?.cloudTargets.releases .filter(({ isPrerelease }) => !isPrerelease || filters.includePrereleases) - .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + .sort(compareFirmwareReleases); const selectedRelease = releases?.find(({ id }) => id === version); // Select first release if none is selected or current version not in releases. diff --git a/src/renderer/pages/flash/steps/firmware/FirmwareReleasesPicker.tsx b/src/renderer/pages/flash/steps/firmware/FirmwareReleasesPicker.tsx index c04c617a..e2274f48 100644 --- a/src/renderer/pages/flash/steps/firmware/FirmwareReleasesPicker.tsx +++ b/src/renderer/pages/flash/steps/firmware/FirmwareReleasesPicker.tsx @@ -5,6 +5,7 @@ import VersionTargetForm, { VersionFilters, } from "renderer/components/VersionTargetForm"; import { useTranslation } from "react-i18next"; +import { compareFirmwareReleases } from "shared/firmwareVersions"; type Props = { onChanged: (values: { @@ -68,8 +69,7 @@ const FirmwareReleasesPicker: React.FC = ({ (release) => release.id === version )?.isPrerelease; - // TODO: sort releases by date, need to add date to schema - const sortedReleases = useSorted(releases, () => 0); + const sortedReleases = useSorted(releases, compareFirmwareReleases); const selectedFirmware = releases?.find((release) => release.id === version); const targets = releaseTargetsQuery.data?.edgeTxRelease?.firmwareBundle.targets.map( diff --git a/src/shared/__tests__/firmwareVersions.spec.ts b/src/shared/__tests__/firmwareVersions.spec.ts new file mode 100644 index 00000000..ebe5c58d --- /dev/null +++ b/src/shared/__tests__/firmwareVersions.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { compareFirmwareReleases } from "shared/firmwareVersions"; +import { exampleReleasesList } from "test-utils/data"; + +describe("compareFirmwareReleases", () => { + it("sorts an interleaved list into descending semver order", () => { + const releases = [ + { id: "v2.12.0" }, + { id: "v2.11.6" }, + { id: "v2.12.1" }, + { id: "v2.11.5" }, + { id: "v2.12.2" }, + ]; + + expect(releases.sort(compareFirmwareReleases).map((r) => r.id)).toEqual([ + "v2.12.2", + "v2.12.1", + "v2.12.0", + "v2.11.6", + "v2.11.5", + ]); + }); + + it("always sorts non-semver ids like 'nightly' first", () => { + const releases = [{ id: "v2.12.2" }, { id: "nightly" }, { id: "v2.12.0" }]; + + expect(releases.sort(compareFirmwareReleases).map((r) => r.id)).toEqual([ + "nightly", + "v2.12.2", + "v2.12.0", + ]); + }); + + it("sorts prereleases below their corresponding final release", () => { + const releases = [ + { id: "v2.5.0-rc1" }, + { id: "v2.5.0" }, + { id: "v2.5.0-rc3" }, + { id: "v2.5.0-rc2" }, + ]; + + expect(releases.sort(compareFirmwareReleases).map((r) => r.id)).toEqual([ + "v2.5.0", + "v2.5.0-rc3", + "v2.5.0-rc2", + "v2.5.0-rc1", + ]); + }); + + it("reproduces the canonical order of the example releases mock data", () => { + const shuffled = [...exampleReleasesList].reverse(); + + expect(shuffled.sort(compareFirmwareReleases).map((r) => r.id)).toEqual( + exampleReleasesList.map((r) => r.id) + ); + }); + + it("does not throw on empty, single-element, or already-sorted arrays", () => { + expect([].sort(compareFirmwareReleases)).toEqual([]); + expect([{ id: "v1.0.0" }].sort(compareFirmwareReleases)).toEqual([ + { id: "v1.0.0" }, + ]); + expect( + [{ id: "v2.0.0" }, { id: "v1.0.0" }] + .sort(compareFirmwareReleases) + .map((r) => r.id) + ).toEqual(["v2.0.0", "v1.0.0"]); + }); +}); diff --git a/src/shared/firmwareVersions.ts b/src/shared/firmwareVersions.ts new file mode 100644 index 00000000..c2dbe191 --- /dev/null +++ b/src/shared/firmwareVersions.ts @@ -0,0 +1,22 @@ +import semver from "semver"; + +/** + * Compares two firmware releases by their id (git tag name, e.g. "v2.12.2") + * for descending version order (latest first). + * + * Non-semver ids (e.g. "nightly", a rolling build) always sort first, + * ahead of every numbered release. + */ +export const compareFirmwareReleases = ( + a: T, + b: T +): number => { + const aVersion = semver.valid(a.id); + const bVersion = semver.valid(b.id); + + if (!aVersion && !bVersion) return 0; + if (!aVersion) return -1; + if (!bVersion) return 1; + + return semver.rcompare(aVersion, bVersion); +};