From f2f2972d1d39ed86e34eac5506d0e8ec5415f27a Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Fri, 15 Nov 2024 14:36:36 +0100 Subject: [PATCH 1/6] feat: adding downloadFileToCacheDir --- packages/hub/src/lib/cache-management.ts | 13 +- packages/hub/src/lib/download-file.spec.ts | 291 +++++++++++++++++++++ packages/hub/src/lib/download-file.ts | 126 +++++++++ 3 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 packages/hub/src/lib/download-file.spec.ts diff --git a/packages/hub/src/lib/cache-management.ts b/packages/hub/src/lib/cache-management.ts index aecbf271e1..fc9e459d6a 100644 --- a/packages/hub/src/lib/cache-management.ts +++ b/packages/hub/src/lib/cache-management.ts @@ -16,12 +16,19 @@ function getHuggingFaceHubCache(): string { return process.env["HUGGINGFACE_HUB_CACHE"] ?? getDefaultCachePath(); } -function getHFHubCache(): string { +export function getHFHubCache(): string { return process.env["HF_HUB_CACHE"] ?? getHuggingFaceHubCache(); } const FILES_TO_IGNORE: string[] = [".DS_Store"]; +export const REPO_ID_SEPARATOR: string = "--"; + +export function getRepoFolderName({ name, type }: RepoId): string { + const parts = [`${type}s`, ...name.split("/")] + return parts.join(REPO_ID_SEPARATOR); +} + export interface CachedFileInfo { path: string; /** @@ -107,12 +114,12 @@ export async function scanCacheDir(cacheDir: string | undefined = undefined): Pr export async function scanCachedRepo(repoPath: string): Promise { // get the directory name const name = basename(repoPath); - if (!name.includes("--")) { + if (!name.includes(REPO_ID_SEPARATOR)) { throw new Error(`Repo path is not a valid HuggingFace cache directory: ${name}`); } // parse the repoId from directory name - const [type, ...remaining] = name.split("--"); + const [type, ...remaining] = name.split(REPO_ID_SEPARATOR); const repoType = parseRepoType(type); const repoId = remaining.join("/"); diff --git a/packages/hub/src/lib/download-file.spec.ts b/packages/hub/src/lib/download-file.spec.ts new file mode 100644 index 0000000000..45c62bfca9 --- /dev/null +++ b/packages/hub/src/lib/download-file.spec.ts @@ -0,0 +1,291 @@ +import { expect, test, describe, vi, beforeEach } from "vitest"; +import { downloadFile, downloadFileToCacheDir } from "./download-file"; +import type { RepoDesignation, RepoId } from "../types/public"; +import { dirname, join } from "node:path"; +import { lstat, mkdir, stat, symlink, writeFile, rename } from "node:fs/promises"; +import { pathsInfo } from "./paths-info"; +import type { Stats } from "node:fs"; +import { getHFHubCache, getRepoFolderName } from "./cache-management"; +import { toRepoId } from "../utils/toRepoId"; + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), + rename: vi.fn(), + symlink: vi.fn(), + lstat: vi.fn(), + mkdir: vi.fn(), + stat: vi.fn() +})); + +vi.mock('./paths-info', () => ({ + pathsInfo: vi.fn(), +})); + +const DUMMY_REPO: RepoId = { + name: 'hello-world', + type: 'model', +}; + +const DUMMY_ETAG = "dummy-etag"; + +// utility test method to get blob file path +function _getBlobFile(params: { + repo: RepoDesignation; + etag: string; + cacheDir?: string, // default to {@link getHFHubCache} +}) { + return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "blobs", params.etag); +} + +// utility test method to get snapshot file path +function _getSnapshotFile(params: { + repo: RepoDesignation; + path: string; + revision : string; + cacheDir?: string, // default to {@link getHFHubCache} +}) { + return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "snapshots", params.revision, params.path); +} + +describe('downloadFileToCacheDir', () => { + const fetchMock: typeof fetch = vi.fn(); + beforeEach(() => { + vi.resetAllMocks(); + // mock 200 request + vi.mocked(fetchMock).mockResolvedValue({ + status: 200, + ok: true, + body: 'dummy-body' + } as unknown as Response); + + // prevent to use caching + vi.mocked(stat).mockRejectedValue(new Error('Do not exists')); + vi.mocked(lstat).mockRejectedValue(new Error('Do not exists')); + }); + + test('should throw an error if fileDownloadInfo return nothing', async () => { + await expect(async () => { + await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + }).rejects.toThrowError('cannot get path info for /README.md'); + + expect(pathsInfo).toHaveBeenCalledWith(expect.objectContaining({ + repo: DUMMY_REPO, + paths: ['/README.md'], + fetch: fetchMock, + })); + }); + + test('existing symlinked and blob should not re-download it', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dd4bc8b21efa05ec961e3efc4ee5e3832a3679c7", + }); + // stat ensure a symlink and the pointed file exists + vi.mocked(stat).mockResolvedValue({} as Stats) // prevent default mocked reject + + const output = await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + revision: "dd4bc8b21efa05ec961e3efc4ee5e3832a3679c7", + }); + + expect(stat).toHaveBeenCalledOnce(); + // Get call argument for stat + const starArg = vi.mocked(stat).mock.calls[0][0]; + + expect(starArg).toBe(expectPointer) + expect(fetchMock).not.toHaveBeenCalledWith(); + + expect(output).toBe(expectPointer); + }); + + test('existing blob should only create the symlink', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dummy-commit-hash", + }); + // //blobs/ + const expectedBlob = _getBlobFile({ + repo: DUMMY_REPO, + etag: DUMMY_ETAG, + }); + + // mock existing blob only no symlink + vi.mocked(lstat).mockResolvedValue({} as Stats); + // mock pathsInfo resolve content + vi.mocked(pathsInfo).mockResolvedValue([{ + oid: DUMMY_ETAG, + size: 55, + path: 'README.md', + type: 'file', + lastCommit: { + date: new Date(), + id: 'dummy-commit-hash', + title: 'Commit msg', + }, + }]); + + const output = await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + + expect(stat).not.toHaveBeenCalled(); + // should have check for the blob + expect(lstat).toHaveBeenCalled(); + expect(vi.mocked(lstat).mock.calls[0][0]).toBe(expectedBlob); + + // symlink should have been created + expect(symlink).toHaveBeenCalledOnce(); + // no download done + expect(fetchMock).not.toHaveBeenCalled(); + + expect(output).toBe(expectPointer); + }); + + test('expect resolve value to be the pointer path of downloaded file', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dummy-commit-hash", + }); + // //blobs/ + const expectedBlob = _getBlobFile({ + repo: DUMMY_REPO, + etag: DUMMY_ETAG, + }); + + vi.mocked(pathsInfo).mockResolvedValue([{ + oid: DUMMY_ETAG, + size: 55, + path: 'README.md', + type: 'file', + lastCommit: { + date: new Date(), + id: 'dummy-commit-hash', + title: 'Commit msg', + }, + }]); + + const output = await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + + // expect blobs and snapshots folder to have been mkdir + expect(vi.mocked(mkdir).mock.calls[0][0]).toBe(dirname(expectedBlob)); + expect(vi.mocked(mkdir).mock.calls[1][0]).toBe(dirname(expectPointer)); + + expect(output).toBe(expectPointer); + }); + + test('should write fetch response to blob', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dummy-commit-hash", + }); + // //blobs/ + const expectedBlob = _getBlobFile({ + repo: DUMMY_REPO, + etag: DUMMY_ETAG, + }); + + // mock pathsInfo resolve content + vi.mocked(pathsInfo).mockResolvedValue([{ + oid: DUMMY_ETAG, + size: 55, + path: 'README.md', + type: 'file', + lastCommit: { + date: new Date(), + id: 'dummy-commit-hash', + title: 'Commit msg', + }, + }]); + + await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + + const incomplete = `${expectedBlob}.incomplete`; + // 1. should write fetch#response#body to incomplete file + expect(writeFile).toHaveBeenCalledWith(incomplete, 'dummy-body'); + // 2. should rename the incomplete to the blob expected name + expect(rename).toHaveBeenCalledWith(incomplete, expectedBlob); + // 3. should create symlink pointing to blob + expect(symlink).toHaveBeenCalledWith(expectedBlob, expectPointer); + }); +}); + +describe("downloadFile", () => { + test("hubUrl params should overwrite HUB_URL", async () => { + const fetchMock: typeof fetch = vi.fn(); + vi.mocked(fetchMock).mockResolvedValue({ + status: 200, + ok: true, + } as Response); + + await downloadFile({ + repo: DUMMY_REPO, + path: '/README.md', + hubUrl: 'http://dummy-hub', + fetch: fetchMock, + }); + + expect(fetchMock).toHaveBeenCalledWith('http://dummy-hub/hello-world/resolve/main//README.md', expect.anything()); + }); + + test("raw params should use raw url", async () => { + const fetchMock: typeof fetch = vi.fn(); + vi.mocked(fetchMock).mockResolvedValue({ + status: 200, + ok: true, + } as Response); + + await downloadFile({ + repo: DUMMY_REPO, + path: 'README.md', + raw: true, + fetch: fetchMock, + }); + + expect(fetchMock).toHaveBeenCalledWith('https://huggingface.co/hello-world/raw/main/README.md', expect.anything()); + }); + + test("internal server error should propagate the error", async () => { + const fetchMock: typeof fetch = vi.fn(); + vi.mocked(fetchMock).mockResolvedValue({ + status: 500, + ok: false, + headers: new Map([["Content-Type", "application/json"]]), + json: () => ({ + error: 'Dummy internal error', + }), + } as unknown as Response); + + await expect(async () => { + await downloadFile({ + repo: DUMMY_REPO, + path: 'README.md', + raw: true, + fetch: fetchMock, + }); + }).rejects.toThrowError('Dummy internal error'); + }); +}); \ No newline at end of file diff --git a/packages/hub/src/lib/download-file.ts b/packages/hub/src/lib/download-file.ts index 4f6ebde2e3..0d11413065 100644 --- a/packages/hub/src/lib/download-file.ts +++ b/packages/hub/src/lib/download-file.ts @@ -3,6 +3,132 @@ import { createApiError } from "../error"; import type { CredentialsParams, RepoDesignation } from "../types/public"; import { checkCredentials } from "../utils/checkCredentials"; import { toRepoId } from "../utils/toRepoId"; +import { getHFHubCache, getRepoFolderName } from "./cache-management"; +import { dirname, join } from "node:path"; +import { writeFile, rename, symlink, lstat, mkdir, stat } from "node:fs/promises"; +import type { CommitInfo, PathInfo } from "./paths-info"; +import { pathsInfo } from "./paths-info"; + +export const REGEX_COMMIT_HASH: RegExp = new RegExp("^[0-9a-f]{40}$"); + +function getFilePointer(storageFolder: string, revision: string, relativeFilename: string): string { + const snapshotPath = join(storageFolder, "snapshots"); + return join(snapshotPath, revision, relativeFilename); +} + +/** + * handy method to check if a file exists, or the pointer of a symlinks exists + * @param path + * @param followSymlinks + */ +async function exists(path: string, followSymlinks?: boolean): Promise { + try { + if(followSymlinks) { + await stat(path); + } else { + await lstat(path); + } + return true; + } catch (err: unknown) { + return false; + } +} + +/** + * Download a given file if it's not already present in the local cache. + * @param params + * @return the symlink to the blob object + */ +export async function downloadFileToCacheDir( + params: { + repo: RepoDesignation; + path: string; + /** + * If true, will download the raw git file. + * + * For example, when calling on a file stored with Git LFS, the pointer file will be downloaded instead. + */ + raw?: boolean; + /** + * An optional Git revision id which can be a branch name, a tag, or a commit hash. + * + * @default "main" + */ + revision?: string; + hubUrl?: string; + cacheDir?: string, + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & Partial +): Promise { + // get revision provided or default to main + const revision = params.revision ?? "main"; + const cacheDir = params.cacheDir ?? getHFHubCache(); + // get repo id + const repoId = toRepoId(params.repo); + // get storage folder + const storageFolder = join(cacheDir, getRepoFolderName(repoId)); + + let commitHash: string | undefined; + + // if user provides a commitHash as revision, and they already have the file on disk, shortcut everything. + if (REGEX_COMMIT_HASH.test(revision)) { + commitHash = revision; + const pointerPath = getFilePointer(storageFolder, revision, params.path); + if (await exists(pointerPath, true)) return pointerPath; + } + + const pathsInformation: (PathInfo & { lastCommit: CommitInfo })[] = await pathsInfo({ + ...params, + paths: [params.path], + revision: revision, + expand: true, + }); + if (!pathsInformation || pathsInformation.length !== 1) throw new Error(`cannot get path info for ${params.path}`); + + let etag: string; + if (pathsInformation[0].lfs) { + etag = pathsInformation[0].lfs.oid; // get the LFS pointed file oid + } else { + etag = pathsInformation[0].oid; // get the repo file if not a LFS pointer + } + + const pointerPath = getFilePointer(storageFolder, commitHash ?? pathsInformation[0].lastCommit.id, params.path); + const blobPath = join(storageFolder, "blobs", etag); + + // mkdir blob and pointer path parent directory + await mkdir(dirname(blobPath), { recursive: true }); + await mkdir(dirname(pointerPath), { recursive: true }); + + // We might already have the blob but not the pointer + // shortcut the download if needed + if (await exists(blobPath)) { + // create symlinks in snapshot folder to blob object + await symlink(blobPath, pointerPath); + return pointerPath; + } + + const incomplete = `${blobPath}.incomplete`; + console.debug(`Downloading ${params.path} to ${incomplete}`); + + const response: Response | null = await downloadFile({ + ...params, + revision: commitHash, + }); + + if (!response || !response.ok || !response.body) throw new Error(`invalid response for file ${params.path}`); + + // @ts-expect-error resp.body is a Stream, but Stream in internal to node + await writeFile(incomplete, response.body); + + // rename .incomplete file to expect blob + await rename(incomplete, blobPath); + // create symlinks in snapshot folder to blob object + await symlink(blobPath, pointerPath); + return pointerPath; +} /** * @returns null when the file doesn't exist From 57df98ebbda165d55007dfd0532ae4afaec6a955 Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Fri, 15 Nov 2024 15:54:06 +0100 Subject: [PATCH 2/6] refactor: moving to download-file-cache.ts file --- .../hub/src/lib/download-file-cache.spec.ts | 234 ++++++++++++++++++ packages/hub/src/lib/download-file-cache.ts | 129 ++++++++++ packages/hub/src/lib/download-file.spec.ts | 232 +---------------- packages/hub/src/lib/download-file.ts | 126 ---------- packages/hub/src/lib/index.ts | 1 + 5 files changed, 367 insertions(+), 355 deletions(-) create mode 100644 packages/hub/src/lib/download-file-cache.spec.ts create mode 100644 packages/hub/src/lib/download-file-cache.ts diff --git a/packages/hub/src/lib/download-file-cache.spec.ts b/packages/hub/src/lib/download-file-cache.spec.ts new file mode 100644 index 0000000000..09d7f9fdac --- /dev/null +++ b/packages/hub/src/lib/download-file-cache.spec.ts @@ -0,0 +1,234 @@ +import { expect, test, describe, vi, beforeEach } from "vitest"; +import type { RepoDesignation, RepoId } from "../types/public"; +import { dirname, join } from "node:path"; +import { lstat, mkdir, stat, symlink, writeFile, rename } from "node:fs/promises"; +import { pathsInfo } from "./paths-info"; +import type { Stats } from "node:fs"; +import { getHFHubCache, getRepoFolderName } from "./cache-management"; +import { toRepoId } from "../utils/toRepoId"; +import { downloadFileToCacheDir } from "./download-file-cache"; + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), + rename: vi.fn(), + symlink: vi.fn(), + lstat: vi.fn(), + mkdir: vi.fn(), + stat: vi.fn() +})); + +vi.mock('./paths-info', () => ({ + pathsInfo: vi.fn(), +})); + +const DUMMY_REPO: RepoId = { + name: 'hello-world', + type: 'model', +}; + +const DUMMY_ETAG = "dummy-etag"; + +// utility test method to get blob file path +function _getBlobFile(params: { + repo: RepoDesignation; + etag: string; + cacheDir?: string, // default to {@link getHFHubCache} +}) { + return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "blobs", params.etag); +} + +// utility test method to get snapshot file path +function _getSnapshotFile(params: { + repo: RepoDesignation; + path: string; + revision : string; + cacheDir?: string, // default to {@link getHFHubCache} +}) { + return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "snapshots", params.revision, params.path); +} + +describe('downloadFileToCacheDir', () => { + const fetchMock: typeof fetch = vi.fn(); + beforeEach(() => { + vi.resetAllMocks(); + // mock 200 request + vi.mocked(fetchMock).mockResolvedValue({ + status: 200, + ok: true, + body: 'dummy-body' + } as unknown as Response); + + // prevent to use caching + vi.mocked(stat).mockRejectedValue(new Error('Do not exists')); + vi.mocked(lstat).mockRejectedValue(new Error('Do not exists')); + }); + + test('should throw an error if fileDownloadInfo return nothing', async () => { + await expect(async () => { + await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + }).rejects.toThrowError('cannot get path info for /README.md'); + + expect(pathsInfo).toHaveBeenCalledWith(expect.objectContaining({ + repo: DUMMY_REPO, + paths: ['/README.md'], + fetch: fetchMock, + })); + }); + + test('existing symlinked and blob should not re-download it', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dd4bc8b21efa05ec961e3efc4ee5e3832a3679c7", + }); + // stat ensure a symlink and the pointed file exists + vi.mocked(stat).mockResolvedValue({} as Stats) // prevent default mocked reject + + const output = await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + revision: "dd4bc8b21efa05ec961e3efc4ee5e3832a3679c7", + }); + + expect(stat).toHaveBeenCalledOnce(); + // Get call argument for stat + const starArg = vi.mocked(stat).mock.calls[0][0]; + + expect(starArg).toBe(expectPointer) + expect(fetchMock).not.toHaveBeenCalledWith(); + + expect(output).toBe(expectPointer); + }); + + test('existing blob should only create the symlink', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dummy-commit-hash", + }); + // //blobs/ + const expectedBlob = _getBlobFile({ + repo: DUMMY_REPO, + etag: DUMMY_ETAG, + }); + + // mock existing blob only no symlink + vi.mocked(lstat).mockResolvedValue({} as Stats); + // mock pathsInfo resolve content + vi.mocked(pathsInfo).mockResolvedValue([{ + oid: DUMMY_ETAG, + size: 55, + path: 'README.md', + type: 'file', + lastCommit: { + date: new Date(), + id: 'dummy-commit-hash', + title: 'Commit msg', + }, + }]); + + const output = await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + + expect(stat).not.toHaveBeenCalled(); + // should have check for the blob + expect(lstat).toHaveBeenCalled(); + expect(vi.mocked(lstat).mock.calls[0][0]).toBe(expectedBlob); + + // symlink should have been created + expect(symlink).toHaveBeenCalledOnce(); + // no download done + expect(fetchMock).not.toHaveBeenCalled(); + + expect(output).toBe(expectPointer); + }); + + test('expect resolve value to be the pointer path of downloaded file', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dummy-commit-hash", + }); + // //blobs/ + const expectedBlob = _getBlobFile({ + repo: DUMMY_REPO, + etag: DUMMY_ETAG, + }); + + vi.mocked(pathsInfo).mockResolvedValue([{ + oid: DUMMY_ETAG, + size: 55, + path: 'README.md', + type: 'file', + lastCommit: { + date: new Date(), + id: 'dummy-commit-hash', + title: 'Commit msg', + }, + }]); + + const output = await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + + // expect blobs and snapshots folder to have been mkdir + expect(vi.mocked(mkdir).mock.calls[0][0]).toBe(dirname(expectedBlob)); + expect(vi.mocked(mkdir).mock.calls[1][0]).toBe(dirname(expectPointer)); + + expect(output).toBe(expectPointer); + }); + + test('should write fetch response to blob', async () => { + // ///snapshots/README.md + const expectPointer = _getSnapshotFile({ + repo: DUMMY_REPO, + path: '/README.md', + revision: "dummy-commit-hash", + }); + // //blobs/ + const expectedBlob = _getBlobFile({ + repo: DUMMY_REPO, + etag: DUMMY_ETAG, + }); + + // mock pathsInfo resolve content + vi.mocked(pathsInfo).mockResolvedValue([{ + oid: DUMMY_ETAG, + size: 55, + path: 'README.md', + type: 'file', + lastCommit: { + date: new Date(), + id: 'dummy-commit-hash', + title: 'Commit msg', + }, + }]); + + await downloadFileToCacheDir({ + repo: DUMMY_REPO, + path: '/README.md', + fetch: fetchMock, + }); + + const incomplete = `${expectedBlob}.incomplete`; + // 1. should write fetch#response#body to incomplete file + expect(writeFile).toHaveBeenCalledWith(incomplete, 'dummy-body'); + // 2. should rename the incomplete to the blob expected name + expect(rename).toHaveBeenCalledWith(incomplete, expectedBlob); + // 3. should create symlink pointing to blob + expect(symlink).toHaveBeenCalledWith(expectedBlob, expectPointer); + }); +}); \ No newline at end of file diff --git a/packages/hub/src/lib/download-file-cache.ts b/packages/hub/src/lib/download-file-cache.ts new file mode 100644 index 0000000000..97bca15daa --- /dev/null +++ b/packages/hub/src/lib/download-file-cache.ts @@ -0,0 +1,129 @@ +import { getHFHubCache, getRepoFolderName } from "./cache-management"; +import { dirname, join } from "node:path"; +import { writeFile, rename, symlink, lstat, mkdir, stat } from "node:fs/promises"; +import type { CommitInfo, PathInfo } from "./paths-info"; +import { pathsInfo } from "./paths-info"; +import type { CredentialsParams, RepoDesignation } from "../types/public"; +import { toRepoId } from "../utils/toRepoId"; +import { downloadFile } from "./download-file"; + +export const REGEX_COMMIT_HASH: RegExp = new RegExp("^[0-9a-f]{40}$"); + +function getFilePointer(storageFolder: string, revision: string, relativeFilename: string): string { + const snapshotPath = join(storageFolder, "snapshots"); + return join(snapshotPath, revision, relativeFilename); +} + +/** + * handy method to check if a file exists, or the pointer of a symlinks exists + * @param path + * @param followSymlinks + */ +async function exists(path: string, followSymlinks?: boolean): Promise { + try { + if(followSymlinks) { + await stat(path); + } else { + await lstat(path); + } + return true; + } catch (err: unknown) { + return false; + } +} + +/** + * Download a given file if it's not already present in the local cache. + * @param params + * @return the symlink to the blob object + */ +export async function downloadFileToCacheDir( + params: { + repo: RepoDesignation; + path: string; + /** + * If true, will download the raw git file. + * + * For example, when calling on a file stored with Git LFS, the pointer file will be downloaded instead. + */ + raw?: boolean; + /** + * An optional Git revision id which can be a branch name, a tag, or a commit hash. + * + * @default "main" + */ + revision?: string; + hubUrl?: string; + cacheDir?: string, + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & Partial +): Promise { + // get revision provided or default to main + const revision = params.revision ?? "main"; + const cacheDir = params.cacheDir ?? getHFHubCache(); + // get repo id + const repoId = toRepoId(params.repo); + // get storage folder + const storageFolder = join(cacheDir, getRepoFolderName(repoId)); + + let commitHash: string | undefined; + + // if user provides a commitHash as revision, and they already have the file on disk, shortcut everything. + if (REGEX_COMMIT_HASH.test(revision)) { + commitHash = revision; + const pointerPath = getFilePointer(storageFolder, revision, params.path); + if (await exists(pointerPath, true)) return pointerPath; + } + + const pathsInformation: (PathInfo & { lastCommit: CommitInfo })[] = await pathsInfo({ + ...params, + paths: [params.path], + revision: revision, + expand: true, + }); + if (!pathsInformation || pathsInformation.length !== 1) throw new Error(`cannot get path info for ${params.path}`); + + let etag: string; + if (pathsInformation[0].lfs) { + etag = pathsInformation[0].lfs.oid; // get the LFS pointed file oid + } else { + etag = pathsInformation[0].oid; // get the repo file if not a LFS pointer + } + + const pointerPath = getFilePointer(storageFolder, commitHash ?? pathsInformation[0].lastCommit.id, params.path); + const blobPath = join(storageFolder, "blobs", etag); + + // mkdir blob and pointer path parent directory + await mkdir(dirname(blobPath), { recursive: true }); + await mkdir(dirname(pointerPath), { recursive: true }); + + // We might already have the blob but not the pointer + // shortcut the download if needed + if (await exists(blobPath)) { + // create symlinks in snapshot folder to blob object + await symlink(blobPath, pointerPath); + return pointerPath; + } + + const incomplete = `${blobPath}.incomplete`; + console.debug(`Downloading ${params.path} to ${incomplete}`); + + const response: Response | null = await downloadFile({ + ...params, + revision: commitHash, + }); + + if (!response || !response.ok || !response.body) throw new Error(`invalid response for file ${params.path}`); + + // @ts-expect-error resp.body is a Stream, but Stream in internal to node + await writeFile(incomplete, response.body); + + // rename .incomplete file to expect blob + await rename(incomplete, blobPath); + // create symlinks in snapshot folder to blob object + await symlink(blobPath, pointerPath); + return pointerPath; +} diff --git a/packages/hub/src/lib/download-file.spec.ts b/packages/hub/src/lib/download-file.spec.ts index 45c62bfca9..f442f152a1 100644 --- a/packages/hub/src/lib/download-file.spec.ts +++ b/packages/hub/src/lib/download-file.spec.ts @@ -1,238 +1,12 @@ -import { expect, test, describe, vi, beforeEach } from "vitest"; -import { downloadFile, downloadFileToCacheDir } from "./download-file"; -import type { RepoDesignation, RepoId } from "../types/public"; -import { dirname, join } from "node:path"; -import { lstat, mkdir, stat, symlink, writeFile, rename } from "node:fs/promises"; -import { pathsInfo } from "./paths-info"; -import type { Stats } from "node:fs"; -import { getHFHubCache, getRepoFolderName } from "./cache-management"; -import { toRepoId } from "../utils/toRepoId"; - -vi.mock('node:fs/promises', () => ({ - writeFile: vi.fn(), - rename: vi.fn(), - symlink: vi.fn(), - lstat: vi.fn(), - mkdir: vi.fn(), - stat: vi.fn() -})); - -vi.mock('./paths-info', () => ({ - pathsInfo: vi.fn(), -})); +import { expect, test, describe, vi } from "vitest"; +import { downloadFile } from "./download-file"; +import type { RepoId } from "../types/public"; const DUMMY_REPO: RepoId = { name: 'hello-world', type: 'model', }; -const DUMMY_ETAG = "dummy-etag"; - -// utility test method to get blob file path -function _getBlobFile(params: { - repo: RepoDesignation; - etag: string; - cacheDir?: string, // default to {@link getHFHubCache} -}) { - return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "blobs", params.etag); -} - -// utility test method to get snapshot file path -function _getSnapshotFile(params: { - repo: RepoDesignation; - path: string; - revision : string; - cacheDir?: string, // default to {@link getHFHubCache} -}) { - return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "snapshots", params.revision, params.path); -} - -describe('downloadFileToCacheDir', () => { - const fetchMock: typeof fetch = vi.fn(); - beforeEach(() => { - vi.resetAllMocks(); - // mock 200 request - vi.mocked(fetchMock).mockResolvedValue({ - status: 200, - ok: true, - body: 'dummy-body' - } as unknown as Response); - - // prevent to use caching - vi.mocked(stat).mockRejectedValue(new Error('Do not exists')); - vi.mocked(lstat).mockRejectedValue(new Error('Do not exists')); - }); - - test('should throw an error if fileDownloadInfo return nothing', async () => { - await expect(async () => { - await downloadFileToCacheDir({ - repo: DUMMY_REPO, - path: '/README.md', - fetch: fetchMock, - }); - }).rejects.toThrowError('cannot get path info for /README.md'); - - expect(pathsInfo).toHaveBeenCalledWith(expect.objectContaining({ - repo: DUMMY_REPO, - paths: ['/README.md'], - fetch: fetchMock, - })); - }); - - test('existing symlinked and blob should not re-download it', async () => { - // ///snapshots/README.md - const expectPointer = _getSnapshotFile({ - repo: DUMMY_REPO, - path: '/README.md', - revision: "dd4bc8b21efa05ec961e3efc4ee5e3832a3679c7", - }); - // stat ensure a symlink and the pointed file exists - vi.mocked(stat).mockResolvedValue({} as Stats) // prevent default mocked reject - - const output = await downloadFileToCacheDir({ - repo: DUMMY_REPO, - path: '/README.md', - fetch: fetchMock, - revision: "dd4bc8b21efa05ec961e3efc4ee5e3832a3679c7", - }); - - expect(stat).toHaveBeenCalledOnce(); - // Get call argument for stat - const starArg = vi.mocked(stat).mock.calls[0][0]; - - expect(starArg).toBe(expectPointer) - expect(fetchMock).not.toHaveBeenCalledWith(); - - expect(output).toBe(expectPointer); - }); - - test('existing blob should only create the symlink', async () => { - // ///snapshots/README.md - const expectPointer = _getSnapshotFile({ - repo: DUMMY_REPO, - path: '/README.md', - revision: "dummy-commit-hash", - }); - // //blobs/ - const expectedBlob = _getBlobFile({ - repo: DUMMY_REPO, - etag: DUMMY_ETAG, - }); - - // mock existing blob only no symlink - vi.mocked(lstat).mockResolvedValue({} as Stats); - // mock pathsInfo resolve content - vi.mocked(pathsInfo).mockResolvedValue([{ - oid: DUMMY_ETAG, - size: 55, - path: 'README.md', - type: 'file', - lastCommit: { - date: new Date(), - id: 'dummy-commit-hash', - title: 'Commit msg', - }, - }]); - - const output = await downloadFileToCacheDir({ - repo: DUMMY_REPO, - path: '/README.md', - fetch: fetchMock, - }); - - expect(stat).not.toHaveBeenCalled(); - // should have check for the blob - expect(lstat).toHaveBeenCalled(); - expect(vi.mocked(lstat).mock.calls[0][0]).toBe(expectedBlob); - - // symlink should have been created - expect(symlink).toHaveBeenCalledOnce(); - // no download done - expect(fetchMock).not.toHaveBeenCalled(); - - expect(output).toBe(expectPointer); - }); - - test('expect resolve value to be the pointer path of downloaded file', async () => { - // ///snapshots/README.md - const expectPointer = _getSnapshotFile({ - repo: DUMMY_REPO, - path: '/README.md', - revision: "dummy-commit-hash", - }); - // //blobs/ - const expectedBlob = _getBlobFile({ - repo: DUMMY_REPO, - etag: DUMMY_ETAG, - }); - - vi.mocked(pathsInfo).mockResolvedValue([{ - oid: DUMMY_ETAG, - size: 55, - path: 'README.md', - type: 'file', - lastCommit: { - date: new Date(), - id: 'dummy-commit-hash', - title: 'Commit msg', - }, - }]); - - const output = await downloadFileToCacheDir({ - repo: DUMMY_REPO, - path: '/README.md', - fetch: fetchMock, - }); - - // expect blobs and snapshots folder to have been mkdir - expect(vi.mocked(mkdir).mock.calls[0][0]).toBe(dirname(expectedBlob)); - expect(vi.mocked(mkdir).mock.calls[1][0]).toBe(dirname(expectPointer)); - - expect(output).toBe(expectPointer); - }); - - test('should write fetch response to blob', async () => { - // ///snapshots/README.md - const expectPointer = _getSnapshotFile({ - repo: DUMMY_REPO, - path: '/README.md', - revision: "dummy-commit-hash", - }); - // //blobs/ - const expectedBlob = _getBlobFile({ - repo: DUMMY_REPO, - etag: DUMMY_ETAG, - }); - - // mock pathsInfo resolve content - vi.mocked(pathsInfo).mockResolvedValue([{ - oid: DUMMY_ETAG, - size: 55, - path: 'README.md', - type: 'file', - lastCommit: { - date: new Date(), - id: 'dummy-commit-hash', - title: 'Commit msg', - }, - }]); - - await downloadFileToCacheDir({ - repo: DUMMY_REPO, - path: '/README.md', - fetch: fetchMock, - }); - - const incomplete = `${expectedBlob}.incomplete`; - // 1. should write fetch#response#body to incomplete file - expect(writeFile).toHaveBeenCalledWith(incomplete, 'dummy-body'); - // 2. should rename the incomplete to the blob expected name - expect(rename).toHaveBeenCalledWith(incomplete, expectedBlob); - // 3. should create symlink pointing to blob - expect(symlink).toHaveBeenCalledWith(expectedBlob, expectPointer); - }); -}); - describe("downloadFile", () => { test("hubUrl params should overwrite HUB_URL", async () => { const fetchMock: typeof fetch = vi.fn(); diff --git a/packages/hub/src/lib/download-file.ts b/packages/hub/src/lib/download-file.ts index 0d11413065..4f6ebde2e3 100644 --- a/packages/hub/src/lib/download-file.ts +++ b/packages/hub/src/lib/download-file.ts @@ -3,132 +3,6 @@ import { createApiError } from "../error"; import type { CredentialsParams, RepoDesignation } from "../types/public"; import { checkCredentials } from "../utils/checkCredentials"; import { toRepoId } from "../utils/toRepoId"; -import { getHFHubCache, getRepoFolderName } from "./cache-management"; -import { dirname, join } from "node:path"; -import { writeFile, rename, symlink, lstat, mkdir, stat } from "node:fs/promises"; -import type { CommitInfo, PathInfo } from "./paths-info"; -import { pathsInfo } from "./paths-info"; - -export const REGEX_COMMIT_HASH: RegExp = new RegExp("^[0-9a-f]{40}$"); - -function getFilePointer(storageFolder: string, revision: string, relativeFilename: string): string { - const snapshotPath = join(storageFolder, "snapshots"); - return join(snapshotPath, revision, relativeFilename); -} - -/** - * handy method to check if a file exists, or the pointer of a symlinks exists - * @param path - * @param followSymlinks - */ -async function exists(path: string, followSymlinks?: boolean): Promise { - try { - if(followSymlinks) { - await stat(path); - } else { - await lstat(path); - } - return true; - } catch (err: unknown) { - return false; - } -} - -/** - * Download a given file if it's not already present in the local cache. - * @param params - * @return the symlink to the blob object - */ -export async function downloadFileToCacheDir( - params: { - repo: RepoDesignation; - path: string; - /** - * If true, will download the raw git file. - * - * For example, when calling on a file stored with Git LFS, the pointer file will be downloaded instead. - */ - raw?: boolean; - /** - * An optional Git revision id which can be a branch name, a tag, or a commit hash. - * - * @default "main" - */ - revision?: string; - hubUrl?: string; - cacheDir?: string, - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & Partial -): Promise { - // get revision provided or default to main - const revision = params.revision ?? "main"; - const cacheDir = params.cacheDir ?? getHFHubCache(); - // get repo id - const repoId = toRepoId(params.repo); - // get storage folder - const storageFolder = join(cacheDir, getRepoFolderName(repoId)); - - let commitHash: string | undefined; - - // if user provides a commitHash as revision, and they already have the file on disk, shortcut everything. - if (REGEX_COMMIT_HASH.test(revision)) { - commitHash = revision; - const pointerPath = getFilePointer(storageFolder, revision, params.path); - if (await exists(pointerPath, true)) return pointerPath; - } - - const pathsInformation: (PathInfo & { lastCommit: CommitInfo })[] = await pathsInfo({ - ...params, - paths: [params.path], - revision: revision, - expand: true, - }); - if (!pathsInformation || pathsInformation.length !== 1) throw new Error(`cannot get path info for ${params.path}`); - - let etag: string; - if (pathsInformation[0].lfs) { - etag = pathsInformation[0].lfs.oid; // get the LFS pointed file oid - } else { - etag = pathsInformation[0].oid; // get the repo file if not a LFS pointer - } - - const pointerPath = getFilePointer(storageFolder, commitHash ?? pathsInformation[0].lastCommit.id, params.path); - const blobPath = join(storageFolder, "blobs", etag); - - // mkdir blob and pointer path parent directory - await mkdir(dirname(blobPath), { recursive: true }); - await mkdir(dirname(pointerPath), { recursive: true }); - - // We might already have the blob but not the pointer - // shortcut the download if needed - if (await exists(blobPath)) { - // create symlinks in snapshot folder to blob object - await symlink(blobPath, pointerPath); - return pointerPath; - } - - const incomplete = `${blobPath}.incomplete`; - console.debug(`Downloading ${params.path} to ${incomplete}`); - - const response: Response | null = await downloadFile({ - ...params, - revision: commitHash, - }); - - if (!response || !response.ok || !response.body) throw new Error(`invalid response for file ${params.path}`); - - // @ts-expect-error resp.body is a Stream, but Stream in internal to node - await writeFile(incomplete, response.body); - - // rename .incomplete file to expect blob - await rename(incomplete, blobPath); - // create symlinks in snapshot folder to blob object - await symlink(blobPath, pointerPath); - return pointerPath; -} /** * @returns null when the file doesn't exist diff --git a/packages/hub/src/lib/index.ts b/packages/hub/src/lib/index.ts index b79385dc5f..093c3869cb 100644 --- a/packages/hub/src/lib/index.ts +++ b/packages/hub/src/lib/index.ts @@ -8,6 +8,7 @@ export * from "./delete-file"; export * from "./delete-files"; export * from "./delete-repo"; export * from "./download-file"; +export * from "./download-file-cache"; export * from "./file-download-info"; export * from "./file-exists"; export * from "./list-commits"; From 75bec6486d48a90f78755131c0e08488c8bfdf51 Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Sat, 16 Nov 2024 15:26:42 +0100 Subject: [PATCH 3/6] fix: apply suggestion by @coyotte508 --- packages/hub/src/lib/cache-management.ts | 4 ++-- ...e-cache.spec.ts => download-file-to-cache-dir.spec.ts} | 8 ++++---- ...wnload-file-cache.ts => download-file-to-cache-dir.ts} | 4 ++-- packages/hub/src/lib/index.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) rename packages/hub/src/lib/{download-file-cache.spec.ts => download-file-to-cache-dir.spec.ts} (93%) rename packages/hub/src/lib/{download-file-cache.ts => download-file-to-cache-dir.ts} (96%) diff --git a/packages/hub/src/lib/cache-management.ts b/packages/hub/src/lib/cache-management.ts index fc9e459d6a..98c3be5b40 100644 --- a/packages/hub/src/lib/cache-management.ts +++ b/packages/hub/src/lib/cache-management.ts @@ -16,7 +16,7 @@ function getHuggingFaceHubCache(): string { return process.env["HUGGINGFACE_HUB_CACHE"] ?? getDefaultCachePath(); } -export function getHFHubCache(): string { +export function getHFHubCachePath(): string { return process.env["HF_HUB_CACHE"] ?? getHuggingFaceHubCache(); } @@ -70,7 +70,7 @@ export interface HFCacheInfo { } export async function scanCacheDir(cacheDir: string | undefined = undefined): Promise { - if (!cacheDir) cacheDir = getHFHubCache(); + if (!cacheDir) cacheDir = getHFHubCachePath(); const s = await stat(cacheDir); if (!s.isDirectory()) { diff --git a/packages/hub/src/lib/download-file-cache.spec.ts b/packages/hub/src/lib/download-file-to-cache-dir.spec.ts similarity index 93% rename from packages/hub/src/lib/download-file-cache.spec.ts rename to packages/hub/src/lib/download-file-to-cache-dir.spec.ts index 09d7f9fdac..05e2e6de9e 100644 --- a/packages/hub/src/lib/download-file-cache.spec.ts +++ b/packages/hub/src/lib/download-file-to-cache-dir.spec.ts @@ -4,9 +4,9 @@ import { dirname, join } from "node:path"; import { lstat, mkdir, stat, symlink, writeFile, rename } from "node:fs/promises"; import { pathsInfo } from "./paths-info"; import type { Stats } from "node:fs"; -import { getHFHubCache, getRepoFolderName } from "./cache-management"; +import { getHFHubCachePath, getRepoFolderName } from "./cache-management"; import { toRepoId } from "../utils/toRepoId"; -import { downloadFileToCacheDir } from "./download-file-cache"; +import { downloadFileToCacheDir } from "./download-file-to-cache-dir"; vi.mock('node:fs/promises', () => ({ writeFile: vi.fn(), @@ -34,7 +34,7 @@ function _getBlobFile(params: { etag: string; cacheDir?: string, // default to {@link getHFHubCache} }) { - return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "blobs", params.etag); + return join(params.cacheDir ?? getHFHubCachePath(), getRepoFolderName(toRepoId(params.repo)), "blobs", params.etag); } // utility test method to get snapshot file path @@ -44,7 +44,7 @@ function _getSnapshotFile(params: { revision : string; cacheDir?: string, // default to {@link getHFHubCache} }) { - return join(params.cacheDir ?? getHFHubCache(), getRepoFolderName(toRepoId(params.repo)), "snapshots", params.revision, params.path); + return join(params.cacheDir ?? getHFHubCachePath(), getRepoFolderName(toRepoId(params.repo)), "snapshots", params.revision, params.path); } describe('downloadFileToCacheDir', () => { diff --git a/packages/hub/src/lib/download-file-cache.ts b/packages/hub/src/lib/download-file-to-cache-dir.ts similarity index 96% rename from packages/hub/src/lib/download-file-cache.ts rename to packages/hub/src/lib/download-file-to-cache-dir.ts index 97bca15daa..72869f3075 100644 --- a/packages/hub/src/lib/download-file-cache.ts +++ b/packages/hub/src/lib/download-file-to-cache-dir.ts @@ -1,4 +1,4 @@ -import { getHFHubCache, getRepoFolderName } from "./cache-management"; +import { getHFHubCachePath, getRepoFolderName } from "./cache-management"; import { dirname, join } from "node:path"; import { writeFile, rename, symlink, lstat, mkdir, stat } from "node:fs/promises"; import type { CommitInfo, PathInfo } from "./paths-info"; @@ -63,7 +63,7 @@ export async function downloadFileToCacheDir( ): Promise { // get revision provided or default to main const revision = params.revision ?? "main"; - const cacheDir = params.cacheDir ?? getHFHubCache(); + const cacheDir = params.cacheDir ?? getHFHubCachePath(); // get repo id const repoId = toRepoId(params.repo); // get storage folder diff --git a/packages/hub/src/lib/index.ts b/packages/hub/src/lib/index.ts index 093c3869cb..c2a2fbe06c 100644 --- a/packages/hub/src/lib/index.ts +++ b/packages/hub/src/lib/index.ts @@ -8,7 +8,7 @@ export * from "./delete-file"; export * from "./delete-files"; export * from "./delete-repo"; export * from "./download-file"; -export * from "./download-file-cache"; +export * from "./download-file-to-cache-dir"; export * from "./file-download-info"; export * from "./file-exists"; export * from "./list-commits"; From 59464e454bc984af55b1b89ad9d42afe0c5e5a8d Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Sun, 17 Nov 2024 12:44:43 +0100 Subject: [PATCH 4/6] chore(config): exclude download-file-to-cache-dir.ts browser release --- packages/hub/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hub/package.json b/packages/hub/package.json index 7802e23899..14af8c6fdf 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -21,6 +21,7 @@ "./src/utils/sha256-node.ts": false, "./src/utils/FileBlob.ts": false, "./src/lib/cache-management.ts": false, + "./src/lib/download-file-to-cache-dir.ts": false, "./dist/index.js": "./dist/browser/index.js", "./dist/index.mjs": "./dist/browser/index.mjs" }, From b16dd19b04c1099f4274226d8527d0566eb8f3ba Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:34:56 +0100 Subject: [PATCH 5/6] chore(vitest): exclude download-file-to-cache-dir.spec.ts --- packages/hub/vitest-browser.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hub/vitest-browser.config.mts b/packages/hub/vitest-browser.config.mts index e106a2fbaa..c02c5efe70 100644 --- a/packages/hub/vitest-browser.config.mts +++ b/packages/hub/vitest-browser.config.mts @@ -2,6 +2,6 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { - exclude: [...configDefaults.exclude, "src/utils/FileBlob.spec.ts", "src/lib/cache-management.spec.ts"], + exclude: [...configDefaults.exclude, "src/utils/FileBlob.spec.ts", "src/lib/cache-management.spec.ts", "download-file-to-cache-dir.spec.ts"], }, }); From 220cb574c3d6e6887e5b59d806dd911dc2a070f7 Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:48:49 +0100 Subject: [PATCH 6/6] fix: packages/hub/vitest-browser.config.mts Co-authored-by: Eliott C. --- packages/hub/vitest-browser.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hub/vitest-browser.config.mts b/packages/hub/vitest-browser.config.mts index c02c5efe70..60fcbfbfcf 100644 --- a/packages/hub/vitest-browser.config.mts +++ b/packages/hub/vitest-browser.config.mts @@ -2,6 +2,6 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { - exclude: [...configDefaults.exclude, "src/utils/FileBlob.spec.ts", "src/lib/cache-management.spec.ts", "download-file-to-cache-dir.spec.ts"], + exclude: [...configDefaults.exclude, "src/utils/FileBlob.spec.ts", "src/lib/cache-management.spec.ts", "src/lib/download-file-to-cache-dir.spec.ts"], }, });