mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 20:21:23 +00:00
refactor(core): dedupe shared config and runtime helpers
This commit is contained in:
@@ -14,6 +14,29 @@ const { createService, shutdown, registerUnhandledRejectionHandler, logWarn, log
|
||||
const asString = (value: unknown, fallback: string) =>
|
||||
typeof value === "string" && value.trim() ? value : fallback;
|
||||
|
||||
function mockCiaoService(params?: {
|
||||
advertise?: ReturnType<typeof vi.fn>;
|
||||
destroy?: ReturnType<typeof vi.fn>;
|
||||
serviceState?: string;
|
||||
on?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const advertise = params?.advertise ?? vi.fn().mockResolvedValue(undefined);
|
||||
const destroy = params?.destroy ?? vi.fn().mockResolvedValue(undefined);
|
||||
const on = params?.on ?? vi.fn();
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: params?.serviceState ?? "announced",
|
||||
on,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
return { advertise, destroy, on };
|
||||
}
|
||||
|
||||
vi.mock("../logger.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../logger.js")>("../logger.js");
|
||||
return {
|
||||
@@ -96,18 +119,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
setTimeout(resolve, 250);
|
||||
}),
|
||||
);
|
||||
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
mockCiaoService({ advertise, destroy });
|
||||
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
@@ -149,18 +161,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
|
||||
const destroy = vi.fn().mockResolvedValue(undefined);
|
||||
const advertise = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
mockCiaoService({ advertise, destroy });
|
||||
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
@@ -188,20 +189,10 @@ describe("gateway bonjour advertiser", () => {
|
||||
const advertise = vi.fn().mockResolvedValue(undefined);
|
||||
const onCalls: Array<{ event: string }> = [];
|
||||
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
const on = vi.fn((event: string) => {
|
||||
onCalls.push({ event });
|
||||
});
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on,
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
const on = vi.fn((event: string) => {
|
||||
onCalls.push({ event });
|
||||
});
|
||||
mockCiaoService({ advertise, destroy, on });
|
||||
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
@@ -228,18 +219,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
shutdown.mockImplementation(async () => {
|
||||
order.push("shutdown");
|
||||
});
|
||||
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
mockCiaoService({ advertise, destroy });
|
||||
|
||||
const cleanup = vi.fn(() => {
|
||||
order.push("cleanup");
|
||||
@@ -272,18 +252,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom")) // initial advertise fails
|
||||
.mockResolvedValue(undefined); // watchdog retry succeeds
|
||||
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "unannounced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
mockCiaoService({ advertise, destroy, serviceState: "unannounced" });
|
||||
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
@@ -319,18 +288,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
const advertise = vi.fn(() => {
|
||||
throw new Error("sync-fail");
|
||||
});
|
||||
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "unannounced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
mockCiaoService({ advertise, destroy, serviceState: "unannounced" });
|
||||
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
@@ -352,17 +310,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
|
||||
const destroy = vi.fn().mockResolvedValue(undefined);
|
||||
const advertise = vi.fn().mockResolvedValue(undefined);
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
mockCiaoService({ advertise, destroy });
|
||||
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
const resolved = absInMock(p);
|
||||
return resolved === fixturesRoot.slice(0, -1) || resolved.startsWith(fixturesRoot);
|
||||
};
|
||||
const readFixtureEntry = (p: string) => state.entries.get(absInMock(p));
|
||||
|
||||
const wrapped = {
|
||||
...actual,
|
||||
@@ -38,25 +39,25 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
// 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}'`);
|
||||
const entry = readFixtureEntry(p);
|
||||
if (entry?.kind === "file") {
|
||||
return entry.content;
|
||||
}
|
||||
return entry.content;
|
||||
throw new Error(`ENOENT: no such file, open '${p}'`);
|
||||
},
|
||||
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}'`);
|
||||
const entry = readFixtureEntry(p);
|
||||
if (entry?.kind === "file") {
|
||||
return { isFile: () => true, isDirectory: () => false };
|
||||
}
|
||||
return {
|
||||
isFile: () => entry.kind === "file",
|
||||
isDirectory: () => entry.kind === "dir",
|
||||
};
|
||||
if (entry?.kind === "dir") {
|
||||
return { isFile: () => false, isDirectory: () => true };
|
||||
}
|
||||
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
|
||||
},
|
||||
realpathSync: (p: string) =>
|
||||
isFixturePath(p)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pruneMapToMaxSize } from "./map-size.js";
|
||||
|
||||
export type DedupeCache = {
|
||||
check: (key: string | undefined | null, now?: number) => boolean;
|
||||
clear: () => void;
|
||||
@@ -32,13 +34,7 @@ export function createDedupeCache(options: DedupeCacheOptions): DedupeCache {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
pruneMapToMaxSize(cache, maxSize);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,29 +9,12 @@ async function writeEnvFile(filePath: string, contents: string) {
|
||||
await fs.writeFile(filePath, contents, "utf8");
|
||||
}
|
||||
|
||||
describe("loadDotEnv", () => {
|
||||
it("loads ~/.openclaw/.env as fallback without overriding CWD .env", async () => {
|
||||
const prevEnv = { ...process.env };
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
|
||||
const cwdDir = path.join(base, "cwd");
|
||||
const stateDir = path.join(base, "state");
|
||||
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\nBAR=1\n");
|
||||
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
|
||||
|
||||
process.chdir(cwdDir);
|
||||
delete process.env.FOO;
|
||||
delete process.env.BAR;
|
||||
|
||||
loadDotEnv({ quiet: true });
|
||||
|
||||
expect(process.env.FOO).toBe("from-cwd");
|
||||
expect(process.env.BAR).toBe("1");
|
||||
|
||||
async function withIsolatedEnvAndCwd(run: () => Promise<void>) {
|
||||
const prevEnv = { ...process.env };
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in prevEnv)) {
|
||||
@@ -45,40 +28,49 @@ describe("loadDotEnv", () => {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("loadDotEnv", () => {
|
||||
it("loads ~/.openclaw/.env as fallback without overriding CWD .env", async () => {
|
||||
await withIsolatedEnvAndCwd(async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
|
||||
const cwdDir = path.join(base, "cwd");
|
||||
const stateDir = path.join(base, "state");
|
||||
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\nBAR=1\n");
|
||||
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
|
||||
|
||||
process.chdir(cwdDir);
|
||||
delete process.env.FOO;
|
||||
delete process.env.BAR;
|
||||
|
||||
loadDotEnv({ quiet: true });
|
||||
|
||||
expect(process.env.FOO).toBe("from-cwd");
|
||||
expect(process.env.BAR).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override an already-set env var from the shell", async () => {
|
||||
const prevEnv = { ...process.env };
|
||||
const prevCwd = process.cwd();
|
||||
await withIsolatedEnvAndCwd(async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
|
||||
const cwdDir = path.join(base, "cwd");
|
||||
const stateDir = path.join(base, "state");
|
||||
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
|
||||
const cwdDir = path.join(base, "cwd");
|
||||
const stateDir = path.join(base, "state");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.FOO = "from-shell";
|
||||
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.FOO = "from-shell";
|
||||
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
|
||||
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
|
||||
|
||||
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
|
||||
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
|
||||
process.chdir(cwdDir);
|
||||
|
||||
process.chdir(cwdDir);
|
||||
loadDotEnv({ quiet: true });
|
||||
|
||||
loadDotEnv({ quiet: true });
|
||||
|
||||
expect(process.env.FOO).toBe("from-shell");
|
||||
|
||||
process.chdir(prevCwd);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in prevEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(prevEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
expect(process.env.FOO).toBe("from-shell");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,18 +24,40 @@ function getFirstDeliveryText(deliver: ReturnType<typeof vi.fn>): string {
|
||||
return firstCall?.payloads?.[0]?.text ?? "";
|
||||
}
|
||||
|
||||
const TARGETS_CFG = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
function createForwarder(params: {
|
||||
cfg: OpenClawConfig;
|
||||
deliver?: ReturnType<typeof vi.fn>;
|
||||
resolveSessionTarget?: () => { channel: string; to: string } | null;
|
||||
}) {
|
||||
const deliver = params.deliver ?? vi.fn().mockResolvedValue([]);
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => params.cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: params.resolveSessionTarget ?? (() => null),
|
||||
});
|
||||
return { deliver, forwarder };
|
||||
}
|
||||
|
||||
describe("exec approval forwarder", () => {
|
||||
it("forwards to session target and resolves", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
const { deliver, forwarder } = createForwarder({
|
||||
cfg,
|
||||
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
|
||||
});
|
||||
|
||||
@@ -56,23 +78,7 @@ describe("exec approval forwarder", () => {
|
||||
|
||||
it("forwards to explicit targets and expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: () => null,
|
||||
});
|
||||
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
|
||||
|
||||
await forwarder.handleRequested(baseRequest);
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
@@ -83,23 +89,7 @@ describe("exec approval forwarder", () => {
|
||||
|
||||
it("formats single-line commands as inline code", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: () => null,
|
||||
});
|
||||
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
|
||||
|
||||
await forwarder.handleRequested(baseRequest);
|
||||
|
||||
@@ -108,23 +98,7 @@ describe("exec approval forwarder", () => {
|
||||
|
||||
it("formats complex commands as fenced code blocks", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: () => null,
|
||||
});
|
||||
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
|
||||
|
||||
await forwarder.handleRequested({
|
||||
...baseRequest,
|
||||
@@ -139,7 +113,6 @@ describe("exec approval forwarder", () => {
|
||||
|
||||
it("skips discord forwarding when discord exec approvals target channel", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
channels: {
|
||||
@@ -153,10 +126,8 @@ describe("exec approval forwarder", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
const { deliver, forwarder } = createForwarder({
|
||||
cfg,
|
||||
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
|
||||
});
|
||||
|
||||
@@ -167,23 +138,7 @@ describe("exec approval forwarder", () => {
|
||||
|
||||
it("uses a longer fence when command already contains triple backticks", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: () => null,
|
||||
});
|
||||
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
|
||||
|
||||
await forwarder.handleRequested({
|
||||
...baseRequest,
|
||||
|
||||
@@ -34,6 +34,26 @@ function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
|
||||
}
|
||||
|
||||
function createSafeBinJqCase(params: { command: string; seedFileName?: string }) {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
if (params.seedFileName) {
|
||||
fs.writeFileSync(path.join(dir, params.seedFileName), "{}");
|
||||
}
|
||||
const res = analyzeShellCommand({
|
||||
command: params.command,
|
||||
cwd: dir,
|
||||
env: makePathEnv(binDir),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
return { dir, segment: res.segments[0] };
|
||||
}
|
||||
|
||||
describe("exec approvals allowlist matching", () => {
|
||||
it("ignores basename-only patterns", () => {
|
||||
const resolution = {
|
||||
@@ -389,20 +409,7 @@ describe("exec approvals safe bins", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const res = analyzeShellCommand({
|
||||
command: "jq .foo",
|
||||
cwd: dir,
|
||||
env: makePathEnv(binDir),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const segment = res.segments[0];
|
||||
const { dir, segment } = createSafeBinJqCase({ command: "jq .foo" });
|
||||
const ok = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
@@ -416,22 +423,10 @@ describe("exec approvals safe bins", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const file = path.join(dir, "secret.json");
|
||||
fs.writeFileSync(file, "{}");
|
||||
const res = analyzeShellCommand({
|
||||
const { dir, segment } = createSafeBinJqCase({
|
||||
command: "jq .foo secret.json",
|
||||
cwd: dir,
|
||||
env: makePathEnv(binDir),
|
||||
seedFileName: "secret.json",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const segment = res.segments[0];
|
||||
const ok = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
|
||||
@@ -62,6 +62,25 @@ function makeProcStat(pid: number, startTime: number) {
|
||||
return `${pid} (node) ${fields.join(" ")}`;
|
||||
}
|
||||
|
||||
function createLockPayload(params: { configPath: string; startTime: number; createdAt?: string }) {
|
||||
return {
|
||||
pid: process.pid,
|
||||
createdAt: params.createdAt ?? new Date().toISOString(),
|
||||
configPath: params.configPath,
|
||||
startTime: params.startTime,
|
||||
};
|
||||
}
|
||||
|
||||
function mockProcStatRead(params: { onProcRead: () => string }) {
|
||||
const readFileSync = fsSync.readFileSync;
|
||||
return vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
|
||||
if (filePath === `/proc/${process.pid}/stat`) {
|
||||
return params.onProcRead();
|
||||
}
|
||||
return readFileSync(filePath as never, encoding as never) as never;
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway lock", () => {
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
|
||||
@@ -119,21 +138,12 @@ describe("gateway lock", () => {
|
||||
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = {
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
configPath,
|
||||
startTime: 111,
|
||||
};
|
||||
const payload = createLockPayload({ configPath, startTime: 111 });
|
||||
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
|
||||
|
||||
const readFileSync = fsSync.readFileSync;
|
||||
const statValue = makeProcStat(process.pid, 222);
|
||||
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
|
||||
if (filePath === `/proc/${process.pid}/stat`) {
|
||||
return statValue;
|
||||
}
|
||||
return readFileSync(filePath as never, encoding as never) as never;
|
||||
const spy = mockProcStatRead({
|
||||
onProcRead: () => statValue,
|
||||
});
|
||||
|
||||
const lock = await acquireGatewayLock({
|
||||
@@ -154,20 +164,13 @@ describe("gateway lock", () => {
|
||||
vi.useRealTimers();
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = {
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
configPath,
|
||||
startTime: 111,
|
||||
};
|
||||
const payload = createLockPayload({ configPath, startTime: 111 });
|
||||
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
|
||||
|
||||
const readFileSync = fsSync.readFileSync;
|
||||
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
|
||||
if (filePath === `/proc/${process.pid}/stat`) {
|
||||
const spy = mockProcStatRead({
|
||||
onProcRead: () => {
|
||||
throw new Error("EACCES");
|
||||
}
|
||||
return readFileSync(filePath as never, encoding as never) as never;
|
||||
},
|
||||
});
|
||||
|
||||
const pending = acquireGatewayLock({
|
||||
@@ -182,17 +185,17 @@ describe("gateway lock", () => {
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
const stalePayload = {
|
||||
...payload,
|
||||
const stalePayload = createLockPayload({
|
||||
configPath,
|
||||
startTime: 111,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
};
|
||||
});
|
||||
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
|
||||
|
||||
const staleSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
|
||||
if (filePath === `/proc/${process.pid}/stat`) {
|
||||
const staleSpy = mockProcStatRead({
|
||||
onProcRead: () => {
|
||||
throw new Error("EACCES");
|
||||
}
|
||||
return readFileSync(filePath as never, encoding as never) as never;
|
||||
},
|
||||
});
|
||||
|
||||
const lock = await acquireGatewayLock({
|
||||
|
||||
@@ -86,6 +86,40 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
expect(calledCtx?.Body).not.toContain("heartbeat poll");
|
||||
};
|
||||
|
||||
const runCronReminderCase = async (
|
||||
tmpPrefix: string,
|
||||
enqueue: (sessionKey: string) => void,
|
||||
): Promise<{
|
||||
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
|
||||
sendTelegram: ReturnType<typeof vi.fn>;
|
||||
getReplySpy: ReturnType<typeof vi.spyOn<typeof replyModule, "getReplyFromConfig">>;
|
||||
}> => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tmpPrefix));
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "155462274",
|
||||
});
|
||||
const getReplySpy = vi
|
||||
.spyOn(replyModule, "getReplyFromConfig")
|
||||
.mockResolvedValue({ text: "Relay this reminder now" });
|
||||
|
||||
try {
|
||||
const { cfg, sessionKey } = await createConfig(tmpDir);
|
||||
enqueue(sessionKey);
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
reason: "cron:reminder-job",
|
||||
deps: {
|
||||
sendTelegram,
|
||||
},
|
||||
});
|
||||
return { result, sendTelegram, getReplySpy };
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
it("does not use CRON_EVENT_PROMPT when only a HEARTBEAT_OK event is present", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ghost-"));
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
@@ -122,68 +156,28 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
});
|
||||
|
||||
it("uses CRON_EVENT_PROMPT when an actionable cron event exists", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-"));
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "155462274",
|
||||
});
|
||||
const getReplySpy = vi
|
||||
.spyOn(replyModule, "getReplyFromConfig")
|
||||
.mockResolvedValue({ text: "Relay this reminder now" });
|
||||
|
||||
try {
|
||||
const { cfg } = await createConfig(tmpDir);
|
||||
enqueueSystemEvent("Reminder: Check Base Scout results", {
|
||||
sessionKey: resolveMainSessionKey(cfg),
|
||||
});
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
reason: "cron:reminder-job",
|
||||
deps: {
|
||||
sendTelegram,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
const { result, sendTelegram, getReplySpy } = await runCronReminderCase(
|
||||
"openclaw-cron-",
|
||||
(sessionKey) => {
|
||||
enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey });
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe("ran");
|
||||
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses CRON_EVENT_PROMPT when cron events are mixed with heartbeat noise", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-mixed-"));
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "155462274",
|
||||
});
|
||||
const getReplySpy = vi
|
||||
.spyOn(replyModule, "getReplyFromConfig")
|
||||
.mockResolvedValue({ text: "Relay this reminder now" });
|
||||
|
||||
try {
|
||||
const { cfg, sessionKey } = await createConfig(tmpDir);
|
||||
enqueueSystemEvent("HEARTBEAT_OK", { sessionKey });
|
||||
enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey });
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
reason: "cron:reminder-job",
|
||||
deps: {
|
||||
sendTelegram,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
const { result, sendTelegram, getReplySpy } = await runCronReminderCase(
|
||||
"openclaw-cron-mixed-",
|
||||
(sessionKey) => {
|
||||
enqueueSystemEvent("HEARTBEAT_OK", { sessionKey });
|
||||
enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey });
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe("ran");
|
||||
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => {
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
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 { 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 * as replyModule from "../auto-reply/reply.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
|
||||
|
||||
// Avoid pulling optional runtime deps during isolated runs.
|
||||
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
|
||||
|
||||
beforeEach(() => {
|
||||
const runtime = createPluginRuntime();
|
||||
setTelegramRuntime(runtime);
|
||||
setWhatsAppRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
installHeartbeatRunnerTestRuntime();
|
||||
|
||||
describe("resolveHeartbeatIntervalMs", () => {
|
||||
async function seedSessionStore(
|
||||
@@ -82,21 +66,16 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
replySpy: ReturnType<typeof vi.spyOn>;
|
||||
}) => Promise<T>,
|
||||
) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
return await fn({ tmpDir, storePath, replySpy });
|
||||
return await withTempHeartbeatSandbox(fn);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { startHeartbeatRunner } from "./heartbeat-runner.js";
|
||||
|
||||
describe("startHeartbeatRunner", () => {
|
||||
function startDefaultRunner(runOnce: (typeof startHeartbeatRunner)[0]["runOnce"]) {
|
||||
return startHeartbeatRunner({
|
||||
cfg: {
|
||||
agents: { defaults: { heartbeat: { every: "30m" } } },
|
||||
} as OpenClawConfig,
|
||||
runOnce,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
@@ -14,12 +23,7 @@ describe("startHeartbeatRunner", () => {
|
||||
|
||||
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
||||
|
||||
const runner = startHeartbeatRunner({
|
||||
cfg: {
|
||||
agents: { defaults: { heartbeat: { every: "30m" } } },
|
||||
} as OpenClawConfig,
|
||||
runOnce: runSpy,
|
||||
});
|
||||
const runner = startDefaultRunner(runSpy);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
|
||||
|
||||
@@ -69,12 +73,7 @@ describe("startHeartbeatRunner", () => {
|
||||
return { status: "ran", durationMs: 1 };
|
||||
});
|
||||
|
||||
const runner = startHeartbeatRunner({
|
||||
cfg: {
|
||||
agents: { defaults: { heartbeat: { every: "30m" } } },
|
||||
} as OpenClawConfig,
|
||||
runOnce: runSpy,
|
||||
});
|
||||
const runner = startDefaultRunner(runSpy);
|
||||
|
||||
// First heartbeat fires and throws
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
|
||||
@@ -124,12 +123,7 @@ describe("startHeartbeatRunner", () => {
|
||||
|
||||
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
||||
|
||||
const runner = startHeartbeatRunner({
|
||||
cfg: {
|
||||
agents: { defaults: { heartbeat: { every: "30m" } } },
|
||||
} as OpenClawConfig,
|
||||
runOnce: runSpy,
|
||||
});
|
||||
const runner = startDefaultRunner(runSpy);
|
||||
|
||||
runner.stop();
|
||||
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
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 { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { slackPlugin } from "../../extensions/slack/src/channel.js";
|
||||
import { setSlackRuntime } from "../../extensions/slack/src/runtime.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 * as replyModule from "../auto-reply/reply.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
|
||||
|
||||
// Avoid pulling optional runtime deps during isolated runs.
|
||||
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
|
||||
|
||||
beforeEach(() => {
|
||||
const runtime = createPluginRuntime();
|
||||
setSlackRuntime(runtime);
|
||||
setTelegramRuntime(runtime);
|
||||
setWhatsAppRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "slack", plugin: slackPlugin, source: "test" },
|
||||
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
installHeartbeatRunnerTestRuntime({ includeSlack: true });
|
||||
|
||||
describe("runHeartbeatOnce", () => {
|
||||
it("uses the delivery target as sender when lastTo differs", async () => {
|
||||
|
||||
40
src/infra/heartbeat-runner.test-harness.ts
Normal file
40
src/infra/heartbeat-runner.test-harness.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { beforeEach } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import { slackPlugin } from "../../extensions/slack/src/channel.js";
|
||||
import { setSlackRuntime } from "../../extensions/slack/src/runtime.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 { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
const slackChannelPlugin = slackPlugin as unknown as ChannelPlugin;
|
||||
const telegramChannelPlugin = telegramPlugin as unknown as ChannelPlugin;
|
||||
const whatsappChannelPlugin = whatsappPlugin as unknown as ChannelPlugin;
|
||||
|
||||
export function installHeartbeatRunnerTestRuntime(params?: { includeSlack?: boolean }): void {
|
||||
beforeEach(() => {
|
||||
const runtime = createPluginRuntime();
|
||||
setTelegramRuntime(runtime);
|
||||
setWhatsAppRuntime(runtime);
|
||||
if (params?.includeSlack) {
|
||||
setSlackRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "slack", plugin: slackChannelPlugin, source: "test" },
|
||||
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,25 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
|
||||
|
||||
describe("resolveHeartbeatVisibility", () => {
|
||||
function createTelegramAccountHeartbeatConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
heartbeat: {
|
||||
showOk: true,
|
||||
},
|
||||
accounts: {
|
||||
primary: {
|
||||
heartbeat: {
|
||||
showOk: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
it("returns default values when no config is provided", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
|
||||
@@ -136,46 +155,14 @@ describe("resolveHeartbeatVisibility", () => {
|
||||
});
|
||||
|
||||
it("handles missing accountId gracefully", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
heartbeat: {
|
||||
showOk: true,
|
||||
},
|
||||
accounts: {
|
||||
primary: {
|
||||
heartbeat: {
|
||||
showOk: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const cfg = createTelegramAccountHeartbeatConfig();
|
||||
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
|
||||
|
||||
expect(result.showOk).toBe(true);
|
||||
});
|
||||
|
||||
it("handles non-existent account gracefully", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
heartbeat: {
|
||||
showOk: true,
|
||||
},
|
||||
accounts: {
|
||||
primary: {
|
||||
heartbeat: {
|
||||
showOk: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const cfg = createTelegramAccountHeartbeatConfig();
|
||||
const result = resolveHeartbeatVisibility({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
|
||||
@@ -8,6 +8,25 @@ import {
|
||||
} from "./heartbeat-wake.js";
|
||||
|
||||
describe("heartbeat-wake", () => {
|
||||
async function expectRetryAfterDefaultDelay(params: {
|
||||
handler: ReturnType<typeof vi.fn>;
|
||||
initialReason: string;
|
||||
expectedRetryReason: string;
|
||||
}) {
|
||||
setHeartbeatWakeHandler(params.handler);
|
||||
requestHeartbeatNow({ reason: params.initialReason, coalesceMs: 0 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(params.handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(params.handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(params.handler).toHaveBeenCalledTimes(2);
|
||||
expect(params.handler.mock.calls[1]?.[0]).toEqual({ reason: params.expectedRetryReason });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetHeartbeatWakeStateForTests();
|
||||
});
|
||||
@@ -44,19 +63,11 @@ describe("heartbeat-wake", () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" })
|
||||
.mockResolvedValueOnce({ status: "ran", durationMs: 1 });
|
||||
setHeartbeatWakeHandler(handler);
|
||||
|
||||
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler.mock.calls[1]?.[0]).toEqual({ reason: "interval" });
|
||||
await expectRetryAfterDefaultDelay({
|
||||
handler,
|
||||
initialReason: "interval",
|
||||
expectedRetryReason: "interval",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps retry cooldown even when a sooner request arrives", async () => {
|
||||
@@ -87,19 +98,11 @@ describe("heartbeat-wake", () => {
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce({ status: "skipped", reason: "disabled" });
|
||||
setHeartbeatWakeHandler(handler);
|
||||
|
||||
requestHeartbeatNow({ reason: "exec-event", coalesceMs: 0 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler.mock.calls[1]?.[0]).toEqual({ reason: "exec-event" });
|
||||
await expectRetryAfterDefaultDelay({
|
||||
handler,
|
||||
initialReason: "exec-event",
|
||||
expectedRetryReason: "exec-event",
|
||||
});
|
||||
});
|
||||
|
||||
it("stale disposer does not clear a newer handler", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockServerResponse } from "../test-utils/mock-http-response.js";
|
||||
import {
|
||||
installRequestBodyLimitGuard,
|
||||
isRequestBodyLimitError,
|
||||
@@ -52,24 +53,6 @@ function createMockRequest(params: {
|
||||
return req;
|
||||
}
|
||||
|
||||
function createMockResponse(): ServerResponse & { body?: string } {
|
||||
const headers: Record<string, string> = {};
|
||||
const res = {
|
||||
headersSent: false,
|
||||
statusCode: 200,
|
||||
setHeader: (key: string, value: string) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
return res;
|
||||
},
|
||||
end: (body?: string) => {
|
||||
res.headersSent = true;
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
} as unknown as ServerResponse & { body?: string };
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("http body limits", () => {
|
||||
it("reads body within max bytes", async () => {
|
||||
const req = createMockRequest({ chunks: ['{"ok":true}'] });
|
||||
@@ -104,7 +87,7 @@ describe("http body limits", () => {
|
||||
headers: { "content-length": "9999" },
|
||||
emitEnd: false,
|
||||
});
|
||||
const res = createMockResponse();
|
||||
const res = createMockServerResponse();
|
||||
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128 });
|
||||
expect(guard.isTripped()).toBe(true);
|
||||
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
|
||||
@@ -113,7 +96,7 @@ describe("http body limits", () => {
|
||||
|
||||
it("guard rejects streamed oversized body", async () => {
|
||||
const req = createMockRequest({ chunks: ["small", "x".repeat(256)], emitEnd: false });
|
||||
const res = createMockResponse();
|
||||
const res = createMockServerResponse();
|
||||
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128, responseFormat: "text" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(guard.isTripped()).toBe(true);
|
||||
|
||||
@@ -18,6 +18,21 @@ import { getShellPathFromLoginShell, resetShellPathCacheForTests } from "./shell
|
||||
import { listTailnetAddresses } from "./tailnet.js";
|
||||
|
||||
describe("infra runtime", () => {
|
||||
function setupRestartSignalSuite() {
|
||||
beforeEach(() => {
|
||||
__testing.resetSigusr1State();
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
__testing.resetSigusr1State();
|
||||
});
|
||||
}
|
||||
|
||||
describe("ensureBinary", () => {
|
||||
it("passes through when binary exists", async () => {
|
||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||
@@ -69,18 +84,7 @@ describe("infra runtime", () => {
|
||||
});
|
||||
|
||||
describe("restart authorization", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetSigusr1State();
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
__testing.resetSigusr1State();
|
||||
});
|
||||
setupRestartSignalSuite();
|
||||
|
||||
it("authorizes exactly once when scheduled restart emits", async () => {
|
||||
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false);
|
||||
@@ -124,18 +128,7 @@ describe("infra runtime", () => {
|
||||
});
|
||||
|
||||
describe("pre-restart deferral check", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetSigusr1State();
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
__testing.resetSigusr1State();
|
||||
});
|
||||
setupRestartSignalSuite();
|
||||
|
||||
it("emits SIGUSR1 immediately when no deferral check is registered", async () => {
|
||||
const emitSpy = vi.spyOn(process, "emit");
|
||||
|
||||
15
src/infra/map-size.ts
Normal file
15
src/infra/map-size.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function pruneMapToMaxSize<K, V>(map: Map<K, V>, maxSize: number): void {
|
||||
const limit = Math.max(0, Math.floor(maxSize));
|
||||
if (limit <= 0) {
|
||||
map.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
while (map.size > limit) {
|
||||
const oldest = map.keys().next();
|
||||
if (oldest.done) {
|
||||
break;
|
||||
}
|
||||
map.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ const state = vi.hoisted(() => ({
|
||||
|
||||
const abs = (p: string) => path.resolve(p);
|
||||
const fx = (...parts: string[]) => path.join(FIXTURE_BASE, ...parts);
|
||||
const vitestRootWithSep = `${abs(VITEST_FS_BASE)}${path.sep}`;
|
||||
const isFixturePath = (p: string) => {
|
||||
const resolved = abs(p);
|
||||
return resolved === vitestRootWithSep.slice(0, -1) || resolved.startsWith(vitestRootWithSep);
|
||||
};
|
||||
|
||||
function setFile(p: string, content = "") {
|
||||
state.entries.set(abs(p), { kind: "file", content });
|
||||
@@ -21,23 +26,16 @@ function setFile(p: string, 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),
|
||||
isFixturePath(p) ? state.entries.has(abs(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));
|
||||
const entry = state.entries.get(abs(p));
|
||||
if (!entry || entry.kind !== "file") {
|
||||
throw new Error(`ENOENT: no such file, open '${p}'`);
|
||||
}
|
||||
@@ -48,7 +46,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return actual.statSync(p as any) as unknown;
|
||||
}
|
||||
const entry = state.entries.get(absInMock(p));
|
||||
const entry = state.entries.get(abs(p));
|
||||
if (!entry) {
|
||||
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
|
||||
}
|
||||
@@ -58,22 +56,13 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
};
|
||||
},
|
||||
realpathSync: (p: string) =>
|
||||
isFixturePath(p)
|
||||
? (state.realpaths.get(absInMock(p)) ?? absInMock(p))
|
||||
: actual.realpathSync(p),
|
||||
isFixturePath(p) ? (state.realpaths.get(abs(p)) ?? abs(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) => {
|
||||
@@ -81,7 +70,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
// 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));
|
||||
const entry = state.entries.get(abs(p));
|
||||
if (!entry || entry.kind !== "file") {
|
||||
throw new Error(`ENOENT: no such file, open '${p}'`);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("sendMessage replyToId threading", () => {
|
||||
setRegistry(emptyRegistry);
|
||||
});
|
||||
|
||||
it("passes replyToId through to the outbound adapter", async () => {
|
||||
const setupMattermostCapture = () => {
|
||||
const capturedCtx: Record<string, unknown>[] = [];
|
||||
const plugin = createMattermostLikePlugin({
|
||||
onSendText: (ctx) => {
|
||||
@@ -97,6 +97,11 @@ describe("sendMessage replyToId threading", () => {
|
||||
},
|
||||
});
|
||||
setRegistry(createTestRegistry([{ pluginId: "mattermost", source: "test", plugin }]));
|
||||
return capturedCtx;
|
||||
};
|
||||
|
||||
it("passes replyToId through to the outbound adapter", async () => {
|
||||
const capturedCtx = setupMattermostCapture();
|
||||
|
||||
await sendMessage({
|
||||
cfg: {},
|
||||
@@ -111,13 +116,7 @@ describe("sendMessage replyToId threading", () => {
|
||||
});
|
||||
|
||||
it("passes threadId through to the outbound adapter", async () => {
|
||||
const capturedCtx: Record<string, unknown>[] = [];
|
||||
const plugin = createMattermostLikePlugin({
|
||||
onSendText: (ctx) => {
|
||||
capturedCtx.push(ctx);
|
||||
},
|
||||
});
|
||||
setRegistry(createTestRegistry([{ pluginId: "mattermost", source: "test", plugin }]));
|
||||
const capturedCtx = setupMattermostCapture();
|
||||
|
||||
await sendMessage({
|
||||
cfg: {},
|
||||
|
||||
@@ -7,12 +7,22 @@ const makeResponse = (status: number, body: unknown): Response => {
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const toRequestUrl = (input: Parameters<typeof fetch>[0]): string =>
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const createAntigravityFetch = (
|
||||
handler: (url: string, init?: Parameters<typeof fetch>[1]) => Promise<Response> | Response,
|
||||
) =>
|
||||
vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input, init) =>
|
||||
handler(toRequestUrl(input), init),
|
||||
);
|
||||
|
||||
const getRequestBody = (init?: Parameters<typeof fetch>[1]) =>
|
||||
typeof init?.body === "string" ? init.body : undefined;
|
||||
|
||||
describe("fetchAntigravityUsage", () => {
|
||||
it("returns 3 windows when both endpoints succeed", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 750,
|
||||
@@ -69,10 +79,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("returns Credits only when loadCodeAssist succeeds but fetchAvailableModels fails", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 250,
|
||||
@@ -103,10 +110,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("returns model IDs when fetchAvailableModels succeeds but loadCodeAssist fails", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Internal server error");
|
||||
}
|
||||
@@ -144,27 +148,22 @@ describe("fetchAntigravityUsage", () => {
|
||||
|
||||
it("uses cloudaicompanionProject string as project id", async () => {
|
||||
let capturedBody: string | undefined;
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
|
||||
async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const mockFetch = createAntigravityFetch(async (url, init) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 900,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
cloudaicompanionProject: "projects/alpha",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 900,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
cloudaicompanionProject: "projects/alpha",
|
||||
});
|
||||
}
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
capturedBody = getRequestBody(init);
|
||||
return makeResponse(200, { models: {} });
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
capturedBody = init?.body?.toString();
|
||||
return makeResponse(200, { models: {} });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
},
|
||||
);
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
@@ -173,27 +172,22 @@ describe("fetchAntigravityUsage", () => {
|
||||
|
||||
it("uses cloudaicompanionProject object id when present", async () => {
|
||||
let capturedBody: string | undefined;
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
|
||||
async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const mockFetch = createAntigravityFetch(async (url, init) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 900,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
cloudaicompanionProject: { id: "projects/beta" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 900,
|
||||
planInfo: { monthlyPromptCredits: 1000 },
|
||||
cloudaicompanionProject: { id: "projects/beta" },
|
||||
});
|
||||
}
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
capturedBody = getRequestBody(init);
|
||||
return makeResponse(200, { models: {} });
|
||||
}
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
capturedBody = init?.body?.toString();
|
||||
return makeResponse(200, { models: {} });
|
||||
}
|
||||
|
||||
return makeResponse(404, "not found");
|
||||
},
|
||||
);
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await fetchAntigravityUsage("token-123", 5000, mockFetch);
|
||||
|
||||
@@ -201,10 +195,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("returns error snapshot when both endpoints fail", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(403, { error: { message: "Access denied" } });
|
||||
}
|
||||
@@ -226,10 +217,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("returns Token expired when fetchAvailableModels returns 401 and no windows", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Boom");
|
||||
}
|
||||
@@ -248,10 +236,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("extracts plan info from currentTier.name", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 500,
|
||||
@@ -274,10 +259,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("falls back to planType when currentTier.name is missing", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 500,
|
||||
@@ -300,10 +282,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
|
||||
it("includes reset times in model windows", async () => {
|
||||
const resetTime = "2026-01-10T12:00:00Z";
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
@@ -328,10 +307,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("parses string numbers correctly", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: "600",
|
||||
@@ -364,10 +340,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("skips internal models", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 500,
|
||||
@@ -395,10 +368,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("sorts models by usage and shows individual model IDs", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
@@ -440,10 +410,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("returns Token expired error on 401 from loadCodeAssist", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(401, { error: { message: "Unauthorized" } });
|
||||
}
|
||||
@@ -459,10 +426,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("handles empty models array gracefully", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, {
|
||||
availablePromptCredits: 800,
|
||||
@@ -486,10 +450,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("handles missing credits fields gracefully", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(200, { planType: "Free" });
|
||||
}
|
||||
@@ -517,10 +478,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("handles invalid reset time gracefully", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return makeResponse(500, "Error");
|
||||
}
|
||||
@@ -546,10 +504,7 @@ describe("fetchAntigravityUsage", () => {
|
||||
});
|
||||
|
||||
it("handles network errors with graceful degradation", async () => {
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
const mockFetch = createAntigravityFetch(async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
throw new Error("Network failure");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,48 @@ import {
|
||||
type UsageSummary,
|
||||
} from "./provider-usage.js";
|
||||
|
||||
const minimaxRemainsEndpoint = "api.minimaxi.com/v1/api/openplatform/coding_plan/remains";
|
||||
|
||||
function makeResponse(status: number, body: unknown): Response {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
}
|
||||
|
||||
function toRequestUrl(input: Parameters<typeof fetch>[0]): string {
|
||||
return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
}
|
||||
|
||||
function createMinimaxOnlyFetch(payload: unknown) {
|
||||
return vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
if (toRequestUrl(input).includes(minimaxRemainsEndpoint)) {
|
||||
return makeResponse(200, payload);
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
}
|
||||
|
||||
async function expectMinimaxUsage(
|
||||
payload: unknown,
|
||||
expectedUsedPercent: number,
|
||||
expectedPlan?: string,
|
||||
) {
|
||||
const mockFetch = createMinimaxOnlyFetch(payload);
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
auth: [{ provider: "minimax", token: "token-1b" }],
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const minimax = summary.providers.find((p) => p.provider === "minimax");
|
||||
expect(minimax?.windows[0]?.usedPercent).toBe(expectedUsedPercent);
|
||||
if (expectedPlan !== undefined) {
|
||||
expect(minimax?.plan).toBe(expectedPlan);
|
||||
}
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("provider usage formatting", () => {
|
||||
it("returns null when no usage is available", () => {
|
||||
const summary: UsageSummary = { updatedAt: 0, providers: [] };
|
||||
@@ -71,15 +113,8 @@ describe("provider usage formatting", () => {
|
||||
|
||||
describe("provider usage loading", () => {
|
||||
it("loads usage snapshots with injected auth", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const url = toRequestUrl(input);
|
||||
if (url.includes("api.anthropic.com")) {
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
@@ -103,7 +138,7 @@ describe("provider usage loading", () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
|
||||
if (url.includes(minimaxRemainsEndpoint)) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
@@ -138,115 +173,55 @@ describe("provider usage loading", () => {
|
||||
});
|
||||
|
||||
it("handles nested MiniMax usage payloads", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
plan_name: "Coding Plan",
|
||||
usage: {
|
||||
prompt_limit: 200,
|
||||
prompt_remain: 50,
|
||||
next_reset_time: "2026-01-07T05:00:00Z",
|
||||
},
|
||||
await expectMinimaxUsage(
|
||||
{
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
plan_name: "Coding Plan",
|
||||
usage: {
|
||||
prompt_limit: 200,
|
||||
prompt_remain: 50,
|
||||
next_reset_time: "2026-01-07T05:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
auth: [{ provider: "minimax", token: "token-1b" }],
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const minimax = summary.providers.find((p) => p.provider === "minimax");
|
||||
expect(minimax?.windows[0]?.usedPercent).toBe(75);
|
||||
expect(minimax?.plan).toBe("Coding Plan");
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
75,
|
||||
"Coding Plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers MiniMax count-based usage when percent looks inverted", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
prompt_limit: 200,
|
||||
prompt_remain: 150,
|
||||
usage_percent: 75,
|
||||
next_reset_time: "2026-01-07T05:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
auth: [{ provider: "minimax", token: "token-1b" }],
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const minimax = summary.providers.find((p) => p.provider === "minimax");
|
||||
expect(minimax?.windows[0]?.usedPercent).toBe(25);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
await expectMinimaxUsage(
|
||||
{
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
prompt_limit: 200,
|
||||
prompt_remain: 150,
|
||||
usage_percent: 75,
|
||||
next_reset_time: "2026-01-07T05:00:00Z",
|
||||
},
|
||||
},
|
||||
25,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles MiniMax model_remains usage payloads", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
model_remains: [
|
||||
{
|
||||
start_time: 1736217600,
|
||||
end_time: 1736235600,
|
||||
remains_time: 600,
|
||||
current_interval_total_count: 120,
|
||||
current_interval_usage_count: 30,
|
||||
model_name: "MiniMax-M2.1",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
auth: [{ provider: "minimax", token: "token-1b" }],
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const minimax = summary.providers.find((p) => p.provider === "minimax");
|
||||
expect(minimax?.windows[0]?.usedPercent).toBe(25);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
await expectMinimaxUsage(
|
||||
{
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
model_remains: [
|
||||
{
|
||||
start_time: 1736217600,
|
||||
end_time: 1736235600,
|
||||
remains_time: 600,
|
||||
current_interval_total_count: 120,
|
||||
current_interval_usage_count: 30,
|
||||
model_name: "MiniMax-M2.1",
|
||||
},
|
||||
],
|
||||
},
|
||||
25,
|
||||
);
|
||||
});
|
||||
|
||||
it("discovers Claude usage from token auth profiles", async () => {
|
||||
|
||||
28
src/infra/runtime-status.ts
Normal file
28
src/infra/runtime-status.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
type RuntimeStatusFormatInput = {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
state?: string;
|
||||
details?: string[];
|
||||
};
|
||||
|
||||
export function formatRuntimeStatusWithDetails({
|
||||
status,
|
||||
pid,
|
||||
state,
|
||||
details = [],
|
||||
}: RuntimeStatusFormatInput): string {
|
||||
const runtimeStatus = status ?? "unknown";
|
||||
const fullDetails: string[] = [];
|
||||
if (pid) {
|
||||
fullDetails.push(`pid ${pid}`);
|
||||
}
|
||||
if (state && state.toLowerCase() !== runtimeStatus) {
|
||||
fullDetails.push(`state ${state}`);
|
||||
}
|
||||
for (const detail of details) {
|
||||
if (detail) {
|
||||
fullDetails.push(detail);
|
||||
}
|
||||
}
|
||||
return fullDetails.length > 0 ? `${runtimeStatus} (${fullDetails.join(", ")})` : runtimeStatus;
|
||||
}
|
||||
@@ -2,20 +2,25 @@ import { spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockSpawnChild = EventEmitter & {
|
||||
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
|
||||
kill?: (signal?: string) => void;
|
||||
};
|
||||
|
||||
function createMockSpawnChild() {
|
||||
const child = new EventEmitter() as MockSpawnChild;
|
||||
const stdout = new EventEmitter() as MockSpawnChild["stdout"];
|
||||
stdout!.setEncoding = vi.fn();
|
||||
child.stdout = stdout;
|
||||
child.kill = vi.fn();
|
||||
return { child, stdout };
|
||||
}
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
const spawn = vi.fn(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
|
||||
kill?: (signal?: string) => void;
|
||||
};
|
||||
const stdout = new EventEmitter() as EventEmitter & {
|
||||
setEncoding?: (enc: string) => void;
|
||||
};
|
||||
stdout.setEncoding = vi.fn();
|
||||
child.stdout = stdout;
|
||||
child.kill = vi.fn();
|
||||
const { child, stdout } = createMockSpawnChild();
|
||||
process.nextTick(() => {
|
||||
stdout.emit(
|
||||
stdout?.emit(
|
||||
"data",
|
||||
[
|
||||
"user steipete",
|
||||
@@ -60,16 +65,7 @@ describe("ssh-config", () => {
|
||||
|
||||
it("returns null when ssh -G fails", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
|
||||
kill?: (signal?: string) => void;
|
||||
};
|
||||
const stdout = new EventEmitter() as EventEmitter & {
|
||||
setEncoding?: (enc: string) => void;
|
||||
};
|
||||
stdout.setEncoding = vi.fn();
|
||||
child.stdout = stdout;
|
||||
child.kill = vi.fn();
|
||||
const { child } = createMockSpawnChild();
|
||||
process.nextTick(() => {
|
||||
child.emit("exit", 1);
|
||||
});
|
||||
|
||||
@@ -2,26 +2,39 @@ import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
|
||||
|
||||
function fallbackTmp(uid = 501) {
|
||||
return path.join("/var/fallback", `openclaw-${uid}`);
|
||||
}
|
||||
|
||||
function resolveWithMocks(params: {
|
||||
lstatSync: ReturnType<typeof vi.fn>;
|
||||
accessSync?: ReturnType<typeof vi.fn>;
|
||||
uid?: number;
|
||||
tmpdirPath?: string;
|
||||
}) {
|
||||
const accessSync = params.accessSync ?? vi.fn();
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => params.uid ?? 501);
|
||||
const tmpdir = vi.fn(() => params.tmpdirPath ?? "/var/fallback");
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync,
|
||||
lstatSync: params.lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
return { resolved, accessSync, lstatSync: params.lstatSync, mkdirSync, tmpdir };
|
||||
}
|
||||
|
||||
describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
it("prefers /tmp/openclaw when it already exists and is writable", () => {
|
||||
const accessSync = vi.fn();
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 501,
|
||||
mode: 0o40700,
|
||||
}));
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => 501);
|
||||
const tmpdir = vi.fn(() => "/var/fallback");
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
const { resolved, accessSync, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(lstatSync).toHaveBeenCalledTimes(1);
|
||||
expect(accessSync).toHaveBeenCalledTimes(1);
|
||||
@@ -30,15 +43,11 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
});
|
||||
|
||||
it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => {
|
||||
const accessSync = vi.fn();
|
||||
const lstatSync = vi.fn(() => {
|
||||
const err = new Error("missing") as Error & { code?: string };
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
});
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => 501);
|
||||
const tmpdir = vi.fn(() => "/var/fallback");
|
||||
|
||||
// second lstat call (after mkdir) should succeed
|
||||
lstatSync.mockImplementationOnce(() => {
|
||||
@@ -53,13 +62,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
mode: 0o40700,
|
||||
}));
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
const { resolved, accessSync, mkdirSync, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
|
||||
expect(accessSync).toHaveBeenCalledWith("/tmp", expect.any(Number));
|
||||
@@ -68,26 +71,15 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
});
|
||||
|
||||
it("falls back to os.tmpdir()/openclaw when /tmp/openclaw is not a directory", () => {
|
||||
const accessSync = vi.fn();
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 501,
|
||||
mode: 0o100644,
|
||||
}));
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => 501);
|
||||
const tmpdir = vi.fn(() => "/var/fallback");
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -102,91 +94,53 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
});
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => 501);
|
||||
const tmpdir = vi.fn(() => "/var/fallback");
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
const { resolved, tmpdir } = resolveWithMocks({
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back when /tmp/openclaw is a symlink", () => {
|
||||
const accessSync = vi.fn();
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => true,
|
||||
uid: 501,
|
||||
mode: 0o120777,
|
||||
}));
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => 501);
|
||||
const tmpdir = vi.fn(() => "/var/fallback");
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back when /tmp/openclaw is not owned by the current user", () => {
|
||||
const accessSync = vi.fn();
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 0,
|
||||
mode: 0o40700,
|
||||
}));
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => 501);
|
||||
const tmpdir = vi.fn(() => "/var/fallback");
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back when /tmp/openclaw is group/other writable", () => {
|
||||
const accessSync = vi.fn();
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 501,
|
||||
mode: 0o40777,
|
||||
}));
|
||||
const mkdirSync = vi.fn();
|
||||
const getuid = vi.fn(() => 501);
|
||||
const tmpdir = vi.fn(() => "/var/fallback");
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
getuid,
|
||||
tmpdir,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,13 +105,28 @@ describe("runGatewayUpdate", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("skips git update when worktree is dirty", async () => {
|
||||
async function setupGitCheckout(options?: { packageManager?: string }) {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
const pkg: Record<string, string> = { name: "openclaw", version: "1.0.0" };
|
||||
if (options?.packageManager) {
|
||||
pkg.packageManager = options.packageManager;
|
||||
}
|
||||
await fs.writeFile(path.join(tempDir, "package.json"), JSON.stringify(pkg), "utf-8");
|
||||
}
|
||||
|
||||
async function setupUiIndex() {
|
||||
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
|
||||
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
|
||||
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
|
||||
return uiIndexPath;
|
||||
}
|
||||
|
||||
async function removeControlUiAssets() {
|
||||
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
it("skips git update when worktree is dirty", async () => {
|
||||
await setupGitCheckout();
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
@@ -131,12 +146,7 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
|
||||
it("aborts rebase on failure", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
await setupGitCheckout();
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
@@ -164,15 +174,8 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
|
||||
it("uses stable tag when beta tag is older than release", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
|
||||
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
|
||||
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
|
||||
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
|
||||
await setupUiIndex();
|
||||
const stableTag = "v1.0.1-1";
|
||||
const betaTag = "v1.0.0-beta.2";
|
||||
const { runner, calls } = createRunner({
|
||||
@@ -243,29 +246,18 @@ describe("runGatewayUpdate", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
return { stdout: nodeModules, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === params.expectedInstallCommand) {
|
||||
const { calls, runCommand } = createGlobalInstallHarness({
|
||||
pkgRoot,
|
||||
npmRootOutput: nodeModules,
|
||||
installCommand: params.expectedInstallCommand,
|
||||
onInstall: async () => {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: pkgRoot,
|
||||
@@ -278,6 +270,37 @@ describe("runGatewayUpdate", () => {
|
||||
return { calls, result };
|
||||
}
|
||||
|
||||
const createGlobalInstallHarness = (params: {
|
||||
pkgRoot: string;
|
||||
npmRootOutput?: string;
|
||||
installCommand: string;
|
||||
onInstall?: () => Promise<void>;
|
||||
}) => {
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
if (params.npmRootOutput) {
|
||||
return { stdout: params.npmRootOutput, stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === params.installCommand) {
|
||||
await params.onInstall?.();
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
return { calls, runCommand };
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
title: "updates global npm installs when detected",
|
||||
@@ -364,29 +387,17 @@ describe("runGatewayUpdate", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "bun add -g openclaw@latest") {
|
||||
const { calls, runCommand } = createGlobalInstallHarness({
|
||||
pkgRoot,
|
||||
installCommand: "bun add -g openclaw@latest",
|
||||
onInstall: async () => {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: pkgRoot,
|
||||
@@ -429,12 +440,7 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
|
||||
it("fails with a clear reason when openclaw.mjs is missing", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
|
||||
await fs.rm(path.join(tempDir, "openclaw.mjs"), { force: true });
|
||||
|
||||
const stableTag = "v1.0.1-1";
|
||||
@@ -463,15 +469,8 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
|
||||
it("repairs UI assets when doctor run removes control-ui files", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
|
||||
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
|
||||
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
|
||||
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
|
||||
const uiIndexPath = await setupUiIndex();
|
||||
|
||||
const stableTag = "v1.0.1-1";
|
||||
const { runCommand, calls, doctorKey, getUiBuildCount } = createStableTagRunner({
|
||||
@@ -481,9 +480,7 @@ describe("runGatewayUpdate", () => {
|
||||
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
|
||||
await fs.writeFile(uiIndexPath, `<html>${count}</html>`, "utf-8");
|
||||
},
|
||||
onDoctor: async () => {
|
||||
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
|
||||
},
|
||||
onDoctor: removeControlUiAssets,
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
@@ -500,15 +497,8 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
|
||||
it("fails when UI assets are still missing after post-doctor repair", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
|
||||
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
|
||||
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
|
||||
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
|
||||
const uiIndexPath = await setupUiIndex();
|
||||
|
||||
const stableTag = "v1.0.1-1";
|
||||
const { runCommand } = createStableTagRunner({
|
||||
@@ -520,9 +510,7 @@ describe("runGatewayUpdate", () => {
|
||||
await fs.writeFile(uiIndexPath, "<html>built</html>", "utf-8");
|
||||
}
|
||||
},
|
||||
onDoctor: async () => {
|
||||
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
|
||||
},
|
||||
onDoctor: removeControlUiAssets,
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("update-startup", () => {
|
||||
suiteCase = 0;
|
||||
});
|
||||
|
||||
it("logs update hint for npm installs when newer tag exists", async () => {
|
||||
async function runUpdateCheckAndReadState(channel: "stable" | "beta") {
|
||||
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: "/opt/openclaw",
|
||||
@@ -123,49 +123,35 @@ describe("update-startup", () => {
|
||||
|
||||
const log = { info: vi.fn() };
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: { update: { channel: "stable" } },
|
||||
cfg: { update: { channel } },
|
||||
log,
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
});
|
||||
|
||||
const statePath = path.join(tempDir, "update-check.json");
|
||||
const parsed = JSON.parse(await fs.readFile(statePath, "utf-8")) as {
|
||||
lastNotifiedVersion?: string;
|
||||
lastNotifiedTag?: string;
|
||||
};
|
||||
return { log, parsed };
|
||||
}
|
||||
|
||||
it("logs update hint for npm installs when newer tag exists", async () => {
|
||||
const { log, parsed } = await runUpdateCheckAndReadState("stable");
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("update available (latest): v2.0.0"),
|
||||
);
|
||||
|
||||
const statePath = path.join(tempDir, "update-check.json");
|
||||
const raw = await fs.readFile(statePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { lastNotifiedVersion?: string };
|
||||
expect(parsed.lastNotifiedVersion).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("uses latest when beta tag is older than release", async () => {
|
||||
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: "/opt/openclaw",
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
} satisfies UpdateCheckResult);
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const log = { info: vi.fn() };
|
||||
await runGatewayUpdateCheck({
|
||||
cfg: { update: { channel: "beta" } },
|
||||
log,
|
||||
isNixMode: false,
|
||||
allowInTests: true,
|
||||
});
|
||||
const { log, parsed } = await runUpdateCheckAndReadState("beta");
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("update available (latest): v2.0.0"),
|
||||
);
|
||||
|
||||
const statePath = path.join(tempDir, "update-check.json");
|
||||
const raw = await fs.readFile(statePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { lastNotifiedTag?: string };
|
||||
expect(parsed.lastNotifiedTag).toBe("latest");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user