fix: 替换所有测试文件的 rm 为 retryRm,修复 Windows EBUSY

This commit is contained in:
2026-06-11 23:38:02 +08:00
parent 3cac492e78
commit 15ad256d1e
10 changed files with 82 additions and 121 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { injectClaudeCode, updateClaudeCode } from "../../src/adapters/claude-code.ts"; import { injectClaudeCode, updateClaudeCode } from "../../src/adapters/claude-code.ts";
@@ -11,7 +12,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("injectClaudeCode", () => { describe("injectClaudeCode", () => {

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { injectOpenCode, updateOpenCode } from "../../src/adapters/opencode.ts"; import { injectOpenCode, updateOpenCode } from "../../src/adapters/opencode.ts";
@@ -11,7 +12,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("injectOpenCode", () => { describe("injectOpenCode", () => {
@@ -217,7 +218,7 @@ describe("injectOpenCode with command prefix", () => {
); );
expect(content).toContain("bunx @lanyuanxiaoyao/rune discuss"); expect(content).toContain("bunx @lanyuanxiaoyao/rune discuss");
} finally { } finally {
await rm(tmpDir, { recursive: true, force: true }); await retryRm(tmpDir);
} }
}); });
@@ -232,7 +233,7 @@ describe("injectOpenCode with command prefix", () => {
); );
expect(content).toContain("rune discuss"); expect(content).toContain("rune discuss");
} finally { } finally {
await rm(tmpDir, { recursive: true, force: true }); await retryRm(tmpDir);
} }
}); });
@@ -247,7 +248,7 @@ describe("injectOpenCode with command prefix", () => {
); );
expect(content).toContain("bunx @lanyuanxiaoyao/rune status"); expect(content).toContain("bunx @lanyuanxiaoyao/rune status");
} finally { } finally {
await rm(tmpDir, { recursive: true, force: true }); await retryRm(tmpDir);
} }
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { runInit } from "../../src/commands/init.ts"; import { runInit } from "../../src/commands/init.ts";
import { loadConfig, getChangeDir } from "../../src/core/config.ts"; import { loadConfig, getChangeDir } from "../../src/core/config.ts";
@@ -14,7 +15,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("create 命令(工具命令,非 SDD 阶段)", () => { describe("create 命令(工具命令,非 SDD 阶段)", () => {

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, rm, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { runInit } from "../../src/commands/init.ts"; import { runInit } from "../../src/commands/init.ts";
import { CommandError } from "../../src/cli/errors.ts"; import { CommandError } from "../../src/cli/errors.ts";
@@ -12,7 +13,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("runInit", () => { describe("runInit", () => {

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { import {
assembleDiscussPrompt, assembleDiscussPrompt,
@@ -18,7 +19,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("assembleDiscussPrompt", () => { describe("assembleDiscussPrompt", () => {

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { loadConfig, findProjectRoot, getRuneDir, validateConfig } from "../../src/core/config.ts"; import { loadConfig, findProjectRoot, getRuneDir, validateConfig } from "../../src/core/config.ts";
import { ConfigError } from "../../src/cli/errors.ts"; import { ConfigError } from "../../src/cli/errors.ts";
@@ -12,7 +13,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("findProjectRoot", () => { describe("findProjectRoot", () => {
@@ -188,7 +189,7 @@ describe("mergeConfig 保留 metadata", () => {
expect(config.metadata!.command).toBe("bunx @lanyuanxiaoyao/rune"); expect(config.metadata!.command).toBe("bunx @lanyuanxiaoyao/rune");
expect(config.stages.discuss!.prompt).toBe("自定义讨论"); expect(config.stages.discuss!.prompt).toBe("自定义讨论");
} finally { } finally {
await rm(tmpDir, { recursive: true, force: true }); await retryRm(tmpDir);
} }
}); });

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { scanChanges, scanArchives } from "../../src/core/scanner.ts"; import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
import type { RuneConfig } from "../../src/types.ts"; import type { RuneConfig } from "../../src/types.ts";
@@ -11,7 +12,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("scanChanges", () => { describe("scanChanges", () => {

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; import { describe, it, expect } from "bun:test";
import { mkdir, access, rm } from "node:fs/promises"; import { mkdir, access } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { retryRm } from "./cleanup.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_cleanup_test__"); const TMP_DIR = join(import.meta.dir, "__tmp_cleanup_test__");
@@ -14,106 +15,54 @@ async function dirExists(path: string): Promise<boolean> {
} }
describe("retryRm", () => { describe("retryRm", () => {
describe("正常删除", () => {
afterEach(async () => {
if (await dirExists(TMP_DIR)) {
await rm(TMP_DIR, { recursive: true, force: true });
}
});
it("删除存在的目录", async () => { it("删除存在的目录", async () => {
const { retryRm } = await import("./cleanup.ts");
await mkdir(TMP_DIR, { recursive: true }); await mkdir(TMP_DIR, { recursive: true });
await retryRm(TMP_DIR); await retryRm(TMP_DIR);
expect(await dirExists(TMP_DIR)).toBe(false); expect(await dirExists(TMP_DIR)).toBe(false);
}); });
it("删除不存在的路径不报错force:true", async () => { it("删除不存在的路径不报错force:true", async () => {
const { retryRm } = await import("./cleanup.ts");
const nonExist = join(import.meta.dir, "__non_exist__"); const nonExist = join(import.meta.dir, "__non_exist__");
await expect(retryRm(nonExist)).resolves.toBeUndefined(); await expect(retryRm(nonExist)).resolves.toBeUndefined();
}); });
});
describe("EBUSY 重试成功", () => { it("EBUSY 重试成功", async () => {
let callCount: number; let callCount = 0;
const fakeRm = async () => {
beforeEach(() => {
callCount = 0;
mock.module("node:fs/promises", () => ({
rm: mock(async () => {
callCount++; callCount++;
if (callCount === 1) { if (callCount === 1) {
const err: any = new Error("EBUSY"); const err: any = new Error("EBUSY");
err.code = "EBUSY"; err.code = "EBUSY";
throw err; throw err;
} }
}), };
})); await retryRm("/some/path", { maxRetries: 3, baseDelay: 10, _rm: fakeRm as any });
});
afterEach(() => {
mock.restore();
});
it("EBUSY 后重试成功", async () => {
const { retryRm } = await import("./cleanup.ts");
await retryRm("/some/path", { maxRetries: 3, baseDelay: 10 });
expect(callCount).toBe(2); expect(callCount).toBe(2);
}); });
});
describe("重试耗尽后抛出错误", () => { it("maxRetries 耗尽后抛出最后一个错误", async () => {
let callCount: number; let callCount = 0;
const fakeRm = async () => {
beforeEach(() => {
callCount = 0;
mock.module("node:fs/promises", () => ({
rm: mock(async () => {
callCount++; callCount++;
const err: any = new Error("EBUSY"); const err: any = new Error("EBUSY");
err.code = "EBUSY"; err.code = "EBUSY";
throw err; throw err;
}), };
})); await expect(
}); retryRm("/some/path", { maxRetries: 2, baseDelay: 10, _rm: fakeRm as any }),
).rejects.toThrow("EBUSY");
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); expect(callCount).toBe(3);
}); });
});
describe("非 EBUSY/EPERM 错误不重试", () => { it("ENOENT 错误直接抛出不重试", async () => {
let callCount: number; let callCount = 0;
const fakeRm = async () => {
beforeEach(() => {
callCount = 0;
mock.module("node:fs/promises", () => ({
rm: mock(async () => {
callCount++; callCount++;
const err: any = new Error("ENOENT: no such file or directory"); const err: any = new Error("ENOENT: no such file or directory");
err.code = "ENOENT"; err.code = "ENOENT";
throw err; throw err;
}), };
})); await expect(retryRm("/some/path", { _rm: fakeRm as any })).rejects.toThrow("ENOENT");
});
afterEach(() => {
mock.restore();
});
it("ENOENT 错误直接抛出不重试", async () => {
const { retryRm } = await import("./cleanup.ts");
await expect(retryRm("/some/path")).rejects.toThrow("ENOENT");
expect(callCount).toBe(1); expect(callCount).toBe(1);
}); });
});
}); });

View File

@@ -1,14 +1,18 @@
import { rm } from "node:fs/promises"; import { rm as fsRm } from "node:fs/promises";
const RETRY_CODES = new Set(["EBUSY", "EPERM"]); const RETRY_CODES = new Set(["EBUSY", "EPERM"]);
export async function retryRm( export async function retryRm(
path: string, path: string,
{ maxRetries = 5, baseDelay = 100 }: { maxRetries?: number; baseDelay?: number } = {}, {
maxRetries = 5,
baseDelay = 100,
_rm = fsRm,
}: { maxRetries?: number; baseDelay?: number; _rm?: typeof fsRm } = {},
): Promise<void> { ): Promise<void> {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
await rm(path, { recursive: true, force: true }); await _rm(path, { recursive: true, force: true });
return; return;
} catch (err: any) { } catch (err: any) {
if (!RETRY_CODES.has(err.code) || attempt === maxRetries) { if (!RETRY_CODES.has(err.code) || attempt === maxRetries) {

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, writeFile, rm, rename } from "node:fs/promises"; import { mkdir, writeFile, rename } from "node:fs/promises";
import { retryRm } from "../helpers/cleanup.ts";
import { join } from "node:path"; import { join } from "node:path";
import { runInit } from "../../src/commands/init.ts"; import { runInit } from "../../src/commands/init.ts";
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts"; import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
@@ -20,7 +21,7 @@ beforeEach(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true }); await retryRm(TMP_DIR);
}); });
describe("完整 SDD 流程", () => { describe("完整 SDD 流程", () => {