返回 DeepSeek-Reasonix
desktop_release.test.ts
根目录 / workers / crash-report / src / desktop_release.test.ts
1 import { afterEach, describe, expect, it, vi } from "vitest";
2 import {
3 cliReleaseChannel,
4 desktopReleaseChannel,
5 handleCLIRelease,
6 handleDesktopReleaseManifest,
7 } from "./desktop_release";
8 import worker from "./index";
9
10 const sha256 = "a".repeat(64);
11
12 function desktopManifest(version: string, base?: string) {
13 const releaseBase = base ?? `https://dl.reasonix.io/desktop-${version}/`;
14 const asset = (name: string) => {
15 const url = releaseBase + name;
16 return { url, sig: `${url}.minisig`, size: 42, sha256 };
17 };
18 return {
19 version,
20 download_page: "https://reasonix.io/?download=desktop#start",
21 platforms: {
22 "darwin-arm64": asset("Reasonix-darwin-arm64.zip"),
23 "darwin-amd64": asset("Reasonix-darwin-amd64.zip"),
24 "windows-amd64": asset("Reasonix-windows-amd64-installer.exe"),
25 "windows-arm64": asset("Reasonix-windows-arm64-installer.exe"),
26 "linux-amd64": asset("Reasonix-linux-amd64.tar.gz"),
27 },
28 native_packages: {
29 "linux-amd64": asset("Reasonix-linux-amd64.deb"),
30 },
31 downloads: {
32 "Reasonix-darwin-arm64.dmg": asset("Reasonix-darwin-arm64.dmg"),
33 "Reasonix-darwin-amd64.dmg": asset("Reasonix-darwin-amd64.dmg"),
34 "Reasonix-darwin-universal.dmg": asset("Reasonix-darwin-universal.dmg"),
35 "Reasonix-windows-amd64.zip": asset("Reasonix-windows-amd64.zip"),
36 },
37 };
38 }
39
40 function desktopManifestText(version: string, base?: string): string {
41 return JSON.stringify(desktopManifest(version, base));
42 }
43
44 function githubDesktopRelease(version: string, overrides: Record<string, unknown> = {}) {
45 const tag = `desktop-${version}`;
46 return {
47 tag_name: tag,
48 draft: false,
49 prerelease: false,
50 assets: [{
51 name: "latest.json",
52 browser_download_url:
53 `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${tag}/latest.json`,
54 size: 42,
55 }],
56 ...overrides,
57 };
58 }
59
60 const cliAssets = [
61 "reasonix-darwin-amd64.tar.gz",
62 "reasonix-darwin-arm64.tar.gz",
63 "reasonix-linux-amd64.tar.gz",
64 "reasonix-linux-arm64.tar.gz",
65 "reasonix-windows-amd64.zip",
66 "reasonix-windows-arm64.zip",
67 "SHA256SUMS",
68 ];
69
70 const cliRelease = (tag: string, prerelease: boolean) => ({
71 tag_name: tag,
72 prerelease,
73 html_url: `https://github.com/esengine/DeepSeek-Reasonix/releases/tag/${tag}`,
74 assets: cliAssets.map((name) => ({
75 name,
76 browser_download_url: `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${tag}/${name}`,
77 size: 42,
78 })),
79 });
80
81 afterEach(() => {
82 vi.unstubAllGlobals();
83 });
84
85 describe("desktop Preview release gateway", () => {
86 it("recognizes Preview and the legacy Canary compatibility route", () => {
87 expect(desktopReleaseChannel("/v1/desktop/releases/preview/latest.json")).toBe("preview");
88 expect(desktopReleaseChannel("/v1/desktop/releases/canary/latest.json")).toBe("canary");
89 expect(desktopReleaseChannel("/v1/desktop/releases/rc/latest.json")).toBeNull();
90 });
91
92 it("serves a complete Preview manifest from the canonical pointer first", async () => {
93 const fetchMock = vi.fn(async (_url: string) =>
94 new Response(desktopManifestText("v1.2.0-preview.7"), { status: 200 }),
95 );
96 vi.stubGlobal("fetch", fetchMock);
97
98 const response = await handleDesktopReleaseManifest("preview");
99
100 expect(response.status).toBe(200);
101 expect(response.headers.get("access-control-allow-origin")).toBe("*");
102 expect(response.headers.get("x-reasonix-release-source")).toBe("r2-preview");
103 expect(fetchMock).toHaveBeenCalledTimes(1);
104 expect(fetchMock.mock.calls[0]?.[0]).toBe("https://dl.reasonix.io/preview/latest.json");
105 });
106
107 it("keeps serving the signed rolling Preview manifest during pointer migration", async () => {
108 const legacy = desktopManifest(
109 "v1.18.0-preview.62",
110 "https://dl.reasonix.io/desktop-preview/",
111 );
112 Reflect.deleteProperty(legacy, "downloads");
113 const fetchMock = vi.fn(async () =>
114 new Response(JSON.stringify(legacy), { status: 200 }),
115 );
116 vi.stubGlobal("fetch", fetchMock);
117
118 const response = await handleDesktopReleaseManifest("preview");
119
120 expect(response.status).toBe(200);
121 expect(response.headers.get("x-reasonix-release-source")).toBe("r2-preview");
122 expect(fetchMock).toHaveBeenCalledTimes(1);
123 });
124
125 it("rejects a new-format Preview manifest that still uses rolling assets", async () => {
126 const rolling = desktopManifest(
127 "v1.18.0-preview.63",
128 "https://dl.reasonix.io/desktop-preview/",
129 );
130 const fetchMock = vi
131 .fn()
132 .mockResolvedValueOnce(new Response(JSON.stringify(rolling), { status: 200 }))
133 .mockResolvedValueOnce(new Response("missing", { status: 404 }));
134 vi.stubGlobal("fetch", fetchMock);
135
136 const response = await handleDesktopReleaseManifest("preview");
137
138 expect(response.status).toBe(502);
139 expect(fetchMock).toHaveBeenCalledTimes(2);
140 });
141
142 it("continues to the compatibility pointer after an invalid 200 response", async () => {
143 const invalid = desktopManifest("v1.2.0-preview.8");
144 invalid.platforms["darwin-arm64"].size = 0;
145 const fetchMock = vi
146 .fn()
147 .mockResolvedValueOnce(new Response(JSON.stringify(invalid), { status: 200 }))
148 .mockResolvedValueOnce(new Response(desktopManifestText("v1.2.0-preview.7"), { status: 200 }));
149 vi.stubGlobal("fetch", fetchMock);
150
151 const response = await handleDesktopReleaseManifest("preview");
152
153 expect(response.status).toBe(200);
154 expect(response.headers.get("x-reasonix-release-source")).toBe("r2-canary-compat");
155 expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
156 "https://dl.reasonix.io/preview/latest.json",
157 "https://dl.reasonix.io/canary/latest.json",
158 ]);
159 });
160
161 it("rejects hostile URLs and incomplete Desktop manifests", async () => {
162 const cases: Array<[string, (manifest: ReturnType<typeof desktopManifest>) => void]> = [
163 ["malicious host", (manifest) => {
164 const url = "https://evil.invalid/desktop-v1.2.0-preview.7/Reasonix-darwin-arm64.zip";
165 Object.assign(manifest.platforms["darwin-arm64"], { url, sig: `${url}.minisig` });
166 }],
167 ["userinfo", (manifest) => {
168 const url = "https://dl.reasonix.io@evil.invalid/desktop-v1.2.0-preview.7/Reasonix-darwin-arm64.zip";
169 Object.assign(manifest.platforms["darwin-arm64"], { url, sig: `${url}.minisig` });
170 }],
171 ["http", (manifest) => {
172 const url = "http://dl.reasonix.io/desktop-v1.2.0-preview.7/Reasonix-darwin-arm64.zip";
173 Object.assign(manifest.platforms["darwin-arm64"], { url, sig: `${url}.minisig` });
174 }],
175 ["wrong channel path", (manifest) => {
176 const url = "https://dl.reasonix.io/preview/Reasonix-darwin-arm64.zip";
177 Object.assign(manifest.platforms["darwin-arm64"], { url, sig: `${url}.minisig` });
178 }],
179 ["wrong filename", (manifest) => {
180 const url = "https://dl.reasonix.io/desktop-v1.2.0-preview.7/Reasonix-darwin-amd64.zip";
181 Object.assign(manifest.platforms["darwin-arm64"], { url, sig: `${url}.minisig` });
182 }],
183 ["missing asset", (manifest) => {
184 delete (manifest.platforms as Partial<typeof manifest.platforms>)["windows-arm64"];
185 }],
186 ["missing website download", (manifest) => {
187 delete (manifest.downloads as Partial<typeof manifest.downloads>)["Reasonix-darwin-universal.dmg"];
188 }],
189 ["invalid website download", (manifest) => {
190 manifest.downloads["Reasonix-windows-amd64.zip"].size = 0;
191 }],
192 ["bad SHA", (manifest) => {
193 manifest.platforms["darwin-arm64"].sha256 = "A".repeat(64);
194 }],
195 ["zero size", (manifest) => {
196 manifest.platforms["darwin-arm64"].size = 0;
197 }],
198 ["size above release maximum", (manifest) => {
199 manifest.platforms["darwin-arm64"].size = 1073741825;
200 }],
201 ["bad signature", (manifest) => {
202 manifest.platforms["darwin-arm64"].sig += "?mirror=1";
203 }],
204 ["wrong download page", (manifest) => {
205 manifest.download_page = "https://evil.invalid/download";
206 }],
207 ];
208
209 for (const [name, mutate] of cases) {
210 const manifest = desktopManifest("v1.2.0-preview.7");
211 mutate(manifest);
212 const fetchMock = vi
213 .fn()
214 .mockResolvedValueOnce(new Response(JSON.stringify(manifest), { status: 200 }))
215 .mockResolvedValueOnce(new Response("missing", { status: 404 }));
216 vi.stubGlobal("fetch", fetchMock);
217
218 const response = await handleDesktopReleaseManifest("preview");
219
220 expect(response.status, name).toBe(502);
221 expect(fetchMock, name).toHaveBeenCalledTimes(2);
222 vi.unstubAllGlobals();
223 }
224 });
225 });
226
227 describe("desktop Stable GitHub fallback", () => {
228 it("accepts the exact versioned R2 asset directory", async () => {
229 const fetchMock = vi.fn(async (_url: string) =>
230 new Response(desktopManifestText("v1.18.0"), { status: 200 }),
231 );
232 vi.stubGlobal("fetch", fetchMock);
233
234 const response = await handleDesktopReleaseManifest("stable");
235
236 expect(response.status).toBe(200);
237 expect(response.headers.get("x-reasonix-release-source")).toBe("r2-stable");
238 expect(fetchMock).toHaveBeenCalledTimes(1);
239 });
240
241 it("keeps serving a legacy Stable manifest that predates website downloads", async () => {
242 const legacy = desktopManifest("v1.17.21");
243 Reflect.deleteProperty(legacy, "downloads");
244 const fetchMock = vi.fn(async () =>
245 new Response(JSON.stringify(legacy), { status: 200 }),
246 );
247 vi.stubGlobal("fetch", fetchMock);
248
249 const response = await handleDesktopReleaseManifest("stable");
250
251 expect(response.status).toBe(200);
252 expect(response.headers.get("x-reasonix-release-source")).toBe("r2-stable");
253 expect(fetchMock).toHaveBeenCalledTimes(1);
254 });
255
256 it("keeps serving the historical two-download manifest", async () => {
257 const historical = desktopManifest("v1.17.21");
258 Reflect.deleteProperty(historical.downloads, "Reasonix-darwin-arm64.dmg");
259 Reflect.deleteProperty(historical.downloads, "Reasonix-darwin-amd64.dmg");
260 const fetchMock = vi.fn(async () =>
261 new Response(JSON.stringify(historical), { status: 200 }),
262 );
263 vi.stubGlobal("fetch", fetchMock);
264
265 const response = await handleDesktopReleaseManifest("stable");
266 expect(response.status).toBe(200);
267 });
268
269 it("accepts null but rejects an empty downloads object as legacy", async () => {
270 const nullDownloads = desktopManifest("v1.17.21");
271 Reflect.set(nullDownloads, "downloads", null);
272 const nullFetch = vi.fn(async () =>
273 new Response(JSON.stringify(nullDownloads), { status: 200 }),
274 );
275 vi.stubGlobal("fetch", nullFetch);
276
277 const legacyResponse = await handleDesktopReleaseManifest("stable");
278 expect(legacyResponse.status).toBe(200);
279 expect(nullFetch).toHaveBeenCalledTimes(1);
280
281 const emptyDownloads = desktopManifest("v1.17.21");
282 Reflect.set(emptyDownloads, "downloads", {});
283 const emptyFetch = vi
284 .fn()
285 .mockResolvedValueOnce(new Response(JSON.stringify(emptyDownloads), { status: 200 }))
286 .mockResolvedValueOnce(new Response("missing", { status: 404 }));
287 vi.stubGlobal("fetch", emptyFetch);
288
289 const invalidResponse = await handleDesktopReleaseManifest("stable");
290 expect(invalidResponse.status).toBe(502);
291 expect(emptyFetch).toHaveBeenCalledTimes(2);
292 });
293
294 it("uses /releases/latest and requires the release tag to match the manifest version", async () => {
295 const githubBase =
296 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.18.0/";
297 const invalidR2 = desktopManifest("v1.19.0");
298 invalidR2.platforms["darwin-arm64"].size = 0;
299 const fetchMock = vi
300 .fn()
301 .mockResolvedValueOnce(new Response(JSON.stringify(invalidR2), { status: 200 }))
302 .mockResolvedValueOnce(new Response(JSON.stringify(githubDesktopRelease("v1.18.0")), { status: 200 }))
303 .mockResolvedValueOnce(new Response(desktopManifestText("v1.18.0", githubBase), { status: 200 }));
304 vi.stubGlobal("fetch", fetchMock);
305
306 const response = await handleDesktopReleaseManifest("stable");
307 const body = await response.json() as { version?: string };
308
309 expect(response.status).toBe(200);
310 expect(body.version).toBe("v1.18.0");
311 expect(response.headers.get("x-reasonix-release-source")).toBe("github-desktop-release");
312 expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
313 "https://dl.reasonix.io/latest/latest.json",
314 "https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/latest",
315 `${githubBase}latest.json`,
316 ]);
317 });
318
319 it("rejects a GitHub manifest whose version disagrees with the latest release tag", async () => {
320 const manifestBase =
321 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.17.9/";
322 const fetchMock = vi
323 .fn()
324 .mockResolvedValueOnce(new Response("missing", { status: 404 }))
325 .mockResolvedValueOnce(new Response(JSON.stringify(githubDesktopRelease("v1.18.0")), { status: 200 }))
326 .mockResolvedValueOnce(new Response(desktopManifestText("v1.17.9", manifestBase), { status: 200 }));
327 vi.stubGlobal("fetch", fetchMock);
328
329 const response = await handleDesktopReleaseManifest("stable");
330
331 expect(response.status).toBe(502);
332 expect(fetchMock).toHaveBeenCalledTimes(3);
333 });
334
335 it("rejects a non-canonical or zero-byte latest.json release asset", async () => {
336 for (const asset of [
337 {
338 name: "latest.json",
339 browser_download_url:
340 "https://evil.invalid/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.18.0/latest.json",
341 size: 42,
342 },
343 {
344 name: "latest.json",
345 browser_download_url:
346 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.18.0/latest.json",
347 size: 0,
348 },
349 ]) {
350 const release = githubDesktopRelease("v1.18.0", { assets: [asset] });
351 const fetchMock = vi
352 .fn()
353 .mockResolvedValueOnce(new Response("missing", { status: 404 }))
354 .mockResolvedValueOnce(new Response(JSON.stringify(release), { status: 200 }));
355 vi.stubGlobal("fetch", fetchMock);
356
357 const response = await handleDesktopReleaseManifest("stable");
358
359 expect(response.status).toBe(502);
360 expect(fetchMock).toHaveBeenCalledTimes(2);
361 vi.unstubAllGlobals();
362 }
363 });
364 });
365
366 describe("release gateway HTTP method contract", () => {
367 const env = {} as Parameters<typeof worker.fetch>[1];
368
369 it("answers CORS preflight without loading an upstream release", async () => {
370 const fetchMock = vi.fn();
371 vi.stubGlobal("fetch", fetchMock);
372
373 const response = await worker.fetch(
374 new Request("https://crash.reasonix.io/v1/cli/releases/stable/latest.json", {
375 method: "OPTIONS",
376 }),
377 env,
378 );
379
380 expect(response.status).toBe(204);
381 expect(response.headers.get("access-control-allow-origin")).toBe("*");
382 expect(response.headers.get("access-control-allow-methods")).toBe("GET, HEAD, OPTIONS");
383 expect(response.headers.get("access-control-max-age")).toBe("86400");
384 expect(response.headers.get("allow")).toBe("GET, HEAD, OPTIONS");
385 expect(fetchMock).not.toHaveBeenCalled();
386 });
387
388 it("serves HEAD with GET status and headers but no body", async () => {
389 const fetchMock = vi.fn(async (_url: string) =>
390 new Response(desktopManifestText("v1.2.0-preview.7"), { status: 200 }),
391 );
392 vi.stubGlobal("fetch", fetchMock);
393
394 const response = await worker.fetch(
395 new Request("https://crash.reasonix.io/v1/desktop/releases/preview/latest.json", {
396 method: "HEAD",
397 }),
398 env,
399 );
400
401 expect(response.status).toBe(200);
402 expect(await response.text()).toBe("");
403 expect(response.headers.get("access-control-allow-origin")).toBe("*");
404 expect(response.headers.get("x-reasonix-release-source")).toBe("r2-preview");
405 expect(fetchMock).toHaveBeenCalledTimes(1);
406 });
407
408 it("returns a CORS-aware 405 for unsupported release methods", async () => {
409 const fetchMock = vi.fn();
410 vi.stubGlobal("fetch", fetchMock);
411
412 const response = await worker.fetch(
413 new Request("https://crash.reasonix.io/v1/cli/releases/preview/latest.json", {
414 method: "POST",
415 }),
416 env,
417 );
418
419 expect(response.status).toBe(405);
420 expect(response.headers.get("access-control-allow-origin")).toBe("*");
421 expect(response.headers.get("allow")).toBe("GET, HEAD, OPTIONS");
422 expect(fetchMock).not.toHaveBeenCalled();
423 });
424 });
425
426 describe("CLI public release gateway", () => {
427 it("recognizes only Stable and Preview routes", () => {
428 expect(cliReleaseChannel("/v1/cli/releases/stable/latest.json")).toBe("stable");
429 expect(cliReleaseChannel("/v1/cli/releases/preview/latest.json")).toBe("preview");
430 expect(cliReleaseChannel("/v1/cli/releases/rc/latest.json")).toBeNull();
431 });
432
433 it("serves a complete strict Preview pointer from R2", async () => {
434 const fetchMock = vi.fn(async (_url: string) =>
435 new Response(JSON.stringify(cliRelease("v1.18.0-preview.1", true)), { status: 200 }),
436 );
437 vi.stubGlobal("fetch", fetchMock);
438
439 const response = await handleCLIRelease("preview");
440 const body = await response.json() as { tag_name?: string };
441
442 expect(body.tag_name).toBe("v1.18.0-preview.1");
443 expect(response.headers.get("access-control-allow-origin")).toBe("*");
444 expect(response.headers.get("x-reasonix-release-source")).toBe("r2-cli-preview");
445 expect(fetchMock.mock.calls[0]?.[0]).toBe("https://dl.reasonix.io/cli/preview/latest.json");
446 });
447
448 it("rewrites release notes to the canonical repository tag URL", async () => {
449 const release = cliRelease("v1.18.0", false);
450 release.html_url = "https://evil.invalid/phishing";
451 const fetchMock = vi.fn(async () =>
452 new Response(JSON.stringify(release), { status: 200 }),
453 );
454 vi.stubGlobal("fetch", fetchMock);
455
456 const response = await handleCLIRelease("stable");
457 const body = await response.json() as { html_url?: string };
458
459 expect(body.html_url).toBe(
460 "https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.18.0",
461 );
462 });
463
464 it("falls back after an invalid 200 and strictly filters GitHub releases", async () => {
465 const invalidPointer = cliRelease("v1.18.0-preview.20", true);
466 invalidPointer.assets[0]!.size = 0;
467 const releases = [
468 cliRelease("v1.19.0-rc.1", true),
469 cliRelease("v1.18.0-preview.2", true),
470 cliRelease("v1.18.0-preview.12", true),
471 cliRelease("v1.18.0-preview.13", false),
472 cliRelease("v1.17.21", false),
473 ];
474 const fetchMock = vi
475 .fn()
476 .mockResolvedValueOnce(new Response(JSON.stringify(invalidPointer), { status: 200 }))
477 .mockResolvedValueOnce(new Response(JSON.stringify(releases), { status: 200 }));
478 vi.stubGlobal("fetch", fetchMock);
479
480 const response = await handleCLIRelease("preview");
481 const body = await response.json() as { tag_name?: string };
482
483 expect(body.tag_name).toBe("v1.18.0-preview.12");
484 expect(response.headers.get("x-reasonix-release-source")).toBe("github-cli-releases");
485 expect(fetchMock.mock.calls[1]?.[0]).toContain("releases?per_page=100");
486 });
487
488 it("requires every CLI asset URL to be canonical", async () => {
489 const invalidURLs = [
490 "https://evil.invalid/esengine/DeepSeek-Reasonix/releases/download/v1.20.0/reasonix-darwin-amd64.tar.gz",
491 "https://github.com@evil.invalid/esengine/DeepSeek-Reasonix/releases/download/v1.20.0/reasonix-darwin-amd64.tar.gz",
492 "http://github.com/esengine/DeepSeek-Reasonix/releases/download/v1.20.0/reasonix-darwin-amd64.tar.gz",
493 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v1.19.0/reasonix-darwin-amd64.tar.gz",
494 "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v1.20.0/reasonix-darwin-arm64.tar.gz",
495 ];
496
497 for (const browserDownloadURL of invalidURLs) {
498 const invalid = cliRelease("v1.20.0", false);
499 invalid.assets[0]!.browser_download_url = browserDownloadURL;
500 const fetchMock = vi
501 .fn()
502 .mockResolvedValueOnce(new Response(JSON.stringify(invalid), { status: 200 }))
503 .mockResolvedValueOnce(new Response(JSON.stringify([cliRelease("v1.19.0", false)]), { status: 200 }));
504 vi.stubGlobal("fetch", fetchMock);
505
506 const response = await handleCLIRelease("stable");
507 const body = await response.json() as { tag_name?: string };
508
509 expect(body.tag_name).toBe("v1.19.0");
510 expect(response.headers.get("x-reasonix-release-source")).toBe("github-cli-releases");
511 vi.unstubAllGlobals();
512 }
513 });
514
515 it("rejects non-positive, malformed, and oversized CLI asset sizes", async () => {
516 const invalidSizes: unknown[] = [
517 0,
518 -1,
519 "42",
520 undefined,
521 1.5,
522 1073741825,
523 Number.MAX_SAFE_INTEGER + 1,
524 NaN,
525 ];
526 for (const size of invalidSizes) {
527 const invalid = cliRelease("v1.20.0", false);
528 (invalid.assets[0] as { size?: unknown }).size = size;
529 const fetchMock = vi
530 .fn()
531 .mockResolvedValueOnce(new Response(JSON.stringify(invalid), { status: 200 }))
532 .mockResolvedValueOnce(new Response(JSON.stringify([cliRelease("v1.19.0", false)]), { status: 200 }));
533 vi.stubGlobal("fetch", fetchMock);
534
535 const response = await handleCLIRelease("stable");
536 const body = await response.json() as { tag_name?: string };
537
538 expect(body.tag_name, `size ${String(size)}`).toBe("v1.19.0");
539 expect(response.headers.get("x-reasonix-release-source")).toBe("github-cli-releases");
540 vi.unstubAllGlobals();
541 }
542 });
543
544 it("rejects duplicate required CLI assets", async () => {
545 const invalid = cliRelease("v1.20.0", false);
546 invalid.assets.push({ ...invalid.assets[0]! });
547 const fetchMock = vi
548 .fn()
549 .mockResolvedValueOnce(new Response(JSON.stringify(invalid), { status: 200 }))
550 .mockResolvedValueOnce(new Response(JSON.stringify([cliRelease("v1.19.0", false)]), { status: 200 }));
551 vi.stubGlobal("fetch", fetchMock);
552
553 const response = await handleCLIRelease("stable");
554 const body = await response.json() as { tag_name?: string };
555
556 expect(body.tag_name).toBe("v1.19.0");
557 expect(response.headers.get("x-reasonix-release-source")).toBe("github-cli-releases");
558 });
559
560 it("rejects incomplete releases and compares huge Stable versions exactly", async () => {
561 const incomplete = cliRelease("v100000000000000000001.0.0", false);
562 incomplete.assets.pop();
563 const releases = [
564 incomplete,
565 cliRelease("v99999999999999999999.999.999", false),
566 cliRelease("v100000000000000000000.0.0", false),
567 ];
568 const fetchMock = vi
569 .fn()
570 .mockResolvedValueOnce(new Response(JSON.stringify(incomplete), { status: 200 }))
571 .mockResolvedValueOnce(new Response(JSON.stringify(releases), { status: 200 }));
572 vi.stubGlobal("fetch", fetchMock);
573
574 const response = await handleCLIRelease("stable");
575 const body = await response.json() as { tag_name?: string };
576
577 expect(body.tag_name).toBe("v100000000000000000000.0.0");
578 expect(response.headers.get("x-reasonix-release-source")).toBe("github-cli-releases");
579 expect(fetchMock).toHaveBeenCalledTimes(2);
580 });
581 });
582
582 lines TYPESCRIPT