Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions electron/update-checker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,68 @@ describe("checkLatestRelease", () => {
);
});

// releases/latest never returns a prerelease, so an RC read it and never saw the next RC (#1447).
describe("on a release candidate", () => {
const release = (tag: string, prerelease: boolean) => ({
tag_name: tag,
html_url: `https://github.com/getopenscreen/openscreen/releases/tag/${tag}`,
draft: false,
prerelease,
});
const releases = [
release("v2.1.2", false),
release("v2.2.0-rc.2", true),
release("v2.2.0-rc.1", true),
];

it("offers the next RC from the release list", async () => {
const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(releases));

await expect(
checkLatestRelease({ currentVersion: "2.2.0-rc.1", fetchLatest }),
).resolves.toEqual({
kind: "available",
currentVersion: "2.2.0-rc.1",
latestVersion: "2.2.0-rc.2",
releaseUrl: "https://github.com/getopenscreen/openscreen/releases/tag/v2.2.0-rc.2",
});
expect(fetchLatest).toHaveBeenCalledWith(
"https://api.github.com/repos/getopenscreen/openscreen/releases?per_page=30",
expect.anything(),
);
});

it("offers the stable release that supersedes it", async () => {
const fetchLatest = vi
.fn()
.mockResolvedValue(releaseResponse([release("v2.2.0", false), ...releases]));

await expect(
checkLatestRelease({ currentVersion: "2.2.0-rc.2", fetchLatest }),
).resolves.toMatchObject({ kind: "available", latestVersion: "2.2.0" });
});

it("reports current on the newest RC", async () => {
const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(releases));

await expect(
checkLatestRelease({ currentVersion: "2.2.0-rc.2", fetchLatest }),
).resolves.toMatchObject({ kind: "current", latestVersion: "2.2.0-rc.2" });
});

it.each([
[[]],
[release("v2.2.0-rc.2", true)],
[[{ ...release("v9.9.9", true), draft: true }]],
])("rejects an empty, non-list or draft response: %o", async (payload) => {
const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(payload));

await expect(
checkLatestRelease({ currentVersion: "2.2.0-rc.1", fetchLatest }),
).rejects.toThrow("invalid GitHub release response");
});
});

it("rejects unsuccessful or malformed GitHub responses", async () => {
const unavailable = vi.fn().mockResolvedValue(releaseResponse({}, 503));
await expect(
Expand Down
51 changes: 38 additions & 13 deletions electron/update-checker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const LATEST_RELEASE_API = "https://api.github.com/repos/getopenscreen/openscreen/releases/latest";
const RELEASES_API = "https://api.github.com/repos/getopenscreen/openscreen/releases?per_page=30";
const OFFICIAL_RELEASE_PREFIX = "/getopenscreen/openscreen/releases/tag/";

interface ReleaseResponse {
Expand Down Expand Up @@ -118,12 +119,47 @@ export type UpdateCheckResult =
latestVersion: string;
};

interface Release {
tag_name: string;
html_url: string;
}

function asRelease(payload: unknown, allowPrerelease: boolean): Release {
const fields = payload as Record<string, unknown>;
if (
typeof payload !== "object" ||
payload === null ||
typeof fields.tag_name !== "string" ||
typeof fields.html_url !== "string" ||
fields.draft !== false ||
(allowPrerelease ? typeof fields.prerelease !== "boolean" : fields.prerelease !== false)
) {
throw new Error("invalid GitHub release response");
}
return payload as Release;
}

function newestRelease(payload: unknown): Release {
if (!Array.isArray(payload) || payload.length === 0) {
throw new Error("invalid GitHub release response");
}
return payload
.map((entry) => asRelease(entry, true))
.reduce((newest, release) =>
compareVersions(release.tag_name, newest.tag_name) > 0 ? release : newest,
);
}

export async function checkLatestRelease(options: {
currentVersion: string;
fetchLatest: FetchLatestRelease;
signal?: AbortSignal;
}): Promise<UpdateCheckResult> {
const response = await options.fetchLatest(LATEST_RELEASE_API, {
const current = parseVersion(options.currentVersion);
// releases/latest only ever resolves to a stable release, so an RC would never be offered the
// next RC (#1447). An RC tester opted into prereleases: compare against them too.
const onPrerelease = current.prerelease.length > 0;
const response = await options.fetchLatest(onPrerelease ? RELEASES_API : LATEST_RELEASE_API, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Expand All @@ -133,18 +169,7 @@ export async function checkLatestRelease(options: {
if (!response.ok) throw new Error(`GitHub release check failed (${response.status})`);

const payload = await response.json();
if (
typeof payload !== "object" ||
payload === null ||
typeof (payload as Record<string, unknown>).tag_name !== "string" ||
typeof (payload as Record<string, unknown>).html_url !== "string" ||
(payload as Record<string, unknown>).draft !== false ||
(payload as Record<string, unknown>).prerelease !== false
) {
throw new Error("invalid GitHub release response");
}
const release = payload as { tag_name: string; html_url: string };
const current = parseVersion(options.currentVersion);
const release = onPrerelease ? newestRelease(payload) : asRelease(payload, false);
const latest = parseVersion(release.tag_name);
const comparison = compareVersions(latest.normalized, current.normalized);
if (comparison <= 0) {
Expand Down
Loading