|
1 | | -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; |
2 | | -import * as fs from "node:fs"; |
3 | | -import * as os from "node:os"; |
4 | | -import * as path from "node:path"; |
| 1 | +import { describe, it, expect, vi, beforeEach } from "vitest"; |
5 | 2 | import { downloadGgufFile } from "./huggingface"; |
6 | | - |
7 | | -function fakeResponse(opts: { status: number; headers?: Record<string, string>; chunks?: string[] }) { |
8 | | - const chunks = (opts.chunks ?? []).map((c) => new TextEncoder().encode(c)); |
9 | | - let i = 0; |
10 | | - return { |
11 | | - ok: opts.status >= 200 && opts.status < 300, |
12 | | - status: opts.status, |
13 | | - headers: { get: (name: string) => opts.headers?.[name.toLowerCase()] ?? null }, |
14 | | - body: { |
15 | | - getReader: () => ({ |
16 | | - read: async () => (i < chunks.length ? { done: false, value: chunks[i++] } : { done: true, value: undefined }), |
17 | | - }), |
18 | | - }, |
19 | | - } as unknown as Response; |
20 | | -} |
21 | | - |
22 | | -describe("downloadGgufFile", () => { |
23 | | - let dir: string; |
24 | | - let destPath: string; |
25 | | - |
| 3 | +import { downloadGgufFileNative } from "./native-downloader"; |
| 4 | + |
| 5 | +// The actual HTTP/resume/verify logic now lives in the Rust native addon |
| 6 | +// (lib/), covered by its own test suite there (mock-HTTP-server-based, see |
| 7 | +// lib/src/download/tests.rs) — a JS-side `vi.stubGlobal("fetch", ...)` can't |
| 8 | +// reach code that no longer runs in JS. What's left to verify from the TS |
| 9 | +// side is just this file's adapter: does it call the native function with |
| 10 | +// the right arguments, and does it translate the native progress/error |
| 11 | +// shapes into what callers (main.ts's hf:downloadFile handler) expect. |
| 12 | +vi.mock("./native-downloader", () => ({ |
| 13 | + downloadGgufFileNative: vi.fn(), |
| 14 | +})); |
| 15 | + |
| 16 | +const nativeMock = vi.mocked(downloadGgufFileNative); |
| 17 | + |
| 18 | +describe("downloadGgufFile (native adapter)", () => { |
26 | 19 | beforeEach(() => { |
27 | | - dir = fs.mkdtempSync(path.join(os.tmpdir(), "hf-download-test-")); |
28 | | - destPath = path.join(dir, "model.gguf"); |
29 | | - }); |
30 | | - |
31 | | - afterEach(() => { |
32 | | - vi.unstubAllGlobals(); |
| 20 | + nativeMock.mockReset(); |
33 | 21 | }); |
34 | 22 |
|
35 | | - it("downloads fresh, reports progress, and renames the .part file to the final name on success", async () => { |
36 | | - const progress: { receivedBytes: number; totalBytes: number | null }[] = []; |
37 | | - vi.stubGlobal( |
38 | | - "fetch", |
39 | | - vi.fn(async () => fakeResponse({ status: 200, headers: { "content-length": "10" }, chunks: ["hello", "world"] })) |
40 | | - ); |
| 23 | + it("calls the native addon with modelId/filename/destPath and no token when none is given", async () => { |
| 24 | + nativeMock.mockResolvedValue(undefined); |
41 | 25 |
|
42 | | - await downloadGgufFile("org/model", "model.gguf", destPath, (p) => progress.push(p)); |
| 26 | + await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {}); |
43 | 27 |
|
44 | | - expect(fs.existsSync(destPath)).toBe(true); |
45 | | - expect(fs.existsSync(destPath + ".part")).toBe(false); |
46 | | - expect(fs.readFileSync(destPath, "utf8")).toBe("helloworld"); |
47 | | - expect(progress.at(-1)).toEqual({ receivedBytes: 10, totalBytes: 10 }); |
| 28 | + expect(nativeMock).toHaveBeenCalledWith( |
| 29 | + "org/model", |
| 30 | + "model.gguf", |
| 31 | + "/tmp/model.gguf", |
| 32 | + undefined, |
| 33 | + expect.any(Function) |
| 34 | + ); |
48 | 35 | }); |
49 | 36 |
|
50 | | - it("resumes from an existing .part file using a Range request instead of starting over", async () => { |
51 | | - fs.writeFileSync(destPath + ".part", "hello"); |
52 | | - const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { |
53 | | - expect((init?.headers as Record<string, string>).Range).toBe("bytes=5-"); |
54 | | - return fakeResponse({ status: 206, headers: { "content-range": "bytes 5-9/10" }, chunks: ["world"] }); |
55 | | - }); |
56 | | - vi.stubGlobal("fetch", fetchMock); |
| 37 | + it("passes a null token through as undefined, matching the native addon's Option<String> signature", async () => { |
| 38 | + nativeMock.mockResolvedValue(undefined); |
57 | 39 |
|
58 | | - await downloadGgufFile("org/model", "model.gguf", destPath, () => {}); |
| 40 | + await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {}, null); |
59 | 41 |
|
60 | | - expect(fs.readFileSync(destPath, "utf8")).toBe("helloworld"); |
| 42 | + expect(nativeMock.mock.calls[0][3]).toBeUndefined(); |
61 | 43 | }); |
62 | 44 |
|
63 | | - it("discards a stale partial and starts over when the server ignores the Range request", async () => { |
64 | | - fs.writeFileSync(destPath + ".part", "OLD-STALE-DATA"); |
65 | | - vi.stubGlobal( |
66 | | - "fetch", |
67 | | - vi.fn(async () => fakeResponse({ status: 200, headers: { "content-length": "5" }, chunks: ["fresh"] })) |
68 | | - ); |
| 45 | + it("passes a real token through unchanged", async () => { |
| 46 | + nativeMock.mockResolvedValue(undefined); |
69 | 47 |
|
70 | | - await downloadGgufFile("org/model", "model.gguf", destPath, () => {}); |
| 48 | + await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {}, "secret-token"); |
71 | 49 |
|
72 | | - expect(fs.readFileSync(destPath, "utf8")).toBe("fresh"); |
| 50 | + expect(nativeMock.mock.calls[0][3]).toBe("secret-token"); |
73 | 51 | }); |
74 | 52 |
|
75 | | - it("keeps the .part file (doesn't delete it) when the connection drops mid-stream, so a retry can resume", async () => { |
76 | | - vi.stubGlobal("fetch", vi.fn(async () => ({ |
77 | | - ok: true, |
78 | | - status: 200, |
79 | | - headers: { get: () => "20" }, |
80 | | - body: { |
81 | | - getReader: () => ({ |
82 | | - read: async () => { |
83 | | - throw new Error("connection reset"); |
84 | | - }, |
85 | | - }), |
86 | | - }, |
87 | | - } as unknown as Response))); |
88 | | - |
89 | | - await expect(downloadGgufFile("org/model", "model.gguf", destPath, () => {})).rejects.toThrow("connection reset"); |
90 | | - expect(fs.existsSync(destPath + ".part")).toBe(true); |
91 | | - expect(fs.existsSync(destPath)).toBe(false); |
92 | | - }); |
| 53 | + it("adapts native progress events, mapping a missing totalBytes to null", async () => { |
| 54 | + const progress: { receivedBytes: number; totalBytes: number | null }[] = []; |
| 55 | + nativeMock.mockImplementation(async (_modelId, _filename, _dest, _token, onProgress) => { |
| 56 | + onProgress(null, { receivedBytes: 5, totalBytes: undefined }); |
| 57 | + onProgress(null, { receivedBytes: 10, totalBytes: 10 }); |
| 58 | + }); |
93 | 59 |
|
94 | | - it("keeps the .part file and throws when the stream ends short of the expected size", async () => { |
95 | | - vi.stubGlobal( |
96 | | - "fetch", |
97 | | - vi.fn(async () => fakeResponse({ status: 200, headers: { "content-length": "100" }, chunks: ["short"] })) |
98 | | - ); |
| 60 | + await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", (p) => progress.push(p)); |
99 | 61 |
|
100 | | - await expect(downloadGgufFile("org/model", "model.gguf", destPath, () => {})).rejects.toThrow(/incomplete/); |
101 | | - expect(fs.existsSync(destPath + ".part")).toBe(true); |
| 62 | + expect(progress).toEqual([ |
| 63 | + { receivedBytes: 5, totalBytes: null }, |
| 64 | + { receivedBytes: 10, totalBytes: 10 }, |
| 65 | + ]); |
102 | 66 | }); |
103 | 67 |
|
104 | | - it("discards a partial and retries once when the server responds 416 (range no longer satisfiable)", async () => { |
105 | | - fs.writeFileSync(destPath + ".part", "stale"); |
106 | | - const fetchMock = vi |
107 | | - .fn() |
108 | | - .mockResolvedValueOnce(fakeResponse({ status: 416 })) |
109 | | - .mockResolvedValueOnce(fakeResponse({ status: 200, headers: { "content-length": "5" }, chunks: ["fresh"] })); |
110 | | - vi.stubGlobal("fetch", fetchMock); |
111 | | - |
112 | | - await downloadGgufFile("org/model", "model.gguf", destPath, () => {}); |
| 68 | + it("propagates the native call's rejection unchanged, so callers' error logging keeps working", async () => { |
| 69 | + const nativeError = new Error( |
| 70 | + 'Download of "model.gguf" was incomplete (got 5 of 10 bytes) — try downloading it again to resume.' |
| 71 | + ); |
| 72 | + nativeMock.mockRejectedValue(nativeError); |
113 | 73 |
|
114 | | - expect(fs.readFileSync(destPath, "utf8")).toBe("fresh"); |
115 | | - expect(fetchMock).toHaveBeenCalledTimes(2); |
| 74 | + await expect(downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {})).rejects.toBe(nativeError); |
116 | 75 | }); |
117 | 76 | }); |
0 commit comments