Skip to content

Commit bf4ee02

Browse files
authored
Add non-mocked test for symlink (#1308)
Alternative to #1307 Fix #1306 I was just too confused by the src/dst params, nodej's official docs could be clearer too. Now the params are explictly named and there is a real test to check the filesystem contents. By the way, it's a `copy` operation not a `rename` (because it was already the case, the `new_blob` param was never passed) Also took the opportunty to use relative symlinks cc @Wauplin for better windows support cc @jeffmaury @axel7083
1 parent e0de008 commit bf4ee02

File tree

6 files changed

+98
-70
lines changed

6 files changed

+98
-70
lines changed

packages/hub/src/lib/download-file-to-cache-dir.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,6 @@ describe("downloadFileToCacheDir", () => {
288288
// 2. should rename the incomplete to the blob expected name
289289
expect(rename).toHaveBeenCalledWith(incomplete, expectedBlob);
290290
// 3. should create symlink pointing to blob
291-
expect(createSymlink).toHaveBeenCalledWith(expectedBlob, expectPointer);
291+
expect(createSymlink).toHaveBeenCalledWith({ sourcePath: expectedBlob, finalPath: expectPointer });
292292
});
293293
});

packages/hub/src/lib/download-file-to-cache-dir.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export async function downloadFileToCacheDir(
108108
// shortcut the download if needed
109109
if (await exists(blobPath)) {
110110
// create symlinks in snapshot folder to blob object
111-
await createSymlink(blobPath, pointerPath);
111+
await createSymlink({ sourcePath: blobPath, finalPath: pointerPath });
112112
return pointerPath;
113113
}
114114

@@ -128,6 +128,6 @@ export async function downloadFileToCacheDir(
128128
// rename .incomplete file to expect blob
129129
await rename(incomplete, blobPath);
130130
// create symlinks in snapshot folder to blob object
131-
await createSymlink(blobPath, pointerPath);
131+
await createSymlink({ sourcePath: blobPath, finalPath: pointerPath });
132132
return pointerPath;
133133
}

packages/hub/src/utils/XetBlob.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe("XetBlob", () => {
9898
}
9999
return fetch(url, opts);
100100
},
101-
internalLogging: true,
101+
// internalLogging: true,
102102
});
103103

104104
const xetDownload = await blob.slice(0, 200_000).arrayBuffer();
@@ -124,7 +124,7 @@ describe("XetBlob", () => {
124124
},
125125
hash: "7b3b6d07673a88cf467e67c1f7edef1a8c268cbf66e9dd9b0366322d4ab56d9b",
126126
size: 5_234_139_343,
127-
internalLogging: true,
127+
// internalLogging: true,
128128
});
129129

