511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
assertAbsolutePathInput,
|
|
canonicalPathFromExistingAncestor,
|
|
ensureAbsoluteDirectory,
|
|
findExistingAncestor,
|
|
resolveAbsolutePathForRead,
|
|
resolveAbsolutePathForWrite,
|
|
} from "../src/absolute-path.js";
|
|
import {
|
|
createTarEntryPreflightChecker,
|
|
readTarEntryInfo,
|
|
} from "../src/archive-tar.js";
|
|
import { resolveArchiveKind, resolvePackedRootDir } from "../src/archive-kind.js";
|
|
import { pathExists, pathExistsSync } from "../src/fs.js";
|
|
import {
|
|
expandHomePrefix,
|
|
resolveEffectiveHomeDir,
|
|
resolveHomeRelativePath,
|
|
resolveOsHomeDir,
|
|
resolveOsHomeRelativePath,
|
|
resolveRequiredHomeDir,
|
|
resolveRequiredOsHomeDir,
|
|
resolveUserPath,
|
|
} from "../src/home-dir.js";
|
|
import { movePathWithCopyFallback } from "../src/move-path.js";
|
|
import { createSidecarLockManager, withSidecarLock } from "../src/sidecar-lock.js";
|
|
import {
|
|
hasNonEmptyString,
|
|
localeLowercasePreservingWhitespace,
|
|
lowercasePreservingWhitespace,
|
|
normalizeFastMode,
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeNullableString,
|
|
normalizeOptionalLowercaseString,
|
|
normalizeOptionalString,
|
|
normalizeOptionalStringifiedId,
|
|
normalizeOptionalThreadValue,
|
|
normalizeStringifiedOptionalString,
|
|
readStringValue,
|
|
resolvePrimaryStringValue,
|
|
} from "../src/string-coerce.js";
|
|
import { movePathToTrash } from "../src/trash.js";
|
|
|
|
const tempDirs = new Set<string>();
|
|
|
|
async function tempRoot(prefix: string): Promise<string> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
tempDirs.add(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
for (const dir of tempDirs) {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
tempDirs.clear();
|
|
});
|
|
|
|
describe("string coercion helpers", () => {
|
|
it("normalizes optional string-like values", () => {
|
|
expect(readStringValue("x")).toBe("x");
|
|
expect(readStringValue(1)).toBeUndefined();
|
|
expect(normalizeNullableString(" hi ")).toBe("hi");
|
|
expect(normalizeNullableString(" ")).toBeNull();
|
|
expect(normalizeOptionalString(" ok ")).toBe("ok");
|
|
expect(normalizeOptionalString(null)).toBeUndefined();
|
|
expect(normalizeStringifiedOptionalString(42)).toBe("42");
|
|
expect(normalizeStringifiedOptionalString(true)).toBe("true");
|
|
expect(normalizeStringifiedOptionalString(12n)).toBe("12");
|
|
expect(normalizeStringifiedOptionalString({})).toBeUndefined();
|
|
expect(normalizeOptionalLowercaseString(" YES ")).toBe("yes");
|
|
expect(normalizeLowercaseStringOrEmpty(undefined)).toBe("");
|
|
expect(lowercasePreservingWhitespace(" A B ")).toBe(" a b ");
|
|
expect(localeLowercasePreservingWhitespace(" A B ")).toBe(" a b ");
|
|
expect(resolvePrimaryStringValue({ primary: " value " })).toBe("value");
|
|
expect(resolvePrimaryStringValue({ primary: " " })).toBeUndefined();
|
|
expect(resolvePrimaryStringValue(" direct ")).toBe("direct");
|
|
expect(normalizeOptionalThreadValue(4.9)).toBe(4);
|
|
expect(normalizeOptionalThreadValue(Number.NaN)).toBeUndefined();
|
|
expect(normalizeOptionalStringifiedId(7)).toBe("7");
|
|
expect(hasNonEmptyString(" x ")).toBe(true);
|
|
expect(hasNonEmptyString(" ")).toBe(false);
|
|
});
|
|
|
|
it("parses fast mode aliases", () => {
|
|
for (const value of [true, "on", "true", "yes", "1", "enable", "enabled", "fast"]) {
|
|
expect(normalizeFastMode(value)).toBe(true);
|
|
}
|
|
for (const value of [false, "off", "false", "no", "0", "disable", "disabled", "normal"]) {
|
|
expect(normalizeFastMode(value)).toBe(false);
|
|
}
|
|
expect(normalizeFastMode(null)).toBeUndefined();
|
|
expect(normalizeFastMode("maybe")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("home directory helpers", () => {
|
|
it("resolves explicit and OS home values", () => {
|
|
const env = {
|
|
OPENCLAW_HOME: "~/openclaw",
|
|
HOME: "/home/tester",
|
|
USERPROFILE: "/users/fallback",
|
|
};
|
|
expect(resolveEffectiveHomeDir(env, () => "/os/home")).toBe(path.resolve("/home/tester/openclaw"));
|
|
expect(resolveOsHomeDir(env, () => "/os/home")).toBe(path.resolve("/home/tester"));
|
|
expect(resolveRequiredHomeDir({}, () => "")).toBe(path.resolve(process.cwd()));
|
|
expect(resolveRequiredOsHomeDir({}, () => "")).toBe(path.resolve(process.cwd()));
|
|
expect(expandHomePrefix("~/file", { home: "/home/tester" })).toBe(
|
|
path.join("/home/tester", "file"),
|
|
);
|
|
expect(expandHomePrefix("plain", { home: "/home/tester" })).toBe("plain");
|
|
expect(expandHomePrefix("~other/file", { home: "/home/tester" })).toBe("~other/file");
|
|
});
|
|
|
|
it("resolves user paths through legacy and explicit option shapes", () => {
|
|
const env = { OPENCLAW_HOME: "/configured", HOME: "/home/tester" };
|
|
expect(resolveHomeRelativePath("~/state", { env })).toBe(path.resolve("/configured/state"));
|
|
expect(resolveOsHomeRelativePath("~/state", { env })).toBe(path.resolve("/home/tester/state"));
|
|
expect(resolveUserPath("~/state", env)).toBe(path.resolve("/configured/state"));
|
|
expect(resolveUserPath(" ./relative ", { env })).toBe(path.resolve("./relative"));
|
|
expect(resolveHomeRelativePath(" ", { env })).toBe("");
|
|
expect(resolveOsHomeRelativePath(" ", { env })).toBe("");
|
|
});
|
|
|
|
it("ignores unusable home values", () => {
|
|
expect(resolveEffectiveHomeDir({ OPENCLAW_HOME: "undefined", HOME: "null" }, () => "/real"))
|
|
.toBe(path.resolve("/real"));
|
|
expect(resolveEffectiveHomeDir({ OPENCLAW_HOME: "~" }, () => "")).toBeUndefined();
|
|
expect(resolveOsHomeDir({}, () => {
|
|
throw new Error("no home");
|
|
})).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("archive kind and tar preflight helpers", () => {
|
|
it("detects archive kinds and packed root layouts", async () => {
|
|
expect(resolveArchiveKind("PLUGIN.ZIP")).toBe("zip");
|
|
expect(resolveArchiveKind("pkg.tar.gz")).toBe("tar");
|
|
expect(resolveArchiveKind("pkg.tgz")).toBe("tar");
|
|
expect(resolveArchiveKind("pkg.txt")).toBeNull();
|
|
|
|
const root = await tempRoot("fs-safe-packed-");
|
|
const packageDir = path.join(root, "package");
|
|
await fs.mkdir(packageDir);
|
|
expect(await resolvePackedRootDir(root)).toBe(packageDir);
|
|
|
|
await fs.rm(packageDir, { recursive: true });
|
|
await fs.writeFile(path.join(root, "manifest.json"), "{}", "utf8");
|
|
expect(await resolvePackedRootDir(root, { rootMarkers: [" ", "manifest.json"] })).toBe(root);
|
|
|
|
await fs.rm(path.join(root, "manifest.json"));
|
|
await fs.mkdir(path.join(root, "only"));
|
|
expect(await resolvePackedRootDir(root)).toBe(path.join(root, "only"));
|
|
|
|
await fs.mkdir(path.join(root, "second"));
|
|
await expect(resolvePackedRootDir(root)).rejects.toThrow("unexpected archive layout");
|
|
});
|
|
|
|
it("normalizes tar entries and rejects unsafe entries", () => {
|
|
expect(readTarEntryInfo({ path: "a.txt", type: "File", size: 4.9 })).toEqual({
|
|
path: "a.txt",
|
|
type: "File",
|
|
size: 4,
|
|
});
|
|
expect(readTarEntryInfo({ path: "a.txt", type: "File", size: -1 })).toMatchObject({ size: 0 });
|
|
expect(readTarEntryInfo(null)).toEqual({ path: "", type: "", size: 0 });
|
|
|
|
const check = createTarEntryPreflightChecker({
|
|
rootDir: "/tmp/extract",
|
|
stripComponents: 1,
|
|
limits: { maxEntries: 2, maxEntryBytes: 10, maxExtractedBytes: 20 },
|
|
});
|
|
expect(() => check({ path: "package/", type: "Directory", size: 0 })).not.toThrow();
|
|
expect(() => check({ path: "package/file.txt", type: "File", size: 4 })).not.toThrow();
|
|
expect(() => check({ path: "package/link", type: "SymbolicLink", size: 0 })).toThrow(
|
|
"tar entry is a link",
|
|
);
|
|
expect(() => check({ path: "../escape", type: "File", size: 1 })).toThrow();
|
|
|
|
const countCheck = createTarEntryPreflightChecker({
|
|
rootDir: "/tmp/extract",
|
|
limits: { maxEntries: 1 },
|
|
});
|
|
countCheck({ path: "one.txt", type: "File", size: 1 });
|
|
expect(() => countCheck({ path: "two.txt", type: "File", size: 1 })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("absolute path helpers", () => {
|
|
it("validates absolute path inputs", () => {
|
|
expect(() => assertAbsolutePathInput("")).toThrow("path is required");
|
|
expect(() => assertAbsolutePathInput("relative")).toThrow("path must be absolute");
|
|
expect(() => assertAbsolutePathInput(`${path.sep}tmp\0bad`)).toThrow("NUL");
|
|
expect(assertAbsolutePathInput(path.join(path.sep, "tmp", "..", "tmp", "x"))).toBe(
|
|
path.join(path.sep, "tmp", "x"),
|
|
);
|
|
});
|
|
|
|
it("finds ancestors and resolves reads/writes", async () => {
|
|
const root = await fs.realpath(await tempRoot("fs-safe-absolute-"));
|
|
const nested = path.join(root, "nested");
|
|
const filePath = path.join(nested, "file.txt");
|
|
await fs.mkdir(nested);
|
|
await fs.writeFile(filePath, "ok", "utf8");
|
|
|
|
expect(await findExistingAncestor(path.join(nested, "missing", "file.txt"))).toBe(nested);
|
|
expect(await canonicalPathFromExistingAncestor(path.join(nested, "missing", "file.txt")))
|
|
.toBe(path.join(await fs.realpath(nested), "missing", "file.txt"));
|
|
await expect(resolveAbsolutePathForRead(filePath)).resolves.toMatchObject({
|
|
path: filePath,
|
|
canonicalPath: await fs.realpath(filePath),
|
|
});
|
|
await expect(resolveAbsolutePathForWrite(path.join(nested, "new.txt"))).resolves.toMatchObject({
|
|
path: path.join(nested, "new.txt"),
|
|
parentDir: nested,
|
|
parentExists: true,
|
|
});
|
|
await expect(resolveAbsolutePathForRead(path.join(root, "missing.txt"))).rejects.toMatchObject({
|
|
code: "not-found",
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")("rejects symlinked absolute paths by default", async () => {
|
|
const root = await tempRoot("fs-safe-absolute-link-");
|
|
const realDir = path.join(root, "real");
|
|
const linkDir = path.join(root, "link");
|
|
await fs.mkdir(realDir);
|
|
await fs.writeFile(path.join(realDir, "file.txt"), "ok", "utf8");
|
|
await fs.symlink(realDir, linkDir);
|
|
|
|
await expect(resolveAbsolutePathForRead(path.join(linkDir, "file.txt"))).rejects.toMatchObject({
|
|
code: "symlink",
|
|
});
|
|
await expect(
|
|
resolveAbsolutePathForRead(path.join(linkDir, "file.txt"), { symlinks: "follow" }),
|
|
).resolves.toMatchObject({ canonicalPath: await fs.realpath(path.join(realDir, "file.txt")) });
|
|
await expect(resolveAbsolutePathForWrite(path.join(linkDir, "new.txt"))).rejects.toMatchObject({
|
|
code: "symlink",
|
|
});
|
|
});
|
|
|
|
it("safely creates missing absolute directory parents from a real ancestor", async () => {
|
|
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-"));
|
|
const targetDir = path.join(root, "nested", "deeper");
|
|
|
|
await expect(
|
|
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory", mode: 0o700 }),
|
|
).resolves.toEqual({ ok: true, path: targetDir });
|
|
expect((await fs.stat(targetDir)).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it("rejects relative absolute-directory inputs", async () => {
|
|
await expect(
|
|
ensureAbsoluteDirectory(path.join("..", "..", "..", "escape"), {
|
|
scopeLabel: "output directory",
|
|
}),
|
|
).resolves.toMatchObject({ ok: false, code: "invalid-path" });
|
|
});
|
|
|
|
it("rejects absolute directory creation when the existing target is not a directory", async () => {
|
|
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-file-"));
|
|
const targetPath = path.join(root, "file.txt");
|
|
await fs.writeFile(targetPath, "file", "utf8");
|
|
|
|
await expect(
|
|
ensureAbsoluteDirectory(targetPath, { scopeLabel: "output directory" }),
|
|
).resolves.toMatchObject({ ok: false, code: "not-file" });
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects absolute directory creation through symlinked existing segments",
|
|
async () => {
|
|
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-link-"));
|
|
const outside = await fs.realpath(await tempRoot("fs-safe-absolute-dir-outside-"));
|
|
const linkDir = path.join(root, "link");
|
|
await fs.symlink(outside, linkDir);
|
|
|
|
await expect(
|
|
ensureAbsoluteDirectory(path.join(linkDir, "nested"), {
|
|
scopeLabel: "output directory",
|
|
}),
|
|
).resolves.toMatchObject({ ok: false, code: "symlink" });
|
|
await expect(fs.readdir(outside)).resolves.toEqual([]);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects absolute directory creation when an existing parent is swapped before mkdir",
|
|
async () => {
|
|
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-race-"));
|
|
const outside = await fs.realpath(await tempRoot("fs-safe-absolute-dir-race-outside-"));
|
|
const parentDir = path.join(root, "parent");
|
|
const targetDir = path.join(parentDir, "child");
|
|
await fs.mkdir(parentDir);
|
|
|
|
const realLstat = fs.lstat.bind(fs);
|
|
let swapped = false;
|
|
const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
|
|
const candidate = String(args[0]);
|
|
if (!swapped && candidate === targetDir) {
|
|
swapped = true;
|
|
await fs.rename(parentDir, `${parentDir}-real`);
|
|
await fs.symlink(outside, parentDir, "dir");
|
|
}
|
|
return await realLstat(...args);
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
|
|
).resolves.toMatchObject({ ok: false, code: "symlink" });
|
|
} finally {
|
|
lstatSpy.mockRestore();
|
|
}
|
|
|
|
await expect(fs.stat(path.join(outside, "child"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects absolute directory creation when the existing target changes before return",
|
|
async () => {
|
|
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-target-race-"));
|
|
const outside = await fs.realpath(
|
|
await tempRoot("fs-safe-absolute-dir-target-race-outside-"),
|
|
);
|
|
const targetDir = path.join(root, "target");
|
|
await fs.mkdir(targetDir);
|
|
|
|
const realRealpath = fs.realpath.bind(fs);
|
|
let swapped = false;
|
|
const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (...args) => {
|
|
const candidate = String(args[0]);
|
|
if (!swapped && candidate === targetDir) {
|
|
swapped = true;
|
|
const resolved = await realRealpath(...args);
|
|
await fs.rename(targetDir, `${targetDir}-real`);
|
|
await fs.symlink(outside, targetDir, "dir");
|
|
return resolved;
|
|
}
|
|
return await realRealpath(...args);
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
|
|
).resolves.toMatchObject({ ok: false, code: "not-file" });
|
|
} finally {
|
|
realpathSpy.mockRestore();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("rethrows operational absolute directory creation failures", async () => {
|
|
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-io-"));
|
|
const targetDir = path.join(root, "nested");
|
|
const realMkdir = fs.mkdir.bind(fs);
|
|
const mkdirSpy = vi.spyOn(fs, "mkdir").mockImplementation(async (...args) => {
|
|
if (String(args[0]) === targetDir) {
|
|
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
}
|
|
return await realMkdir(...args);
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
|
|
).rejects.toMatchObject({ code: "EACCES" });
|
|
} finally {
|
|
mkdirSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("filesystem utility helpers", () => {
|
|
it("checks path existence through stat semantics", async () => {
|
|
const root = await tempRoot("fs-safe-exists-");
|
|
const filePath = path.join(root, "file.txt");
|
|
await fs.writeFile(filePath, "ok", "utf8");
|
|
await expect(pathExists(filePath)).resolves.toBe(true);
|
|
await expect(pathExists(path.join(root, "missing.txt"))).resolves.toBe(false);
|
|
expect(pathExistsSync(filePath)).toBe(true);
|
|
expect(pathExistsSync(path.join(root, "missing.txt"))).toBe(false);
|
|
});
|
|
|
|
it("moves paths with rename and copy fallback semantics", async () => {
|
|
const root = await tempRoot("fs-safe-move-");
|
|
const from = path.join(root, "from.txt");
|
|
const to = path.join(root, "to.txt");
|
|
await fs.writeFile(from, "ok", "utf8");
|
|
await movePathWithCopyFallback({ from, to });
|
|
await expect(fs.readFile(to, "utf8")).resolves.toBe("ok");
|
|
await expect(fs.stat(from)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
});
|
|
|
|
describe("trash helper", () => {
|
|
it("moves allowed temp paths to trash and rejects disallowed roots", async () => {
|
|
const root = await tempRoot("fs-safe-trash-");
|
|
const filePath = path.join(root, "delete-me.txt");
|
|
await fs.writeFile(filePath, "trash", "utf8");
|
|
|
|
await expect(movePathToTrash(path.join(root, "missing.txt"), { allowedRoots: [root] }))
|
|
.rejects
|
|
.toThrow();
|
|
await expect(movePathToTrash(filePath, { allowedRoots: [path.join(root, "other")] }))
|
|
.rejects
|
|
.toThrow("outside allowed roots");
|
|
|
|
const dest = await movePathToTrash(filePath, { allowedRoots: [root] });
|
|
try {
|
|
expect(path.basename(dest)).toBe("delete-me.txt");
|
|
expect(fsSync.existsSync(dest)).toBe(true);
|
|
expect(fsSync.existsSync(filePath)).toBe(false);
|
|
} finally {
|
|
await fs.rm(path.dirname(dest), { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("sidecar lock manager", () => {
|
|
it("acquires, reenters, lists, force releases, and drains locks", async () => {
|
|
const root = await tempRoot("fs-safe-sidecar-");
|
|
const targetPath = path.join(root, "state.json");
|
|
const manager = createSidecarLockManager(`coverage-${Date.now()}-${Math.random()}`);
|
|
|
|
const lock = await manager.acquire({
|
|
targetPath,
|
|
staleMs: 60_000,
|
|
allowReentrant: true,
|
|
metadata: { test: true },
|
|
payload: () => ({ owner: "coverage" }),
|
|
});
|
|
const reentrant = await manager.acquire({
|
|
targetPath,
|
|
staleMs: 60_000,
|
|
allowReentrant: true,
|
|
payload: () => ({ owner: "coverage" }),
|
|
});
|
|
expect(manager.heldEntries()).toHaveLength(1);
|
|
expect(manager.heldEntries()[0]?.metadata).toEqual({ test: true });
|
|
await reentrant.release();
|
|
expect(await manager.heldEntries()[0]?.forceRelease()).toBe(true);
|
|
await lock.release();
|
|
expect(manager.heldEntries()).toEqual([]);
|
|
|
|
const value = await manager.withLock(
|
|
{
|
|
targetPath,
|
|
staleMs: 60_000,
|
|
payload: () => ({ owner: "coverage" }),
|
|
},
|
|
async () => 42,
|
|
);
|
|
expect(value).toBe(42);
|
|
await manager.drain();
|
|
manager.reset();
|
|
});
|
|
|
|
it("times out on stale locks without deleting them by path", async () => {
|
|
const root = await tempRoot("fs-safe-sidecar-timeout-");
|
|
const targetPath = path.join(root, "state.json");
|
|
const lockPath = `${targetPath}.lock`;
|
|
const manager = createSidecarLockManager(`coverage-timeout-${Date.now()}-${Math.random()}`);
|
|
await fs.writeFile(lockPath, "{\"createdAt\":\"2000-01-01T00:00:00.000Z\"}\n", "utf8");
|
|
|
|
await expect(
|
|
manager.acquire({
|
|
targetPath,
|
|
lockPath,
|
|
staleMs: 1,
|
|
timeoutMs: 1,
|
|
retry: { retries: 0, minTimeout: 1, maxTimeout: 1 },
|
|
payload: () => ({ owner: "coverage" }),
|
|
}),
|
|
).rejects.toMatchObject({ code: "file_lock_stale" });
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain("2000");
|
|
|
|
await fs.writeFile(lockPath, "{\"createdAt\":\"2999-01-01T00:00:00.000Z\"}\n", "utf8");
|
|
await expect(
|
|
manager.acquire({
|
|
targetPath,
|
|
lockPath,
|
|
staleMs: 60_000,
|
|
timeoutMs: 1,
|
|
retry: { retries: 0, minTimeout: 1, maxTimeout: 1 },
|
|
shouldReclaim: () => false,
|
|
payload: () => ({ owner: "coverage" }),
|
|
}),
|
|
).rejects.toMatchObject({ code: "file_lock_timeout" });
|
|
await fs.rm(lockPath, { force: true });
|
|
|
|
await expect(
|
|
withSidecarLock(
|
|
targetPath,
|
|
{
|
|
managerKey: `coverage-wrapper-${Date.now()}-${Math.random()}`,
|
|
staleMs: 60_000,
|
|
payload: () => ({ owner: "coverage" }),
|
|
},
|
|
async () => "locked",
|
|
),
|
|
).resolves.toBe("locked");
|
|
});
|
|
});
|