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