130130
const xetDownload = await blob.slice(10_000_000, 10_100_000).arrayBuffer();
Lines changed: 72 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,89 @@
1-
import { describe, test, expect, vi, beforeEach } from "vitest";
2-
import * as fs from "node:fs/promises";
1+
/* eslint-disable @typescript-eslint/consistent-type-imports */
2+
/* eslint-disable @typescript-eslint/no-explicit-any */
3+
import { describe, expect, it, vi } from "vitest";
34
import { createSymlink } from "./symlink";
4-
import * as path from "node:path";
5-
import type { FileHandle } from "node:fs/promises";
6-
7-
vi.mock("node:fs/promises", () => ({
8-
rm: vi.fn(),
9-
symlink: vi.fn(),
10-
rename: vi.fn(),
11-
copyFile: vi.fn(),
12-
mkdir: vi.fn(),
13-
mkdtemp: vi.fn(),
14-
open: vi.fn(),
15-
}));
5+
import { readFileSync, writeFileSync } from "node:fs";
6+
import { lstat, rm } from "node:fs/promises";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
9+
10+
let failSymlink = false;
11+
vi.mock("node:fs/promises", async (importOriginal) => ({
12+
...(await importOriginal<typeof import("node:fs/promises")>()),
13+
symlink: async (...args: any[]) => {
14+
if (failSymlink) {
15+
failSymlink = false;
16+
throw new Error("Symlink not supported");
17+
}
1618

17-
vi.mock("node:os", () => ({
18-
platform: vi.fn(),
19+
// @ts-expect-error - ignore
20+
return (await importOriginal<typeof import("node:fs/promises")>()).symlink(...args);
21+
},
1922
}));
2023

21-
describe("createSymlink", () => {
22-
const src = "/path/to/src";
23-
const dst = "/path/to/dst";
24+
describe("utils/symlink", () => {
25+
it("should create a symlink", async () => {
26+
writeFileSync(join(tmpdir(), "test.txt"), "hello world");
27+
await createSymlink({
28+
sourcePath: join(tmpdir(), "test.txt"),
29+
finalPath: join(tmpdir(), "test-symlink.txt"),
30+
});
2431

25-
beforeEach(() => {
26-
vi.resetAllMocks();
27-
vi.mocked(fs.mkdtemp).mockImplementation(async (prefix) => `${prefix}/temp`);
28-
vi.mocked(fs.open).mockResolvedValue({ close: vi.fn() } as unknown as FileHandle);
29-
});
32+
const stats = await lstat(join(tmpdir(), "test-symlink.txt"));
33+
expect(stats.isSymbolicLink()).toBe(process.platform !== "win32");
3034

31-
test("should remove existing destination", async () => {
32-
await createSymlink(dst, src);
33-
expect(fs.rm).toHaveBeenCalledWith(dst);
35+
// Test file content
36+
const content = readFileSync(join(tmpdir(), "test-symlink.txt"), "utf8");
37+
expect(content).toBe("hello world");
38+
39+
// Cleanup
40+
await rm(join(tmpdir(), "test-symlink.txt"));
41+
await rm(join(tmpdir(), "test.txt"));
3442
});
3543

36-
describe("symlink not supported (Windows)", () => {
37-
beforeEach(() => {
38-
vi.mocked(fs.symlink).mockRejectedValue(new Error("Symlink not supported"));
44+
it("should work when symlinking twice", async () => {
45+
writeFileSync(join(tmpdir(), "test.txt"), "hello world");
46+
writeFileSync(join(tmpdir(), "test2.txt"), "hello world2");
47+
await createSymlink({
48+
sourcePath: join(tmpdir(), "test.txt"),
49+
finalPath: join(tmpdir(), "test-symlink.txt"),
3950
});
40-
41-
test("should copyfile", async () => {
42-
await createSymlink(dst, src);
43-
expect(fs.copyFile).toHaveBeenCalledWith(path.resolve(dst), path.resolve(src));
51+
await createSymlink({
52+
sourcePath: join(tmpdir(), "test2.txt"),
53+
finalPath: join(tmpdir(), "test-symlink.txt"),
4454
});
4555

46-
test("should rename file if new_blob is true", async () => {
47-
await createSymlink(dst, src, true);
48-
expect(fs.rename).toHaveBeenCalledWith(path.resolve(dst), path.resolve(src));
49-
});
56+
const stats = await lstat(join(tmpdir(), "test-symlink.txt"));
57+
expect(stats.isSymbolicLink()).toBe(process.platform !== "win32");
58+
59+
// Test file content
60+
const content = readFileSync(join(tmpdir(), "test-symlink.txt"), "utf8");
61+
expect(content).toBe("hello world2");
62+
63+
// Cleanup
64+
await rm(join(tmpdir(), "test-symlink.txt"));
65+
await rm(join(tmpdir(), "test.txt"));
66+
await rm(join(tmpdir(), "test2.txt"));
5067
});
5168

52-
describe("symlink supported", () => {
53-
test("should symlink", async () => {
54-
await createSymlink(dst, src);
55-
expect(fs.symlink).toHaveBeenCalledWith(path.resolve(dst), path.resolve(src));
56-
});
69+
it("should work when symlink doesn't work (windows)", async () => {
70+
writeFileSync(join(tmpdir(), "test.txt"), "hello world");
5771

58-
test("should symlink if new_blob is true", async () => {
59-
await createSymlink(dst, src, true);
60-
expect(fs.symlink).toHaveBeenCalledWith(path.resolve(dst), path.resolve(src));
72+
failSymlink = true;
73+
await createSymlink({
74+
sourcePath: join(tmpdir(), "test.txt"),
75+
finalPath: join(tmpdir(), "test-symlink.txt"),
6176
});
77+
78+
const stats = await lstat(join(tmpdir(), "test-symlink.txt"));
79+
expect(stats.isSymbolicLink()).toBe(false);
80+
81+
// Test file content
82+
const content = readFileSync(join(tmpdir(), "test-symlink.txt"), "utf8");
83+
expect(content).toBe("hello world");
84+
85+
// Cleanup
86+
await rm(join(tmpdir(), "test-symlink.txt"));
87+
await rm(join(tmpdir(), "test.txt"));
6288
});
6389
});

packages/hub/src/utils/symlink.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,28 +36,30 @@ function expandUser(path: string): string {
3636
* having the actual file in `dst`. If it is a new file (`new_blob=True`), we move it to `dst`. If it is not a new file
3737
* (`new_blob=False`), we don't know if the blob file is already referenced elsewhere. To avoid breaking existing
3838
* cache, the file is duplicated on the disk.
39-
*
40-
* In case symlinks are not supported, a warning message is displayed to the user once when loading `huggingface_hub`.
41-
* The warning message can be disabled with the `HF_HUB_DISABLE_SYMLINKS_WARNING` environment variable.
4239
*/
43-
export async function createSymlink(dst: string, src: string, new_blob?: boolean): Promise<void> {
40+
export async function createSymlink(params: {
41+
/**
42+
* The path to the symlink.
43+
*/
44+
finalPath: string;
45+
/**
46+
* The path the symlink should point to.
47+
*/
48+
sourcePath: string;
49+
}): Promise<void> {
50+
const abs_src = path.resolve(expandUser(params.sourcePath));
51+
const abs_dst = path.resolve(expandUser(params.finalPath));
52+
4453
try {
45-
await fs.rm(dst);
46-
} catch (_e: unknown) {
47-
/* empty */
54+
await fs.rm(abs_dst);
55+
} catch {
56+
// ignore
4857
}
49-
const abs_src = path.resolve(expandUser(src));
50-
const abs_dst = path.resolve(expandUser(dst));
5158

5259
try {
53-
await fs.symlink(abs_dst, abs_src);
54-
} catch (_e: unknown) {
55-
if (new_blob) {
56-
console.info(`Symlink not supported. Moving file from ${abs_src} to ${abs_dst}`);
57-
await fs.rename(abs_dst, abs_src);
58-
} else {
59-
console.info(`Symlink not supported. Copying file from ${abs_src} to ${abs_dst}`);
60-
await fs.copyFile(abs_dst, abs_src);
61-
}
60+
await fs.symlink(path.relative(path.dirname(abs_dst), abs_src), abs_dst);
61+
} catch {
62+
console.info(`Symlink not supported. Copying file from ${abs_src} to ${abs_dst}`);
63+
await fs.copyFile(abs_src, abs_dst);
6264
}
6365
}

packages/hub/vitest.config.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
22

33
export default defineConfig({
44
test: {
5-
testTimeout: 30_000,
5+
testTimeout: 60_000,
66
},
77
});

0 commit comments

Comments
 (0)