mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 21:11:26 +00:00
perf(test): speed up suites and reduce fs churn
This commit is contained in:
@@ -1,299 +1,203 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveControlUiDistIndexHealth,
|
||||
resolveControlUiDistIndexPath,
|
||||
resolveControlUiDistIndexPathForRoot,
|
||||
resolveControlUiRepoRoot,
|
||||
resolveControlUiRootOverrideSync,
|
||||
resolveControlUiRootSync,
|
||||
} from "./control-ui-assets.js";
|
||||
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/** Try to create a symlink; returns false if the OS denies it (Windows CI without Developer Mode). */
|
||||
async function trySymlink(target: string, linkPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.symlink(target, linkPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
entries: new Map<string, FakeFsEntry>(),
|
||||
realpaths: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
const abs = (p: string) => path.resolve(p);
|
||||
|
||||
function setFile(p: string, content = "") {
|
||||
state.entries.set(abs(p), { kind: "file", content });
|
||||
}
|
||||
|
||||
async function canonicalPath(p: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(p);
|
||||
} catch {
|
||||
return path.resolve(p);
|
||||
}
|
||||
function setDir(p: string) {
|
||||
state.entries.set(abs(p), { kind: "dir" });
|
||||
}
|
||||
|
||||
describe("control UI assets helpers", () => {
|
||||
let fixtureRoot = "";
|
||||
let caseId = 0;
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const pathMod = await import("node:path");
|
||||
const absInMock = (p: string) => pathMod.resolve(p);
|
||||
const fixturesRoot = `${absInMock("fixtures")}${pathMod.sep}`;
|
||||
const isFixturePath = (p: string) => {
|
||||
const resolved = absInMock(p);
|
||||
return resolved === fixturesRoot.slice(0, -1) || resolved.startsWith(fixturesRoot);
|
||||
};
|
||||
|
||||
async function withTempDir<T>(fn: (tmp: string) => Promise<T>): Promise<T> {
|
||||
const tmp = path.join(fixtureRoot, `case-${caseId++}`);
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
return await fn(tmp);
|
||||
}
|
||||
const wrapped = {
|
||||
...actual,
|
||||
existsSync: (p: string) =>
|
||||
isFixturePath(p) ? state.entries.has(absInMock(p)) : actual.existsSync(p),
|
||||
readFileSync: (p: string, encoding?: unknown) => {
|
||||
if (!isFixturePath(p)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return actual.readFileSync(p as any, encoding as any) as unknown;
|
||||
}
|
||||
const entry = state.entries.get(absInMock(p));
|
||||
if (!entry || entry.kind !== "file") {
|
||||
throw new Error(`ENOENT: no such file, open '${p}'`);
|
||||
}
|
||||
return entry.content;
|
||||
},
|
||||
statSync: (p: string) => {
|
||||
if (!isFixturePath(p)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return actual.statSync(p as any) as unknown;
|
||||
}
|
||||
const entry = state.entries.get(absInMock(p));
|
||||
if (!entry) {
|
||||
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
|
||||
}
|
||||
return {
|
||||
isFile: () => entry.kind === "file",
|
||||
isDirectory: () => entry.kind === "dir",
|
||||
};
|
||||
},
|
||||
realpathSync: (p: string) =>
|
||||
isFixturePath(p)
|
||||
? (state.realpaths.get(absInMock(p)) ?? absInMock(p))
|
||||
: actual.realpathSync(p),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
|
||||
});
|
||||
return { ...wrapped, default: wrapped };
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (fixtureRoot) {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
vi.mock("./openclaw-root.js", () => ({
|
||||
resolveOpenClawPackageRoot: vi.fn(async () => null),
|
||||
resolveOpenClawPackageRootSync: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe("control UI assets helpers (fs-mocked)", () => {
|
||||
beforeEach(() => {
|
||||
state.entries.clear();
|
||||
state.realpaths.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves repo root from src argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
|
||||
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
|
||||
await fs.mkdir(path.join(tmp, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "src", "index.ts"), "export {};\n");
|
||||
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
|
||||
|
||||
expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe(tmp);
|
||||
});
|
||||
const root = abs("fixtures/ui-src");
|
||||
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
|
||||
|
||||
const argv1 = path.join(root, "src", "index.ts");
|
||||
expect(resolveControlUiRepoRoot(argv1)).toBe(root);
|
||||
});
|
||||
|
||||
it("resolves repo root from dist argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
|
||||
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
|
||||
await fs.mkdir(path.join(tmp, "dist"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "index.js"), "export {};\n");
|
||||
it("resolves repo root by traversing up (dist argv1)", async () => {
|
||||
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
|
||||
|
||||
expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe(tmp);
|
||||
});
|
||||
const root = abs("fixtures/ui-dist");
|
||||
setFile(path.join(root, "package.json"), "{}\n");
|
||||
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
|
||||
|
||||
const argv1 = path.join(root, "dist", "index.js");
|
||||
expect(resolveControlUiRepoRoot(argv1)).toBe(root);
|
||||
});
|
||||
|
||||
it("resolves dist control-ui index path for dist argv1", async () => {
|
||||
const argv1 = path.resolve("/tmp", "pkg", "dist", "index.js");
|
||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
||||
|
||||
const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js"));
|
||||
const distDir = path.dirname(argv1);
|
||||
expect(await resolveControlUiDistIndexPath(argv1)).toBe(
|
||||
await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe(
|
||||
path.join(distDir, "control-ui", "index.html"),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves control-ui root for dist bundle argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "bundle.js"), "export {};\n");
|
||||
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
it("uses resolveOpenClawPackageRoot when available", async () => {
|
||||
const openclawRoot = await import("./openclaw-root.js");
|
||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
||||
|
||||
expect(resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "bundle.js") })).toBe(
|
||||
path.join(tmp, "dist", "control-ui"),
|
||||
);
|
||||
const pkgRoot = abs("fixtures/openclaw");
|
||||
(
|
||||
openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValueOnce(pkgRoot);
|
||||
|
||||
await expect(resolveControlUiDistIndexPath(abs("fixtures/bin/openclaw"))).resolves.toBe(
|
||||
path.join(pkgRoot, "dist", "control-ui", "index.html"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to package.json name matching when root resolution fails", async () => {
|
||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
||||
|
||||
const root = abs("fixtures/fallback");
|
||||
setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
|
||||
await expect(resolveControlUiDistIndexPath(path.join(root, "openclaw.mjs"))).resolves.toBe(
|
||||
path.join(root, "dist", "control-ui", "index.html"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when fallback package name does not match", async () => {
|
||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
||||
|
||||
const root = abs("fixtures/not-openclaw");
|
||||
setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" }));
|
||||
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
|
||||
await expect(resolveControlUiDistIndexPath(path.join(root, "index.mjs"))).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("reports health for missing + existing dist assets", async () => {
|
||||
const { resolveControlUiDistIndexHealth } = await import("./control-ui-assets.js");
|
||||
|
||||
const root = abs("fixtures/health");
|
||||
const indexPath = path.join(root, "dist", "control-ui", "index.html");
|
||||
|
||||
await expect(resolveControlUiDistIndexHealth({ root })).resolves.toEqual({
|
||||
indexPath,
|
||||
exists: false,
|
||||
});
|
||||
|
||||
setFile(indexPath, "<html></html>\n");
|
||||
await expect(resolveControlUiDistIndexHealth({ root })).resolves.toEqual({
|
||||
indexPath,
|
||||
exists: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves control-ui root for dist/gateway bundle argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.mkdir(path.join(tmp, "dist", "gateway"), { recursive: true });
|
||||
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "gateway", "control-ui.js"), "export {};\n");
|
||||
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
it("resolves control-ui root from override file or directory", async () => {
|
||||
const { resolveControlUiRootOverrideSync } = await import("./control-ui-assets.js");
|
||||
|
||||
expect(
|
||||
resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "gateway", "control-ui.js") }),
|
||||
).toBe(path.join(tmp, "dist", "control-ui"));
|
||||
});
|
||||
const root = abs("fixtures/override");
|
||||
const uiDir = path.join(root, "dist", "control-ui");
|
||||
const indexPath = path.join(uiDir, "index.html");
|
||||
|
||||
setDir(uiDir);
|
||||
setFile(indexPath, "<html></html>\n");
|
||||
|
||||
expect(resolveControlUiRootOverrideSync(uiDir)).toBe(uiDir);
|
||||
expect(resolveControlUiRootOverrideSync(indexPath)).toBe(uiDir);
|
||||
expect(resolveControlUiRootOverrideSync(path.join(uiDir, "missing.html"))).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves control-ui root from override directory or index.html", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const uiDir = path.join(tmp, "dist", "control-ui");
|
||||
await fs.mkdir(uiDir, { recursive: true });
|
||||
await fs.writeFile(path.join(uiDir, "index.html"), "<html></html>\n");
|
||||
it("resolves control-ui root for dist bundle argv1 and moduleUrl candidates", async () => {
|
||||
const openclawRoot = await import("./openclaw-root.js");
|
||||
const { resolveControlUiRootSync } = await import("./control-ui-assets.js");
|
||||
|
||||
expect(resolveControlUiRootOverrideSync(uiDir)).toBe(uiDir);
|
||||
expect(resolveControlUiRootOverrideSync(path.join(uiDir, "index.html"))).toBe(uiDir);
|
||||
expect(resolveControlUiRootOverrideSync(path.join(uiDir, "missing.html"))).toBeNull();
|
||||
});
|
||||
});
|
||||
const pkgRoot = abs("fixtures/openclaw-bundle");
|
||||
(
|
||||
openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValueOnce(pkgRoot);
|
||||
|
||||
it("resolves dist control-ui index path from package root argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
|
||||
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
const uiDir = path.join(pkgRoot, "dist", "control-ui");
|
||||
setFile(path.join(uiDir, "index.html"), "<html></html>\n");
|
||||
|
||||
expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe(
|
||||
path.join(tmp, "dist", "control-ui", "index.html"),
|
||||
);
|
||||
});
|
||||
});
|
||||
// argv1Dir candidate: <argv1Dir>/control-ui
|
||||
expect(resolveControlUiRootSync({ argv1: path.join(pkgRoot, "dist", "bundle.js") })).toBe(
|
||||
uiDir,
|
||||
);
|
||||
|
||||
it("resolves control-ui root for package entrypoint argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
|
||||
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
|
||||
expect(resolveControlUiRootSync({ argv1: path.join(tmp, "openclaw.mjs") })).toBe(
|
||||
path.join(tmp, "dist", "control-ui"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves dist control-ui index path from .bin argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const binDir = path.join(tmp, "node_modules", ".bin");
|
||||
const pkgRoot = path.join(tmp, "node_modules", "openclaw");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(path.join(pkgRoot, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(binDir, "openclaw"), "#!/usr/bin/env node\n");
|
||||
await fs.writeFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(pkgRoot, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
|
||||
expect(await resolveControlUiDistIndexPath(path.join(binDir, "openclaw"))).toBe(
|
||||
path.join(pkgRoot, "dist", "control-ui", "index.html"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves via fallback when package root resolution fails but package name matches", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
// Package named "openclaw" but resolveOpenClawPackageRoot failed for other reasons
|
||||
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
|
||||
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
|
||||
expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe(
|
||||
path.join(tmp, "dist", "control-ui", "index.html"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when package name does not match openclaw", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
// Package with different name should not be resolved
|
||||
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "malicious-pkg" }));
|
||||
await fs.writeFile(path.join(tmp, "index.mjs"), "export {};\n");
|
||||
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
|
||||
expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no control-ui assets exist", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
// Just a package.json, no dist/control-ui
|
||||
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "some-pkg" }));
|
||||
await fs.writeFile(path.join(tmp, "index.mjs"), "export {};\n");
|
||||
|
||||
expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports health for existing control-ui assets at a known root", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const indexPath = resolveControlUiDistIndexPathForRoot(tmp);
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true });
|
||||
await fs.writeFile(indexPath, "<html></html>\n");
|
||||
|
||||
await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({
|
||||
indexPath,
|
||||
exists: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("reports health for missing control-ui assets at a known root", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const indexPath = resolveControlUiDistIndexPathForRoot(tmp);
|
||||
await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({
|
||||
indexPath,
|
||||
exists: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves control-ui root when argv1 is a symlink (nvm scenario)", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const realPkg = path.join(tmp, "real-pkg");
|
||||
const bin = path.join(tmp, "bin");
|
||||
await fs.mkdir(realPkg, { recursive: true });
|
||||
await fs.mkdir(bin, { recursive: true });
|
||||
await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n");
|
||||
await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
const ok = await trySymlink(
|
||||
path.join("..", "real-pkg", "openclaw.mjs"),
|
||||
path.join(bin, "openclaw"),
|
||||
);
|
||||
if (!ok) {
|
||||
return; // symlinks not supported (Windows CI)
|
||||
}
|
||||
|
||||
const resolvedRoot = resolveControlUiRootSync({ argv1: path.join(bin, "openclaw") });
|
||||
expect(resolvedRoot).not.toBeNull();
|
||||
expect(await canonicalPath(resolvedRoot ?? "")).toBe(
|
||||
await canonicalPath(path.join(realPkg, "dist", "control-ui")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves package root via symlinked argv1", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const realPkg = path.join(tmp, "real-pkg");
|
||||
const bin = path.join(tmp, "bin");
|
||||
await fs.mkdir(realPkg, { recursive: true });
|
||||
await fs.mkdir(bin, { recursive: true });
|
||||
await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n");
|
||||
await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
const ok = await trySymlink(
|
||||
path.join("..", "real-pkg", "openclaw.mjs"),
|
||||
path.join(bin, "openclaw"),
|
||||
);
|
||||
if (!ok) {
|
||||
return; // symlinks not supported (Windows CI)
|
||||
}
|
||||
|
||||
const packageRoot = await resolveOpenClawPackageRoot({ argv1: path.join(bin, "openclaw") });
|
||||
expect(packageRoot).not.toBeNull();
|
||||
expect(await canonicalPath(packageRoot ?? "")).toBe(await canonicalPath(realPkg));
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves dist index path via symlinked argv1 (async)", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const realPkg = path.join(tmp, "real-pkg");
|
||||
const bin = path.join(tmp, "bin");
|
||||
await fs.mkdir(realPkg, { recursive: true });
|
||||
await fs.mkdir(bin, { recursive: true });
|
||||
await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n");
|
||||
await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true });
|
||||
await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||
const ok = await trySymlink(
|
||||
path.join("..", "real-pkg", "openclaw.mjs"),
|
||||
path.join(bin, "openclaw"),
|
||||
);
|
||||
if (!ok) {
|
||||
return; // symlinks not supported (Windows CI)
|
||||
}
|
||||
|
||||
const indexPath = await resolveControlUiDistIndexPath(path.join(bin, "openclaw"));
|
||||
expect(indexPath).not.toBeNull();
|
||||
expect(await canonicalPath(indexPath ?? "")).toBe(
|
||||
await canonicalPath(path.join(realPkg, "dist", "control-ui", "index.html")),
|
||||
);
|
||||
});
|
||||
// moduleUrl candidate: <moduleDir>/control-ui
|
||||
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "bundle.js")).toString();
|
||||
expect(resolveControlUiRootSync({ moduleUrl })).toBe(uiDir);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js";
|
||||
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
|
||||
|
||||
@@ -67,6 +67,13 @@ describe("gateway lock", () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Other suites occasionally leave global spies behind (Date.now, setTimeout, etc.).
|
||||
// This test relies on fake timers advancing Date.now and setTimeout deterministically.
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
@@ -76,30 +83,45 @@ describe("gateway lock", () => {
|
||||
});
|
||||
|
||||
it("blocks concurrent acquisition until release", async () => {
|
||||
vi.useRealTimers();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const lock = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 80,
|
||||
pollIntervalMs: 5,
|
||||
timeoutMs: 20,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
let settled = false;
|
||||
const pending = acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 80,
|
||||
pollIntervalMs: 5,
|
||||
timeoutMs: 20,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
void pending.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
// Drive the retry loop without real sleeping.
|
||||
for (let i = 0; i < 20 && !settled; i += 1) {
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await Promise.resolve();
|
||||
}
|
||||
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
|
||||
|
||||
await lock?.release();
|
||||
const lock2 = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 80,
|
||||
pollIntervalMs: 5,
|
||||
timeoutMs: 20,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
await lock2?.release();
|
||||
await cleanup();
|
||||
@@ -107,6 +129,7 @@ describe("gateway lock", () => {
|
||||
|
||||
it("treats recycled linux pid as stale when start time mismatches", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = {
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
||||
import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js";
|
||||
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
|
||||
import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js";
|
||||
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
isHeartbeatEnabledForAgent,
|
||||
resolveHeartbeatIntervalMs,
|
||||
@@ -33,16 +29,90 @@ import {
|
||||
// Avoid pulling optional runtime deps during isolated runs.
|
||||
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
|
||||
|
||||
let previousRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
|
||||
let testRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
|
||||
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
const createCaseDir = async (prefix: string) => {
|
||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
previousRegistry = getActivePluginRegistry();
|
||||
|
||||
const whatsappPlugin = createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound });
|
||||
whatsappPlugin.config = {
|
||||
...whatsappPlugin.config,
|
||||
resolveAllowFrom: ({ cfg }) =>
|
||||
cfg.channels?.whatsapp?.allowFrom?.map((entry) => String(entry)) ?? [],
|
||||
};
|
||||
|
||||
const telegramPlugin = createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ to, text, deps, accountId }) => {
|
||||
if (!deps?.sendTelegram) {
|
||||
throw new Error("sendTelegram missing");
|
||||
}
|
||||
const res = await deps.sendTelegram(to, text, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "telegram", messageId: res.messageId, chatId: res.chatId };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, deps, accountId }) => {
|
||||
if (!deps?.sendTelegram) {
|
||||
throw new Error("sendTelegram missing");
|
||||
}
|
||||
const res = await deps.sendTelegram(to, text, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
});
|
||||
return { channel: "telegram", messageId: res.messageId, chatId: res.chatId };
|
||||
},
|
||||
},
|
||||
});
|
||||
telegramPlugin.config = {
|
||||
...telegramPlugin.config,
|
||||
listAccountIds: (cfg) => Object.keys(cfg.channels?.telegram?.accounts ?? {}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const channel = cfg.channels?.telegram;
|
||||
const normalized = accountId?.trim();
|
||||
if (normalized && channel?.accounts?.[normalized]?.allowFrom) {
|
||||
return channel.accounts[normalized].allowFrom?.map((entry) => String(entry)) ?? [];
|
||||
}
|
||||
return channel?.allowFrom?.map((entry) => String(entry)) ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
testRegistry = createTestRegistry([
|
||||
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||
]);
|
||||
setActivePluginRegistry(testRegistry);
|
||||
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-suite-"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const runtime = createPluginRuntime();
|
||||
setTelegramRuntime(runtime);
|
||||
setWhatsAppRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
if (testRegistry) {
|
||||
setActivePluginRegistry(testRegistry);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (fixtureRoot) {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
if (previousRegistry) {
|
||||
setActivePluginRegistry(previousRegistry);
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatIntervalMs", () => {
|
||||
@@ -397,7 +467,7 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("uses the last non-empty payload for delivery", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const tmpDir = await createCaseDir("hb-last-payload");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
@@ -415,18 +485,14 @@ describe("runHeartbeatOnce", () => {
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
|
||||
@@ -450,12 +516,11 @@ describe("runHeartbeatOnce", () => {
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses per-agent heartbeat overrides and session keys", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const tmpDir = await createCaseDir("hb-agent-overrides");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
@@ -479,18 +544,14 @@ describe("runHeartbeatOnce", () => {
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}),
|
||||
);
|
||||
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
@@ -520,12 +581,11 @@ describe("runHeartbeatOnce", () => {
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses non-default agent sessionFile from templated stores", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const tmpDir = await createCaseDir("hb-templated-store");
|
||||
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions", "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const agentId = "ops";
|
||||
@@ -598,12 +658,11 @@ describe("runHeartbeatOnce", () => {
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs heartbeats in the explicit session key when configured", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const tmpDir = await createCaseDir("hb-explicit-session");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
@@ -635,24 +694,20 @@ describe("runHeartbeatOnce", () => {
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[mainSessionKey]: {
|
||||
sessionId: "sid-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
[groupSessionKey]: {
|
||||
sessionId: "sid-group",
|
||||
updatedAt: Date.now() + 10_000,
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: groupId,
|
||||
},
|
||||
JSON.stringify({
|
||||
[mainSessionKey]: {
|
||||
sessionId: "sid-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
[groupSessionKey]: {
|
||||
sessionId: "sid-group",
|
||||
updatedAt: Date.now() + 10_000,
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: groupId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Group alert" }]);
|
||||
@@ -681,12 +736,11 @@ describe("runHeartbeatOnce", () => {
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const tmpDir = await createCaseDir("hb-dup-suppress");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
@@ -704,20 +758,16 @@ describe("runHeartbeatOnce", () => {
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastHeartbeatText: "Final alert",
|
||||
lastHeartbeatSentAt: 0,
|
||||
},
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastHeartbeatText: "Final alert",
|
||||
lastHeartbeatSentAt: 0,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
||||
@@ -737,12 +787,11 @@ describe("runHeartbeatOnce", () => {
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can include reasoning payloads when enabled", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const tmpDir = await createCaseDir("hb-reasoning");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
@@ -764,19 +813,15 @@ describe("runHeartbeatOnce", () => {
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([
|
||||
@@ -809,12 +854,11 @@ describe("runHeartbeatOnce", () => {
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "+1555", "Final alert", expect.any(Object));
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const tmpDir = await createCaseDir("hb-reasoning-heartbeat-ok");
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
@@ -836,19 +880,15 @@ describe("runHeartbeatOnce", () => {
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([
|
||||
@@ -880,7 +920,6 @@ describe("runHeartbeatOnce", () => {
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
150
src/infra/openclaw-root.test.ts
Normal file
150
src/infra/openclaw-root.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
|
||||
|
||||
const VITEST_FS_BASE = path.join(path.parse(process.cwd()).root, "__openclaw_vitest__");
|
||||
const FIXTURE_BASE = path.join(VITEST_FS_BASE, "openclaw-root");
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
entries: new Map<string, FakeFsEntry>(),
|
||||
realpaths: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
const abs = (p: string) => path.resolve(p);
|
||||
const fx = (...parts: string[]) => path.join(FIXTURE_BASE, ...parts);
|
||||
|
||||
function setFile(p: string, content = "") {
|
||||
state.entries.set(abs(p), { kind: "file", content });
|
||||
}
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const pathMod = await import("node:path");
|
||||
const absInMock = (p: string) => pathMod.resolve(p);
|
||||
const vitestRoot = `${absInMock(VITEST_FS_BASE)}${pathMod.sep}`;
|
||||
const isFixturePath = (p: string) => {
|
||||
const resolved = absInMock(p);
|
||||
return resolved === vitestRoot.slice(0, -1) || resolved.startsWith(vitestRoot);
|
||||
};
|
||||
const wrapped = {
|
||||
...actual,
|
||||
existsSync: (p: string) =>
|
||||
isFixturePath(p) ? state.entries.has(absInMock(p)) : actual.existsSync(p),
|
||||
readFileSync: (p: string, encoding?: unknown) => {
|
||||
if (!isFixturePath(p)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return actual.readFileSync(p as any, encoding as any) as unknown;
|
||||
}
|
||||
const entry = state.entries.get(absInMock(p));
|
||||
if (!entry || entry.kind !== "file") {
|
||||
throw new Error(`ENOENT: no such file, open '${p}'`);
|
||||
}
|
||||
return encoding ? entry.content : Buffer.from(entry.content, "utf-8");
|
||||
},
|
||||
statSync: (p: string) => {
|
||||
if (!isFixturePath(p)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return actual.statSync(p as any) as unknown;
|
||||
}
|
||||
const entry = state.entries.get(absInMock(p));
|
||||
if (!entry) {
|
||||
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
|
||||
}
|
||||
return {
|
||||
isFile: () => entry.kind === "file",
|
||||
isDirectory: () => entry.kind === "dir",
|
||||
};
|
||||
},
|
||||
realpathSync: (p: string) =>
|
||||
isFixturePath(p)
|
||||
? (state.realpaths.get(absInMock(p)) ?? absInMock(p))
|
||||
: actual.realpathSync(p),
|
||||
};
|
||||
return { ...wrapped, default: wrapped };
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
const pathMod = await import("node:path");
|
||||
const absInMock = (p: string) => pathMod.resolve(p);
|
||||
const vitestRoot = `${absInMock(VITEST_FS_BASE)}${pathMod.sep}`;
|
||||
const isFixturePath = (p: string) => {
|
||||
const resolved = absInMock(p);
|
||||
return resolved === vitestRoot.slice(0, -1) || resolved.startsWith(vitestRoot);
|
||||
};
|
||||
const wrapped = {
|
||||
...actual,
|
||||
readFile: async (p: string, encoding?: unknown) => {
|
||||
if (!isFixturePath(p)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (await actual.readFile(p as any, encoding as any)) as unknown;
|
||||
}
|
||||
const entry = state.entries.get(absInMock(p));
|
||||
if (!entry || entry.kind !== "file") {
|
||||
throw new Error(`ENOENT: no such file, open '${p}'`);
|
||||
}
|
||||
return entry.content;
|
||||
},
|
||||
};
|
||||
return { ...wrapped, default: wrapped };
|
||||
});
|
||||
|
||||
describe("resolveOpenClawPackageRoot", () => {
|
||||
beforeEach(() => {
|
||||
state.entries.clear();
|
||||
state.realpaths.clear();
|
||||
});
|
||||
|
||||
it("resolves package root from .bin argv1", async () => {
|
||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
||||
|
||||
const project = fx("bin-scenario");
|
||||
const argv1 = path.join(project, "node_modules", ".bin", "openclaw");
|
||||
const pkgRoot = path.join(project, "node_modules", "openclaw");
|
||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
|
||||
expect(resolveOpenClawPackageRootSync({ argv1 })).toBe(pkgRoot);
|
||||
});
|
||||
|
||||
it("resolves package root via symlinked argv1", async () => {
|
||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
||||
|
||||
const project = fx("symlink-scenario");
|
||||
const bin = path.join(project, "bin", "openclaw");
|
||||
const realPkg = path.join(project, "real-pkg");
|
||||
state.realpaths.set(abs(bin), abs(path.join(realPkg, "openclaw.mjs")));
|
||||
setFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
|
||||
expect(resolveOpenClawPackageRootSync({ argv1: bin })).toBe(realPkg);
|
||||
});
|
||||
|
||||
it("prefers moduleUrl candidates", async () => {
|
||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
||||
|
||||
const pkgRoot = fx("moduleurl");
|
||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "index.js")).toString();
|
||||
|
||||
expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot);
|
||||
});
|
||||
|
||||
it("returns null for non-openclaw package roots", async () => {
|
||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
||||
|
||||
const pkgRoot = fx("not-openclaw");
|
||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));
|
||||
|
||||
expect(resolveOpenClawPackageRootSync({ cwd: pkgRoot })).toBeNull();
|
||||
});
|
||||
|
||||
it("async resolver matches sync behavior", async () => {
|
||||
const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js");
|
||||
|
||||
const pkgRoot = fx("async");
|
||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
|
||||
await expect(resolveOpenClawPackageRoot({ cwd: pkgRoot })).resolves.toBe(pkgRoot);
|
||||
});
|
||||
});
|
||||
@@ -1,237 +1,215 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ensureOpenClawCliOnPath } from "./path-env.js";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
dirs: new Set<string>(),
|
||||
executables: new Set<string>(),
|
||||
}));
|
||||
|
||||
const abs = (p: string) => path.resolve(p);
|
||||
const setDir = (p: string) => state.dirs.add(abs(p));
|
||||
const setExe = (p: string) => state.executables.add(abs(p));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const pathMod = await import("node:path");
|
||||
const absInMock = (p: string) => pathMod.resolve(p);
|
||||
|
||||
const wrapped = {
|
||||
...actual,
|
||||
constants: { ...actual.constants, X_OK: actual.constants.X_OK ?? 1 },
|
||||
accessSync: (p: string, mode?: number) => {
|
||||
// `mode` is ignored in tests; we only model "is executable" or "not".
|
||||
if (!state.executables.has(absInMock(p))) {
|
||||
throw new Error(`EACCES: permission denied, access '${p}' (mode=${mode ?? 0})`);
|
||||
}
|
||||
},
|
||||
statSync: (p: string) => ({
|
||||
// Avoid throws for non-existent paths; the code under test only cares about isDirectory().
|
||||
isDirectory: () => state.dirs.has(absInMock(p)),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...wrapped, default: wrapped };
|
||||
});
|
||||
|
||||
let ensureOpenClawCliOnPath: typeof import("./path-env.js").ensureOpenClawCliOnPath;
|
||||
|
||||
describe("ensureOpenClawCliOnPath", () => {
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`);
|
||||
await fs.mkdir(tmp);
|
||||
return tmp;
|
||||
}
|
||||
const envKeys = [
|
||||
"PATH",
|
||||
"OPENCLAW_PATH_BOOTSTRAPPED",
|
||||
"OPENCLAW_ALLOW_PROJECT_LOCAL_BIN",
|
||||
"MISE_DATA_DIR",
|
||||
"HOMEBREW_PREFIX",
|
||||
"HOMEBREW_BREW_FILE",
|
||||
"XDG_BIN_HOME",
|
||||
] as const;
|
||||
let envSnapshot: Record<(typeof envKeys)[number], string | undefined>;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-"));
|
||||
({ ensureOpenClawCliOnPath } = await import("./path-env.js"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
beforeEach(() => {
|
||||
envSnapshot = Object.fromEntries(envKeys.map((k) => [k, process.env[k]])) as typeof envSnapshot;
|
||||
state.dirs.clear();
|
||||
state.executables.clear();
|
||||
|
||||
setDir("/usr/bin");
|
||||
setDir("/bin");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("prepends the bundled app bin dir when a sibling openclaw exists", async () => {
|
||||
const tmp = await makeTmpDir();
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
await fs.mkdir(appBinDir);
|
||||
const cliPath = path.join(appBinDir, "openclaw");
|
||||
await fs.writeFile(cliPath, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(cliPath, 0o755);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
try {
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: cliPath,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
const updated = process.env.PATH ?? "";
|
||||
expect(updated.split(path.delimiter)[0]).toBe(appBinDir);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
afterEach(() => {
|
||||
for (const k of envKeys) {
|
||||
const value = envSnapshot[k];
|
||||
if (value === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
|
||||
process.env[k] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("prepends the bundled app bin dir when a sibling openclaw exists", () => {
|
||||
const tmp = abs("/tmp/openclaw-path/case-bundled");
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
const cliPath = path.join(appBinDir, "openclaw");
|
||||
setDir(tmp);
|
||||
setDir(appBinDir);
|
||||
setExe(cliPath);
|
||||
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: cliPath,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
const updated = process.env.PATH ?? "";
|
||||
expect(updated.split(path.delimiter)[0]).toBe(appBinDir);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
process.env.PATH = "/bin";
|
||||
process.env.OPENCLAW_PATH_BOOTSTRAPPED = "1";
|
||||
try {
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: "/tmp/does-not-matter",
|
||||
cwd: "/tmp",
|
||||
homeDir: "/tmp",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(process.env.PATH).toBe("/bin");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
} else {
|
||||
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
|
||||
}
|
||||
}
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: "/tmp/does-not-matter",
|
||||
cwd: "/tmp",
|
||||
homeDir: "/tmp",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(process.env.PATH).toBe("/bin");
|
||||
});
|
||||
|
||||
it("prepends mise shims when available", async () => {
|
||||
const tmp = await makeTmpDir();
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
const originalMiseDataDir = process.env.MISE_DATA_DIR;
|
||||
try {
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
await fs.mkdir(appBinDir);
|
||||
const appCli = path.join(appBinDir, "openclaw");
|
||||
await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(appCli, 0o755);
|
||||
it("prepends mise shims when available", () => {
|
||||
const tmp = abs("/tmp/openclaw-path/case-mise");
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
const appCli = path.join(appBinDir, "openclaw");
|
||||
setDir(tmp);
|
||||
setDir(appBinDir);
|
||||
setExe(appCli);
|
||||
|
||||
const miseDataDir = path.join(tmp, "mise");
|
||||
const shimsDir = path.join(miseDataDir, "shims");
|
||||
await fs.mkdir(shimsDir, { recursive: true });
|
||||
process.env.MISE_DATA_DIR = miseDataDir;
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
const miseDataDir = path.join(tmp, "mise");
|
||||
const shimsDir = path.join(miseDataDir, "shims");
|
||||
setDir(miseDataDir);
|
||||
setDir(shimsDir);
|
||||
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
process.env.MISE_DATA_DIR = miseDataDir;
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
|
||||
const updated = process.env.PATH ?? "";
|
||||
const parts = updated.split(path.delimiter);
|
||||
const appBinIndex = parts.indexOf(appBinDir);
|
||||
const shimsIndex = parts.indexOf(shimsDir);
|
||||
expect(appBinIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(shimsIndex).toBeGreaterThan(appBinIndex);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
} else {
|
||||
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
|
||||
}
|
||||
if (originalMiseDataDir === undefined) {
|
||||
delete process.env.MISE_DATA_DIR;
|
||||
} else {
|
||||
process.env.MISE_DATA_DIR = originalMiseDataDir;
|
||||
}
|
||||
}
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
const updated = process.env.PATH ?? "";
|
||||
const parts = updated.split(path.delimiter);
|
||||
const appBinIndex = parts.indexOf(appBinDir);
|
||||
const shimsIndex = parts.indexOf(shimsDir);
|
||||
expect(appBinIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(shimsIndex).toBeGreaterThan(appBinIndex);
|
||||
});
|
||||
|
||||
it("only appends project-local node_modules/.bin when explicitly enabled", async () => {
|
||||
const tmp = await makeTmpDir();
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
try {
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
await fs.mkdir(appBinDir);
|
||||
const appCli = path.join(appBinDir, "openclaw");
|
||||
await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(appCli, 0o755);
|
||||
it("only appends project-local node_modules/.bin when explicitly enabled", () => {
|
||||
const tmp = abs("/tmp/openclaw-path/case-project-local");
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
const appCli = path.join(appBinDir, "openclaw");
|
||||
setDir(tmp);
|
||||
setDir(appBinDir);
|
||||
setExe(appCli);
|
||||
|
||||
const localBinDir = path.join(tmp, "node_modules", ".bin");
|
||||
await fs.mkdir(localBinDir, { recursive: true });
|
||||
const localCli = path.join(localBinDir, "openclaw");
|
||||
await fs.writeFile(localCli, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(localCli, 0o755);
|
||||
const localBinDir = path.join(tmp, "node_modules", ".bin");
|
||||
const localCli = path.join(localBinDir, "openclaw");
|
||||
setDir(path.join(tmp, "node_modules"));
|
||||
setDir(localBinDir);
|
||||
setExe(localCli);
|
||||
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter);
|
||||
expect(withoutOptIn.includes(localBinDir)).toBe(false);
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
});
|
||||
const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter);
|
||||
expect(withoutOptIn.includes(localBinDir)).toBe(false);
|
||||
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
allowProjectLocalBin: true,
|
||||
});
|
||||
const withOptIn = (process.env.PATH ?? "").split(path.delimiter);
|
||||
const usrBinIndex = withOptIn.indexOf("/usr/bin");
|
||||
const localIndex = withOptIn.indexOf(localBinDir);
|
||||
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(localIndex).toBeGreaterThan(usrBinIndex);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
} else {
|
||||
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
|
||||
}
|
||||
}
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: appCli,
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "darwin",
|
||||
allowProjectLocalBin: true,
|
||||
});
|
||||
const withOptIn = (process.env.PATH ?? "").split(path.delimiter);
|
||||
const usrBinIndex = withOptIn.indexOf("/usr/bin");
|
||||
const localIndex = withOptIn.indexOf(localBinDir);
|
||||
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(localIndex).toBeGreaterThan(usrBinIndex);
|
||||
});
|
||||
|
||||
it("prepends Linuxbrew dirs when present", async () => {
|
||||
const tmp = await makeTmpDir();
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
const originalHomebrewPrefix = process.env.HOMEBREW_PREFIX;
|
||||
const originalHomebrewBrewFile = process.env.HOMEBREW_BREW_FILE;
|
||||
const originalXdgBinHome = process.env.XDG_BIN_HOME;
|
||||
try {
|
||||
const execDir = path.join(tmp, "exec");
|
||||
await fs.mkdir(execDir);
|
||||
it("prepends Linuxbrew dirs when present", () => {
|
||||
const tmp = abs("/tmp/openclaw-path/case-linuxbrew");
|
||||
const execDir = path.join(tmp, "exec");
|
||||
setDir(tmp);
|
||||
setDir(execDir);
|
||||
|
||||
const linuxbrewBin = path.join(tmp, ".linuxbrew", "bin");
|
||||
const linuxbrewSbin = path.join(tmp, ".linuxbrew", "sbin");
|
||||
await fs.mkdir(linuxbrewBin, { recursive: true });
|
||||
await fs.mkdir(linuxbrewSbin, { recursive: true });
|
||||
const linuxbrewDir = path.join(tmp, ".linuxbrew");
|
||||
const linuxbrewBin = path.join(linuxbrewDir, "bin");
|
||||
const linuxbrewSbin = path.join(linuxbrewDir, "sbin");
|
||||
setDir(linuxbrewDir);
|
||||
setDir(linuxbrewBin);
|
||||
setDir(linuxbrewSbin);
|
||||
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
delete process.env.HOMEBREW_PREFIX;
|
||||
delete process.env.HOMEBREW_BREW_FILE;
|
||||
delete process.env.XDG_BIN_HOME;
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
delete process.env.HOMEBREW_PREFIX;
|
||||
delete process.env.HOMEBREW_BREW_FILE;
|
||||
delete process.env.XDG_BIN_HOME;
|
||||
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: path.join(execDir, "node"),
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "linux",
|
||||
});
|
||||
ensureOpenClawCliOnPath({
|
||||
execPath: path.join(execDir, "node"),
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
const updated = process.env.PATH ?? "";
|
||||
const parts = updated.split(path.delimiter);
|
||||
expect(parts[0]).toBe(linuxbrewBin);
|
||||
expect(parts[1]).toBe(linuxbrewSbin);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
} else {
|
||||
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
|
||||
}
|
||||
if (originalHomebrewPrefix === undefined) {
|
||||
delete process.env.HOMEBREW_PREFIX;
|
||||
} else {
|
||||
process.env.HOMEBREW_PREFIX = originalHomebrewPrefix;
|
||||
}
|
||||
if (originalHomebrewBrewFile === undefined) {
|
||||
delete process.env.HOMEBREW_BREW_FILE;
|
||||
} else {
|
||||
process.env.HOMEBREW_BREW_FILE = originalHomebrewBrewFile;
|
||||
}
|
||||
if (originalXdgBinHome === undefined) {
|
||||
delete process.env.XDG_BIN_HOME;
|
||||
} else {
|
||||
process.env.XDG_BIN_HOME = originalXdgBinHome;
|
||||
}
|
||||
}
|
||||
const updated = process.env.PATH ?? "";
|
||||
const parts = updated.split(path.delimiter);
|
||||
expect(parts[0]).toBe(linuxbrewBin);
|
||||
expect(parts[1]).toBe(linuxbrewSbin);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,71 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { resolveProviderAuths } from "./provider-usage.auth.js";
|
||||
|
||||
describe("resolveProviderAuths key normalization", () => {
|
||||
let suiteRoot = "";
|
||||
let suiteCase = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-suite-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(suiteRoot, { recursive: true, force: true });
|
||||
suiteRoot = "";
|
||||
suiteCase = 0;
|
||||
});
|
||||
|
||||
async function withSuiteHome<T>(
|
||||
fn: (home: string) => Promise<T>,
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<T> {
|
||||
const base = path.join(suiteRoot, `case-${++suiteCase}`);
|
||||
await fs.mkdir(base, { recursive: true });
|
||||
await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true });
|
||||
|
||||
const keysToRestore = new Set<string>([
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
...Object.keys(env),
|
||||
]);
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of keysToRestore) {
|
||||
snapshot[key] = process.env[key];
|
||||
}
|
||||
|
||||
process.env.HOME = base;
|
||||
process.env.USERPROFILE = base;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw");
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("strips embedded CR/LF from env keys", async () => {
|
||||
await withTempHome(
|
||||
await withSuiteHome(
|
||||
async () => {
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: ["zai", "minimax", "xiaomi"],
|
||||
@@ -18,17 +77,15 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
]);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
ZAI_API_KEY: "zai-\r\nkey",
|
||||
MINIMAX_API_KEY: "minimax-\r\nkey",
|
||||
XIAOMI_API_KEY: "xiaomi-\r\nkey",
|
||||
},
|
||||
ZAI_API_KEY: "zai-\r\nkey",
|
||||
MINIMAX_API_KEY: "minimax-\r\nkey",
|
||||
XIAOMI_API_KEY: "xiaomi-\r\nkey",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("strips embedded CR/LF from stored auth profiles (token + api_key)", async () => {
|
||||
await withTempHome(
|
||||
await withSuiteHome(
|
||||
async (home) => {
|
||||
const agentDir = path.join(home, ".openclaw", "agents", "main", "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
@@ -57,11 +114,9 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
]);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
MINIMAX_API_KEY: undefined,
|
||||
MINIMAX_CODE_PLAN_KEY: undefined,
|
||||
XIAOMI_API_KEY: undefined,
|
||||
},
|
||||
MINIMAX_API_KEY: undefined,
|
||||
MINIMAX_CODE_PLAN_KEY: undefined,
|
||||
XIAOMI_API_KEY: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { waitForTransportReady } from "./transport-ready.js";
|
||||
|
||||
// Perf: `sleepWithAbort` uses `node:timers/promises` which isn't controlled by fake timers.
|
||||
// Route sleeps through global `setTimeout` so tests can advance time deterministically.
|
||||
vi.mock("./backoff.js", () => ({
|
||||
sleepWithAbort: async (ms: number) => {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
}));
|
||||
|
||||
describe("waitForTransportReady", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -16,7 +27,8 @@ describe("waitForTransportReady", () => {
|
||||
const readyPromise = waitForTransportReady({
|
||||
label: "test transport",
|
||||
timeoutMs: 220,
|
||||
logAfterMs: 60,
|
||||
// Deterministic: first attempt at t=0 won't log; second attempt at t=50 will.
|
||||
logAfterMs: 1,
|
||||
logIntervalMs: 1_000,
|
||||
pollIntervalMs: 50,
|
||||
runtime,
|
||||
@@ -29,9 +41,7 @@ describe("waitForTransportReady", () => {
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await readyPromise;
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
@@ -48,8 +58,9 @@ describe("waitForTransportReady", () => {
|
||||
runtime,
|
||||
check: async () => ({ ok: false, error: "still down" }),
|
||||
});
|
||||
const asserted = expect(waitPromise).rejects.toThrow("test transport not ready");
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await expect(waitPromise).rejects.toThrow("test transport not ready");
|
||||
await asserted;
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user