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
246 changes: 218 additions & 28 deletions apps/marketing/src/lib/releases.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as NodeAssert from "node:assert/strict";
import { describe, it } from "vite-plus/test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
blockDownloadUntilResolved,
Expand Down Expand Up @@ -30,6 +30,20 @@ const release = {
],
} satisfies Release;

class DownloadLinkStub extends EventTarget {
href = RELEASES_URL;
attributes = new Map<string, string>();

removeAttribute(name: string) {
this.attributes.delete(name);
if (name === "href") this.href = "";
}

setAttribute(name: string, value: string) {
this.attributes.set(name, value);
}
}

describe("release downloads", () => {
it("selects safe assets for supported platforms", () => {
const cases = [
Expand All @@ -55,19 +69,8 @@ describe("release downloads", () => {
});

it("blocks a fast click until the primary download resolves", () => {
class Link extends EventTarget {
href = "https://wrong.example";
attributes = new Map<string, string>();
removeAttribute(name: string) {
this.attributes.delete(name);
if (name === "href") this.href = "";
}
setAttribute(name: string, value: string) {
this.attributes.set(name, value);
}
}

const link = new Link();
const link = new DownloadLinkStub();
link.href = "https://wrong.example";
const resolve = blockDownloadUntilResolved(link);
const pendingClick = new Event("click", { cancelable: true });
link.dispatchEvent(pendingClick);
Expand All @@ -82,23 +85,11 @@ describe("release downloads", () => {
});

it("uses one resolver for direct assets and fallback links", async () => {
class Link extends EventTarget {
href = RELEASES_URL;
attributes = new Map<string, string>();
removeAttribute(name: string) {
this.attributes.delete(name);
if (name === "href") this.href = "";
}
setAttribute(name: string, value: string) {
this.attributes.set(name, value);
}
}

let finishRelease!: (value: Release) => void;
const pendingRelease = new Promise<Release>((resolve) => {
finishRelease = resolve;
});
const link = new Link();
const link = new DownloadLinkStub();
const resolving = resolveAssetDownload(link, "x64.exe", pendingRelease);
const pendingClick = new Event("click", { cancelable: true });
link.dispatchEvent(pendingClick);
Expand All @@ -109,7 +100,7 @@ describe("release downloads", () => {
NodeAssert.equal(await resolving, release.assets[1]);
NodeAssert.equal(link.href, "https://downloads.example/windows");

const fallback = new Link();
const fallback = new DownloadLinkStub();
NodeAssert.equal(
await resolveAssetDownload(fallback, "x64.exe", Promise.reject(new Error("offline"))),
null,
Expand Down Expand Up @@ -139,3 +130,202 @@ describe("release downloads", () => {
NodeAssert.equal(requiresUnsignedInstall("x64.AppImage"), true);
});
});

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((finish) => {
resolve = finish;
});
return { promise, resolve };
}

function releaseResponse(data: unknown = release): Response {
return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } });
}

describe("bounded shared release requests", () => {
const cache = new Map<string, string>();
const storage = {
getItem: vi.fn((key: string) => cache.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => cache.set(key, value)),
removeItem: vi.fn((key: string) => cache.delete(key)),
};
const fetchMock = vi.fn<typeof fetch>();

beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
vi.resetAllMocks();
cache.clear();
vi.stubGlobal("sessionStorage", storage);
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it("shares a single flight across independently resolved links and caches success", async () => {
const response = deferred<Response>();
fetchMock.mockReturnValue(response.promise);
const { fetchLatestRelease, resolveAssetDownload } = await import("./releases");
const first = fetchLatestRelease();
expect(fetchLatestRelease()).toBe(first);
const mac = new DownloadLinkStub();
const windows = new DownloadLinkStub();
const downloads = [
resolveAssetDownload(mac, "arm64.dmg"),
resolveAssetDownload(windows, "x64.exe"),
];
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(1);

response.resolve(releaseResponse());
await Promise.all(downloads);
expect(await first).toEqual(release);
expect(mac.href).toBe(release.assets[0].browser_download_url);
expect(windows.href).toBe(release.assets[1].browser_download_url);
expect(await fetchLatestRelease()).toEqual(release);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(storage.setItem).toHaveBeenCalledExactlyOnceWith(
"akeru-latest-release",
JSON.stringify(release),
);
expect(vi.getTimerCount()).toBe(0);
});

it.each(["headers", "body"])(
"aborts stalled %s and restores every fallback at the shared deadline",
async (stage) => {
const stalled = deferred<Response>();
const body = deferred<Release>();
fetchMock.mockReturnValue(
stage === "headers"
? stalled.promise
: Promise.resolve({ ok: true, json: () => body.promise } as Response),
);
const { fetchLatestRelease, resolveAssetDownload, RELEASE_REQUEST_TIMEOUT_MS } =
await import("./releases");
const cards = [new DownloadLinkStub(), new DownloadLinkStub(), new DownloadLinkStub()];
const downloads = cards.map((card) => resolveAssetDownload(card, "x64.exe"));
const request = fetchLatestRelease().catch((error: unknown) => error);
const signal = fetchMock.mock.calls[0]?.[1]?.signal;
expect(signal?.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(RELEASE_REQUEST_TIMEOUT_MS - 1);
expect(cards.every((card) => card.attributes.get("aria-disabled") === "true")).toBe(true);
await vi.advanceTimersByTimeAsync(1);
expect(await request).toEqual(new Error("GitHub release request timed out"));
expect(await Promise.all(downloads)).toEqual([null, null, null]);
expect(signal?.aborted).toBe(true);
for (const card of cards) {
expect(card.href).toBe(RELEASES_URL);
expect(card.attributes.has("aria-disabled")).toBe(false);
const click = new Event("click", { cancelable: true });
card.dispatchEvent(click);
expect(click.defaultPrevented).toBe(false);
}
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(storage.setItem).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);

const retry = deferred<Response>();
fetchMock.mockReturnValueOnce(retry.promise);
const retried = fetchLatestRelease();
stalled.resolve(releaseResponse());
body.resolve(release);
await Promise.resolve();
expect(fetchLatestRelease()).toBe(retried);
const nextRelease = { ...release, tag_name: "v1.2.4" };
retry.resolve(releaseResponse(nextRelease));
expect(await retried).toEqual(nextRelease);
expect(await fetchLatestRelease()).toEqual(nextRelease);
expect(storage.setItem).toHaveBeenCalledExactlyOnceWith(
"akeru-latest-release",
JSON.stringify(nextRelease),
);
expect(cards.every((card) => card.href === RELEASES_URL)).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
},
);

it.each(["network", "http", "json", "schema"])(
"does not cache %s failures and permits retry",
async (failure) => {
switch (failure) {
case "network":
fetchMock.mockRejectedValueOnce(new Error("offline"));
break;
case "http":
fetchMock.mockResolvedValueOnce(new Response("rate limited", { status: 403 }));
break;
case "json":
fetchMock.mockResolvedValueOnce(new Response("not json"));
break;
default:
fetchMock.mockResolvedValueOnce(releaseResponse({ ...release, assets: [null] }));
}
const { fetchLatestRelease, resolveAssetDownload } = await import("./releases");
const link = new DownloadLinkStub();
expect(await resolveAssetDownload(link, "arm64.dmg")).toBeNull();
expect(link.href).toBe(RELEASES_URL);
expect(link.attributes.has("aria-disabled")).toBe(false);
expect(storage.setItem).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);

fetchMock.mockResolvedValueOnce(releaseResponse());
expect(await fetchLatestRelease()).toEqual(release);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
},
);

it("uses a valid session cache without network work or a deadline", async () => {
cache.set("akeru-latest-release", JSON.stringify(release));
const { fetchLatestRelease } = await import("./releases");
expect(await fetchLatestRelease()).toEqual(release);
expect(fetchMock).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});

it.each(["invalid json", JSON.stringify({ tag_name: "preview" })])(
"replaces invalid session data: %s",
async (cached) => {
cache.set("akeru-latest-release", cached);
fetchMock.mockResolvedValueOnce(releaseResponse());
const { fetchLatestRelease } = await import("./releases");
expect(await fetchLatestRelease()).toEqual(release);
expect(storage.removeItem).toHaveBeenCalledExactlyOnceWith("akeru-latest-release");
expect(cache.get("akeru-latest-release")).toBe(JSON.stringify(release));
expect(vi.getTimerCount()).toBe(0);
},
);

it("keeps successful downloads and the memory cache when session storage is denied", async () => {
storage.getItem.mockImplementation(() => {
throw new Error("denied");
});
storage.setItem.mockImplementation(() => {
throw new Error("quota exceeded");
});
fetchMock.mockResolvedValueOnce(releaseResponse());
const { fetchLatestRelease, resolveAssetDownload } = await import("./releases");
const link = new DownloadLinkStub();
expect(await resolveAssetDownload(link, "arm64.dmg")).toEqual(release.assets[0]);
expect(link.href).toBe(release.assets[0].browser_download_url);
expect(await fetchLatestRelease()).toEqual(release);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});

it("uses the releases fallback when a valid release has no matching asset", async () => {
fetchMock.mockResolvedValueOnce(releaseResponse({ ...release, assets: [] }));
const { resolveAssetDownload } = await import("./releases");
const link = new DownloadLinkStub();
expect(await resolveAssetDownload(link, "arm64.dmg")).toBeNull();
expect(link.href).toBe(RELEASES_URL);
expect(link.attributes.has("aria-disabled")).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
});
73 changes: 61 additions & 12 deletions apps/marketing/src/lib/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export const RELEASES_URL = `https://github.com/${REPO}/releases`;

const API_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
const CACHE_KEY = "akeru-latest-release";
export const RELEASE_REQUEST_TIMEOUT_MS = 5_000;

let latestRelease: Release | undefined;
let releaseRequest: Promise<Release> | undefined;

export interface ReleaseAsset {
name: string;
Expand Down Expand Up @@ -101,27 +105,72 @@ export async function resolveAssetDownload(
}
}

export async function fetchLatestRelease(): Promise<Release> {
const cached = sessionStorage.getItem(CACHE_KEY);
export function fetchLatestRelease(): Promise<Release> {
if (latestRelease) return Promise.resolve(latestRelease);
if (releaseRequest) return releaseRequest;

const cached = readCachedRelease();
if (cached) {
latestRelease = cached;
return Promise.resolve(cached);
}

releaseRequest = requestLatestRelease()
.then((data) => {
latestRelease = data;
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
} catch {
// Downloads still work when session storage is unavailable or full.
}
return data;
})
.finally(() => {
releaseRequest = undefined;
});
return releaseRequest;
}

function readCachedRelease(): Release | undefined {
try {
const cached = sessionStorage.getItem(CACHE_KEY);
if (!cached) return undefined;
try {
const data: unknown = JSON.parse(cached);
if (isRelease(data)) return data;
sessionStorage.removeItem(CACHE_KEY);
} catch {
sessionStorage.removeItem(CACHE_KEY);
// Invalid cached data is replaced by the next successful request.
}
sessionStorage.removeItem(CACHE_KEY);
} catch {
// Storage access can be denied independently of network access.
}
return undefined;
}

const response = await fetch(API_URL);
if (!response.ok) throw new Error(`GitHub release request failed: ${response.status}`);

const data: unknown = await response.json();
if (!isRelease(data)) throw new Error("GitHub returned an invalid release");

sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
async function requestLatestRelease(): Promise<Release> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error("GitHub release request timed out"));
controller.abort();
}, RELEASE_REQUEST_TIMEOUT_MS);
});
const request = async () => {
const response = await fetch(API_URL, { signal: controller.signal });
if (!response.ok) throw new Error(`GitHub release request failed: ${response.status}`);
const data: unknown = await response.json();
if (!isRelease(data)) throw new Error("GitHub returned an invalid release");
return data;
};

return data;
try {
// The deadline covers the response body as well as headers, even if abort is ignored.
return await Promise.race([request(), deadline]);
} finally {
clearTimeout(timer);
}
}

function isRelease(value: unknown): value is Release {
Expand Down
Loading