diff --git a/test/helpers/fs.ts b/test/helpers/fs.ts new file mode 100644 index 0000000..ca3abc7 --- /dev/null +++ b/test/helpers/fs.ts @@ -0,0 +1,9 @@ +import { promises as fs } from "node:fs"; + +/** + * Removes a file or a directory for a test's cleanup, retrying a busy one: Windows can report EBUSY on a copy of + * node a test has just run (a lingering handle, or a scan). Node retries only with `recursive: true` (harmless on a + * file); its backoff is linear, so 6 × 200 ms waits at most 4.2 s — inside vitest's 10 s hook timeout, twice. An + * error that outlasts the retries still throws. + */ +export const removeWithRetry = (p: string): Promise => fs.rm(p, { recursive: true, force: true, maxRetries: 6, retryDelay: 200 }); diff --git a/test/local-bin.test.ts b/test/local-bin.test.ts index 4b47402..4bfad2c 100644 --- a/test/local-bin.test.ts +++ b/test/local-bin.test.ts @@ -4,6 +4,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { execProgram, isLocalBin, onPath, pathEntries, spawnInherited } from "../src/core/proc.js"; import { dockerRunner, dockerVersion } from "../src/core/services.js"; import { tmpDir } from "./helpers/forge.js"; +import { removeWithRetry } from "./helpers/fs.js"; // 0.28.2 — `npx craftar` puts `/node_modules/.bin`, and every ancestor's, first on PATH. craftar runs in a // workspace or a Forge, so a program committed there would be the one it runs: those entries are skipped, in the @@ -45,8 +46,8 @@ beforeAll(async () => { await fs.chmod(path.join(PROJECT_BIN, TOOL + EXE), 0o755); }); afterAll(async () => { - await fs.rm(path.join(LOCAL, TOOL + EXE), { force: true }); - await fs.rm(PROJECT, { recursive: true, force: true }); + await removeWithRetry(path.join(LOCAL, TOOL + EXE)); + await removeWithRetry(PROJECT); }); describe("isLocalBin — the node_modules/.bin of a directory or of one above it", () => { @@ -118,7 +119,7 @@ describe("onPath — a program in such an entry is not found", () => { expect(await onPath(TOOL, env, PROJECT)).toBeNull(); expect(await onPath(TOOL, env, path.join(PROJECT, "packages", "app"))).toBeNull(); const elsewhere = await fs.realpath(await tmpDir("craftar-local-bin-elsewhere-")); - cleanups.push(() => fs.rm(elsewhere, { recursive: true, force: true })); + cleanups.push(() => removeWithRetry(elsewhere)); expect(await onPath(TOOL, env, elsewhere)).toBe(path.join(PROJECT_BIN, TOOL + EXE)); }); diff --git a/test/proc.test.ts b/test/proc.test.ts index 406fdbe..104e3a1 100644 --- a/test/proc.test.ts +++ b/test/proc.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { capture, execProgram, onPath, pathEntries, spawnInherited } from "../src/core/proc.js"; import { tmpDir } from "./helpers/forge.js"; +import { removeWithRetry } from "./helpers/fs.js"; // design D3 — the two ways craftar runs an external program besides git: no shell, an argument array. @@ -70,7 +71,7 @@ beforeAll(async () => { await fs.copyFile(process.execPath, path.join(PLANTED, TOOL + EXE)); await fs.chmod(path.join(PLANTED, TOOL + EXE), 0o755); }); -afterAll(() => fs.rm(PLANTED, { recursive: true, force: true })); +afterAll(() => removeWithRetry(PLANTED)); /** The process environment with PATH replaced, whatever the key's case (Windows spells it `Path`). */ function withPath(value: string): NodeJS.ProcessEnv {