mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
1474 lines
46 KiB
TypeScript
1474 lines
46 KiB
TypeScript
import type { Mock } from "vitest";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import type { PluginCompatibilityNotice } from "../plugins/status.js";
|
|
import { createCompatibilityNotice } from "../plugins/status.test-helpers.js";
|
|
import { captureEnv } from "../test-utils/env.js";
|
|
|
|
let envSnapshot: ReturnType<typeof captureEnv>;
|
|
|
|
beforeAll(() => {
|
|
envSnapshot = captureEnv(["OPENCLAW_PROFILE"]);
|
|
process.env.OPENCLAW_PROFILE = "isolated";
|
|
});
|
|
|
|
afterAll(() => {
|
|
envSnapshot.restore();
|
|
});
|
|
|
|
function createDefaultSessionStoreEntry() {
|
|
return {
|
|
updatedAt: Date.now() - 60_000,
|
|
verboseLevel: "on",
|
|
thinkingLevel: "low",
|
|
inputTokens: 2_000,
|
|
outputTokens: 3_000,
|
|
cacheRead: 2_000,
|
|
cacheWrite: 1_000,
|
|
totalTokens: 5_000,
|
|
totalTokensFresh: true as boolean,
|
|
contextTokens: 10_000,
|
|
model: "pi:opus",
|
|
sessionId: "abc123",
|
|
systemSent: true,
|
|
};
|
|
}
|
|
|
|
function createUnknownUsageSessionStore() {
|
|
return {
|
|
"+1000": {
|
|
updatedAt: Date.now() - 60_000,
|
|
inputTokens: 2_000,
|
|
outputTokens: 3_000,
|
|
contextTokens: 10_000,
|
|
model: "pi:opus",
|
|
},
|
|
};
|
|
}
|
|
|
|
function createChannelIssueCollector(channel: string) {
|
|
return (accounts: Array<Record<string, unknown>>) =>
|
|
accounts
|
|
.filter((account) => typeof account.lastError === "string" && account.lastError)
|
|
.map((account) => ({
|
|
channel,
|
|
accountId: typeof account.accountId === "string" ? account.accountId : "default",
|
|
message: `Channel error: ${String(account.lastError)}`,
|
|
}));
|
|
}
|
|
|
|
function createErrorChannelPlugin(params: { id: string; label: string; docsPath: string }) {
|
|
return {
|
|
id: params.id,
|
|
meta: {
|
|
id: params.id,
|
|
label: params.label,
|
|
selectionLabel: params.label,
|
|
docsPath: params.docsPath,
|
|
blurb: "mock",
|
|
},
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({}),
|
|
},
|
|
status: {
|
|
collectStatusIssues: createChannelIssueCollector(params.id),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function withUnknownUsageStore(run: () => Promise<void>) {
|
|
mocks.loadSessionStore.mockReturnValue(createUnknownUsageSessionStore());
|
|
await run();
|
|
}
|
|
|
|
function getRuntimeLogs() {
|
|
return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0]));
|
|
}
|
|
|
|
function getRuntimeLog(index: number): string {
|
|
const call = runtimeLogMock.mock.calls[index];
|
|
if (!call) {
|
|
throw new Error(`expected runtime log call ${index}`);
|
|
}
|
|
return String(call[0]);
|
|
}
|
|
|
|
function getJoinedRuntimeLogs() {
|
|
return getRuntimeLogs().join("\n");
|
|
}
|
|
|
|
async function runStatusAndGetLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
|
runtimeLogMock.mockClear();
|
|
await statusCommand(args, runtime as never);
|
|
return getRuntimeLogs();
|
|
}
|
|
|
|
async function runStatusAndGetJoinedLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
|
await runStatusAndGetLogs(args);
|
|
return getJoinedRuntimeLogs();
|
|
}
|
|
|
|
type ProbeGatewayResult = {
|
|
ok: boolean;
|
|
url: string;
|
|
connectLatencyMs: number | null;
|
|
error: string | null;
|
|
connectErrorDetails?: unknown;
|
|
close: { code: number; reason: string } | null;
|
|
health: unknown;
|
|
status: unknown;
|
|
presence: unknown;
|
|
configSnapshot: unknown;
|
|
};
|
|
|
|
function mockProbeGatewayResult(overrides: Partial<ProbeGatewayResult>) {
|
|
mocks.probeGateway.mockReset();
|
|
mocks.probeGateway.mockResolvedValue({
|
|
...createDefaultProbeGatewayResult(),
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function createDefaultProbeGatewayResult(): ProbeGatewayResult {
|
|
return {
|
|
ok: false,
|
|
url: "ws://127.0.0.1:18789",
|
|
connectLatencyMs: null,
|
|
error: "timeout",
|
|
close: null,
|
|
health: null,
|
|
status: null,
|
|
presence: null,
|
|
configSnapshot: null,
|
|
};
|
|
}
|
|
|
|
function createDefaultSecurityAuditResult() {
|
|
return {
|
|
ts: 0,
|
|
summary: { critical: 1, warn: 1, info: 2 },
|
|
findings: [
|
|
{
|
|
checkId: "test.critical",
|
|
severity: "critical",
|
|
title: "Test critical finding",
|
|
detail: "Something is very wrong\nbut on two lines",
|
|
remediation: "Do the thing",
|
|
},
|
|
{
|
|
checkId: "test.warn",
|
|
severity: "warn",
|
|
title: "Test warning finding",
|
|
detail: "Something is maybe wrong",
|
|
},
|
|
{
|
|
checkId: "test.info",
|
|
severity: "info",
|
|
title: "Test info finding",
|
|
detail: "FYI only",
|
|
},
|
|
{
|
|
checkId: "test.info2",
|
|
severity: "info",
|
|
title: "Another info finding",
|
|
detail: "More FYI",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async function createStatusServiceSummary(
|
|
service: ReturnType<(typeof mocks)["resolveGatewayService"]>,
|
|
) {
|
|
const [loaded, runtime, command] = await Promise.all([
|
|
service.isLoaded(),
|
|
service.readRuntime(),
|
|
service.readCommand(),
|
|
]);
|
|
return {
|
|
label: service.label,
|
|
installed: Boolean(command) || runtime?.status === "running",
|
|
loaded,
|
|
managedByOpenClaw: Boolean(command),
|
|
externallyManaged: !command && runtime?.status === "running",
|
|
loadedText: service.loadedText,
|
|
runtime,
|
|
runtimeShort: runtime?.pid ? `pid ${runtime.pid}` : null,
|
|
};
|
|
}
|
|
|
|
function createSessionStatusRows() {
|
|
const agents = (mocks.listGatewayAgentsBasic().agents ?? [
|
|
{ id: "main", name: "Main" },
|
|
]) as Array<{
|
|
id: string;
|
|
}>;
|
|
const byAgent = agents.map((agent: { id: string }) => {
|
|
const path = mocks.resolveStorePath("sessions", { agentId: agent.id });
|
|
const store = mocks.loadSessionStore(path) as Record<
|
|
string,
|
|
ReturnType<typeof createDefaultSessionStoreEntry>
|
|
>;
|
|
const recent = Object.entries(store).map(([key, entry]) => {
|
|
const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : null;
|
|
const total = typeof entry.totalTokens === "number" ? entry.totalTokens : null;
|
|
return {
|
|
agentId: agent.id,
|
|
key,
|
|
kind: key.startsWith("+") ? ("direct" as const) : ("unknown" as const),
|
|
sessionId: entry.sessionId,
|
|
updatedAt: entry.updatedAt ?? null,
|
|
age: typeof entry.updatedAt === "number" ? Math.max(0, Date.now() - entry.updatedAt) : null,
|
|
thinkingLevel: entry.thinkingLevel,
|
|
verboseLevel: entry.verboseLevel,
|
|
inputTokens: entry.inputTokens,
|
|
outputTokens: entry.outputTokens,
|
|
totalTokens: total,
|
|
totalTokensFresh: typeof entry.totalTokens === "number" ? entry.totalTokensFresh : false,
|
|
cacheRead: entry.cacheRead,
|
|
cacheWrite: entry.cacheWrite,
|
|
remainingTokens:
|
|
total !== null && contextTokens !== null ? Math.max(0, contextTokens - total) : null,
|
|
percentUsed:
|
|
total !== null && contextTokens ? Math.round((total / contextTokens) * 100) : null,
|
|
model: typeof entry.model === "string" ? entry.model : null,
|
|
contextTokens,
|
|
flags: [
|
|
...(entry.verboseLevel ? [`verbose:${entry.verboseLevel}`] : []),
|
|
...(entry.thinkingLevel ? [`think:${entry.thinkingLevel}`] : []),
|
|
],
|
|
};
|
|
});
|
|
return { agentId: agent.id, path, count: recent.length, recent };
|
|
});
|
|
const recent = byAgent.flatMap((entry) => entry.recent);
|
|
return {
|
|
paths: byAgent.map((entry) => entry.path),
|
|
count: recent.length,
|
|
defaults: {
|
|
model: recent[0]?.model ?? "pi:opus",
|
|
contextTokens: recent[0]?.contextTokens ?? 10_000,
|
|
},
|
|
recent,
|
|
byAgent,
|
|
};
|
|
}
|
|
|
|
async function createMockStatusScanResult(params: { includePluginCompatibility?: boolean } = {}) {
|
|
const cfg = mocks.loadConfig();
|
|
const gatewayProbe = await mocks.probeGateway();
|
|
const gatewayReachable = gatewayProbe.ok === true;
|
|
const gatewayAuthWarning =
|
|
cfg.gateway?.auth?.token && typeof cfg.gateway.auth.token === "object"
|
|
? "gateway.auth.token unavailable"
|
|
: undefined;
|
|
const agentStatus = {
|
|
...mocks.listGatewayAgentsBasic(),
|
|
bootstrapPendingCount: 0,
|
|
totalSessions: 1,
|
|
agents: mocks
|
|
.listGatewayAgentsBasic()
|
|
.agents.map((agent: { id: string; name?: string }) =>
|
|
Object.assign({}, agent, { bootstrapPending: false, activeSessions: 1 }),
|
|
),
|
|
};
|
|
const sessions = createSessionStatusRows();
|
|
const channelIssues = gatewayReachable
|
|
? [
|
|
{
|
|
channel: "signal",
|
|
accountId: "default",
|
|
message: "gateway: signal-cli unreachable",
|
|
},
|
|
{
|
|
channel: "imessage",
|
|
accountId: "default",
|
|
message: "gateway: imessage permission denied",
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
channel: "signal",
|
|
accountId: "default",
|
|
message: "Channel error: signal-cli unreachable",
|
|
},
|
|
{
|
|
channel: "imessage",
|
|
accountId: "default",
|
|
message: "Channel error: imessage permission denied",
|
|
},
|
|
];
|
|
const pluginCompatibility =
|
|
params.includePluginCompatibility === false ? [] : mocks.buildPluginCompatibilityNotices();
|
|
return {
|
|
cfg,
|
|
sourceConfig: cfg,
|
|
secretDiagnostics: gatewayAuthWarning ? ["gateway.auth.token unavailable"] : [],
|
|
osSummary: {
|
|
platform: "darwin",
|
|
arch: "arm64",
|
|
release: "23.0.0",
|
|
label: "macos 14.0 (arm64)",
|
|
},
|
|
tailscaleMode: "off",
|
|
tailscaleDns: null,
|
|
tailscaleHttpsUrl: null,
|
|
update: {
|
|
root: "/tmp/openclaw",
|
|
installKind: "git",
|
|
packageManager: "pnpm",
|
|
git: {
|
|
root: "/tmp/openclaw",
|
|
branch: "main",
|
|
upstream: "origin/main",
|
|
dirty: false,
|
|
ahead: 0,
|
|
behind: 0,
|
|
fetchOk: true,
|
|
},
|
|
deps: {
|
|
manager: "pnpm",
|
|
status: "ok",
|
|
lockfilePath: "/tmp/openclaw/pnpm-lock.yaml",
|
|
markerPath: "/tmp/openclaw/node_modules/.modules.yaml",
|
|
},
|
|
registry: { latestVersion: "0.0.0" },
|
|
},
|
|
gatewayConnection: { url: "ws://127.0.0.1:18789" },
|
|
remoteUrlMissing: false,
|
|
gatewayMode: "local" as const,
|
|
gatewayProbeAuth: process.env.OPENCLAW_GATEWAY_TOKEN
|
|
? { token: process.env.OPENCLAW_GATEWAY_TOKEN }
|
|
: {},
|
|
gatewayProbeAuthWarning: gatewayAuthWarning,
|
|
gatewayProbe,
|
|
gatewayReachable,
|
|
gatewaySelf: gatewayProbe.presence ? { host: "gateway", ip: "127.0.0.1" } : null,
|
|
channelIssues,
|
|
agentStatus,
|
|
channels: {
|
|
rows: [
|
|
{ id: "whatsapp", label: "WhatsApp", enabled: true, state: "ok", detail: "linked" },
|
|
{ id: "signal", label: "Signal", enabled: true, state: "warn", detail: "gateway warning" },
|
|
{
|
|
id: "imessage",
|
|
label: "iMessage",
|
|
enabled: true,
|
|
state: "warn",
|
|
detail: "gateway warning",
|
|
},
|
|
],
|
|
details: [],
|
|
},
|
|
summary: {
|
|
runtimeVersion: null,
|
|
heartbeat: { defaultAgentId: "main", agents: [] },
|
|
channelSummary: [],
|
|
queuedSystemEvents: [],
|
|
tasks: mocks.getInspectableTaskRegistrySummary(),
|
|
taskAudit: mocks.getInspectableTaskAuditSummary(),
|
|
sessions,
|
|
},
|
|
memory: null,
|
|
memoryPlugin: { enabled: true, slot: "memory-core" },
|
|
pluginCompatibility,
|
|
};
|
|
}
|
|
|
|
async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>): Promise<T> {
|
|
const prevValue = process.env[key];
|
|
process.env[key] = value;
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
if (prevValue === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = prevValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
hasPotentialConfiguredChannels: vi.fn(() => true),
|
|
loadConfig: vi.fn().mockReturnValue({ session: {} }),
|
|
loadSessionStore: vi.fn().mockReturnValue({
|
|
"+1000": createDefaultSessionStoreEntry(),
|
|
}),
|
|
resolveMainSessionKey: vi.fn().mockReturnValue("agent:main:main"),
|
|
resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"),
|
|
loadNodeHostConfig: vi.fn().mockResolvedValue(null),
|
|
webAuthExists: vi.fn().mockResolvedValue(true),
|
|
getWebAuthAgeMs: vi.fn().mockReturnValue(5000),
|
|
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
|
logWebSelfId: vi.fn(),
|
|
probeGateway: vi.fn().mockResolvedValue({
|
|
...createDefaultProbeGatewayResult(),
|
|
}),
|
|
callGateway: vi.fn().mockResolvedValue({}),
|
|
listGatewayAgentsBasic: vi.fn().mockReturnValue({
|
|
defaultId: "main",
|
|
mainKey: "agent:main:main",
|
|
scope: "per-sender",
|
|
agents: [{ id: "main", name: "Main" }],
|
|
}),
|
|
runSecurityAudit: vi.fn().mockResolvedValue(createDefaultSecurityAuditResult()),
|
|
buildPluginCompatibilityNotices: vi.fn((): PluginCompatibilityNotice[] => []),
|
|
getInspectableTaskRegistrySummary: vi.fn().mockReturnValue({
|
|
total: 0,
|
|
active: 0,
|
|
terminal: 0,
|
|
failures: 0,
|
|
byStatus: {
|
|
queued: 0,
|
|
running: 0,
|
|
succeeded: 0,
|
|
failed: 0,
|
|
timed_out: 0,
|
|
cancelled: 0,
|
|
lost: 0,
|
|
},
|
|
byRuntime: {
|
|
subagent: 0,
|
|
acp: 0,
|
|
cli: 0,
|
|
cron: 0,
|
|
},
|
|
}),
|
|
getInspectableTaskAuditSummary: vi.fn().mockReturnValue({
|
|
total: 0,
|
|
warnings: 0,
|
|
errors: 0,
|
|
byCode: {
|
|
stale_queued: 0,
|
|
stale_running: 0,
|
|
lost: 0,
|
|
delivery_failed: 0,
|
|
missing_cleanup: 0,
|
|
inconsistent_timestamps: 0,
|
|
},
|
|
}),
|
|
resolveGatewayService: vi.fn().mockReturnValue({
|
|
label: "LaunchAgent",
|
|
loadedText: "loaded",
|
|
notLoadedText: "not loaded",
|
|
stage: async () => {},
|
|
install: async () => {},
|
|
uninstall: async () => {},
|
|
stop: async () => {},
|
|
restart: async () => ({ outcome: "completed" as const }),
|
|
isLoaded: async () => true,
|
|
readRuntime: async () => ({ status: "running", pid: 1234 }),
|
|
readCommand: async () => ({
|
|
programArguments: ["node", "dist/entry.js", "gateway"],
|
|
sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist",
|
|
}),
|
|
}),
|
|
resolveNodeService: vi.fn().mockReturnValue({
|
|
label: "LaunchAgent",
|
|
loadedText: "loaded",
|
|
notLoadedText: "not loaded",
|
|
stage: async () => {},
|
|
install: async () => {},
|
|
uninstall: async () => {},
|
|
stop: async () => {},
|
|
restart: async () => ({ outcome: "completed" as const }),
|
|
isLoaded: async () => true,
|
|
readRuntime: async () => ({ status: "running", pid: 4321 }),
|
|
readCommand: async () => ({
|
|
programArguments: ["node", "dist/entry.js", "node-host"],
|
|
sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.node.plist",
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../channels/config-presence.js", () => ({
|
|
hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels,
|
|
hasMeaningfulChannelConfig: (entry: unknown) =>
|
|
Boolean(
|
|
entry && typeof entry === "object" && Object.keys(entry as Record<string, unknown>).length,
|
|
),
|
|
listPotentialConfiguredChannelIds: (cfg: { channels?: Record<string, unknown> }) =>
|
|
Object.keys(cfg.channels ?? {}).filter((key) => key !== "defaults" && key !== "modelByChannel"),
|
|
listPotentialConfiguredChannelPresenceSignals: (cfg: { channels?: Record<string, unknown> }) =>
|
|
Object.keys(cfg.channels ?? {})
|
|
.filter((key) => key !== "defaults" && key !== "modelByChannel")
|
|
.map((channelId) => ({ channelId, source: "config" })),
|
|
}));
|
|
|
|
vi.mock("../plugins/memory-runtime.js", () => ({
|
|
getActiveMemorySearchManager: vi.fn(async ({ agentId }: { agentId: string }) => ({
|
|
manager: {
|
|
probeVectorAvailability: vi.fn(async () => true),
|
|
status: () => ({
|
|
files: 2,
|
|
chunks: 3,
|
|
dirty: false,
|
|
workspaceDir: "/tmp/openclaw",
|
|
dbPath: "/tmp/memory.sqlite",
|
|
provider: "openai",
|
|
model: "text-embedding-3-small",
|
|
requestedProvider: "openai",
|
|
sources: ["memory"],
|
|
sourceCounts: [{ source: "memory", files: 2, chunks: 3 }],
|
|
cache: { enabled: true, entries: 10, maxEntries: 500 },
|
|
fts: { enabled: true, available: true },
|
|
vector: {
|
|
enabled: true,
|
|
available: true,
|
|
extensionPath: "/opt/vec0.dylib",
|
|
dims: 1024,
|
|
},
|
|
}),
|
|
close: vi.fn(async () => {}),
|
|
__agentId: agentId,
|
|
},
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../config/sessions/main-session.js", () => ({
|
|
resolveMainSessionKey: mocks.resolveMainSessionKey,
|
|
}));
|
|
vi.mock("../config/sessions/paths.js", () => ({
|
|
resolveStorePath: mocks.resolveStorePath,
|
|
}));
|
|
vi.mock("../config/sessions/store-read.js", () => ({
|
|
readSessionStoreReadOnly: mocks.loadSessionStore,
|
|
}));
|
|
vi.mock("../config/sessions/types.js", () => ({
|
|
resolveSessionTotalTokens: vi.fn((entry?: { totalTokens?: number }) =>
|
|
typeof entry?.totalTokens === "number" ? entry.totalTokens : undefined,
|
|
),
|
|
resolveFreshSessionTotalTokens: vi.fn(
|
|
(entry?: { totalTokens?: number; totalTokensFresh?: boolean }) =>
|
|
typeof entry?.totalTokens === "number" && entry?.totalTokensFresh !== false
|
|
? entry.totalTokens
|
|
: undefined,
|
|
),
|
|
}));
|
|
vi.mock("../channels/plugins/index.js", () => ({
|
|
listChannelPlugins: () => {
|
|
const plugins = [
|
|
{
|
|
id: "whatsapp",
|
|
meta: {
|
|
id: "whatsapp",
|
|
label: "WhatsApp",
|
|
selectionLabel: "WhatsApp",
|
|
docsPath: "/platforms/whatsapp",
|
|
blurb: "mock",
|
|
},
|
|
config: {
|
|
hasPersistentAuth: () => true,
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({}),
|
|
},
|
|
status: {
|
|
buildChannelSummary: async () => ({ linked: true, authAgeMs: 5000 }),
|
|
},
|
|
},
|
|
{
|
|
...createErrorChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/platforms/signal",
|
|
}),
|
|
},
|
|
{
|
|
...createErrorChannelPlugin({
|
|
id: "imessage",
|
|
label: "iMessage",
|
|
docsPath: "/platforms/mac",
|
|
}),
|
|
},
|
|
] as const;
|
|
return plugins as unknown;
|
|
},
|
|
getChannelPlugin: (channelId: string) =>
|
|
[
|
|
{
|
|
id: "whatsapp",
|
|
meta: {
|
|
id: "whatsapp",
|
|
label: "WhatsApp",
|
|
selectionLabel: "WhatsApp",
|
|
docsPath: "/platforms/whatsapp",
|
|
blurb: "mock",
|
|
},
|
|
config: {
|
|
hasPersistentAuth: () => true,
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({}),
|
|
},
|
|
status: {
|
|
buildChannelSummary: async () => ({ linked: true, authAgeMs: 5000 }),
|
|
},
|
|
},
|
|
{
|
|
...createErrorChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/platforms/signal",
|
|
}),
|
|
},
|
|
{
|
|
...createErrorChannelPlugin({
|
|
id: "imessage",
|
|
label: "iMessage",
|
|
docsPath: "/platforms/mac",
|
|
}),
|
|
},
|
|
].find((plugin) => plugin.id === channelId) as unknown,
|
|
}));
|
|
vi.mock("../plugins/runtime/runtime-web-channel-plugin.js", () => ({
|
|
webAuthExists: mocks.webAuthExists,
|
|
getWebAuthAgeMs: mocks.getWebAuthAgeMs,
|
|
readWebSelfId: mocks.readWebSelfId,
|
|
logWebSelfId: mocks.logWebSelfId,
|
|
}));
|
|
vi.mock("../gateway/probe.js", () => ({
|
|
probeGateway: mocks.probeGateway,
|
|
}));
|
|
vi.mock("../gateway/call.js", () => ({
|
|
callGateway: mocks.callGateway,
|
|
buildGatewayConnectionDetails: vi.fn(() => ({
|
|
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
|
|
})),
|
|
resolveGatewayCredentialsWithSecretInputs: vi.fn(
|
|
async (params: {
|
|
config?: {
|
|
gateway?: {
|
|
auth?: {
|
|
token?: unknown;
|
|
};
|
|
};
|
|
};
|
|
}) => {
|
|
const token = params.config?.gateway?.auth?.token;
|
|
if (token && typeof token === "object" && "source" in token) {
|
|
throw Object.assign(new Error("gateway.auth.token unavailable"), {
|
|
name: "GatewaySecretRefUnavailableError",
|
|
path: "gateway.auth.token",
|
|
});
|
|
}
|
|
const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
|
|
return envToken ? { token: envToken } : {};
|
|
},
|
|
),
|
|
}));
|
|
vi.mock("../gateway/agent-list.js", () => ({
|
|
listGatewayAgentsBasic: mocks.listGatewayAgentsBasic,
|
|
}));
|
|
vi.mock("../infra/openclaw-root.js", () => ({
|
|
resolveOpenClawPackageRoot: vi.fn().mockResolvedValue("/tmp/openclaw"),
|
|
resolveOpenClawPackageRootSync: vi.fn(() => "/tmp/openclaw"),
|
|
}));
|
|
vi.mock("../infra/os-summary.js", () => ({
|
|
resolveOsSummary: () => ({
|
|
platform: "darwin",
|
|
arch: "arm64",
|
|
release: "23.0.0",
|
|
label: "macos 14.0 (arm64)",
|
|
}),
|
|
}));
|
|
vi.mock("../infra/update-check.js", () => ({
|
|
checkUpdateStatus: vi.fn().mockResolvedValue({
|
|
root: "/tmp/openclaw",
|
|
installKind: "git",
|
|
packageManager: "pnpm",
|
|
git: {
|
|
root: "/tmp/openclaw",
|
|
branch: "main",
|
|
upstream: "origin/main",
|
|
dirty: false,
|
|
ahead: 0,
|
|
behind: 0,
|
|
fetchOk: true,
|
|
},
|
|
deps: {
|
|
manager: "pnpm",
|
|
status: "ok",
|
|
lockfilePath: "/tmp/openclaw/pnpm-lock.yaml",
|
|
markerPath: "/tmp/openclaw/node_modules/.modules.yaml",
|
|
},
|
|
registry: { latestVersion: "0.0.0" },
|
|
}),
|
|
formatGitInstallLabel: vi.fn(() => "main · @ deadbeef"),
|
|
compareSemverStrings: vi.fn(() => 0),
|
|
}));
|
|
vi.mock("../config/config.js", () => ({
|
|
getRuntimeConfig: mocks.loadConfig,
|
|
loadConfig: mocks.loadConfig,
|
|
readBestEffortConfig: vi.fn(async () => mocks.loadConfig()),
|
|
resolveGatewayPort: vi.fn(() => 18789),
|
|
}));
|
|
vi.mock("../daemon/service.js", () => ({
|
|
resolveGatewayService: mocks.resolveGatewayService,
|
|
}));
|
|
vi.mock("../daemon/node-service.js", () => ({
|
|
resolveNodeService: mocks.resolveNodeService,
|
|
}));
|
|
vi.mock("../node-host/config.js", () => ({
|
|
loadNodeHostConfig: mocks.loadNodeHostConfig,
|
|
}));
|
|
vi.mock("../tasks/task-registry.maintenance.js", () => ({
|
|
getInspectableTaskRegistrySummary: mocks.getInspectableTaskRegistrySummary,
|
|
getInspectableTaskAuditSummary: mocks.getInspectableTaskAuditSummary,
|
|
}));
|
|
vi.mock("../security/audit.js", () => ({
|
|
runSecurityAudit: mocks.runSecurityAudit,
|
|
}));
|
|
vi.mock("../plugins/status.js", () => ({
|
|
buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices,
|
|
summarizePluginCompatibility: (warnings: PluginCompatibilityNotice[]) => ({
|
|
noticeCount: warnings.length,
|
|
pluginCount: new Set(warnings.map((warning) => warning.pluginId)).size,
|
|
}),
|
|
formatPluginCompatibilityNotice: (notice: PluginCompatibilityNotice) =>
|
|
`${notice.pluginId} ${notice.message}`,
|
|
}));
|
|
|
|
vi.mock("./status.scan.fast-json.js", () => ({
|
|
scanStatusJsonFast: vi.fn(async () =>
|
|
createMockStatusScanResult({ includePluginCompatibility: false }),
|
|
),
|
|
}));
|
|
|
|
vi.mock("./status.scan.js", () => ({
|
|
scanStatus: vi.fn(async () => createMockStatusScanResult()),
|
|
}));
|
|
|
|
vi.mock("./status-runtime-shared.ts", () => ({
|
|
loadStatusProviderUsageModule: vi.fn(async () => ({
|
|
formatUsageReportLines: vi.fn(() => []),
|
|
})),
|
|
resolveStatusGatewayHealth: vi.fn(async () => ({})),
|
|
resolveStatusSecurityAudit: vi.fn(async (input: unknown) =>
|
|
mocks.runSecurityAudit({
|
|
...(typeof input === "object" && input ? input : {}),
|
|
deep: false,
|
|
includeFilesystem: true,
|
|
includeChannelSecurity: true,
|
|
loadPluginSecurityCollectors: false,
|
|
}),
|
|
),
|
|
resolveStatusUsageSummary: vi.fn(async () => undefined),
|
|
resolveStatusRuntimeSnapshot: vi.fn(
|
|
async (params: {
|
|
includeSecurityAudit?: boolean;
|
|
resolveSecurityAudit?: (input: unknown) => Promise<unknown>;
|
|
config: unknown;
|
|
sourceConfig: unknown;
|
|
}) => {
|
|
const securityAudit = params.includeSecurityAudit
|
|
? await (
|
|
params.resolveSecurityAudit ??
|
|
(async (input) =>
|
|
await mocks.runSecurityAudit({
|
|
...(typeof input === "object" && input ? input : {}),
|
|
deep: false,
|
|
includeFilesystem: true,
|
|
includeChannelSecurity: true,
|
|
loadPluginSecurityCollectors: false,
|
|
}))
|
|
)({
|
|
config: params.config,
|
|
sourceConfig: params.sourceConfig,
|
|
})
|
|
: undefined;
|
|
return {
|
|
securityAudit,
|
|
usage: undefined,
|
|
health: undefined,
|
|
lastHeartbeat: null,
|
|
gatewayService: await createStatusServiceSummary(mocks.resolveGatewayService()),
|
|
nodeService: await createStatusServiceSummary(mocks.resolveNodeService()),
|
|
};
|
|
},
|
|
),
|
|
}));
|
|
|
|
import {
|
|
resolveStatusRuntimeSnapshot,
|
|
resolveStatusUsageSummary,
|
|
} from "./status-runtime-shared.ts";
|
|
import { resolvePairingRecoveryContext, statusCommand } from "./status.command.js";
|
|
|
|
const runtime = {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn(),
|
|
};
|
|
|
|
const runtimeLogMock = runtime.log as Mock<(...args: unknown[]) => void>;
|
|
|
|
vi.mock("../channels/chat-meta.js", () => {
|
|
const mockChatChannels = [
|
|
"telegram",
|
|
"whatsapp",
|
|
"discord",
|
|
"irc",
|
|
"googlechat",
|
|
"slack",
|
|
"signal",
|
|
"imessage",
|
|
"line",
|
|
] as const;
|
|
const entries = mockChatChannels.map((id) => ({
|
|
id,
|
|
label: id,
|
|
selectionLabel: id,
|
|
docsPath: `/channels/${id}`,
|
|
blurb: "mock",
|
|
}));
|
|
const byId = Object.fromEntries(entries.map((entry) => [entry.id, entry]));
|
|
return {
|
|
CHAT_CHANNEL_ALIASES: {},
|
|
listChatChannels: () => entries,
|
|
listChatChannelAliases: () => [],
|
|
getChatChannelMeta: (id: (typeof mockChatChannels)[number]) => byId[id],
|
|
normalizeChatChannelId: (raw?: string | null) => {
|
|
const value = raw?.trim().toLowerCase();
|
|
return mockChatChannels.includes(value as (typeof mockChatChannels)[number])
|
|
? (value as (typeof mockChatChannels)[number])
|
|
: null;
|
|
},
|
|
};
|
|
});
|
|
vi.mock("./status.daemon.js", () => ({
|
|
getDaemonStatusSummary: vi.fn(async () => {
|
|
const service = mocks.resolveGatewayService();
|
|
const loaded = await service.isLoaded();
|
|
const runtime = await service.readRuntime();
|
|
const command = await service.readCommand();
|
|
return {
|
|
label: service.label,
|
|
installed: Boolean(command) || runtime?.status === "running",
|
|
loaded,
|
|
managedByOpenClaw: Boolean(command),
|
|
externallyManaged: !command && runtime?.status === "running",
|
|
loadedText: loaded ? service.loadedText : service.notLoadedText,
|
|
runtimeShort: runtime?.pid ? `pid ${runtime.pid}` : null,
|
|
};
|
|
}),
|
|
getNodeDaemonStatusSummary: vi.fn(async () => {
|
|
const service = mocks.resolveNodeService();
|
|
const loaded = await service.isLoaded();
|
|
const runtime = await service.readRuntime();
|
|
const command = await service.readCommand();
|
|
return {
|
|
label: service.label,
|
|
installed: Boolean(command) || runtime?.status === "running",
|
|
loaded,
|
|
managedByOpenClaw: Boolean(command),
|
|
externallyManaged: !command && runtime?.status === "running",
|
|
loadedText: loaded ? service.loadedText : service.notLoadedText,
|
|
runtimeShort: runtime?.pid ? `pid ${runtime.pid}` : null,
|
|
};
|
|
}),
|
|
}));
|
|
|
|
describe("statusCommand", () => {
|
|
afterEach(() => {
|
|
mocks.hasPotentialConfiguredChannels.mockReset();
|
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
|
mocks.loadConfig.mockReset();
|
|
mocks.loadConfig.mockReturnValue({ session: {} });
|
|
mocks.loadSessionStore.mockReset();
|
|
mocks.loadSessionStore.mockReturnValue({
|
|
"+1000": createDefaultSessionStoreEntry(),
|
|
});
|
|
mocks.resolveMainSessionKey.mockReset();
|
|
mocks.resolveMainSessionKey.mockReturnValue("agent:main:main");
|
|
mocks.resolveStorePath.mockReset();
|
|
mocks.resolveStorePath.mockReturnValue("/tmp/sessions.json");
|
|
mocks.loadNodeHostConfig.mockReset();
|
|
mocks.loadNodeHostConfig.mockResolvedValue(null);
|
|
mocks.probeGateway.mockReset();
|
|
mocks.probeGateway.mockResolvedValue(createDefaultProbeGatewayResult());
|
|
mocks.callGateway.mockReset();
|
|
mocks.callGateway.mockResolvedValue({});
|
|
mocks.listGatewayAgentsBasic.mockReset();
|
|
mocks.listGatewayAgentsBasic.mockReturnValue({
|
|
defaultId: "main",
|
|
mainKey: "agent:main:main",
|
|
scope: "per-sender",
|
|
agents: [{ id: "main", name: "Main" }],
|
|
});
|
|
mocks.buildPluginCompatibilityNotices.mockReset();
|
|
mocks.buildPluginCompatibilityNotices.mockReturnValue([]);
|
|
mocks.getInspectableTaskRegistrySummary.mockReset();
|
|
mocks.getInspectableTaskRegistrySummary.mockReturnValue({
|
|
total: 0,
|
|
active: 0,
|
|
terminal: 0,
|
|
failures: 0,
|
|
byStatus: {
|
|
queued: 0,
|
|
running: 0,
|
|
succeeded: 0,
|
|
failed: 0,
|
|
timed_out: 0,
|
|
cancelled: 0,
|
|
lost: 0,
|
|
},
|
|
byRuntime: {
|
|
subagent: 0,
|
|
acp: 0,
|
|
cli: 0,
|
|
cron: 0,
|
|
},
|
|
});
|
|
mocks.getInspectableTaskAuditSummary.mockReset();
|
|
mocks.getInspectableTaskAuditSummary.mockReturnValue({
|
|
total: 0,
|
|
warnings: 0,
|
|
errors: 0,
|
|
byCode: {
|
|
stale_queued: 0,
|
|
stale_running: 0,
|
|
lost: 0,
|
|
delivery_failed: 0,
|
|
missing_cleanup: 0,
|
|
inconsistent_timestamps: 0,
|
|
},
|
|
});
|
|
mocks.runSecurityAudit.mockReset();
|
|
mocks.runSecurityAudit.mockResolvedValue(createDefaultSecurityAuditResult());
|
|
mocks.resolveGatewayService.mockReset();
|
|
mocks.resolveGatewayService.mockReturnValue({
|
|
label: "LaunchAgent",
|
|
loadedText: "loaded",
|
|
notLoadedText: "not loaded",
|
|
stage: async () => {},
|
|
install: async () => {},
|
|
uninstall: async () => {},
|
|
stop: async () => {},
|
|
restart: async () => ({ outcome: "completed" as const }),
|
|
isLoaded: async () => true,
|
|
readRuntime: async () => ({ status: "running", pid: 1234 }),
|
|
readCommand: async () => ({
|
|
programArguments: ["node", "dist/entry.js", "gateway"],
|
|
sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist",
|
|
}),
|
|
});
|
|
mocks.resolveNodeService.mockReset();
|
|
mocks.resolveNodeService.mockReturnValue({
|
|
label: "LaunchAgent",
|
|
loadedText: "loaded",
|
|
notLoadedText: "not loaded",
|
|
stage: async () => {},
|
|
install: async () => {},
|
|
uninstall: async () => {},
|
|
stop: async () => {},
|
|
restart: async () => ({ outcome: "completed" as const }),
|
|
isLoaded: async () => true,
|
|
readRuntime: async () => ({ status: "running", pid: 4321 }),
|
|
readCommand: async () => ({
|
|
programArguments: ["node", "dist/entry.js", "node-host"],
|
|
sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.node.plist",
|
|
}),
|
|
});
|
|
runtimeLogMock.mockClear();
|
|
(runtime.error as Mock<(...args: unknown[]) => void>).mockClear();
|
|
});
|
|
|
|
it("prints JSON and includes security audit only when all is requested", async () => {
|
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(false);
|
|
mocks.buildPluginCompatibilityNotices.mockReturnValue([
|
|
createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }),
|
|
]);
|
|
await statusCommand({ json: true }, runtime as never);
|
|
const payload = JSON.parse(getRuntimeLog(0));
|
|
expect(payload.linkChannel).toBeUndefined();
|
|
expect(payload.memory).toBeNull();
|
|
expect(payload.memoryPlugin.enabled).toBe(true);
|
|
expect(payload.memoryPlugin.slot).toBe("memory-core");
|
|
expect(payload.sessions.count).toBe(1);
|
|
expect(payload.sessions.paths).toContain("/tmp/sessions.json");
|
|
expect(payload.sessions.defaults.model).toBe("pi:opus");
|
|
expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0);
|
|
expect(payload.sessions.recent[0].percentUsed).toBe(50);
|
|
expect(payload.sessions.recent[0].cacheRead).toBe(2_000);
|
|
expect(payload.sessions.recent[0].cacheWrite).toBe(1_000);
|
|
expect(payload.sessions.recent[0].totalTokensFresh).toBe(true);
|
|
expect(payload.sessions.recent[0].remainingTokens).toBe(5000);
|
|
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
|
|
expect(payload.securityAudit).toBeUndefined();
|
|
expect(payload.gatewayService.label).toBe("LaunchAgent");
|
|
expect(payload.nodeService.label).toBe("LaunchAgent");
|
|
expect(payload.pluginCompatibility).toEqual({
|
|
count: 0,
|
|
warnings: [],
|
|
});
|
|
expect(payload.tasks).toEqual(
|
|
expect.objectContaining({
|
|
total: 0,
|
|
active: 0,
|
|
byStatus: expect.objectContaining({ queued: 0, running: 0 }),
|
|
}),
|
|
);
|
|
expect(mocks.runSecurityAudit).not.toHaveBeenCalled();
|
|
|
|
runtimeLogMock.mockClear();
|
|
await statusCommand({ json: true, all: true }, runtime as never);
|
|
|
|
const allPayload = JSON.parse(getRuntimeLog(0));
|
|
expect(allPayload.securityAudit.summary.critical).toBe(1);
|
|
expect(allPayload.securityAudit.summary.warn).toBe(1);
|
|
expect(mocks.runSecurityAudit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
includeFilesystem: true,
|
|
includeChannelSecurity: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("scopes usage resolution to the scanned config", async () => {
|
|
const snapshotMock = resolveStatusRuntimeSnapshot as Mock;
|
|
const usageMock = resolveStatusUsageSummary as Mock;
|
|
snapshotMock.mockClear();
|
|
usageMock.mockClear();
|
|
|
|
await statusCommand({ usage: true, timeoutMs: 1234 }, runtime as never);
|
|
|
|
const params = snapshotMock.mock.calls.at(-1)?.[0] as
|
|
| {
|
|
config: unknown;
|
|
timeoutMs?: number;
|
|
usage?: boolean;
|
|
resolveUsage?: (input: { config: unknown; timeoutMs?: number }) => Promise<unknown>;
|
|
}
|
|
| undefined;
|
|
expect(params).toBeDefined();
|
|
expect(params).toMatchObject({ usage: true, timeoutMs: 1234 });
|
|
if (!params?.resolveUsage) {
|
|
throw new Error("missing status usage resolver");
|
|
}
|
|
await params.resolveUsage({
|
|
timeoutMs: 1234,
|
|
config: params.config,
|
|
});
|
|
expect(usageMock).toHaveBeenCalledWith({
|
|
timeoutMs: 1234,
|
|
config: params?.config,
|
|
});
|
|
});
|
|
|
|
it("keeps default text status off the security audit path", async () => {
|
|
await statusCommand({}, runtime as never);
|
|
|
|
expect(mocks.runSecurityAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes deep mode through to the text status scan", async () => {
|
|
const { scanStatus } = await import("./status.scan.js");
|
|
vi.mocked(scanStatus).mockClear();
|
|
|
|
await statusCommand({ deep: true, timeoutMs: 5000 }, runtime as never);
|
|
|
|
expect(scanStatus).toHaveBeenCalledWith(
|
|
{ json: false, timeoutMs: 5000, all: undefined, deep: true },
|
|
runtime,
|
|
);
|
|
});
|
|
|
|
it("surfaces unknown usage when totalTokens is missing", async () => {
|
|
await withUnknownUsageStore(async () => {
|
|
runtimeLogMock.mockClear();
|
|
await statusCommand({ json: true }, runtime as never);
|
|
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
|
|
expect(payload.sessions.recent[0].totalTokens).toBeNull();
|
|
expect(payload.sessions.recent[0].totalTokensFresh).toBe(false);
|
|
expect(payload.sessions.recent[0].percentUsed).toBeNull();
|
|
expect(payload.sessions.recent[0].remainingTokens).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("surfaces stale usage when totalTokens is preserved but not fresh", async () => {
|
|
mocks.loadSessionStore.mockReturnValue({
|
|
"+1000": {
|
|
updatedAt: Date.now() - 60_000,
|
|
totalTokens: 5_000,
|
|
totalTokensFresh: false,
|
|
contextTokens: 10_000,
|
|
model: "pi:opus",
|
|
},
|
|
});
|
|
runtimeLogMock.mockClear();
|
|
await statusCommand({ json: true }, runtime as never);
|
|
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
|
|
expect(payload.sessions.recent[0].totalTokens).toBe(5000);
|
|
expect(payload.sessions.recent[0].totalTokensFresh).toBe(false);
|
|
expect(payload.sessions.recent[0].percentUsed).toBe(50);
|
|
expect(payload.sessions.recent[0].remainingTokens).toBe(5000);
|
|
});
|
|
|
|
it("prints formatted lines with verbose cache details", async () => {
|
|
mocks.buildPluginCompatibilityNotices.mockReturnValue([
|
|
createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }),
|
|
]);
|
|
const logs = await runStatusAndGetLogs({ verbose: true });
|
|
for (const token of [
|
|
"OpenClaw status",
|
|
"Overview",
|
|
"Security audit",
|
|
"Skipped in fast status",
|
|
"Dashboard",
|
|
"macos 14.0 (arm64)",
|
|
"Memory",
|
|
"Plugin compatibility",
|
|
"Channels",
|
|
"WhatsApp",
|
|
"bootstrap files",
|
|
"Tasks",
|
|
"Sessions",
|
|
"+1000",
|
|
"50%",
|
|
"40% cached",
|
|
"LaunchAgent",
|
|
"FAQ:",
|
|
"Troubleshooting:",
|
|
"Next steps:",
|
|
]) {
|
|
expect(logs).toEqual(expect.arrayContaining([expect.stringContaining(token)]));
|
|
}
|
|
expect(logs).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("legacy-plugin still uses legacy before_agent_start"),
|
|
]),
|
|
);
|
|
expect(logs).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringMatching(/openclaw (?:--profile isolated )?status --all/),
|
|
]),
|
|
);
|
|
expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("Cache")]));
|
|
expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("40% hit")]));
|
|
expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("read 2.0k")]));
|
|
});
|
|
|
|
it("shows a maintenance hint when task audit errors are present", async () => {
|
|
mocks.getInspectableTaskRegistrySummary.mockReturnValue({
|
|
total: 1,
|
|
active: 1,
|
|
terminal: 0,
|
|
failures: 1,
|
|
byStatus: {
|
|
queued: 0,
|
|
running: 1,
|
|
succeeded: 0,
|
|
failed: 0,
|
|
timed_out: 0,
|
|
cancelled: 0,
|
|
lost: 0,
|
|
},
|
|
byRuntime: {
|
|
subagent: 0,
|
|
acp: 1,
|
|
cli: 0,
|
|
cron: 0,
|
|
},
|
|
});
|
|
mocks.getInspectableTaskAuditSummary.mockReturnValue({
|
|
total: 1,
|
|
warnings: 0,
|
|
errors: 1,
|
|
byCode: {
|
|
stale_queued: 0,
|
|
stale_running: 1,
|
|
lost: 0,
|
|
delivery_failed: 0,
|
|
missing_cleanup: 0,
|
|
inconsistent_timestamps: 0,
|
|
},
|
|
});
|
|
|
|
const joined = await runStatusAndGetJoinedLogs();
|
|
|
|
expect(joined).toContain("tasks maintenance --apply");
|
|
});
|
|
|
|
it("uses prompt-side denominator for cached percentages", async () => {
|
|
mocks.loadSessionStore.mockReturnValue({
|
|
"+1000": {
|
|
...createDefaultSessionStoreEntry(),
|
|
inputTokens: undefined,
|
|
cacheRead: 1_200,
|
|
cacheWrite: 0,
|
|
totalTokens: 1_000,
|
|
},
|
|
});
|
|
const logs = await runStatusAndGetLogs();
|
|
expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("100% cached")]));
|
|
expect(logs).not.toEqual(expect.arrayContaining([expect.stringContaining("120% cached")]));
|
|
|
|
mocks.loadSessionStore.mockReturnValue({
|
|
"+1000": {
|
|
...createDefaultSessionStoreEntry(),
|
|
inputTokens: 500,
|
|
cacheRead: 2_000,
|
|
cacheWrite: 500,
|
|
totalTokens: 5_000,
|
|
},
|
|
});
|
|
const promptSideLogs = await runStatusAndGetLogs();
|
|
expect(promptSideLogs).toEqual(expect.arrayContaining([expect.stringContaining("67% cached")]));
|
|
expect(promptSideLogs).not.toEqual(
|
|
expect.arrayContaining([expect.stringContaining("40% cached")]),
|
|
);
|
|
});
|
|
|
|
it("shows node-only gateway info when no local gateway service is installed", async () => {
|
|
mocks.resolveGatewayService.mockReturnValueOnce({
|
|
label: "LaunchAgent",
|
|
loadedText: "loaded",
|
|
notLoadedText: "not loaded",
|
|
stage: async () => {},
|
|
install: async () => {},
|
|
uninstall: async () => {},
|
|
stop: async () => {},
|
|
restart: async () => ({ outcome: "completed" as const }),
|
|
isLoaded: async () => false,
|
|
readRuntime: async () => undefined,
|
|
readCommand: async () => null,
|
|
});
|
|
mocks.loadNodeHostConfig.mockResolvedValueOnce({
|
|
version: 1,
|
|
nodeId: "node-1",
|
|
gateway: { host: "gateway.example.com", port: 19000 },
|
|
});
|
|
|
|
const joined = await runStatusAndGetJoinedLogs();
|
|
expect(joined).toContain("node → gateway.example.com:19000 · no local gateway");
|
|
expect(joined).not.toContain("Gateway: local · ws://127.0.0.1:18789");
|
|
expect(joined).toContain("openclaw --profile isolated node status");
|
|
expect(joined).not.toContain("Fix reachability first");
|
|
});
|
|
|
|
it("shows gateway auth when reachable", async () => {
|
|
mocks.loadConfig.mockReturnValue({
|
|
session: {},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
});
|
|
await withEnvVar("OPENCLAW_GATEWAY_TOKEN", "abcd1234", async () => {
|
|
mockProbeGatewayResult({
|
|
ok: true,
|
|
connectLatencyMs: 123,
|
|
error: null,
|
|
health: {},
|
|
status: {},
|
|
presence: [],
|
|
});
|
|
const logs = await runStatusAndGetLogs();
|
|
expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("auth token")]));
|
|
});
|
|
});
|
|
|
|
it("warns instead of crashing when gateway auth SecretRef is unresolved for probe auth", async () => {
|
|
mocks.loadConfig.mockReturnValue({
|
|
session: {},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
gateway: {
|
|
auth: {
|
|
mode: "token",
|
|
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
|
|
},
|
|
},
|
|
secrets: {
|
|
providers: {
|
|
default: { source: "env" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await statusCommand({ json: true }, runtime as never);
|
|
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
|
|
const gatewayAuthMessage = payload.gateway.error ?? payload.gateway.authWarning;
|
|
expect(typeof gatewayAuthMessage).toBe("string");
|
|
expect(gatewayAuthMessage.trim().length).toBeGreaterThan(0);
|
|
if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) {
|
|
expect(
|
|
payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")),
|
|
).toBe(true);
|
|
}
|
|
expect(runtime.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces channel runtime errors from the gateway", async () => {
|
|
mocks.loadConfig.mockReturnValue({
|
|
session: {},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
});
|
|
mockProbeGatewayResult({
|
|
ok: true,
|
|
connectLatencyMs: 10,
|
|
error: null,
|
|
health: {},
|
|
status: {},
|
|
presence: [],
|
|
});
|
|
mocks.callGateway.mockResolvedValueOnce({
|
|
channelAccounts: {
|
|
signal: [
|
|
{
|
|
accountId: "default",
|
|
enabled: true,
|
|
configured: true,
|
|
running: false,
|
|
lastError: "signal-cli unreachable",
|
|
},
|
|
],
|
|
imessage: [
|
|
{
|
|
accountId: "default",
|
|
enabled: true,
|
|
configured: true,
|
|
running: false,
|
|
lastError: "imessage permission denied",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const joined = await runStatusAndGetJoinedLogs();
|
|
expect(joined).toMatch(/Signal/i);
|
|
expect(joined).toMatch(/iMessage/i);
|
|
expect(joined).toMatch(/gateway:/i);
|
|
expect(joined).toMatch(/WARN/);
|
|
});
|
|
|
|
it("prints safe gateway pairing recovery guidance", async () => {
|
|
expect(
|
|
resolvePairingRecoveryContext({
|
|
error: "scope upgrade pending approval (requestId: req-123)",
|
|
closeReason: "pairing required",
|
|
}),
|
|
).toEqual({ requestId: "req-123", reason: "scope-upgrade", remediationHint: null });
|
|
expect(
|
|
resolvePairingRecoveryContext({
|
|
error: "connect failed: pairing required",
|
|
closeReason: "connect failed",
|
|
}),
|
|
).toEqual({ requestId: null, reason: "not-paired", remediationHint: null });
|
|
expect(
|
|
resolvePairingRecoveryContext({
|
|
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
|
|
closeReason: "pairing required (requestId: req-123;rm -rf /)",
|
|
}),
|
|
).toEqual({ requestId: null, reason: "not-paired", remediationHint: null });
|
|
expect(
|
|
resolvePairingRecoveryContext({
|
|
error: "connect failed: pairing required",
|
|
closeReason: "pairing required (requestId: req-close-456)",
|
|
}),
|
|
).toEqual({ requestId: "req-close-456", reason: "not-paired", remediationHint: null });
|
|
expect(
|
|
resolvePairingRecoveryContext({
|
|
details: {
|
|
code: "PAIRING_REQUIRED",
|
|
reason: "scope-upgrade",
|
|
requestId: "req-structured-789",
|
|
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
|
|
},
|
|
}),
|
|
).toEqual({
|
|
requestId: "req-structured-789",
|
|
reason: "scope-upgrade",
|
|
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
|
|
});
|
|
expect(
|
|
resolvePairingRecoveryContext({
|
|
details: {
|
|
code: "PAIRING_REQUIRED",
|
|
reason: "scope-upgrade",
|
|
requestId: "req-structured-789;rm -rf /",
|
|
remediationHint: "\u001b[31mReview\nfirst\u001b[0m",
|
|
},
|
|
}),
|
|
).toEqual({
|
|
requestId: null,
|
|
reason: "scope-upgrade",
|
|
remediationHint: "Review\\nfirst",
|
|
});
|
|
|
|
mocks.loadConfig.mockReturnValue({
|
|
session: {},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
});
|
|
mockProbeGatewayResult({
|
|
error: "scope upgrade pending approval (requestId: req-123)",
|
|
connectErrorDetails: {
|
|
code: "PAIRING_REQUIRED",
|
|
reason: "scope-upgrade",
|
|
requestId: "req-123",
|
|
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
|
|
},
|
|
close: {
|
|
code: 1008,
|
|
reason: "pairing required",
|
|
},
|
|
});
|
|
const joined = await runStatusAndGetJoinedLogs();
|
|
expect(joined).toContain("Gateway scope upgrade approval required.");
|
|
expect(joined).toContain("more scopes than currently approved");
|
|
expect(joined).toContain("devices approve req-123");
|
|
expect(joined).toContain("devices approve --latest");
|
|
expect(joined).toContain("devices list");
|
|
});
|
|
|
|
it("includes sessions across agents in JSON output", async () => {
|
|
const originalAgents = mocks.listGatewayAgentsBasic.getMockImplementation();
|
|
const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation();
|
|
const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation();
|
|
|
|
mocks.listGatewayAgentsBasic.mockReturnValue({
|
|
defaultId: "main",
|
|
mainKey: "agent:main:main",
|
|
scope: "per-sender",
|
|
agents: [
|
|
{ id: "main", name: "Main" },
|
|
{ id: "ops", name: "Ops" },
|
|
],
|
|
});
|
|
mocks.resolveStorePath.mockImplementation((_store, opts) =>
|
|
opts?.agentId === "ops" ? "/tmp/ops.json" : "/tmp/main.json",
|
|
);
|
|
mocks.loadSessionStore.mockImplementation((storePath) => {
|
|
if (storePath === "/tmp/ops.json") {
|
|
return {
|
|
"agent:ops:main": {
|
|
updatedAt: Date.now() - 120_000,
|
|
inputTokens: 1_000,
|
|
outputTokens: 1_000,
|
|
totalTokens: 2_000,
|
|
contextTokens: 10_000,
|
|
model: "pi:opus",
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
"+1000": createDefaultSessionStoreEntry(),
|
|
};
|
|
});
|
|
|
|
await statusCommand({ json: true }, runtime as never);
|
|
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
|
|
expect(payload.sessions.count).toBe(2);
|
|
expect(payload.sessions.paths.length).toBe(2);
|
|
expect(
|
|
payload.sessions.recent.some((sess: { key?: string }) => sess.key === "agent:ops:main"),
|
|
).toBe(true);
|
|
|
|
if (originalAgents) {
|
|
mocks.listGatewayAgentsBasic.mockImplementation(originalAgents);
|
|
}
|
|
if (originalResolveStorePath) {
|
|
mocks.resolveStorePath.mockImplementation(originalResolveStorePath);
|
|
}
|
|
if (originalLoadSessionStore) {
|
|
mocks.loadSessionStore.mockImplementation(originalLoadSessionStore);
|
|
}
|
|
});
|
|
});
|