diff --git a/tests/helpers/cleanup.test.ts b/tests/helpers/cleanup.test.ts new file mode 100644 index 0000000..e9c0860 --- /dev/null +++ b/tests/helpers/cleanup.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; +import { mkdir, access, rm } from "node:fs/promises"; +import { join } from "node:path"; + +const TMP_DIR = join(import.meta.dir, "__tmp_cleanup_test__"); + +async function dirExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +describe("retryRm", () => { + describe("正常删除", () => { + afterEach(async () => { + if (await dirExists(TMP_DIR)) { + await rm(TMP_DIR, { recursive: true, force: true }); + } + }); + + it("删除存在的目录", async () => { + const { retryRm } = await import("./cleanup.ts"); + await mkdir(TMP_DIR, { recursive: true }); + await retryRm(TMP_DIR); + expect(await dirExists(TMP_DIR)).toBe(false); + }); + + it("删除不存在的路径不报错(force:true)", async () => { + const { retryRm } = await import("./cleanup.ts"); + const nonExist = join(import.meta.dir, "__non_exist__"); + await expect(retryRm(nonExist)).resolves.toBeUndefined(); + }); + }); + + describe("EBUSY 重试后成功", () => { + let callCount: number; + + beforeEach(() => { + callCount = 0; + mock.module("node:fs/promises", () => ({ + rm: mock(async () => { + callCount++; + if (callCount === 1) { + const err: any = new Error("EBUSY"); + err.code = "EBUSY"; + throw err; + } + }), + })); + }); + + afterEach(() => { + mock.restore(); + }); + + it("EBUSY 后重试成功", async () => { + const { retryRm } = await import("./cleanup.ts"); + await retryRm("/some/path", { maxRetries: 3, baseDelay: 10 }); + expect(callCount).toBe(2); + }); + }); + + describe("重试耗尽后抛出错误", () => { + let callCount: number; + + beforeEach(() => { + callCount = 0; + mock.module("node:fs/promises", () => ({ + rm: mock(async () => { + callCount++; + const err: any = new Error("EBUSY"); + err.code = "EBUSY"; + throw err; + }), + })); + }); + + afterEach(() => { + mock.restore(); + }); + + it("maxRetries 耗尽后抛出最后一个错误", async () => { + const { retryRm } = await import("./cleanup.ts"); + await expect(retryRm("/some/path", { maxRetries: 2, baseDelay: 10 })).rejects.toThrow( + "EBUSY", + ); + expect(callCount).toBe(3); + }); + }); + + describe("非 EBUSY/EPERM 错误不重试", () => { + let callCount: number; + + beforeEach(() => { + callCount = 0; + mock.module("node:fs/promises", () => ({ + rm: mock(async () => { + callCount++; + const err: any = new Error("ENOENT: no such file or directory"); + err.code = "ENOENT"; + throw err; + }), + })); + }); + + afterEach(() => { + mock.restore(); + }); + + it("ENOENT 错误直接抛出不重试", async () => { + const { retryRm } = await import("./cleanup.ts"); + await expect(retryRm("/some/path")).rejects.toThrow("ENOENT"); + expect(callCount).toBe(1); + }); + }); +}); diff --git a/tests/helpers/cleanup.ts b/tests/helpers/cleanup.ts new file mode 100644 index 0000000..891e187 --- /dev/null +++ b/tests/helpers/cleanup.ts @@ -0,0 +1,21 @@ +import { rm } from "node:fs/promises"; + +const RETRY_CODES = new Set(["EBUSY", "EPERM"]); + +export async function retryRm( + path: string, + { maxRetries = 5, baseDelay = 100 }: { maxRetries?: number; baseDelay?: number } = {}, +): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + await rm(path, { recursive: true, force: true }); + return; + } catch (err: any) { + if (!RETRY_CODES.has(err.code) || attempt === maxRetries) { + throw err; + } + const delay = baseDelay * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +}