mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:38:38 +00:00
test: dedupe and optimize test suites
This commit is contained in:
@@ -1,11 +1,16 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const execSyncMock = vi.fn();
|
const execSyncMock = vi.fn();
|
||||||
const execFileSyncMock = vi.fn();
|
const execFileSyncMock = vi.fn();
|
||||||
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
|
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||||
|
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
|
||||||
|
let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest;
|
||||||
|
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
|
||||||
|
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
|
||||||
|
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
|
||||||
|
|
||||||
function mockExistingClaudeKeychainItem() {
|
function mockExistingClaudeKeychainItem() {
|
||||||
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
|
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
|
||||||
@@ -33,7 +38,6 @@ function getAddGenericPasswordCall() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
|
async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
|
||||||
const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js");
|
|
||||||
return readClaudeCliCredentialsCached({
|
return readClaudeCliCredentialsCached({
|
||||||
allowKeychainPrompt,
|
allowKeychainPrompt,
|
||||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||||
@@ -43,24 +47,31 @@ async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("cli credentials", () => {
|
describe("cli credentials", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
({
|
||||||
|
readClaudeCliCredentialsCached,
|
||||||
|
resetCliCredentialCachesForTest,
|
||||||
|
writeClaudeCliKeychainCredentials,
|
||||||
|
writeClaudeCliCredentials,
|
||||||
|
readCodexCliCredentials,
|
||||||
|
} = await import("./cli-credentials.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
execSyncMock.mockReset();
|
execSyncMock.mockReset();
|
||||||
execFileSyncMock.mockReset();
|
execFileSyncMock.mockReset();
|
||||||
delete process.env.CODEX_HOME;
|
delete process.env.CODEX_HOME;
|
||||||
const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js");
|
|
||||||
resetCliCredentialCachesForTest();
|
resetCliCredentialCachesForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the Claude Code keychain item in place", async () => {
|
it("updates the Claude Code keychain item in place", async () => {
|
||||||
mockExistingClaudeKeychainItem();
|
mockExistingClaudeKeychainItem();
|
||||||
|
|
||||||
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
|
|
||||||
|
|
||||||
const ok = writeClaudeCliKeychainCredentials(
|
const ok = writeClaudeCliKeychainCredentials(
|
||||||
{
|
{
|
||||||
access: "new-access",
|
access: "new-access",
|
||||||
@@ -84,8 +95,6 @@ describe("cli credentials", () => {
|
|||||||
|
|
||||||
mockExistingClaudeKeychainItem();
|
mockExistingClaudeKeychainItem();
|
||||||
|
|
||||||
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
|
|
||||||
|
|
||||||
const ok = writeClaudeCliKeychainCredentials(
|
const ok = writeClaudeCliKeychainCredentials(
|
||||||
{
|
{
|
||||||
access: maliciousToken,
|
access: maliciousToken,
|
||||||
@@ -112,8 +121,6 @@ describe("cli credentials", () => {
|
|||||||
|
|
||||||
mockExistingClaudeKeychainItem();
|
mockExistingClaudeKeychainItem();
|
||||||
|
|
||||||
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
|
|
||||||
|
|
||||||
const ok = writeClaudeCliKeychainCredentials(
|
const ok = writeClaudeCliKeychainCredentials(
|
||||||
{
|
{
|
||||||
access: "safe-access",
|
access: "safe-access",
|
||||||
@@ -156,8 +163,6 @@ describe("cli credentials", () => {
|
|||||||
|
|
||||||
const writeKeychain = vi.fn(() => false);
|
const writeKeychain = vi.fn(() => false);
|
||||||
|
|
||||||
const { writeClaudeCliCredentials } = await import("./cli-credentials.js");
|
|
||||||
|
|
||||||
const ok = writeClaudeCliCredentials(
|
const ok = writeClaudeCliCredentials(
|
||||||
{
|
{
|
||||||
access: "new-access",
|
access: "new-access",
|
||||||
@@ -251,7 +256,6 @@ describe("cli credentials", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
|
||||||
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
|
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
|
||||||
|
|
||||||
expect(creds).toMatchObject({
|
expect(creds).toMatchObject({
|
||||||
@@ -281,7 +285,6 @@ describe("cli credentials", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
const { readCodexCliCredentials } = await import("./cli-credentials.js");
|
|
||||||
const creds = readCodexCliCredentials({ execSync: execSyncMock });
|
const creds = readCodexCliCredentials({ execSync: execSyncMock });
|
||||||
|
|
||||||
expect(creds).toMatchObject({
|
expect(creds).toMatchObject({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression test for #18264: Gateway announcement delivery loop.
|
* Regression test for #18264: Gateway announcement delivery loop.
|
||||||
@@ -55,6 +55,15 @@ vi.mock("./timeout.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("announce loop guard (#18264)", () => {
|
describe("announce loop guard (#18264)", () => {
|
||||||
|
let registry: typeof import("./subagent-registry.js");
|
||||||
|
let announceFn: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
registry = await import("./subagent-registry.js");
|
||||||
|
const subagentAnnounce = await import("./subagent-announce.js");
|
||||||
|
announceFn = vi.mocked(subagentAnnounce.runSubagentAnnounceFlow);
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -67,8 +76,7 @@ describe("announce loop guard (#18264)", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("SubagentRunRecord has announceRetryCount and lastAnnounceRetryAt fields", async () => {
|
test("SubagentRunRecord has announceRetryCount and lastAnnounceRetryAt fields", () => {
|
||||||
const registry = await import("./subagent-registry.js");
|
|
||||||
registry.resetSubagentRegistryForTests();
|
registry.resetSubagentRegistryForTests();
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -94,74 +102,51 @@ describe("announce loop guard (#18264)", () => {
|
|||||||
expect(entry!.lastAnnounceRetryAt).toBeDefined();
|
expect(entry!.lastAnnounceRetryAt).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("expired entries with high retry count are skipped by resumeSubagentRun", async () => {
|
test.each([
|
||||||
const registry = await import("./subagent-registry.js");
|
{
|
||||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
name: "expired entries with high retry count are skipped by resumeSubagentRun",
|
||||||
const announceFn = vi.mocked(runSubagentAnnounceFlow);
|
createEntry: (now: number) => ({
|
||||||
|
// Ended 10 minutes ago (well past ANNOUNCE_EXPIRY_MS of 5 min).
|
||||||
|
runId: "test-expired-loop",
|
||||||
|
childSessionKey: "agent:main:subagent:expired-child",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "agent:main:main",
|
||||||
|
task: "expired test task",
|
||||||
|
cleanup: "keep" as const,
|
||||||
|
createdAt: now - 15 * 60_000,
|
||||||
|
startedAt: now - 14 * 60_000,
|
||||||
|
endedAt: now - 10 * 60_000,
|
||||||
|
announceRetryCount: 3,
|
||||||
|
lastAnnounceRetryAt: now - 9 * 60_000,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entries over retry budget are marked completed without announcing",
|
||||||
|
createEntry: (now: number) => ({
|
||||||
|
runId: "test-retry-budget",
|
||||||
|
childSessionKey: "agent:main:subagent:retry-budget",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "agent:main:main",
|
||||||
|
task: "retry budget test",
|
||||||
|
cleanup: "keep" as const,
|
||||||
|
createdAt: now - 2 * 60_000,
|
||||||
|
startedAt: now - 90_000,
|
||||||
|
endedAt: now - 60_000,
|
||||||
|
announceRetryCount: 3,
|
||||||
|
lastAnnounceRetryAt: now - 30_000,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])("$name", ({ createEntry }) => {
|
||||||
announceFn.mockClear();
|
announceFn.mockClear();
|
||||||
|
|
||||||
registry.resetSubagentRegistryForTests();
|
registry.resetSubagentRegistryForTests();
|
||||||
|
|
||||||
const now = Date.now();
|
const entry = createEntry(Date.now());
|
||||||
// Add a run that ended 10 minutes ago (well past ANNOUNCE_EXPIRY_MS of 5 min)
|
|
||||||
// with 3 retries already attempted
|
|
||||||
const entry = {
|
|
||||||
runId: "test-expired-loop",
|
|
||||||
childSessionKey: "agent:main:subagent:expired-child",
|
|
||||||
requesterSessionKey: "agent:main:main",
|
|
||||||
requesterDisplayKey: "agent:main:main",
|
|
||||||
task: "expired test task",
|
|
||||||
cleanup: "keep",
|
|
||||||
createdAt: now - 15 * 60_000,
|
|
||||||
startedAt: now - 14 * 60_000,
|
|
||||||
endedAt: now - 10 * 60_000, // 10 minutes ago
|
|
||||||
announceRetryCount: 3,
|
|
||||||
lastAnnounceRetryAt: now - 9 * 60_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]]));
|
|
||||||
|
|
||||||
// Initialize the registry — this triggers resumeSubagentRun for persisted entries
|
|
||||||
registry.initSubagentRegistry();
|
|
||||||
|
|
||||||
// The announce flow should NOT be called because the entry has exceeded
|
|
||||||
// both the retry count and the expiry window.
|
|
||||||
expect(announceFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const runs = registry.listSubagentRunsForRequester("agent:main:main");
|
|
||||||
const stored = runs.find((run) => run.runId === entry.runId);
|
|
||||||
expect(stored?.cleanupCompletedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("entries over retry budget are marked completed without announcing", async () => {
|
|
||||||
const registry = await import("./subagent-registry.js");
|
|
||||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
|
||||||
const announceFn = vi.mocked(runSubagentAnnounceFlow);
|
|
||||||
announceFn.mockClear();
|
|
||||||
|
|
||||||
registry.resetSubagentRegistryForTests();
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = {
|
|
||||||
runId: "test-retry-budget",
|
|
||||||
childSessionKey: "agent:main:subagent:retry-budget",
|
|
||||||
requesterSessionKey: "agent:main:main",
|
|
||||||
requesterDisplayKey: "agent:main:main",
|
|
||||||
task: "retry budget test",
|
|
||||||
cleanup: "keep",
|
|
||||||
createdAt: now - 2 * 60_000,
|
|
||||||
startedAt: now - 90_000,
|
|
||||||
endedAt: now - 60_000,
|
|
||||||
announceRetryCount: 3,
|
|
||||||
lastAnnounceRetryAt: now - 30_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]]));
|
loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]]));
|
||||||
|
|
||||||
|
// Initialization attempts resume once, then gives up for exhausted entries.
|
||||||
registry.initSubagentRegistry();
|
registry.initSubagentRegistry();
|
||||||
|
|
||||||
expect(announceFn).not.toHaveBeenCalled();
|
expect(announceFn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const runs = registry.listSubagentRunsForRequester("agent:main:main");
|
const runs = registry.listSubagentRunsForRequester("agent:main:main");
|
||||||
const stored = runs.find((run) => run.runId === entry.runId);
|
const stored = runs.find((run) => run.runId === entry.runId);
|
||||||
expect(stored?.cleanupCompletedAt).toBeDefined();
|
expect(stored?.cleanupCompletedAt).toBeDefined();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import "./subagent-registry.mocks.shared.js";
|
import "./subagent-registry.mocks.shared.js";
|
||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
@@ -17,15 +17,19 @@ vi.mock("./subagent-registry.store.js", () => ({
|
|||||||
saveSubagentRegistryToDisk: vi.fn(() => {}),
|
saveSubagentRegistryToDisk: vi.fn(() => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let subagentRegistry: typeof import("./subagent-registry.js");
|
||||||
|
|
||||||
describe("subagent registry nested agent tracking", () => {
|
describe("subagent registry nested agent tracking", () => {
|
||||||
afterEach(async () => {
|
beforeAll(async () => {
|
||||||
const mod = await import("./subagent-registry.js");
|
subagentRegistry = await import("./subagent-registry.js");
|
||||||
mod.resetSubagentRegistryForTests({ persist: false });
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
subagentRegistry.resetSubagentRegistryForTests({ persist: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("listSubagentRunsForRequester returns children of the requesting session", async () => {
|
it("listSubagentRunsForRequester returns children of the requesting session", async () => {
|
||||||
const { registerSubagentRun, listSubagentRunsForRequester } =
|
const { registerSubagentRun, listSubagentRunsForRequester } = subagentRegistry;
|
||||||
await import("./subagent-registry.js");
|
|
||||||
|
|
||||||
// Main agent spawns a depth-1 orchestrator
|
// Main agent spawns a depth-1 orchestrator
|
||||||
registerSubagentRun({
|
registerSubagentRun({
|
||||||
@@ -67,7 +71,7 @@ describe("subagent registry nested agent tracking", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("announce uses requesterSessionKey to route to the correct parent", async () => {
|
it("announce uses requesterSessionKey to route to the correct parent", async () => {
|
||||||
const { registerSubagentRun } = await import("./subagent-registry.js");
|
const { registerSubagentRun } = subagentRegistry;
|
||||||
// Register a sub-sub-agent whose parent is a sub-agent
|
// Register a sub-sub-agent whose parent is a sub-agent
|
||||||
registerSubagentRun({
|
registerSubagentRun({
|
||||||
runId: "run-subsub",
|
runId: "run-subsub",
|
||||||
@@ -82,7 +86,7 @@ describe("subagent registry nested agent tracking", () => {
|
|||||||
// When announce fires for the sub-sub-agent, it should target the sub-agent (depth-1),
|
// When announce fires for the sub-sub-agent, it should target the sub-agent (depth-1),
|
||||||
// NOT the main session. The registry entry's requesterSessionKey ensures this.
|
// NOT the main session. The registry entry's requesterSessionKey ensures this.
|
||||||
// We verify the registry entry has the correct requesterSessionKey.
|
// We verify the registry entry has the correct requesterSessionKey.
|
||||||
const { listSubagentRunsForRequester } = await import("./subagent-registry.js");
|
const { listSubagentRunsForRequester } = subagentRegistry;
|
||||||
const orchRuns = listSubagentRunsForRequester("agent:main:subagent:orch");
|
const orchRuns = listSubagentRunsForRequester("agent:main:subagent:orch");
|
||||||
expect(orchRuns).toHaveLength(1);
|
expect(orchRuns).toHaveLength(1);
|
||||||
expect(orchRuns[0].requesterSessionKey).toBe("agent:main:subagent:orch");
|
expect(orchRuns[0].requesterSessionKey).toBe("agent:main:subagent:orch");
|
||||||
@@ -90,8 +94,7 @@ describe("subagent registry nested agent tracking", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("countActiveRunsForSession only counts active children of the specific session", async () => {
|
it("countActiveRunsForSession only counts active children of the specific session", async () => {
|
||||||
const { registerSubagentRun, countActiveRunsForSession } =
|
const { registerSubagentRun, countActiveRunsForSession } = subagentRegistry;
|
||||||
await import("./subagent-registry.js");
|
|
||||||
|
|
||||||
// Main spawns orchestrator (active)
|
// Main spawns orchestrator (active)
|
||||||
registerSubagentRun({
|
registerSubagentRun({
|
||||||
@@ -130,8 +133,7 @@ describe("subagent registry nested agent tracking", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("countActiveDescendantRuns traverses through ended parents", async () => {
|
it("countActiveDescendantRuns traverses through ended parents", async () => {
|
||||||
const { addSubagentRunForTests, countActiveDescendantRuns } =
|
const { addSubagentRunForTests, countActiveDescendantRuns } = subagentRegistry;
|
||||||
await import("./subagent-registry.js");
|
|
||||||
|
|
||||||
addSubagentRunForTests({
|
addSubagentRunForTests({
|
||||||
runId: "run-parent-ended",
|
runId: "run-parent-ended",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
|
||||||
import {
|
import {
|
||||||
addSubagentRunForTests,
|
addSubagentRunForTests,
|
||||||
listSubagentRunsForRequester,
|
listSubagentRunsForRequester,
|
||||||
@@ -294,7 +295,6 @@ describe("/compact command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when command is not /compact", async () => {
|
it("returns null when command is not /compact", async () => {
|
||||||
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: { text: true },
|
commands: { text: true },
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
@@ -313,7 +313,6 @@ describe("/compact command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unauthorized /compact commands", async () => {
|
it("rejects unauthorized /compact commands", async () => {
|
||||||
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: { text: true },
|
commands: { text: true },
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
@@ -337,7 +336,6 @@ describe("/compact command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("routes manual compaction with explicit trigger and context metadata", async () => {
|
it("routes manual compaction with explicit trigger and context metadata", async () => {
|
||||||
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: { text: true },
|
commands: { text: true },
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveBrowserConfig } from "./config.js";
|
||||||
import {
|
import {
|
||||||
allocateCdpPort,
|
allocateCdpPort,
|
||||||
allocateColor,
|
allocateColor,
|
||||||
@@ -11,15 +12,12 @@ import {
|
|||||||
} from "./profiles.js";
|
} from "./profiles.js";
|
||||||
|
|
||||||
describe("profile name validation", () => {
|
describe("profile name validation", () => {
|
||||||
it("accepts valid lowercase names", () => {
|
it.each(["openclaw", "work", "my-profile", "test123", "a", "a-b-c-1-2-3", "1test"])(
|
||||||
expect(isValidProfileName("openclaw")).toBe(true);
|
"accepts valid lowercase name: %s",
|
||||||
expect(isValidProfileName("work")).toBe(true);
|
(name) => {
|
||||||
expect(isValidProfileName("my-profile")).toBe(true);
|
expect(isValidProfileName(name)).toBe(true);
|
||||||
expect(isValidProfileName("test123")).toBe(true);
|
},
|
||||||
expect(isValidProfileName("a")).toBe(true);
|
);
|
||||||
expect(isValidProfileName("a-b-c-1-2-3")).toBe(true);
|
|
||||||
expect(isValidProfileName("1test")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty or missing names", () => {
|
it("rejects empty or missing names", () => {
|
||||||
expect(isValidProfileName("")).toBe(false);
|
expect(isValidProfileName("")).toBe(false);
|
||||||
@@ -37,23 +35,19 @@ describe("profile name validation", () => {
|
|||||||
expect(isValidProfileName(maxName)).toBe(true);
|
expect(isValidProfileName(maxName)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects uppercase letters", () => {
|
it.each([
|
||||||
expect(isValidProfileName("MyProfile")).toBe(false);
|
"MyProfile",
|
||||||
expect(isValidProfileName("PROFILE")).toBe(false);
|
"PROFILE",
|
||||||
expect(isValidProfileName("Work")).toBe(false);
|
"Work",
|
||||||
});
|
"my profile",
|
||||||
|
"my_profile",
|
||||||
it("rejects spaces and special characters", () => {
|
"my.profile",
|
||||||
expect(isValidProfileName("my profile")).toBe(false);
|
"my/profile",
|
||||||
expect(isValidProfileName("my_profile")).toBe(false);
|
"my@profile",
|
||||||
expect(isValidProfileName("my.profile")).toBe(false);
|
"-invalid",
|
||||||
expect(isValidProfileName("my/profile")).toBe(false);
|
"--double",
|
||||||
expect(isValidProfileName("my@profile")).toBe(false);
|
])("rejects invalid name: %s", (name) => {
|
||||||
});
|
expect(isValidProfileName(name)).toBe(false);
|
||||||
|
|
||||||
it("rejects names starting with hyphen", () => {
|
|
||||||
expect(isValidProfileName("-invalid")).toBe(false);
|
|
||||||
expect(isValidProfileName("--double")).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,9 +125,8 @@ describe("getUsedPorts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("port collision prevention", () => {
|
describe("port collision prevention", () => {
|
||||||
it("raw config vs resolved config - shows the data source difference", async () => {
|
it("raw config vs resolved config - shows the data source difference", () => {
|
||||||
// This demonstrates WHY the route handler must use resolved config
|
// This demonstrates WHY the route handler must use resolved config
|
||||||
const { resolveBrowserConfig } = await import("./config.js");
|
|
||||||
|
|
||||||
// Fresh config with no profiles defined (like a new install)
|
// Fresh config with no profiles defined (like a new install)
|
||||||
const rawConfigProfiles = undefined;
|
const rawConfigProfiles = undefined;
|
||||||
@@ -148,9 +141,8 @@ describe("port collision prevention", () => {
|
|||||||
expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true);
|
expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("create-profile must use resolved config to avoid port collision", async () => {
|
it("create-profile must use resolved config to avoid port collision", () => {
|
||||||
// The route handler must use state.resolved.profiles, not raw config
|
// The route handler must use state.resolved.profiles, not raw config
|
||||||
const { resolveBrowserConfig } = await import("./config.js");
|
|
||||||
|
|
||||||
// Simulate what happens with raw config (empty) vs resolved config
|
// Simulate what happens with raw config (empty) vs resolved config
|
||||||
const rawConfig: { browser: { profiles?: Record<string, { cdpPort?: number }> } } = {
|
const rawConfig: { browser: { profiles?: Record<string, { cdpPort?: number }> } } = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
let page: { evaluate: ReturnType<typeof vi.fn> } | null = null;
|
let page: { evaluate: ReturnType<typeof vi.fn> } | null = null;
|
||||||
let locator: { evaluate: ReturnType<typeof vi.fn> } | null = null;
|
let locator: { evaluate: ReturnType<typeof vi.fn> } | null = null;
|
||||||
@@ -29,64 +29,61 @@ vi.mock("./pw-session.js", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("evaluateViaPlaywright (abort)", () => {
|
let evaluateViaPlaywright: typeof import("./pw-tools-core.interactions.js").evaluateViaPlaywright;
|
||||||
it("rejects when aborted after page.evaluate starts", async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
|
|
||||||
let evalCalled!: () => void;
|
function createPendingEval() {
|
||||||
const evalCalledPromise = new Promise<void>((resolve) => {
|
let evalCalled!: () => void;
|
||||||
evalCalled = resolve;
|
const evalCalledPromise = new Promise<void>((resolve) => {
|
||||||
});
|
evalCalled = resolve;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
evalCalledPromise,
|
||||||
|
resolveEvalCalled: evalCalled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("evaluateViaPlaywright (abort)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "page.evaluate", fn: "() => 1" },
|
||||||
|
{ label: "locator.evaluate", fn: "(el) => el.textContent", ref: "e1" },
|
||||||
|
])("rejects when aborted after $label starts", async ({ fn, ref }) => {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const pending = createPendingEval();
|
||||||
|
const pendingPromise = new Promise(() => {});
|
||||||
|
|
||||||
page = {
|
page = {
|
||||||
evaluate: vi.fn(() => {
|
evaluate: vi.fn(() => {
|
||||||
evalCalled();
|
if (!ref) {
|
||||||
return new Promise(() => {});
|
pending.resolveEvalCalled();
|
||||||
|
}
|
||||||
|
return pendingPromise;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
locator = { evaluate: vi.fn() };
|
|
||||||
|
|
||||||
const { evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js");
|
|
||||||
const p = evaluateViaPlaywright({
|
|
||||||
cdpUrl: "http://127.0.0.1:9222",
|
|
||||||
fn: "() => 1",
|
|
||||||
signal: ctrl.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
await evalCalledPromise;
|
|
||||||
ctrl.abort(new Error("aborted by test"));
|
|
||||||
|
|
||||||
await expect(p).rejects.toThrow("aborted by test");
|
|
||||||
expect(forceDisconnectPlaywrightForTarget).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when aborted after locator.evaluate starts", async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
|
|
||||||
let evalCalled!: () => void;
|
|
||||||
const evalCalledPromise = new Promise<void>((resolve) => {
|
|
||||||
evalCalled = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
page = { evaluate: vi.fn() };
|
|
||||||
locator = {
|
locator = {
|
||||||
evaluate: vi.fn(() => {
|
evaluate: vi.fn(() => {
|
||||||
evalCalled();
|
if (ref) {
|
||||||
return new Promise(() => {});
|
pending.resolveEvalCalled();
|
||||||
|
}
|
||||||
|
return pendingPromise;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js");
|
|
||||||
const p = evaluateViaPlaywright({
|
const p = evaluateViaPlaywright({
|
||||||
cdpUrl: "http://127.0.0.1:9222",
|
cdpUrl: "http://127.0.0.1:9222",
|
||||||
fn: "(el) => el.textContent",
|
fn,
|
||||||
ref: "e1",
|
ref,
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await evalCalledPromise;
|
await pending.evalCalledPromise;
|
||||||
ctrl.abort(new Error("aborted by test"));
|
ctrl.abort(new Error("aborted by test"));
|
||||||
|
|
||||||
await expect(p).rejects.toThrow("aborted by test");
|
await expect(p).rejects.toThrow("aborted by test");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolveBrowserConfig } from "./config.js";
|
import { resolveBrowserConfig } from "./config.js";
|
||||||
import {
|
import {
|
||||||
refreshResolvedBrowserConfigFromDisk,
|
refreshResolvedBrowserConfigFromDisk,
|
||||||
@@ -40,6 +40,12 @@ vi.mock("../config/config.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("server-context hot-reload profiles", () => {
|
describe("server-context hot-reload profiles", () => {
|
||||||
|
let loadConfig: typeof import("../config/config.js").loadConfig;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ loadConfig } = await import("../config/config.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
cfgProfiles = {
|
cfgProfiles = {
|
||||||
@@ -49,8 +55,6 @@ describe("server-context hot-reload profiles", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forProfile hot-reloads newly added profiles from config", async () => {
|
it("forProfile hot-reloads newly added profiles from config", async () => {
|
||||||
const { loadConfig } = await import("../config/config.js");
|
|
||||||
|
|
||||||
// Start with only openclaw profile
|
// Start with only openclaw profile
|
||||||
// 1. Prime the cache by calling loadConfig() first
|
// 1. Prime the cache by calling loadConfig() first
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
@@ -101,8 +105,6 @@ describe("server-context hot-reload profiles", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forProfile still throws for profiles that don't exist in fresh config", async () => {
|
it("forProfile still throws for profiles that don't exist in fresh config", async () => {
|
||||||
const { loadConfig } = await import("../config/config.js");
|
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||||
const state = {
|
const state = {
|
||||||
@@ -123,8 +125,6 @@ describe("server-context hot-reload profiles", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forProfile refreshes existing profile config after loadConfig cache updates", async () => {
|
it("forProfile refreshes existing profile config after loadConfig cache updates", async () => {
|
||||||
const { loadConfig } = await import("../config/config.js");
|
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||||
const state = {
|
const state = {
|
||||||
@@ -147,8 +147,6 @@ describe("server-context hot-reload profiles", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("listProfiles refreshes config before enumerating profiles", async () => {
|
it("listProfiles refreshes config before enumerating profiles", async () => {
|
||||||
const { loadConfig } = await import("../config/config.js");
|
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||||
const state = {
|
const state = {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runRegisteredCli } from "../test-utils/command-runner.js";
|
||||||
|
|
||||||
const runAcpClientInteractive = vi.fn(async (_opts: unknown) => {});
|
const runAcpClientInteractive = vi.fn(async (_opts: unknown) => {});
|
||||||
const serveAcpGateway = vi.fn(async (_opts: unknown) => {});
|
const serveAcpGateway = vi.fn(async (_opts: unknown) => {});
|
||||||
@@ -25,6 +26,12 @@ vi.mock("../runtime.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("acp cli option collisions", () => {
|
describe("acp cli option collisions", () => {
|
||||||
|
let registerAcpCli: typeof import("./acp-cli.js").registerAcpCli;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerAcpCli } = await import("./acp-cli.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
runAcpClientInteractive.mockClear();
|
runAcpClientInteractive.mockClear();
|
||||||
serveAcpGateway.mockClear();
|
serveAcpGateway.mockClear();
|
||||||
@@ -33,11 +40,10 @@ describe("acp cli option collisions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards --verbose to `acp client` when parent and child option names collide", async () => {
|
it("forwards --verbose to `acp client` when parent and child option names collide", async () => {
|
||||||
const { registerAcpCli } = await import("./acp-cli.js");
|
await runRegisteredCli({
|
||||||
const program = new Command();
|
register: registerAcpCli as (program: Command) => void,
|
||||||
registerAcpCli(program);
|
argv: ["acp", "client", "--verbose"],
|
||||||
|
});
|
||||||
await program.parseAsync(["acp", "client", "--verbose"], { from: "user" });
|
|
||||||
|
|
||||||
expect(runAcpClientInteractive).toHaveBeenCalledWith(
|
expect(runAcpClientInteractive).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -13,40 +13,106 @@ import {
|
|||||||
} from "./argv.js";
|
} from "./argv.js";
|
||||||
|
|
||||||
describe("argv helpers", () => {
|
describe("argv helpers", () => {
|
||||||
it("detects help/version flags", () => {
|
it.each([
|
||||||
expect(hasHelpOrVersion(["node", "openclaw", "--help"])).toBe(true);
|
{
|
||||||
expect(hasHelpOrVersion(["node", "openclaw", "-V"])).toBe(true);
|
name: "help flag",
|
||||||
expect(hasHelpOrVersion(["node", "openclaw", "status"])).toBe(false);
|
argv: ["node", "openclaw", "--help"],
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version flag",
|
||||||
|
argv: ["node", "openclaw", "-V"],
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normal command",
|
||||||
|
argv: ["node", "openclaw", "status"],
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
])("detects help/version flags: $name", ({ argv, expected }) => {
|
||||||
|
expect(hasHelpOrVersion(argv)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts command path ignoring flags and terminator", () => {
|
it.each([
|
||||||
expect(getCommandPath(["node", "openclaw", "status", "--json"], 2)).toEqual(["status"]);
|
{
|
||||||
expect(getCommandPath(["node", "openclaw", "agents", "list"], 2)).toEqual(["agents", "list"]);
|
name: "single command with trailing flag",
|
||||||
expect(getCommandPath(["node", "openclaw", "status", "--", "ignored"], 2)).toEqual(["status"]);
|
argv: ["node", "openclaw", "status", "--json"],
|
||||||
|
expected: ["status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two-part command",
|
||||||
|
argv: ["node", "openclaw", "agents", "list"],
|
||||||
|
expected: ["agents", "list"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "terminator cuts parsing",
|
||||||
|
argv: ["node", "openclaw", "status", "--", "ignored"],
|
||||||
|
expected: ["status"],
|
||||||
|
},
|
||||||
|
])("extracts command path: $name", ({ argv, expected }) => {
|
||||||
|
expect(getCommandPath(argv, 2)).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns primary command", () => {
|
it.each([
|
||||||
expect(getPrimaryCommand(["node", "openclaw", "agents", "list"])).toBe("agents");
|
{
|
||||||
expect(getPrimaryCommand(["node", "openclaw"])).toBeNull();
|
name: "returns first command token",
|
||||||
|
argv: ["node", "openclaw", "agents", "list"],
|
||||||
|
expected: "agents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "returns null when no command exists",
|
||||||
|
argv: ["node", "openclaw"],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
])("returns primary command: $name", ({ argv, expected }) => {
|
||||||
|
expect(getPrimaryCommand(argv)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses boolean flags and ignores terminator", () => {
|
it.each([
|
||||||
expect(hasFlag(["node", "openclaw", "status", "--json"], "--json")).toBe(true);
|
{
|
||||||
expect(hasFlag(["node", "openclaw", "--", "--json"], "--json")).toBe(false);
|
name: "detects flag before terminator",
|
||||||
|
argv: ["node", "openclaw", "status", "--json"],
|
||||||
|
flag: "--json",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignores flag after terminator",
|
||||||
|
argv: ["node", "openclaw", "--", "--json"],
|
||||||
|
flag: "--json",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
])("parses boolean flags: $name", ({ argv, flag, expected }) => {
|
||||||
|
expect(hasFlag(argv, flag)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts flag values with equals and missing values", () => {
|
it.each([
|
||||||
expect(getFlagValue(["node", "openclaw", "status", "--timeout", "5000"], "--timeout")).toBe(
|
{
|
||||||
"5000",
|
name: "value in next token",
|
||||||
);
|
argv: ["node", "openclaw", "status", "--timeout", "5000"],
|
||||||
expect(getFlagValue(["node", "openclaw", "status", "--timeout=2500"], "--timeout")).toBe(
|
expected: "5000",
|
||||||
"2500",
|
},
|
||||||
);
|
{
|
||||||
expect(getFlagValue(["node", "openclaw", "status", "--timeout"], "--timeout")).toBeNull();
|
name: "value in equals form",
|
||||||
expect(getFlagValue(["node", "openclaw", "status", "--timeout", "--json"], "--timeout")).toBe(
|
argv: ["node", "openclaw", "status", "--timeout=2500"],
|
||||||
null,
|
expected: "2500",
|
||||||
);
|
},
|
||||||
expect(getFlagValue(["node", "openclaw", "--", "--timeout=99"], "--timeout")).toBeUndefined();
|
{
|
||||||
|
name: "missing value",
|
||||||
|
argv: ["node", "openclaw", "status", "--timeout"],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "next token is another flag",
|
||||||
|
argv: ["node", "openclaw", "status", "--timeout", "--json"],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flag appears after terminator",
|
||||||
|
argv: ["node", "openclaw", "--", "--timeout=99"],
|
||||||
|
expected: undefined,
|
||||||
|
},
|
||||||
|
])("extracts flag values: $name", ({ argv, expected }) => {
|
||||||
|
expect(getFlagValue(argv, "--timeout")).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses verbose flags", () => {
|
it("parses verbose flags", () => {
|
||||||
@@ -57,79 +123,82 @@ describe("argv helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses positive integer flag values", () => {
|
it.each([
|
||||||
expect(getPositiveIntFlagValue(["node", "openclaw", "status"], "--timeout")).toBeUndefined();
|
{
|
||||||
expect(
|
name: "missing flag",
|
||||||
getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout"], "--timeout"),
|
argv: ["node", "openclaw", "status"],
|
||||||
).toBeNull();
|
expected: undefined,
|
||||||
expect(
|
},
|
||||||
getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout", "5000"], "--timeout"),
|
{
|
||||||
).toBe(5000);
|
name: "missing value",
|
||||||
expect(
|
argv: ["node", "openclaw", "status", "--timeout"],
|
||||||
getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout", "nope"], "--timeout"),
|
expected: null,
|
||||||
).toBeUndefined();
|
},
|
||||||
|
{
|
||||||
|
name: "valid positive integer",
|
||||||
|
argv: ["node", "openclaw", "status", "--timeout", "5000"],
|
||||||
|
expected: 5000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid integer",
|
||||||
|
argv: ["node", "openclaw", "status", "--timeout", "nope"],
|
||||||
|
expected: undefined,
|
||||||
|
},
|
||||||
|
])("parses positive integer flag values: $name", ({ argv, expected }) => {
|
||||||
|
expect(getPositiveIntFlagValue(argv, "--timeout")).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds parse argv from raw args", () => {
|
it("builds parse argv from raw args", () => {
|
||||||
const nodeArgv = buildParseArgv({
|
const cases = [
|
||||||
programName: "openclaw",
|
{
|
||||||
rawArgs: ["node", "openclaw", "status"],
|
rawArgs: ["node", "openclaw", "status"],
|
||||||
});
|
expected: ["node", "openclaw", "status"],
|
||||||
expect(nodeArgv).toEqual(["node", "openclaw", "status"]);
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["node-22", "openclaw", "status"],
|
||||||
|
expected: ["node-22", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["node-22.2.0.exe", "openclaw", "status"],
|
||||||
|
expected: ["node-22.2.0.exe", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["node-22.2", "openclaw", "status"],
|
||||||
|
expected: ["node-22.2", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["node-22.2.exe", "openclaw", "status"],
|
||||||
|
expected: ["node-22.2.exe", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"],
|
||||||
|
expected: ["/usr/bin/node-22.2.0", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["nodejs", "openclaw", "status"],
|
||||||
|
expected: ["nodejs", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["node-dev", "openclaw", "status"],
|
||||||
|
expected: ["node", "openclaw", "node-dev", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["openclaw", "status"],
|
||||||
|
expected: ["node", "openclaw", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rawArgs: ["bun", "src/entry.ts", "status"],
|
||||||
|
expected: ["bun", "src/entry.ts", "status"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
const versionedNodeArgv = buildParseArgv({
|
for (const testCase of cases) {
|
||||||
programName: "openclaw",
|
const parsed = buildParseArgv({
|
||||||
rawArgs: ["node-22", "openclaw", "status"],
|
programName: "openclaw",
|
||||||
});
|
rawArgs: [...testCase.rawArgs],
|
||||||
expect(versionedNodeArgv).toEqual(["node-22", "openclaw", "status"]);
|
});
|
||||||
|
expect(parsed).toEqual([...testCase.expected]);
|
||||||
const versionedNodeWindowsArgv = buildParseArgv({
|
}
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["node-22.2.0.exe", "openclaw", "status"],
|
|
||||||
});
|
|
||||||
expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "openclaw", "status"]);
|
|
||||||
|
|
||||||
const versionedNodePatchlessArgv = buildParseArgv({
|
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["node-22.2", "openclaw", "status"],
|
|
||||||
});
|
|
||||||
expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "openclaw", "status"]);
|
|
||||||
|
|
||||||
const versionedNodeWindowsPatchlessArgv = buildParseArgv({
|
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["node-22.2.exe", "openclaw", "status"],
|
|
||||||
});
|
|
||||||
expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "openclaw", "status"]);
|
|
||||||
|
|
||||||
const versionedNodeWithPathArgv = buildParseArgv({
|
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"],
|
|
||||||
});
|
|
||||||
expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "openclaw", "status"]);
|
|
||||||
|
|
||||||
const nodejsArgv = buildParseArgv({
|
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["nodejs", "openclaw", "status"],
|
|
||||||
});
|
|
||||||
expect(nodejsArgv).toEqual(["nodejs", "openclaw", "status"]);
|
|
||||||
|
|
||||||
const nonVersionedNodeArgv = buildParseArgv({
|
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["node-dev", "openclaw", "status"],
|
|
||||||
});
|
|
||||||
expect(nonVersionedNodeArgv).toEqual(["node", "openclaw", "node-dev", "openclaw", "status"]);
|
|
||||||
|
|
||||||
const directArgv = buildParseArgv({
|
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["openclaw", "status"],
|
|
||||||
});
|
|
||||||
expect(directArgv).toEqual(["node", "openclaw", "status"]);
|
|
||||||
|
|
||||||
const bunArgv = buildParseArgv({
|
|
||||||
programName: "openclaw",
|
|
||||||
rawArgs: ["bun", "src/entry.ts", "status"],
|
|
||||||
});
|
|
||||||
expect(bunArgv).toEqual(["bun", "src/entry.ts", "status"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds parse argv from fallback args", () => {
|
it("builds parse argv from fallback args", () => {
|
||||||
@@ -141,23 +210,36 @@ describe("argv helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("decides when to migrate state", () => {
|
it("decides when to migrate state", () => {
|
||||||
expect(shouldMigrateState(["node", "openclaw", "status"])).toBe(false);
|
const nonMutatingArgv = [
|
||||||
expect(shouldMigrateState(["node", "openclaw", "health"])).toBe(false);
|
["node", "openclaw", "status"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "sessions"])).toBe(false);
|
["node", "openclaw", "health"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "config", "get", "update"])).toBe(false);
|
["node", "openclaw", "sessions"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "config", "unset", "update"])).toBe(false);
|
["node", "openclaw", "config", "get", "update"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "models", "list"])).toBe(false);
|
["node", "openclaw", "config", "unset", "update"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "models", "status"])).toBe(false);
|
["node", "openclaw", "models", "list"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "memory", "status"])).toBe(false);
|
["node", "openclaw", "models", "status"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "agent", "--message", "hi"])).toBe(false);
|
["node", "openclaw", "memory", "status"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "agents", "list"])).toBe(true);
|
["node", "openclaw", "agent", "--message", "hi"],
|
||||||
expect(shouldMigrateState(["node", "openclaw", "message", "send"])).toBe(true);
|
] as const;
|
||||||
|
const mutatingArgv = [
|
||||||
|
["node", "openclaw", "agents", "list"],
|
||||||
|
["node", "openclaw", "message", "send"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const argv of nonMutatingArgv) {
|
||||||
|
expect(shouldMigrateState([...argv])).toBe(false);
|
||||||
|
}
|
||||||
|
for (const argv of mutatingArgv) {
|
||||||
|
expect(shouldMigrateState([...argv])).toBe(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reuses command path for migrate state decisions", () => {
|
it.each([
|
||||||
expect(shouldMigrateStateFromPath(["status"])).toBe(false);
|
{ path: ["status"], expected: false },
|
||||||
expect(shouldMigrateStateFromPath(["config", "get"])).toBe(false);
|
{ path: ["config", "get"], expected: false },
|
||||||
expect(shouldMigrateStateFromPath(["models", "status"])).toBe(false);
|
{ path: ["models", "status"], expected: false },
|
||||||
expect(shouldMigrateStateFromPath(["agents", "list"])).toBe(true);
|
{ path: ["agents", "list"], expected: true },
|
||||||
|
])("reuses command path for migrate state decisions: $path", ({ path, expected }) => {
|
||||||
|
expect(shouldMigrateStateFromPath(path)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Command } from "commander";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const copyToClipboard = vi.fn();
|
const copyToClipboard = vi.fn();
|
||||||
@@ -117,7 +118,6 @@ beforeEach(() => {
|
|||||||
runtime.log.mockReset();
|
runtime.log.mockReset();
|
||||||
runtime.error.mockReset();
|
runtime.error.mockReset();
|
||||||
runtime.exit.mockReset();
|
runtime.exit.mockReset();
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function writeManifest(dir: string) {
|
function writeManifest(dir: string) {
|
||||||
@@ -177,8 +177,6 @@ describe("browser extension install (fs-mocked)", () => {
|
|||||||
const dir = path.join(tmp, "browser", "chrome-extension");
|
const dir = path.join(tmp, "browser", "chrome-extension");
|
||||||
writeManifest(dir);
|
writeManifest(dir);
|
||||||
|
|
||||||
const { Command } = await import("commander");
|
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const browser = program.command("browser").option("--json", "JSON output", false);
|
const browser = program.command("browser").option("--json", "JSON output", false);
|
||||||
registerBrowserExtensionCommands(
|
registerBrowserExtensionCommands(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const gatewayMocks = vi.hoisted(() => ({
|
const gatewayMocks = vi.hoisted(() => ({
|
||||||
callGatewayFromCli: vi.fn(async () => ({
|
callGatewayFromCli: vi.fn(async () => ({
|
||||||
@@ -56,56 +56,63 @@ vi.mock("../runtime.js", () => ({
|
|||||||
defaultRuntime: runtime,
|
defaultRuntime: runtime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let registerBrowserInspectCommands: typeof import("./browser-cli-inspect.js").registerBrowserInspectCommands;
|
||||||
|
|
||||||
describe("browser cli snapshot defaults", () => {
|
describe("browser cli snapshot defaults", () => {
|
||||||
|
const runSnapshot = async (args: string[]) => {
|
||||||
|
const program = new Command();
|
||||||
|
const browser = program.command("browser").option("--json", "JSON output", false);
|
||||||
|
registerBrowserInspectCommands(browser, () => ({}));
|
||||||
|
await program.parseAsync(["browser", "snapshot", ...args], { from: "user" });
|
||||||
|
|
||||||
|
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
|
||||||
|
return params as { path?: string; query?: Record<string, unknown> } | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses config snapshot defaults when mode is not provided", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
label: "uses config snapshot defaults when mode is not provided",
|
||||||
|
args: [],
|
||||||
|
expectMode: "efficient",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "does not apply config snapshot defaults to aria snapshots",
|
||||||
|
args: ["--format", "aria"],
|
||||||
|
expectMode: undefined,
|
||||||
|
},
|
||||||
|
])("$label", async ({ args, expectMode }) => {
|
||||||
configMocks.loadConfig.mockReturnValue({
|
configMocks.loadConfig.mockReturnValue({
|
||||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
if (args.includes("--format")) {
|
||||||
const program = new Command();
|
gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({
|
||||||
const browser = program.command("browser").option("--json", "JSON output", false);
|
ok: true,
|
||||||
registerBrowserInspectCommands(browser, () => ({}));
|
format: "aria",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://example.com",
|
||||||
|
snapshot: "ok",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await program.parseAsync(["browser", "snapshot"], { from: "user" });
|
const params = await runSnapshot(args);
|
||||||
|
|
||||||
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
|
|
||||||
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
|
|
||||||
expect(params?.path).toBe("/snapshot");
|
expect(params?.path).toBe("/snapshot");
|
||||||
expect(params?.query).toMatchObject({
|
if (expectMode === undefined) {
|
||||||
format: "ai",
|
expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined();
|
||||||
mode: "efficient",
|
} else {
|
||||||
});
|
expect(params?.query).toMatchObject({
|
||||||
});
|
format: "ai",
|
||||||
|
mode: expectMode,
|
||||||
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
});
|
||||||
configMocks.loadConfig.mockReturnValue({
|
}
|
||||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
format: "aria",
|
|
||||||
targetId: "t1",
|
|
||||||
url: "https://example.com",
|
|
||||||
snapshot: "ok",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
|
||||||
const program = new Command();
|
|
||||||
const browser = program.command("browser").option("--json", "JSON output", false);
|
|
||||||
registerBrowserInspectCommands(browser, () => ({}));
|
|
||||||
|
|
||||||
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
|
|
||||||
|
|
||||||
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
|
|
||||||
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
|
|
||||||
expect(params?.path).toBe("/snapshot");
|
|
||||||
expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ describe("browser state option collisions", () => {
|
|||||||
return call[1] as { body?: Record<string, unknown> };
|
return call[1] as { body?: Record<string, unknown> };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runBrowserCommand = async (argv: string[]) => {
|
||||||
|
const program = createBrowserProgram();
|
||||||
|
await program.parseAsync(["browser", ...argv], { from: "user" });
|
||||||
|
return getLastRequest();
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.callBrowserRequest.mockClear();
|
mocks.callBrowserRequest.mockClear();
|
||||||
mocks.runBrowserResizeWithOutput.mockClear();
|
mocks.runBrowserResizeWithOutput.mockClear();
|
||||||
@@ -55,35 +61,24 @@ describe("browser state option collisions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards parent-captured --target-id on `browser cookies set`", async () => {
|
it("forwards parent-captured --target-id on `browser cookies set`", async () => {
|
||||||
const program = createBrowserProgram();
|
const request = await runBrowserCommand([
|
||||||
|
"cookies",
|
||||||
|
"set",
|
||||||
|
"session",
|
||||||
|
"abc",
|
||||||
|
"--url",
|
||||||
|
"https://example.com",
|
||||||
|
"--target-id",
|
||||||
|
"tab-1",
|
||||||
|
]);
|
||||||
|
|
||||||
await program.parseAsync(
|
expect((request as { body?: { targetId?: string } }).body?.targetId).toBe("tab-1");
|
||||||
[
|
|
||||||
"browser",
|
|
||||||
"cookies",
|
|
||||||
"set",
|
|
||||||
"session",
|
|
||||||
"abc",
|
|
||||||
"--url",
|
|
||||||
"https://example.com",
|
|
||||||
"--target-id",
|
|
||||||
"tab-1",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const request = getLastRequest() as { body?: { targetId?: string } };
|
|
||||||
expect(request.body?.targetId).toBe("tab-1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => {
|
it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => {
|
||||||
const program = createBrowserProgram();
|
const request = (await runBrowserCommand(["set", "headers", "--json", '{"x-auth":"ok"}'])) as {
|
||||||
|
body?: { headers?: Record<string, string> };
|
||||||
await program.parseAsync(["browser", "set", "headers", "--json", '{"x-auth":"ok"}'], {
|
};
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = getLastRequest() as { body?: { headers?: Record<string, string> } };
|
|
||||||
expect(request.body?.headers).toEqual({ "x-auth": "ok" });
|
expect(request.body?.headers).toEqual({ "x-auth": "ok" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,70 +1,50 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("browser CLI --browser-profile flag", () => {
|
function runBrowserStatus(argv: string[]) {
|
||||||
it("parses --browser-profile from parent command options", () => {
|
const program = new Command();
|
||||||
const program = new Command();
|
program.name("test");
|
||||||
program.name("test");
|
program.option("--profile <name>", "Global config profile");
|
||||||
|
|
||||||
const browser = program
|
const browser = program
|
||||||
.command("browser")
|
.command("browser")
|
||||||
.option("--browser-profile <name>", "Browser profile name");
|
.option("--browser-profile <name>", "Browser profile name");
|
||||||
|
|
||||||
let capturedProfile: string | undefined;
|
let globalProfile: string | undefined;
|
||||||
|
let browserProfile: string | undefined = "should-be-undefined";
|
||||||
|
|
||||||
browser.command("status").action((_opts, cmd) => {
|
browser.command("status").action((_opts, cmd) => {
|
||||||
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
|
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
|
||||||
capturedProfile = parent?.browserProfile;
|
browserProfile = parent?.browserProfile;
|
||||||
});
|
globalProfile = program.opts().profile;
|
||||||
|
|
||||||
program.parse(["node", "test", "browser", "--browser-profile", "onasset", "status"]);
|
|
||||||
|
|
||||||
expect(capturedProfile).toBe("onasset");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to undefined when --browser-profile not provided", () => {
|
program.parse(["node", "test", ...argv]);
|
||||||
const program = new Command();
|
|
||||||
program.name("test");
|
|
||||||
|
|
||||||
const browser = program
|
return { globalProfile, browserProfile };
|
||||||
.command("browser")
|
}
|
||||||
.option("--browser-profile <name>", "Browser profile name");
|
|
||||||
|
|
||||||
let capturedProfile: string | undefined = "should-be-undefined";
|
describe("browser CLI --browser-profile flag", () => {
|
||||||
|
it.each([
|
||||||
browser.command("status").action((_opts, cmd) => {
|
{
|
||||||
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
|
label: "parses --browser-profile from parent command options",
|
||||||
capturedProfile = parent?.browserProfile;
|
argv: ["browser", "--browser-profile", "onasset", "status"],
|
||||||
});
|
expectedBrowserProfile: "onasset",
|
||||||
|
},
|
||||||
program.parse(["node", "test", "browser", "status"]);
|
{
|
||||||
|
label: "defaults to undefined when --browser-profile not provided",
|
||||||
expect(capturedProfile).toBeUndefined();
|
argv: ["browser", "status"],
|
||||||
|
expectedBrowserProfile: undefined,
|
||||||
|
},
|
||||||
|
])("$label", ({ argv, expectedBrowserProfile }) => {
|
||||||
|
const { browserProfile } = runBrowserStatus(argv);
|
||||||
|
expect(browserProfile).toBe(expectedBrowserProfile);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not conflict with global --profile flag", () => {
|
it("does not conflict with global --profile flag", () => {
|
||||||
// The global --profile flag is handled by /entry.js before Commander
|
// The global --profile flag is handled by /entry.js before Commander
|
||||||
// This test verifies --browser-profile is a separate option
|
// This test verifies --browser-profile is a separate option
|
||||||
const program = new Command();
|
const { globalProfile, browserProfile } = runBrowserStatus([
|
||||||
program.name("test");
|
|
||||||
program.option("--profile <name>", "Global config profile");
|
|
||||||
|
|
||||||
const browser = program
|
|
||||||
.command("browser")
|
|
||||||
.option("--browser-profile <name>", "Browser profile name");
|
|
||||||
|
|
||||||
let globalProfile: string | undefined;
|
|
||||||
let browserProfile: string | undefined;
|
|
||||||
|
|
||||||
browser.command("status").action((_opts, cmd) => {
|
|
||||||
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
|
|
||||||
browserProfile = parent?.browserProfile;
|
|
||||||
globalProfile = program.opts().profile;
|
|
||||||
});
|
|
||||||
|
|
||||||
program.parse([
|
|
||||||
"node",
|
|
||||||
"test",
|
|
||||||
"--profile",
|
"--profile",
|
||||||
"dev",
|
"dev",
|
||||||
"browser",
|
"browser",
|
||||||
|
|||||||
@@ -2,40 +2,40 @@ import { Command } from "commander";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { inheritOptionFromParent } from "./command-options.js";
|
import { inheritOptionFromParent } from "./command-options.js";
|
||||||
|
|
||||||
|
function attachRunCommandAndCaptureInheritedToken(command: Command) {
|
||||||
|
let inherited: string | undefined;
|
||||||
|
command
|
||||||
|
.command("run")
|
||||||
|
.option("--token <token>", "Run token")
|
||||||
|
.action((_opts, childCommand) => {
|
||||||
|
inherited = inheritOptionFromParent<string>(childCommand, "token");
|
||||||
|
});
|
||||||
|
return () => inherited;
|
||||||
|
}
|
||||||
|
|
||||||
describe("inheritOptionFromParent", () => {
|
describe("inheritOptionFromParent", () => {
|
||||||
it("inherits from grandparent when parent does not define the option", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
label: "inherits from grandparent when parent does not define the option",
|
||||||
|
parentHasTokenOption: false,
|
||||||
|
argv: ["--token", "root-token", "gateway", "run"],
|
||||||
|
expected: "root-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "prefers nearest ancestor value when multiple ancestors set the same option",
|
||||||
|
parentHasTokenOption: true,
|
||||||
|
argv: ["--token", "root-token", "gateway", "--token", "gateway-token", "run"],
|
||||||
|
expected: "gateway-token",
|
||||||
|
},
|
||||||
|
])("$label", async ({ parentHasTokenOption, argv, expected }) => {
|
||||||
const program = new Command().option("--token <token>", "Root token");
|
const program = new Command().option("--token <token>", "Root token");
|
||||||
const gateway = program.command("gateway");
|
const gateway = parentHasTokenOption
|
||||||
let inherited: string | undefined;
|
? program.command("gateway").option("--token <token>", "Gateway token")
|
||||||
|
: program.command("gateway");
|
||||||
|
const getInherited = attachRunCommandAndCaptureInheritedToken(gateway);
|
||||||
|
|
||||||
gateway
|
await program.parseAsync(argv, { from: "user" });
|
||||||
.command("run")
|
expect(getInherited()).toBe(expected);
|
||||||
.option("--token <token>", "Run token")
|
|
||||||
.action((_opts, command) => {
|
|
||||||
inherited = inheritOptionFromParent<string>(command, "token");
|
|
||||||
});
|
|
||||||
|
|
||||||
await program.parseAsync(["--token", "root-token", "gateway", "run"], { from: "user" });
|
|
||||||
expect(inherited).toBe("root-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers nearest ancestor value when multiple ancestors set the same option", async () => {
|
|
||||||
const program = new Command().option("--token <token>", "Root token");
|
|
||||||
const gateway = program.command("gateway").option("--token <token>", "Gateway token");
|
|
||||||
let inherited: string | undefined;
|
|
||||||
|
|
||||||
gateway
|
|
||||||
.command("run")
|
|
||||||
.option("--token <token>", "Run token")
|
|
||||||
.action((_opts, command) => {
|
|
||||||
inherited = inheritOptionFromParent<string>(command, "token");
|
|
||||||
});
|
|
||||||
|
|
||||||
await program.parseAsync(
|
|
||||||
["--token", "root-token", "gateway", "--token", "gateway-token", "run"],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
expect(inherited).toBe("gateway-token");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not inherit when the child option was set explicitly", async () => {
|
it("does not inherit when the child option was set explicitly", async () => {
|
||||||
@@ -54,18 +54,11 @@ describe("inheritOptionFromParent", () => {
|
|||||||
const program = new Command().option("--token <token>", "Root token");
|
const program = new Command().option("--token <token>", "Root token");
|
||||||
const level1 = program.command("level1");
|
const level1 = program.command("level1");
|
||||||
const level2 = level1.command("level2");
|
const level2 = level1.command("level2");
|
||||||
let inherited: string | undefined;
|
const getInherited = attachRunCommandAndCaptureInheritedToken(level2);
|
||||||
|
|
||||||
level2
|
|
||||||
.command("run")
|
|
||||||
.option("--token <token>", "Run token")
|
|
||||||
.action((_opts, command) => {
|
|
||||||
inherited = inheritOptionFromParent<string>(command, "token");
|
|
||||||
});
|
|
||||||
|
|
||||||
await program.parseAsync(["--token", "root-token", "level1", "level2", "run"], {
|
await program.parseAsync(["--token", "root-token", "level1", "level2", "run"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
expect(inherited).toBeUndefined();
|
expect(getInherited()).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,18 +63,24 @@ function resetGatewayMock() {
|
|||||||
callGatewayFromCli.mockImplementation(defaultGatewayMock);
|
callGatewayFromCli.mockImplementation(defaultGatewayMock);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCronEditAndGetPatch(editArgs: string[]): Promise<CronUpdatePatch> {
|
async function runCronCommand(args: string[]): Promise<void> {
|
||||||
resetGatewayMock();
|
resetGatewayMock();
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" });
|
await program.parseAsync(args, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCronCommandExit(args: string[]): Promise<void> {
|
||||||
|
await expect(runCronCommand(args)).rejects.toThrow("__exit__:1");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCronEditAndGetPatch(editArgs: string[]): Promise<CronUpdatePatch> {
|
||||||
|
await runCronCommand(["cron", "edit", "job-1", ...editArgs]);
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
return (updateCall?.[2] ?? {}) as CronUpdatePatch;
|
return (updateCall?.[2] ?? {}) as CronUpdatePatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCronAddAndGetParams(addArgs: string[]): Promise<CronAddParams> {
|
async function runCronAddAndGetParams(addArgs: string[]): Promise<CronAddParams> {
|
||||||
resetGatewayMock();
|
await runCronCommand(["cron", "add", ...addArgs]);
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(["cron", "add", ...addArgs], { from: "user" });
|
|
||||||
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||||
return (addCall?.[2] ?? {}) as CronAddParams;
|
return (addCall?.[2] ?? {}) as CronAddParams;
|
||||||
}
|
}
|
||||||
@@ -82,9 +88,7 @@ async function runCronAddAndGetParams(addArgs: string[]): Promise<CronAddParams>
|
|||||||
async function runCronSimpleAndGetUpdatePatch(
|
async function runCronSimpleAndGetUpdatePatch(
|
||||||
command: "enable" | "disable",
|
command: "enable" | "disable",
|
||||||
): Promise<{ enabled?: boolean }> {
|
): Promise<{ enabled?: boolean }> {
|
||||||
resetGatewayMock();
|
await runCronCommand(["cron", command, "job-1"]);
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(["cron", command, "job-1"], { from: "user" });
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
return ((updateCall?.[2] as { patch?: { enabled?: boolean } } | undefined)?.patch ?? {}) as {
|
return ((updateCall?.[2] as { patch?: { enabled?: boolean } } | undefined)?.patch ?? {}) as {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -109,31 +113,52 @@ function mockCronEditJobLookup(schedule: unknown): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGatewayCallParams<T>(method: string): T {
|
||||||
|
const call = callGatewayFromCli.mock.calls.find((entry) => entry[0] === method);
|
||||||
|
return (call?.[2] ?? {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCronEditWithScheduleLookup(
|
||||||
|
schedule: unknown,
|
||||||
|
editArgs: string[],
|
||||||
|
): Promise<CronUpdatePatch> {
|
||||||
|
resetGatewayMock();
|
||||||
|
mockCronEditJobLookup(schedule);
|
||||||
|
const program = buildProgram();
|
||||||
|
await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" });
|
||||||
|
return getGatewayCallParams<CronUpdatePatch>("cron.update");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCronEditWithScheduleLookupExit(
|
||||||
|
schedule: unknown,
|
||||||
|
editArgs: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
resetGatewayMock();
|
||||||
|
mockCronEditJobLookup(schedule);
|
||||||
|
const program = buildProgram();
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" }),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
}
|
||||||
|
|
||||||
describe("cron cli", () => {
|
describe("cron cli", () => {
|
||||||
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => {
|
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand([
|
||||||
|
"cron",
|
||||||
const program = buildProgram();
|
"add",
|
||||||
|
"--name",
|
||||||
await program.parseAsync(
|
"Daily",
|
||||||
[
|
"--cron",
|
||||||
"cron",
|
"* * * * *",
|
||||||
"add",
|
"--session",
|
||||||
"--name",
|
"isolated",
|
||||||
"Daily",
|
"--message",
|
||||||
"--cron",
|
"hello",
|
||||||
"* * * * *",
|
"--model",
|
||||||
"--session",
|
" opus ",
|
||||||
"isolated",
|
"--thinking",
|
||||||
"--message",
|
" low ",
|
||||||
"hello",
|
]);
|
||||||
"--model",
|
|
||||||
" opus ",
|
|
||||||
"--thinking",
|
|
||||||
" low ",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||||
const params = addCall?.[2] as {
|
const params = addCall?.[2] as {
|
||||||
@@ -145,25 +170,18 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults isolated cron add to announce delivery", async () => {
|
it("defaults isolated cron add to announce delivery", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand([
|
||||||
|
"cron",
|
||||||
const program = buildProgram();
|
"add",
|
||||||
|
"--name",
|
||||||
await program.parseAsync(
|
"Daily",
|
||||||
[
|
"--cron",
|
||||||
"cron",
|
"* * * * *",
|
||||||
"add",
|
"--session",
|
||||||
"--name",
|
"isolated",
|
||||||
"Daily",
|
"--message",
|
||||||
"--cron",
|
"hello",
|
||||||
"* * * * *",
|
]);
|
||||||
"--session",
|
|
||||||
"isolated",
|
|
||||||
"--message",
|
|
||||||
"hello",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||||
const params = addCall?.[2] as { delivery?: { mode?: string } };
|
const params = addCall?.[2] as { delivery?: { mode?: string } };
|
||||||
@@ -172,26 +190,32 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("infers sessionTarget from payload when --session is omitted", async () => {
|
it("infers sessionTarget from payload when --session is omitted", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand([
|
||||||
|
"cron",
|
||||||
const program = buildProgram();
|
"add",
|
||||||
|
"--name",
|
||||||
await program.parseAsync(
|
"Main reminder",
|
||||||
["cron", "add", "--name", "Main reminder", "--cron", "* * * * *", "--system-event", "hi"],
|
"--cron",
|
||||||
{ from: "user" },
|
"* * * * *",
|
||||||
);
|
"--system-event",
|
||||||
|
"hi",
|
||||||
|
]);
|
||||||
|
|
||||||
let addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
let addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||||
let params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
|
let params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
|
||||||
expect(params?.sessionTarget).toBe("main");
|
expect(params?.sessionTarget).toBe("main");
|
||||||
expect(params?.payload?.kind).toBe("systemEvent");
|
expect(params?.payload?.kind).toBe("systemEvent");
|
||||||
|
|
||||||
resetGatewayMock();
|
await runCronCommand([
|
||||||
|
"cron",
|
||||||
await program.parseAsync(
|
"add",
|
||||||
["cron", "add", "--name", "Isolated task", "--cron", "* * * * *", "--message", "hello"],
|
"--name",
|
||||||
{ from: "user" },
|
"Isolated task",
|
||||||
);
|
"--cron",
|
||||||
|
"* * * * *",
|
||||||
|
"--message",
|
||||||
|
"hello",
|
||||||
|
]);
|
||||||
|
|
||||||
addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||||
params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
|
params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
|
||||||
@@ -200,133 +224,90 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("supports --keep-after-run on cron add", async () => {
|
it("supports --keep-after-run on cron add", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand([
|
||||||
|
"cron",
|
||||||
const program = buildProgram();
|
"add",
|
||||||
|
"--name",
|
||||||
await program.parseAsync(
|
"Keep me",
|
||||||
[
|
"--at",
|
||||||
"cron",
|
"20m",
|
||||||
"add",
|
"--session",
|
||||||
"--name",
|
"main",
|
||||||
"Keep me",
|
"--system-event",
|
||||||
"--at",
|
"hello",
|
||||||
"20m",
|
"--keep-after-run",
|
||||||
"--session",
|
]);
|
||||||
"main",
|
|
||||||
"--system-event",
|
|
||||||
"hello",
|
|
||||||
"--keep-after-run",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||||
const params = addCall?.[2] as { deleteAfterRun?: boolean };
|
const params = addCall?.[2] as { deleteAfterRun?: boolean };
|
||||||
expect(params?.deleteAfterRun).toBe(false);
|
expect(params?.deleteAfterRun).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cron enable sets enabled=true patch", async () => {
|
it.each([
|
||||||
const patch = await runCronSimpleAndGetUpdatePatch("enable");
|
{ command: "enable" as const, expectedEnabled: true },
|
||||||
expect(patch.enabled).toBe(true);
|
{ command: "disable" as const, expectedEnabled: false },
|
||||||
});
|
])("cron $command sets enabled=$expectedEnabled patch", async ({ command, expectedEnabled }) => {
|
||||||
|
const patch = await runCronSimpleAndGetUpdatePatch(command);
|
||||||
it("cron disable sets enabled=false patch", async () => {
|
expect(patch.enabled).toBe(expectedEnabled);
|
||||||
const patch = await runCronSimpleAndGetUpdatePatch("disable");
|
|
||||||
expect(patch.enabled).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends agent id on cron add", async () => {
|
it("sends agent id on cron add", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand([
|
||||||
|
"cron",
|
||||||
const program = buildProgram();
|
"add",
|
||||||
|
"--name",
|
||||||
await program.parseAsync(
|
"Agent pinned",
|
||||||
[
|
"--cron",
|
||||||
"cron",
|
"* * * * *",
|
||||||
"add",
|
"--session",
|
||||||
"--name",
|
"isolated",
|
||||||
"Agent pinned",
|
"--message",
|
||||||
"--cron",
|
"hi",
|
||||||
"* * * * *",
|
"--agent",
|
||||||
"--session",
|
"ops",
|
||||||
"isolated",
|
]);
|
||||||
"--message",
|
|
||||||
"hi",
|
|
||||||
"--agent",
|
|
||||||
"ops",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||||
const params = addCall?.[2] as { agentId?: string };
|
const params = addCall?.[2] as { agentId?: string };
|
||||||
expect(params?.agentId).toBe("ops");
|
expect(params?.agentId).toBe("ops");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits empty model and thinking on cron edit", async () => {
|
it.each([
|
||||||
const patch = await runCronEditAndGetPatch([
|
{
|
||||||
"--message",
|
label: "omits empty model and thinking",
|
||||||
"hello",
|
args: ["--message", "hello", "--model", " ", "--thinking", " "],
|
||||||
"--model",
|
expectedModel: undefined,
|
||||||
" ",
|
expectedThinking: undefined,
|
||||||
"--thinking",
|
},
|
||||||
" ",
|
{
|
||||||
]);
|
label: "trims model and thinking",
|
||||||
|
args: ["--message", "hello", "--model", " opus ", "--thinking", " high "],
|
||||||
expect(patch?.patch?.payload?.model).toBeUndefined();
|
expectedModel: "opus",
|
||||||
expect(patch?.patch?.payload?.thinking).toBeUndefined();
|
expectedThinking: "high",
|
||||||
});
|
},
|
||||||
|
])("cron edit $label", async ({ args, expectedModel, expectedThinking }) => {
|
||||||
it("trims model and thinking on cron edit", async () => {
|
const patch = await runCronEditAndGetPatch(args);
|
||||||
const patch = await runCronEditAndGetPatch([
|
expect(patch?.patch?.payload?.model).toBe(expectedModel);
|
||||||
"--message",
|
expect(patch?.patch?.payload?.thinking).toBe(expectedThinking);
|
||||||
"hello",
|
|
||||||
"--model",
|
|
||||||
" opus ",
|
|
||||||
"--thinking",
|
|
||||||
" high ",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.model).toBe("opus");
|
|
||||||
expect(patch?.patch?.payload?.thinking).toBe("high");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets and clears agent id on cron edit", async () => {
|
it("sets and clears agent id on cron edit", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"]);
|
||||||
|
|
||||||
const program = buildProgram();
|
const patch = getGatewayCallParams<{ patch?: { agentId?: unknown } }>("cron.update");
|
||||||
|
|
||||||
await program.parseAsync(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
|
||||||
const patch = updateCall?.[2] as { patch?: { agentId?: unknown } };
|
|
||||||
expect(patch?.patch?.agentId).toBe("ops");
|
expect(patch?.patch?.agentId).toBe("ops");
|
||||||
|
|
||||||
resetGatewayMock();
|
await runCronCommand(["cron", "edit", "job-2", "--clear-agent"]);
|
||||||
await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], {
|
const clearPatch = getGatewayCallParams<{ patch?: { agentId?: unknown } }>("cron.update");
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
const clearCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
|
||||||
const clearPatch = clearCall?.[2] as { patch?: { agentId?: unknown } };
|
|
||||||
expect(clearPatch?.patch?.agentId).toBeNull();
|
expect(clearPatch?.patch?.agentId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows model/thinking updates without --message", async () => {
|
it("allows model/thinking updates without --message", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand(["cron", "edit", "job-1", "--model", "opus", "--thinking", "low"]);
|
||||||
|
|
||||||
const program = buildProgram();
|
const patch = getGatewayCallParams<{
|
||||||
|
|
||||||
await program.parseAsync(["cron", "edit", "job-1", "--model", "opus", "--thinking", "low"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
|
||||||
const patch = updateCall?.[2] as {
|
|
||||||
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
|
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
|
||||||
};
|
}>("cron.update");
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
expect(patch?.patch?.payload?.model).toBe("opus");
|
expect(patch?.patch?.payload?.model).toBe("opus");
|
||||||
@@ -334,22 +315,23 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates delivery settings without requiring --message", async () => {
|
it("updates delivery settings without requiring --message", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand([
|
||||||
|
"cron",
|
||||||
|
"edit",
|
||||||
|
"job-1",
|
||||||
|
"--deliver",
|
||||||
|
"--channel",
|
||||||
|
"telegram",
|
||||||
|
"--to",
|
||||||
|
"19098680",
|
||||||
|
]);
|
||||||
|
|
||||||
const program = buildProgram();
|
const patch = getGatewayCallParams<{
|
||||||
|
|
||||||
await program.parseAsync(
|
|
||||||
["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
|
||||||
const patch = updateCall?.[2] as {
|
|
||||||
patch?: {
|
patch?: {
|
||||||
payload?: { kind?: string; message?: string };
|
payload?: { kind?: string; message?: string };
|
||||||
delivery?: { mode?: string; channel?: string; to?: string };
|
delivery?: { mode?: string; channel?: string; to?: string };
|
||||||
};
|
};
|
||||||
};
|
}>("cron.update");
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
expect(patch?.patch?.delivery?.mode).toBe("announce");
|
expect(patch?.patch?.delivery?.mode).toBe("announce");
|
||||||
@@ -359,33 +341,21 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("supports --no-deliver on cron edit", async () => {
|
it("supports --no-deliver on cron edit", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand(["cron", "edit", "job-1", "--no-deliver"]);
|
||||||
|
|
||||||
const program = buildProgram();
|
const patch = getGatewayCallParams<{
|
||||||
|
|
||||||
await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" });
|
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
|
||||||
const patch = updateCall?.[2] as {
|
|
||||||
patch?: { payload?: { kind?: string }; delivery?: { mode?: string } };
|
patch?: { payload?: { kind?: string }; delivery?: { mode?: string } };
|
||||||
};
|
}>("cron.update");
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
expect(patch?.patch?.delivery?.mode).toBe("none");
|
expect(patch?.patch?.delivery?.mode).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not include undefined delivery fields when updating message", async () => {
|
it("does not include undefined delivery fields when updating message", async () => {
|
||||||
resetGatewayMock();
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
|
|
||||||
// Update message without delivery flags - should NOT include undefined delivery fields
|
// Update message without delivery flags - should NOT include undefined delivery fields
|
||||||
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
|
await runCronCommand(["cron", "edit", "job-1", "--message", "Updated message"]);
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const patch = getGatewayCallParams<{
|
||||||
const patch = updateCall?.[2] as {
|
|
||||||
patch?: {
|
patch?: {
|
||||||
payload?: {
|
payload?: {
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -396,7 +366,7 @@ describe("cron cli", () => {
|
|||||||
};
|
};
|
||||||
delivery?: unknown;
|
delivery?: unknown;
|
||||||
};
|
};
|
||||||
};
|
}>("cron.update");
|
||||||
|
|
||||||
// Should include the new message
|
// Should include the new message
|
||||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
@@ -427,28 +397,14 @@ describe("cron cli", () => {
|
|||||||
expect(patch?.patch?.delivery?.to).toBe("19098680");
|
expect(patch?.patch?.delivery?.to).toBe("19098680");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes best-effort delivery when provided with message", async () => {
|
it.each([
|
||||||
const patch = await runCronEditAndGetPatch([
|
{ flag: "--best-effort-deliver", expectedBestEffort: true },
|
||||||
"--message",
|
{ flag: "--no-best-effort-deliver", expectedBestEffort: false },
|
||||||
"Updated message",
|
])("applies $flag on cron edit message updates", async ({ flag, expectedBestEffort }) => {
|
||||||
"--best-effort-deliver",
|
const patch = await runCronEditAndGetPatch(["--message", "Updated message", flag]);
|
||||||
]);
|
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
expect(patch?.patch?.delivery?.mode).toBe("announce");
|
expect(patch?.patch?.delivery?.mode).toBe("announce");
|
||||||
expect(patch?.patch?.delivery?.bestEffort).toBe(true);
|
expect(patch?.patch?.delivery?.bestEffort).toBe(expectedBestEffort);
|
||||||
});
|
|
||||||
|
|
||||||
it("includes no-best-effort delivery when provided with message", async () => {
|
|
||||||
const patch = await runCronEditAndGetPatch([
|
|
||||||
"--message",
|
|
||||||
"Updated message",
|
|
||||||
"--no-best-effort-deliver",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
|
||||||
expect(patch?.patch?.delivery?.mode).toBe("announce");
|
|
||||||
expect(patch?.patch?.delivery?.bestEffort).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets explicit stagger for cron add", async () => {
|
it("sets explicit stagger for cron add", async () => {
|
||||||
@@ -485,83 +441,55 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects --stagger with --exact on add", async () => {
|
it("rejects --stagger with --exact on add", async () => {
|
||||||
resetGatewayMock();
|
await expectCronCommandExit([
|
||||||
const program = buildProgram();
|
"cron",
|
||||||
|
"add",
|
||||||
await expect(
|
"--name",
|
||||||
program.parseAsync(
|
"invalid",
|
||||||
[
|
"--cron",
|
||||||
"cron",
|
"0 * * * *",
|
||||||
"add",
|
"--stagger",
|
||||||
"--name",
|
"1m",
|
||||||
"invalid",
|
"--exact",
|
||||||
"--cron",
|
"--session",
|
||||||
"0 * * * *",
|
"main",
|
||||||
"--stagger",
|
"--system-event",
|
||||||
"1m",
|
"tick",
|
||||||
"--exact",
|
]);
|
||||||
"--session",
|
|
||||||
"main",
|
|
||||||
"--system-event",
|
|
||||||
"tick",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects --stagger when schedule is not cron", async () => {
|
it("rejects --stagger when schedule is not cron", async () => {
|
||||||
resetGatewayMock();
|
await expectCronCommandExit([
|
||||||
const program = buildProgram();
|
"cron",
|
||||||
|
"add",
|
||||||
await expect(
|
"--name",
|
||||||
program.parseAsync(
|
"invalid",
|
||||||
[
|
"--every",
|
||||||
"cron",
|
"10m",
|
||||||
"add",
|
"--stagger",
|
||||||
"--name",
|
"30s",
|
||||||
"invalid",
|
"--session",
|
||||||
"--every",
|
"main",
|
||||||
"10m",
|
"--system-event",
|
||||||
"--stagger",
|
"tick",
|
||||||
"30s",
|
]);
|
||||||
"--session",
|
|
||||||
"main",
|
|
||||||
"--system-event",
|
|
||||||
"tick",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets explicit stagger for cron edit", async () => {
|
it("sets explicit stagger for cron edit", async () => {
|
||||||
resetGatewayMock();
|
await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]);
|
||||||
const program = buildProgram();
|
|
||||||
|
|
||||||
await program.parseAsync(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"], {
|
const patch = getGatewayCallParams<{
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
|
||||||
const patch = updateCall?.[2] as {
|
|
||||||
patch?: { schedule?: { kind?: string; staggerMs?: number } };
|
patch?: { schedule?: { kind?: string; staggerMs?: number } };
|
||||||
};
|
}>("cron.update");
|
||||||
expect(patch?.patch?.schedule?.kind).toBe("cron");
|
expect(patch?.patch?.schedule?.kind).toBe("cron");
|
||||||
expect(patch?.patch?.schedule?.staggerMs).toBe(30_000);
|
expect(patch?.patch?.schedule?.staggerMs).toBe(30_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies --exact to existing cron job without requiring --cron on edit", async () => {
|
it("applies --exact to existing cron job without requiring --cron on edit", async () => {
|
||||||
resetGatewayMock();
|
const patch = await runCronEditWithScheduleLookup(
|
||||||
mockCronEditJobLookup({ kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 });
|
{ kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 },
|
||||||
const program = buildProgram();
|
["--exact"],
|
||||||
|
);
|
||||||
await program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" });
|
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
|
||||||
const patch = updateCall?.[2] as {
|
|
||||||
patch?: { schedule?: { kind?: string; expr?: string; tz?: string; staggerMs?: number } };
|
|
||||||
};
|
|
||||||
expect(patch?.patch?.schedule).toEqual({
|
expect(patch?.patch?.schedule).toEqual({
|
||||||
kind: "cron",
|
kind: "cron",
|
||||||
expr: "0 */2 * * *",
|
expr: "0 */2 * * *",
|
||||||
@@ -571,12 +499,6 @@ describe("cron cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects --exact on edit when existing job is not cron", async () => {
|
it("rejects --exact on edit when existing job is not cron", async () => {
|
||||||
resetGatewayMock();
|
await expectCronEditWithScheduleLookupExit({ kind: "every", everyMs: 60_000 }, ["--exact"]);
|
||||||
mockCronEditJobLookup({ kind: "every", everyMs: 60_000 });
|
|
||||||
const program = buildProgram();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,6 +72,25 @@ vi.mock("./progress.js", () => ({
|
|||||||
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(),
|
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { registerDaemonCli } = await import("./daemon-cli.js");
|
||||||
|
|
||||||
|
function createDaemonProgram() {
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerDaemonCli(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDaemonCommand(args: string[]) {
|
||||||
|
const program = createDaemonProgram();
|
||||||
|
await program.parseAsync(args, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFirstJsonRuntimeLine<T>() {
|
||||||
|
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||||
|
return JSON.parse(jsonLine ?? "{}") as T;
|
||||||
|
}
|
||||||
|
|
||||||
describe("daemon-cli coverage", () => {
|
describe("daemon-cli coverage", () => {
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
|
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
|
||||||
@@ -118,12 +137,7 @@ describe("daemon-cli coverage", () => {
|
|||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
callGateway.mockClear();
|
callGateway.mockClear();
|
||||||
|
|
||||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
await runDaemonCommand(["daemon", "status"]);
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerDaemonCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["daemon", "status"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "status" }));
|
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "status" }));
|
||||||
@@ -147,12 +161,7 @@ describe("daemon-cli coverage", () => {
|
|||||||
sourcePath: "/tmp/bot.molt.gateway.plist",
|
sourcePath: "/tmp/bot.molt.gateway.plist",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
await runDaemonCommand(["daemon", "status", "--json"]);
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerDaemonCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["daemon", "status", "--json"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -162,12 +171,11 @@ describe("daemon-cli coverage", () => {
|
|||||||
);
|
);
|
||||||
expect(inspectPortUsage).toHaveBeenCalledWith(19001);
|
expect(inspectPortUsage).toHaveBeenCalledWith(19001);
|
||||||
|
|
||||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
const parsed = parseFirstJsonRuntimeLine<{
|
||||||
const parsed = JSON.parse(jsonLine ?? "{}") as {
|
|
||||||
gateway?: { port?: number; portSource?: string; probeUrl?: string };
|
gateway?: { port?: number; portSource?: string; probeUrl?: string };
|
||||||
config?: { mismatch?: boolean };
|
config?: { mismatch?: boolean };
|
||||||
rpc?: { url?: string; ok?: boolean };
|
rpc?: { url?: string; ok?: boolean };
|
||||||
};
|
}>();
|
||||||
expect(parsed.gateway?.port).toBe(19001);
|
expect(parsed.gateway?.port).toBe(19001);
|
||||||
expect(parsed.gateway?.portSource).toBe("service args");
|
expect(parsed.gateway?.portSource).toBe("service args");
|
||||||
expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001");
|
expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001");
|
||||||
@@ -179,12 +187,7 @@ describe("daemon-cli coverage", () => {
|
|||||||
it("passes deep scan flag for daemon status", async () => {
|
it("passes deep scan flag for daemon status", async () => {
|
||||||
findExtraGatewayServices.mockClear();
|
findExtraGatewayServices.mockClear();
|
||||||
|
|
||||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
await runDaemonCommand(["daemon", "status", "--deep"]);
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerDaemonCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["daemon", "status", "--deep"], { from: "user" });
|
|
||||||
|
|
||||||
expect(findExtraGatewayServices).toHaveBeenCalledWith(
|
expect(findExtraGatewayServices).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -192,81 +195,53 @@ describe("daemon-cli coverage", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("installs the daemon when requested", async () => {
|
it.each([
|
||||||
serviceIsLoaded.mockResolvedValueOnce(false);
|
{ label: "plain output", includeJsonFlag: false },
|
||||||
serviceInstall.mockClear();
|
{ label: "json output", includeJsonFlag: true },
|
||||||
|
])("installs the daemon ($label)", async ({ includeJsonFlag }) => {
|
||||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerDaemonCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["daemon", "install", "--port", "18789"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("installs the daemon with json output", async () => {
|
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
serviceIsLoaded.mockResolvedValueOnce(false);
|
serviceIsLoaded.mockResolvedValueOnce(false);
|
||||||
serviceInstall.mockClear();
|
serviceInstall.mockClear();
|
||||||
|
|
||||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
const args = includeJsonFlag
|
||||||
const program = new Command();
|
? ["daemon", "install", "--port", "18789", "--json"]
|
||||||
program.exitOverride();
|
: ["daemon", "install", "--port", "18789"];
|
||||||
registerDaemonCli(program);
|
await runDaemonCommand(args);
|
||||||
|
|
||||||
await program.parseAsync(["daemon", "install", "--port", "18789", "--json"], {
|
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||||
from: "user",
|
if (includeJsonFlag) {
|
||||||
});
|
const parsed = parseFirstJsonRuntimeLine<{
|
||||||
|
ok?: boolean;
|
||||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
action?: string;
|
||||||
const parsed = JSON.parse(jsonLine ?? "{}") as {
|
result?: string;
|
||||||
ok?: boolean;
|
}>();
|
||||||
action?: string;
|
expect(parsed.ok).toBe(true);
|
||||||
result?: string;
|
expect(parsed.action).toBe("install");
|
||||||
};
|
expect(parsed.result).toBe("installed");
|
||||||
expect(parsed.ok).toBe(true);
|
}
|
||||||
expect(parsed.action).toBe("install");
|
|
||||||
expect(parsed.result).toBe("installed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts and stops the daemon via service helpers", async () => {
|
it.each([
|
||||||
|
{ label: "plain output", includeJsonFlag: false },
|
||||||
|
{ label: "json output", includeJsonFlag: true },
|
||||||
|
])("starts and stops daemon ($label)", async ({ includeJsonFlag }) => {
|
||||||
|
resetRuntimeCapture();
|
||||||
serviceRestart.mockClear();
|
serviceRestart.mockClear();
|
||||||
serviceStop.mockClear();
|
serviceStop.mockClear();
|
||||||
serviceIsLoaded.mockResolvedValue(true);
|
serviceIsLoaded.mockResolvedValue(true);
|
||||||
|
|
||||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
const startArgs = includeJsonFlag ? ["daemon", "start", "--json"] : ["daemon", "start"];
|
||||||
const program = new Command();
|
const stopArgs = includeJsonFlag ? ["daemon", "stop", "--json"] : ["daemon", "stop"];
|
||||||
program.exitOverride();
|
await runDaemonCommand(startArgs);
|
||||||
registerDaemonCli(program);
|
await runDaemonCommand(stopArgs);
|
||||||
|
|
||||||
await program.parseAsync(["daemon", "start"], { from: "user" });
|
|
||||||
await program.parseAsync(["daemon", "stop"], { from: "user" });
|
|
||||||
|
|
||||||
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
||||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||||
});
|
if (includeJsonFlag) {
|
||||||
|
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
|
||||||
it("emits json for daemon start/stop", async () => {
|
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
|
||||||
resetRuntimeCapture();
|
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
|
||||||
serviceRestart.mockClear();
|
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
|
||||||
serviceStop.mockClear();
|
}
|
||||||
serviceIsLoaded.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerDaemonCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["daemon", "start", "--json"], { from: "user" });
|
|
||||||
await program.parseAsync(["daemon", "stop", "--json"], { from: "user" });
|
|
||||||
|
|
||||||
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
|
|
||||||
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
|
|
||||||
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
|
|
||||||
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const loadConfig = vi.fn(() => ({
|
const loadConfig = vi.fn(() => ({
|
||||||
gateway: {
|
gateway: {
|
||||||
@@ -38,7 +38,13 @@ vi.mock("../../runtime.js", () => ({
|
|||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart;
|
||||||
|
|
||||||
describe("runServiceRestart token drift", () => {
|
describe("runServiceRestart token drift", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ runServiceRestart } = await import("./lifecycle-core.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
loadConfig.mockClear();
|
loadConfig.mockClear();
|
||||||
@@ -56,8 +62,6 @@ describe("runServiceRestart token drift", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("emits drift warning when enabled", async () => {
|
it("emits drift warning when enabled", async () => {
|
||||||
const { runServiceRestart } = await import("./lifecycle-core.js");
|
|
||||||
|
|
||||||
await runServiceRestart({
|
await runServiceRestart({
|
||||||
serviceNoun: "Gateway",
|
serviceNoun: "Gateway",
|
||||||
service,
|
service,
|
||||||
@@ -73,8 +77,6 @@ describe("runServiceRestart token drift", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips drift warning when disabled", async () => {
|
it("skips drift warning when disabled", async () => {
|
||||||
const { runServiceRestart } = await import("./lifecycle-core.js");
|
|
||||||
|
|
||||||
await runServiceRestart({
|
await runServiceRestart({
|
||||||
serviceNoun: "Node",
|
serviceNoun: "Node",
|
||||||
service,
|
service,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const callGateway = vi.fn();
|
const callGateway = vi.fn();
|
||||||
const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn());
|
const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn());
|
||||||
@@ -21,29 +21,23 @@ vi.mock("../runtime.js", () => ({
|
|||||||
defaultRuntime: runtime,
|
defaultRuntime: runtime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let registerDevicesCli: typeof import("./devices-cli.js").registerDevicesCli;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerDevicesCli } = await import("./devices-cli.js"));
|
||||||
|
});
|
||||||
|
|
||||||
async function runDevicesApprove(argv: string[]) {
|
async function runDevicesApprove(argv: string[]) {
|
||||||
const { registerDevicesCli } = await import("./devices-cli.js");
|
await runDevicesCommand(["approve", ...argv]);
|
||||||
const program = new Command();
|
|
||||||
registerDevicesCli(program);
|
|
||||||
await program.parseAsync(["devices", "approve", ...argv], { from: "user" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDevicesCommand(argv: string[]) {
|
async function runDevicesCommand(argv: string[]) {
|
||||||
const { registerDevicesCli } = await import("./devices-cli.js");
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
registerDevicesCli(program);
|
registerDevicesCli(program);
|
||||||
await program.parseAsync(["devices", ...argv], { from: "user" });
|
await program.parseAsync(["devices", ...argv], { from: "user" });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("devices cli approve", () => {
|
describe("devices cli approve", () => {
|
||||||
afterEach(() => {
|
|
||||||
callGateway.mockReset();
|
|
||||||
withProgress.mockClear();
|
|
||||||
runtime.log.mockReset();
|
|
||||||
runtime.error.mockReset();
|
|
||||||
runtime.exit.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("approves an explicit request id without listing", async () => {
|
it("approves an explicit request id without listing", async () => {
|
||||||
callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
|
callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
|
||||||
|
|
||||||
@@ -58,17 +52,33 @@ describe("devices cli approve", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-approves the latest pending request when id is omitted", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "id is omitted",
|
||||||
|
args: [] as string[],
|
||||||
|
pending: [
|
||||||
|
{ requestId: "req-1", ts: 1000 },
|
||||||
|
{ requestId: "req-2", ts: 2000 },
|
||||||
|
],
|
||||||
|
expectedRequestId: "req-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "--latest is passed",
|
||||||
|
args: ["req-old", "--latest"] as string[],
|
||||||
|
pending: [
|
||||||
|
{ requestId: "req-2", ts: 2000 },
|
||||||
|
{ requestId: "req-3", ts: 3000 },
|
||||||
|
],
|
||||||
|
expectedRequestId: "req-3",
|
||||||
|
},
|
||||||
|
])("uses latest pending request when $name", async ({ args, pending, expectedRequestId }) => {
|
||||||
callGateway
|
callGateway
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
pending: [
|
pending,
|
||||||
{ requestId: "req-1", ts: 1000 },
|
|
||||||
{ requestId: "req-2", ts: 2000 },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ device: { deviceId: "device-2" } });
|
.mockResolvedValueOnce({ device: { deviceId: "device-2" } });
|
||||||
|
|
||||||
await runDevicesApprove([]);
|
await runDevicesApprove(args);
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenNthCalledWith(
|
expect(callGateway).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
@@ -78,28 +88,7 @@ describe("devices cli approve", () => {
|
|||||||
2,
|
2,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "device.pair.approve",
|
method: "device.pair.approve",
|
||||||
params: { requestId: "req-2" },
|
params: { requestId: expectedRequestId },
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses latest pending request when --latest is passed", async () => {
|
|
||||||
callGateway
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
pending: [
|
|
||||||
{ requestId: "req-2", ts: 2000 },
|
|
||||||
{ requestId: "req-3", ts: 3000 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ device: { deviceId: "device-3" } });
|
|
||||||
|
|
||||||
await runDevicesApprove(["req-old", "--latest"]);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "device.pair.approve",
|
|
||||||
params: { requestId: "req-3" },
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -122,14 +111,6 @@ describe("devices cli approve", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("devices cli remove", () => {
|
describe("devices cli remove", () => {
|
||||||
afterEach(() => {
|
|
||||||
callGateway.mockReset();
|
|
||||||
withProgress.mockClear();
|
|
||||||
runtime.log.mockReset();
|
|
||||||
runtime.error.mockReset();
|
|
||||||
runtime.exit.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes a paired device by id", async () => {
|
it("removes a paired device by id", async () => {
|
||||||
callGateway.mockResolvedValueOnce({ deviceId: "device-1" });
|
callGateway.mockResolvedValueOnce({ deviceId: "device-1" });
|
||||||
|
|
||||||
@@ -146,14 +127,6 @@ describe("devices cli remove", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("devices cli clear", () => {
|
describe("devices cli clear", () => {
|
||||||
afterEach(() => {
|
|
||||||
callGateway.mockReset();
|
|
||||||
withProgress.mockClear();
|
|
||||||
runtime.log.mockReset();
|
|
||||||
runtime.error.mockReset();
|
|
||||||
runtime.exit.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires --yes before clearing", async () => {
|
it("requires --yes before clearing", async () => {
|
||||||
await runDevicesCommand(["clear"]);
|
await runDevicesCommand(["clear"]);
|
||||||
|
|
||||||
@@ -194,55 +167,44 @@ describe("devices cli clear", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("devices cli tokens", () => {
|
describe("devices cli tokens", () => {
|
||||||
afterEach(() => {
|
it.each([
|
||||||
callGateway.mockReset();
|
{
|
||||||
withProgress.mockClear();
|
label: "rotates a token for a device role",
|
||||||
runtime.log.mockReset();
|
argv: [
|
||||||
runtime.error.mockReset();
|
"rotate",
|
||||||
runtime.exit.mockReset();
|
"--device",
|
||||||
});
|
"device-1",
|
||||||
|
"--role",
|
||||||
it("rotates a token for a device role", async () => {
|
"main",
|
||||||
callGateway.mockResolvedValueOnce({ ok: true });
|
"--scope",
|
||||||
|
"messages:send",
|
||||||
await runDevicesCommand([
|
"--scope",
|
||||||
"rotate",
|
"messages:read",
|
||||||
"--device",
|
],
|
||||||
"device-1",
|
expectedCall: {
|
||||||
"--role",
|
|
||||||
"main",
|
|
||||||
"--scope",
|
|
||||||
"messages:send",
|
|
||||||
"--scope",
|
|
||||||
"messages:read",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "device.token.rotate",
|
method: "device.token.rotate",
|
||||||
params: {
|
params: {
|
||||||
deviceId: "device-1",
|
deviceId: "device-1",
|
||||||
role: "main",
|
role: "main",
|
||||||
scopes: ["messages:send", "messages:read"],
|
scopes: ["messages:send", "messages:read"],
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
);
|
},
|
||||||
});
|
{
|
||||||
|
label: "revokes a token for a device role",
|
||||||
it("revokes a token for a device role", async () => {
|
argv: ["revoke", "--device", "device-1", "--role", "main"],
|
||||||
callGateway.mockResolvedValueOnce({ ok: true });
|
expectedCall: {
|
||||||
|
|
||||||
await runDevicesCommand(["revoke", "--device", "device-1", "--role", "main"]);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "device.token.revoke",
|
method: "device.token.revoke",
|
||||||
params: {
|
params: {
|
||||||
deviceId: "device-1",
|
deviceId: "device-1",
|
||||||
role: "main",
|
role: "main",
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
);
|
},
|
||||||
|
])("$label", async ({ argv, expectedCall }) => {
|
||||||
|
callGateway.mockResolvedValueOnce({ ok: true });
|
||||||
|
await runDevicesCommand(argv);
|
||||||
|
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining(expectedCall));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects blank device or role values", async () => {
|
it("rejects blank device or role values", async () => {
|
||||||
@@ -253,3 +215,11 @@ describe("devices cli tokens", () => {
|
|||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
callGateway.mockReset();
|
||||||
|
withProgress.mockClear();
|
||||||
|
runtime.log.mockReset();
|
||||||
|
runtime.error.mockReset();
|
||||||
|
runtime.exit.mockReset();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
|
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
|
||||||
|
|
||||||
const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
|
const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
|
||||||
@@ -67,27 +67,31 @@ describe("exec approvals CLI", () => {
|
|||||||
return program;
|
return program;
|
||||||
};
|
};
|
||||||
|
|
||||||
it("routes get command to local, gateway, and node modes", async () => {
|
const runApprovalsCommand = async (args: string[]) => {
|
||||||
|
const program = createProgram();
|
||||||
|
await program.parseAsync(args, { from: "user" });
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
resetLocalSnapshot();
|
resetLocalSnapshot();
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
const localProgram = createProgram();
|
it("routes get command to local, gateway, and node modes", async () => {
|
||||||
await localProgram.parseAsync(["approvals", "get"], { from: "user" });
|
await runApprovalsCommand(["approvals", "get"]);
|
||||||
|
|
||||||
expect(callGatewayFromCli).not.toHaveBeenCalled();
|
expect(callGatewayFromCli).not.toHaveBeenCalled();
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
const gatewayProgram = createProgram();
|
await runApprovalsCommand(["approvals", "get", "--gateway"]);
|
||||||
await gatewayProgram.parseAsync(["approvals", "get", "--gateway"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
|
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
const nodeProgram = createProgram();
|
await runApprovalsCommand(["approvals", "get", "--node", "macbook"]);
|
||||||
await nodeProgram.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
|
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
@@ -96,18 +100,10 @@ describe("exec approvals CLI", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults allowlist add to wildcard agent", async () => {
|
it("defaults allowlist add to wildcard agent", async () => {
|
||||||
resetLocalSnapshot();
|
|
||||||
resetRuntimeCapture();
|
|
||||||
callGatewayFromCli.mockClear();
|
|
||||||
|
|
||||||
const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals);
|
const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals);
|
||||||
saveExecApprovals.mockClear();
|
saveExecApprovals.mockClear();
|
||||||
|
|
||||||
const program = new Command();
|
await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname"]);
|
||||||
program.exitOverride();
|
|
||||||
registerExecApprovalsCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["approvals", "allowlist", "add", "/usr/bin/uname"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGatewayFromCli).not.toHaveBeenCalledWith(
|
expect(callGatewayFromCli).not.toHaveBeenCalledWith(
|
||||||
"exec.approvals.set",
|
"exec.approvals.set",
|
||||||
@@ -124,7 +120,6 @@ describe("exec approvals CLI", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("removes wildcard allowlist entry and prunes empty agent", async () => {
|
it("removes wildcard allowlist entry and prunes empty agent", async () => {
|
||||||
resetLocalSnapshot();
|
|
||||||
localSnapshot.file = {
|
localSnapshot.file = {
|
||||||
version: 1,
|
version: 1,
|
||||||
agents: {
|
agents: {
|
||||||
@@ -133,16 +128,11 @@ describe("exec approvals CLI", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
resetRuntimeCapture();
|
|
||||||
callGatewayFromCli.mockClear();
|
|
||||||
|
|
||||||
const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals);
|
const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals);
|
||||||
saveExecApprovals.mockClear();
|
saveExecApprovals.mockClear();
|
||||||
|
|
||||||
const program = createProgram();
|
await runApprovalsCommand(["approvals", "allowlist", "remove", "/usr/bin/uname"]);
|
||||||
await program.parseAsync(["approvals", "allowlist", "remove", "/usr/bin/uname"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(saveExecApprovals).toHaveBeenCalledWith(
|
expect(saveExecApprovals).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -85,19 +85,30 @@ vi.mock("../commands/gateway-status.js", () => ({
|
|||||||
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
|
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
|
||||||
|
function createGatewayProgram() {
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerGatewayCli(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGatewayCommand(args: string[]) {
|
||||||
|
const program = createGatewayProgram();
|
||||||
|
await program.parseAsync(args, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectGatewayExit(args: string[]) {
|
||||||
|
await expect(runGatewayCommand(args)).rejects.toThrow("__exit__:1");
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway-cli coverage", () => {
|
describe("gateway-cli coverage", () => {
|
||||||
it("registers call/health commands and routes to callGateway", async () => {
|
it("registers call/health commands and routes to callGateway", async () => {
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
callGateway.mockClear();
|
callGateway.mockClear();
|
||||||
|
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
await runGatewayCommand(["gateway", "call", "health", "--params", '{"x":1}', "--json"]);
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["gateway", "call", "health", "--params", '{"x":1}', "--json"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||||
expect(runtimeLogs.join("\n")).toContain('"ok": true');
|
expect(runtimeLogs.join("\n")).toContain('"ok": true');
|
||||||
@@ -107,48 +118,30 @@ describe("gateway-cli coverage", () => {
|
|||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
gatewayStatusCommand.mockClear();
|
gatewayStatusCommand.mockClear();
|
||||||
|
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
await runGatewayCommand(["gateway", "probe", "--json"]);
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["gateway", "probe", "--json"], { from: "user" });
|
|
||||||
|
|
||||||
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
|
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("registers gateway discover and prints JSON", async () => {
|
it.each([
|
||||||
resetRuntimeCapture();
|
{
|
||||||
discoverGatewayBeacons.mockReset();
|
label: "json output",
|
||||||
discoverGatewayBeacons.mockResolvedValueOnce([
|
args: ["gateway", "discover", "--json"],
|
||||||
{
|
expectedOutput: ['"beacons"', '"wsUrl"', "ws://"],
|
||||||
instanceName: "Studio (OpenClaw)",
|
},
|
||||||
displayName: "Studio",
|
{
|
||||||
domain: "local.",
|
label: "human output",
|
||||||
host: "studio.local",
|
args: ["gateway", "discover", "--timeout", "1"],
|
||||||
lanHost: "studio.local",
|
expectedOutput: [
|
||||||
tailnetDns: "studio.tailnet.ts.net",
|
"Gateway Discovery",
|
||||||
gatewayPort: 18789,
|
"Found 1 gateway(s)",
|
||||||
sshPort: 22,
|
"- Studio openclaw.internal.",
|
||||||
},
|
" tailnet: studio.tailnet.ts.net",
|
||||||
]);
|
" host: studio.openclaw.internal",
|
||||||
|
" ws: ws://studio.openclaw.internal:18789",
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
],
|
||||||
const program = new Command();
|
},
|
||||||
program.exitOverride();
|
])("registers gateway discover and prints $label", async ({ args, expectedOutput }) => {
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["gateway", "discover", "--json"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
|
|
||||||
expect(runtimeLogs.join("\n")).toContain('"beacons"');
|
|
||||||
expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
|
|
||||||
expect(runtimeLogs.join("\n")).toContain("ws://");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers gateway discover and prints human output with details on new lines", async () => {
|
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
discoverGatewayBeacons.mockReset();
|
discoverGatewayBeacons.mockReset();
|
||||||
discoverGatewayBeacons.mockResolvedValueOnce([
|
discoverGatewayBeacons.mockResolvedValueOnce([
|
||||||
@@ -164,38 +157,19 @@ describe("gateway-cli coverage", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
await runGatewayCommand(args);
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["gateway", "discover", "--timeout", "1"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
|
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
|
||||||
const out = runtimeLogs.join("\n");
|
const out = runtimeLogs.join("\n");
|
||||||
expect(out).toContain("Gateway Discovery");
|
for (const text of expectedOutput) {
|
||||||
expect(out).toContain("Found 1 gateway(s)");
|
expect(out).toContain(text);
|
||||||
expect(out).toContain("- Studio openclaw.internal.");
|
}
|
||||||
expect(out).toContain(" tailnet: studio.tailnet.ts.net");
|
|
||||||
expect(out).toContain(" host: studio.openclaw.internal");
|
|
||||||
expect(out).toContain(" ws: ws://studio.openclaw.internal:18789");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates gateway discover timeout", async () => {
|
it("validates gateway discover timeout", async () => {
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
discoverGatewayBeacons.mockReset();
|
discoverGatewayBeacons.mockReset();
|
||||||
|
await expectGatewayExit(["gateway", "discover", "--timeout", "0"]);
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(["gateway", "discover", "--timeout", "0"], {
|
|
||||||
from: "user",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
|
|
||||||
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
|
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
|
||||||
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
|
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
|
||||||
@@ -204,15 +178,7 @@ describe("gateway-cli coverage", () => {
|
|||||||
it("fails gateway call on invalid params JSON", async () => {
|
it("fails gateway call on invalid params JSON", async () => {
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
callGateway.mockClear();
|
callGateway.mockClear();
|
||||||
|
await expectGatewayExit(["gateway", "call", "status", "--params", "not-json"]);
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(["gateway", "call", "status", "--params", "not-json"], { from: "user" }),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
|
|
||||||
expect(callGateway).not.toHaveBeenCalled();
|
expect(callGateway).not.toHaveBeenCalled();
|
||||||
expect(runtimeErrors.join("\n")).toContain("Gateway call failed:");
|
expect(runtimeErrors.join("\n")).toContain("Gateway call failed:");
|
||||||
@@ -221,47 +187,35 @@ describe("gateway-cli coverage", () => {
|
|||||||
it("validates gateway ports and handles force/start errors", async () => {
|
it("validates gateway ports and handles force/start errors", async () => {
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
|
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
|
||||||
|
|
||||||
// Invalid port
|
// Invalid port
|
||||||
const programInvalidPort = new Command();
|
await expectGatewayExit(["gateway", "--port", "0", "--token", "test-token"]);
|
||||||
programInvalidPort.exitOverride();
|
|
||||||
registerGatewayCli(programInvalidPort);
|
|
||||||
await expect(
|
|
||||||
programInvalidPort.parseAsync(["gateway", "--port", "0", "--token", "test-token"], {
|
|
||||||
from: "user",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
|
|
||||||
// Force free failure
|
// Force free failure
|
||||||
forceFreePortAndWait.mockImplementationOnce(async () => {
|
forceFreePortAndWait.mockImplementationOnce(async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
});
|
});
|
||||||
const programForceFail = new Command();
|
await expectGatewayExit([
|
||||||
programForceFail.exitOverride();
|
"gateway",
|
||||||
registerGatewayCli(programForceFail);
|
"--port",
|
||||||
await expect(
|
"18789",
|
||||||
programForceFail.parseAsync(
|
"--token",
|
||||||
["gateway", "--port", "18789", "--token", "test-token", "--force", "--allow-unconfigured"],
|
"test-token",
|
||||||
{ from: "user" },
|
"--force",
|
||||||
),
|
"--allow-unconfigured",
|
||||||
).rejects.toThrow("__exit__:1");
|
]);
|
||||||
|
|
||||||
// Start failure (generic)
|
// Start failure (generic)
|
||||||
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
||||||
const programStartFail = new Command();
|
|
||||||
programStartFail.exitOverride();
|
|
||||||
registerGatewayCli(programStartFail);
|
|
||||||
const beforeSigterm = new Set(process.listeners("SIGTERM"));
|
const beforeSigterm = new Set(process.listeners("SIGTERM"));
|
||||||
const beforeSigint = new Set(process.listeners("SIGINT"));
|
const beforeSigint = new Set(process.listeners("SIGINT"));
|
||||||
await expect(
|
await expectGatewayExit([
|
||||||
programStartFail.parseAsync(
|
"gateway",
|
||||||
["gateway", "--port", "18789", "--token", "test-token", "--allow-unconfigured"],
|
"--port",
|
||||||
{
|
"18789",
|
||||||
from: "user",
|
"--token",
|
||||||
},
|
"test-token",
|
||||||
),
|
"--allow-unconfigured",
|
||||||
).rejects.toThrow("__exit__:1");
|
]);
|
||||||
for (const listener of process.listeners("SIGTERM")) {
|
for (const listener of process.listeners("SIGTERM")) {
|
||||||
if (!beforeSigterm.has(listener)) {
|
if (!beforeSigterm.has(listener)) {
|
||||||
process.removeListener("SIGTERM", listener);
|
process.removeListener("SIGTERM", listener);
|
||||||
@@ -282,17 +236,7 @@ describe("gateway-cli coverage", () => {
|
|||||||
startGatewayServer.mockRejectedValueOnce(
|
startGatewayServer.mockRejectedValueOnce(
|
||||||
new GatewayLockError("another gateway instance is already listening"),
|
new GatewayLockError("another gateway instance is already listening"),
|
||||||
);
|
);
|
||||||
|
await expectGatewayExit(["gateway", "--token", "test-token", "--allow-unconfigured"]);
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
|
|
||||||
from: "user",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
|
|
||||||
expect(startGatewayServer).toHaveBeenCalled();
|
expect(startGatewayServer).toHaveBeenCalled();
|
||||||
expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:");
|
expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:");
|
||||||
@@ -304,17 +248,8 @@ describe("gateway-cli coverage", () => {
|
|||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
startGatewayServer.mockClear();
|
startGatewayServer.mockClear();
|
||||||
|
|
||||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerGatewayCli(program);
|
|
||||||
|
|
||||||
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
||||||
await expect(
|
await expectGatewayExit(["gateway", "--token", "test-token", "--allow-unconfigured"]);
|
||||||
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
|
|
||||||
from: "user",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("__exit__:1");
|
|
||||||
|
|
||||||
expect(startGatewayServer).toHaveBeenCalledWith(19001, expect.anything());
|
expect(startGatewayServer).toHaveBeenCalledWith(19001, expect.anything());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runRegisteredCli } from "../../test-utils/command-runner.js";
|
||||||
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
|
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
|
||||||
|
|
||||||
const callGatewayCli = vi.fn(async (_method: string, _opts: unknown, _params?: unknown) => ({
|
const callGatewayCli = vi.fn(async (_method: string, _opts: unknown, _params?: unknown) => ({
|
||||||
@@ -111,6 +112,12 @@ vi.mock("./discover.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("gateway register option collisions", () => {
|
describe("gateway register option collisions", () => {
|
||||||
|
let registerGatewayCli: typeof import("./register.js").registerGatewayCli;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerGatewayCli } = await import("./register.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
callGatewayCli.mockClear();
|
callGatewayCli.mockClear();
|
||||||
@@ -118,12 +125,9 @@ describe("gateway register option collisions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards --token to gateway call when parent and child option names collide", async () => {
|
it("forwards --token to gateway call when parent and child option names collide", async () => {
|
||||||
const { registerGatewayCli } = await import("./register.js");
|
await runRegisteredCli({
|
||||||
const program = new Command();
|
register: registerGatewayCli as (program: Command) => void,
|
||||||
registerGatewayCli(program);
|
argv: ["gateway", "call", "health", "--token", "tok_call", "--json"],
|
||||||
|
|
||||||
await program.parseAsync(["gateway", "call", "health", "--token", "tok_call", "--json"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callGatewayCli).toHaveBeenCalledWith(
|
expect(callGatewayCli).toHaveBeenCalledWith(
|
||||||
@@ -136,12 +140,9 @@ describe("gateway register option collisions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards --token to gateway probe when parent and child option names collide", async () => {
|
it("forwards --token to gateway probe when parent and child option names collide", async () => {
|
||||||
const { registerGatewayCli } = await import("./register.js");
|
await runRegisteredCli({
|
||||||
const program = new Command();
|
register: registerGatewayCli as (program: Command) => void,
|
||||||
registerGatewayCli(program);
|
argv: ["gateway", "probe", "--token", "tok_probe", "--json"],
|
||||||
|
|
||||||
await program.parseAsync(["gateway", "probe", "--token", "tok_probe", "--json"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(gatewayStatusCommand).toHaveBeenCalledWith(
|
expect(gatewayStatusCommand).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runRegisteredCli } from "../../test-utils/command-runner.js";
|
||||||
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
|
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
|
||||||
|
|
||||||
const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({
|
const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({
|
||||||
@@ -91,6 +92,12 @@ vi.mock("./run-loop.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("gateway run option collisions", () => {
|
describe("gateway run option collisions", () => {
|
||||||
|
let addGatewayRunCommand: typeof import("./run.js").addGatewayRunCommand;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ addGatewayRunCommand } = await import("./run.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetRuntimeCapture();
|
resetRuntimeCapture();
|
||||||
startGatewayServer.mockClear();
|
startGatewayServer.mockClear();
|
||||||
@@ -101,25 +108,27 @@ describe("gateway run option collisions", () => {
|
|||||||
runGatewayLoop.mockClear();
|
runGatewayLoop.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards parent-captured options to `gateway run` subcommand", async () => {
|
async function runGatewayCli(argv: string[]) {
|
||||||
const { addGatewayRunCommand } = await import("./run.js");
|
await runRegisteredCli({
|
||||||
const program = new Command();
|
register: ((program: Command) => {
|
||||||
const gateway = addGatewayRunCommand(program.command("gateway"));
|
const gateway = addGatewayRunCommand(program.command("gateway"));
|
||||||
addGatewayRunCommand(gateway.command("run"));
|
addGatewayRunCommand(gateway.command("run"));
|
||||||
|
}) as (program: Command) => void,
|
||||||
|
argv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await program.parseAsync(
|
it("forwards parent-captured options to `gateway run` subcommand", async () => {
|
||||||
[
|
await runGatewayCli([
|
||||||
"gateway",
|
"gateway",
|
||||||
"run",
|
"run",
|
||||||
"--token",
|
"--token",
|
||||||
"tok_run",
|
"tok_run",
|
||||||
"--allow-unconfigured",
|
"--allow-unconfigured",
|
||||||
"--ws-log",
|
"--ws-log",
|
||||||
"full",
|
"full",
|
||||||
"--force",
|
"--force",
|
||||||
],
|
]);
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(forceFreePortAndWait).toHaveBeenCalledWith(18789, expect.anything());
|
expect(forceFreePortAndWait).toHaveBeenCalledWith(18789, expect.anything());
|
||||||
expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full");
|
expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full");
|
||||||
@@ -134,14 +143,7 @@ describe("gateway run option collisions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => {
|
it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => {
|
||||||
const { addGatewayRunCommand } = await import("./run.js");
|
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
|
||||||
const program = new Command();
|
|
||||||
const gateway = addGatewayRunCommand(program.command("gateway"));
|
|
||||||
addGatewayRunCommand(gateway.command("run"));
|
|
||||||
|
|
||||||
await program.parseAsync(["gateway", "run", "--allow-unconfigured"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(startGatewayServer).toHaveBeenCalledWith(
|
expect(startGatewayServer).toHaveBeenCalledWith(
|
||||||
18789,
|
18789,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { runRegisteredCli } from "../test-utils/command-runner.js";
|
||||||
import { formatLogTimestamp } from "./logs-cli.js";
|
import { formatLogTimestamp } from "./logs-cli.js";
|
||||||
|
|
||||||
const callGatewayFromCli = vi.fn();
|
const callGatewayFromCli = vi.fn();
|
||||||
@@ -12,17 +12,23 @@ vi.mock("./gateway-rpc.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let registerLogsCli: typeof import("./logs-cli.js").registerLogsCli;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerLogsCli } = await import("./logs-cli.js"));
|
||||||
|
});
|
||||||
|
|
||||||
async function runLogsCli(argv: string[]) {
|
async function runLogsCli(argv: string[]) {
|
||||||
const { registerLogsCli } = await import("./logs-cli.js");
|
await runRegisteredCli({
|
||||||
const program = new Command();
|
register: registerLogsCli as (program: import("commander").Command) => void,
|
||||||
program.exitOverride();
|
argv,
|
||||||
registerLogsCli(program);
|
});
|
||||||
await program.parseAsync(argv, { from: "user" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("logs cli", () => {
|
describe("logs cli", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
callGatewayFromCli.mockReset();
|
callGatewayFromCli.mockReset();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes output directly to stdout/stderr", async () => {
|
it("writes output directly to stdout/stderr", async () => {
|
||||||
@@ -37,20 +43,17 @@ describe("logs cli", () => {
|
|||||||
|
|
||||||
const stdoutWrites: string[] = [];
|
const stdoutWrites: string[] = [];
|
||||||
const stderrWrites: string[] = [];
|
const stderrWrites: string[] = [];
|
||||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||||
stdoutWrites.push(String(chunk));
|
stdoutWrites.push(String(chunk));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||||
stderrWrites.push(String(chunk));
|
stderrWrites.push(String(chunk));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await runLogsCli(["logs"]);
|
await runLogsCli(["logs"]);
|
||||||
|
|
||||||
stdoutSpy.mockRestore();
|
|
||||||
stderrSpy.mockRestore();
|
|
||||||
|
|
||||||
expect(stdoutWrites.join("")).toContain("Log file:");
|
expect(stdoutWrites.join("")).toContain("Log file:");
|
||||||
expect(stdoutWrites.join("")).toContain("raw line");
|
expect(stdoutWrites.join("")).toContain("raw line");
|
||||||
expect(stderrWrites.join("")).toContain("Log tail truncated");
|
expect(stderrWrites.join("")).toContain("Log tail truncated");
|
||||||
@@ -70,15 +73,13 @@ describe("logs cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stdoutWrites: string[] = [];
|
const stdoutWrites: string[] = [];
|
||||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||||
stdoutWrites.push(String(chunk));
|
stdoutWrites.push(String(chunk));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await runLogsCli(["logs", "--local-time", "--plain"]);
|
await runLogsCli(["logs", "--local-time", "--plain"]);
|
||||||
|
|
||||||
stdoutSpy.mockRestore();
|
|
||||||
|
|
||||||
const output = stdoutWrites.join("");
|
const output = stdoutWrites.join("");
|
||||||
expect(output).toContain("line one");
|
expect(output).toContain("line one");
|
||||||
const timestamp = output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?/u)?.[0];
|
const timestamp = output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?/u)?.[0];
|
||||||
@@ -93,21 +94,18 @@ describe("logs cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stderrWrites: string[] = [];
|
const stderrWrites: string[] = [];
|
||||||
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => {
|
vi.spyOn(process.stdout, "write").mockImplementation(() => {
|
||||||
const err = new Error("EPIPE") as NodeJS.ErrnoException;
|
const err = new Error("EPIPE") as NodeJS.ErrnoException;
|
||||||
err.code = "EPIPE";
|
err.code = "EPIPE";
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||||
stderrWrites.push(String(chunk));
|
stderrWrites.push(String(chunk));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await runLogsCli(["logs"]);
|
await runLogsCli(["logs"]);
|
||||||
|
|
||||||
stdoutSpy.mockRestore();
|
|
||||||
stderrSpy.mockRestore();
|
|
||||||
|
|
||||||
expect(stderrWrites.join("")).toContain("output stdout closed");
|
expect(stderrWrites.join("")).toContain("output stdout closed");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,15 +141,13 @@ describe("logs cli", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty or invalid timestamps", () => {
|
it.each([
|
||||||
expect(formatLogTimestamp(undefined)).toBe("");
|
{ input: undefined, expected: "" },
|
||||||
expect(formatLogTimestamp("")).toBe("");
|
{ input: "", expected: "" },
|
||||||
expect(formatLogTimestamp("invalid-date")).toBe("invalid-date");
|
{ input: "invalid-date", expected: "invalid-date" },
|
||||||
});
|
{ input: "not-a-date", expected: "not-a-date" },
|
||||||
|
])("preserves timestamp fallback for $input", ({ input, expected }) => {
|
||||||
it("preserves original value for invalid dates", () => {
|
expect(formatLogTimestamp(input)).toBe(expected);
|
||||||
const result = formatLogTimestamp("not-a-date");
|
|
||||||
expect(result).toBe("not-a-date");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const getMemorySearchManager = vi.fn();
|
const getMemorySearchManager = vi.fn();
|
||||||
const loadConfig = vi.fn(() => ({}));
|
const loadConfig = vi.fn(() => ({}));
|
||||||
@@ -20,11 +20,21 @@ vi.mock("../agents/agent-scope.js", () => ({
|
|||||||
resolveDefaultAgentId,
|
resolveDefaultAgentId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(async () => {
|
let registerMemoryCli: typeof import("./memory-cli.js").registerMemoryCli;
|
||||||
|
let defaultRuntime: typeof import("../runtime.js").defaultRuntime;
|
||||||
|
let isVerbose: typeof import("../globals.js").isVerbose;
|
||||||
|
let setVerbose: typeof import("../globals.js").setVerbose;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerMemoryCli } = await import("./memory-cli.js"));
|
||||||
|
({ defaultRuntime } = await import("../runtime.js"));
|
||||||
|
({ isVerbose, setVerbose } = await import("../globals.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
getMemorySearchManager.mockReset();
|
getMemorySearchManager.mockReset();
|
||||||
process.exitCode = undefined;
|
process.exitCode = undefined;
|
||||||
const { setVerbose } = await import("../globals.js");
|
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,15 +65,45 @@ describe("memory cli", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runMemoryCli(args: string[]) {
|
async function runMemoryCli(args: string[]) {
|
||||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.name("test");
|
program.name("test");
|
||||||
registerMemoryCli(program);
|
registerMemoryCli(program);
|
||||||
await program.parseAsync(["memory", ...args], { from: "user" });
|
await program.parseAsync(["memory", ...args], { from: "user" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withQmdIndexDb(content: string, run: (dbPath: string) => Promise<void>) {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-"));
|
||||||
|
const dbPath = path.join(tmpDir, "index.sqlite");
|
||||||
|
try {
|
||||||
|
await fs.writeFile(dbPath, content, "utf-8");
|
||||||
|
await run(dbPath);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCloseFailureAfterCommand(params: {
|
||||||
|
args: string[];
|
||||||
|
manager: Record<string, unknown>;
|
||||||
|
beforeExpect?: () => void;
|
||||||
|
}) {
|
||||||
|
const close = vi.fn(async () => {
|
||||||
|
throw new Error("close boom");
|
||||||
|
});
|
||||||
|
mockManager({ ...params.manager, close });
|
||||||
|
|
||||||
|
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||||
|
await runMemoryCli(params.args);
|
||||||
|
|
||||||
|
params.beforeExpect?.();
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Memory manager close failed: close boom"),
|
||||||
|
);
|
||||||
|
expect(process.exitCode).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
it("prints vector status when available", async () => {
|
it("prints vector status when available", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
mockManager({
|
mockManager({
|
||||||
probeVectorAvailability: vi.fn(async () => true),
|
probeVectorAvailability: vi.fn(async () => true),
|
||||||
@@ -97,7 +137,6 @@ describe("memory cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints vector error when unavailable", async () => {
|
it("prints vector error when unavailable", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
mockManager({
|
mockManager({
|
||||||
probeVectorAvailability: vi.fn(async () => false),
|
probeVectorAvailability: vi.fn(async () => false),
|
||||||
@@ -122,7 +161,6 @@ describe("memory cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints embeddings status when deep", async () => {
|
it("prints embeddings status when deep", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
||||||
mockManager({
|
mockManager({
|
||||||
@@ -141,7 +179,6 @@ describe("memory cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("enables verbose logging with --verbose", async () => {
|
it("enables verbose logging with --verbose", async () => {
|
||||||
const { isVerbose } = await import("../globals.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
mockManager({
|
mockManager({
|
||||||
probeVectorAvailability: vi.fn(async () => true),
|
probeVectorAvailability: vi.fn(async () => true),
|
||||||
@@ -155,28 +192,16 @@ describe("memory cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs close failure after status", async () => {
|
it("logs close failure after status", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
await expectCloseFailureAfterCommand({
|
||||||
const close = vi.fn(async () => {
|
args: ["status"],
|
||||||
throw new Error("close boom");
|
manager: {
|
||||||
|
probeVectorAvailability: vi.fn(async () => true),
|
||||||
|
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockManager({
|
|
||||||
probeVectorAvailability: vi.fn(async () => true),
|
|
||||||
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
|
|
||||||
close,
|
|
||||||
});
|
|
||||||
|
|
||||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
|
||||||
await runMemoryCli(["status"]);
|
|
||||||
|
|
||||||
expect(close).toHaveBeenCalled();
|
|
||||||
expect(error).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Memory manager close failed: close boom"),
|
|
||||||
);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reindexes on status --index", async () => {
|
it("reindexes on status --index", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
const sync = vi.fn(async () => {});
|
const sync = vi.fn(async () => {});
|
||||||
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
||||||
@@ -197,7 +222,6 @@ describe("memory cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("closes manager after index", async () => {
|
it("closes manager after index", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
const sync = vi.fn(async () => {});
|
const sync = vi.fn(async () => {});
|
||||||
mockManager({ sync, close });
|
mockManager({ sync, close });
|
||||||
@@ -211,69 +235,51 @@ describe("memory cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs qmd index file path and size after index", async () => {
|
it("logs qmd index file path and size after index", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
const sync = vi.fn(async () => {});
|
const sync = vi.fn(async () => {});
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-"));
|
await withQmdIndexDb("sqlite-bytes", async (dbPath) => {
|
||||||
const dbPath = path.join(tmpDir, "index.sqlite");
|
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
|
||||||
await fs.writeFile(dbPath, "sqlite-bytes", "utf-8");
|
|
||||||
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
|
|
||||||
|
|
||||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||||
await runMemoryCli(["index"]);
|
await runMemoryCli(["index"]);
|
||||||
|
|
||||||
expectCliSync(sync);
|
expectCliSync(sync);
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: "));
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: "));
|
||||||
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
|
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails index when qmd db file is empty", async () => {
|
it("fails index when qmd db file is empty", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
const sync = vi.fn(async () => {});
|
const sync = vi.fn(async () => {});
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-"));
|
await withQmdIndexDb("", async (dbPath) => {
|
||||||
const dbPath = path.join(tmpDir, "index.sqlite");
|
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
|
||||||
await fs.writeFile(dbPath, "", "utf-8");
|
|
||||||
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
|
|
||||||
|
|
||||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||||
await runMemoryCli(["index"]);
|
await runMemoryCli(["index"]);
|
||||||
|
|
||||||
expectCliSync(sync);
|
expectCliSync(sync);
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("Memory index failed (main): QMD index file is empty"),
|
expect.stringContaining("Memory index failed (main): QMD index file is empty"),
|
||||||
);
|
);
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
expect(process.exitCode).toBe(1);
|
expect(process.exitCode).toBe(1);
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs close failures without failing the command", async () => {
|
it("logs close failures without failing the command", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {
|
|
||||||
throw new Error("close boom");
|
|
||||||
});
|
|
||||||
const sync = vi.fn(async () => {});
|
const sync = vi.fn(async () => {});
|
||||||
mockManager({ sync, close });
|
await expectCloseFailureAfterCommand({
|
||||||
|
args: ["index"],
|
||||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
manager: { sync },
|
||||||
await runMemoryCli(["index"]);
|
beforeExpect: () => {
|
||||||
|
expectCliSync(sync);
|
||||||
expectCliSync(sync);
|
},
|
||||||
expect(close).toHaveBeenCalled();
|
});
|
||||||
expect(error).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Memory manager close failed: close boom"),
|
|
||||||
);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs close failure after search", async () => {
|
it("logs close failure after search", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {
|
|
||||||
throw new Error("close boom");
|
|
||||||
});
|
|
||||||
const search = vi.fn(async () => [
|
const search = vi.fn(async () => [
|
||||||
{
|
{
|
||||||
path: "memory/2026-01-12.md",
|
path: "memory/2026-01-12.md",
|
||||||
@@ -283,21 +289,16 @@ describe("memory cli", () => {
|
|||||||
snippet: "Hello",
|
snippet: "Hello",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
mockManager({ search, close });
|
await expectCloseFailureAfterCommand({
|
||||||
|
args: ["search", "hello"],
|
||||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
manager: { search },
|
||||||
await runMemoryCli(["search", "hello"]);
|
beforeExpect: () => {
|
||||||
|
expect(search).toHaveBeenCalled();
|
||||||
expect(search).toHaveBeenCalled();
|
},
|
||||||
expect(close).toHaveBeenCalled();
|
});
|
||||||
expect(error).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Memory manager close failed: close boom"),
|
|
||||||
);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes manager after search error", async () => {
|
it("closes manager after search error", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
const search = vi.fn(async () => {
|
const search = vi.fn(async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runRegisteredCli } from "../test-utils/command-runner.js";
|
||||||
|
|
||||||
const githubCopilotLoginCommand = vi.fn();
|
const githubCopilotLoginCommand = vi.fn();
|
||||||
const modelsStatusCommand = vi.fn().mockResolvedValue(undefined);
|
const modelsStatusCommand = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -32,12 +34,10 @@ vi.mock("../commands/models.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("models cli", () => {
|
describe("models cli", () => {
|
||||||
let Command: typeof import("commander").Command;
|
|
||||||
let registerModelsCli: (typeof import("./models-cli.js"))["registerModelsCli"];
|
let registerModelsCli: (typeof import("./models-cli.js"))["registerModelsCli"];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Load once; vi.mock above ensures command handlers are already mocked.
|
// Load once; vi.mock above ensures command handlers are already mocked.
|
||||||
({ Command } = await import("commander"));
|
|
||||||
({ registerModelsCli } = await import("./models-cli.js"));
|
({ registerModelsCli } = await import("./models-cli.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +52,13 @@ describe("models cli", () => {
|
|||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runModelsCommand(args: string[]) {
|
||||||
|
await runRegisteredCli({
|
||||||
|
register: registerModelsCli as (program: Command) => void,
|
||||||
|
argv: args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("registers github-copilot login command", async () => {
|
it("registers github-copilot login command", async () => {
|
||||||
const program = createProgram();
|
const program = createProgram();
|
||||||
const models = program.commands.find((cmd) => cmd.name() === "models");
|
const models = program.commands.find((cmd) => cmd.name() === "models");
|
||||||
@@ -74,22 +81,11 @@ describe("models cli", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes --agent to models status", async () => {
|
it.each([
|
||||||
const program = createProgram();
|
{ label: "status flag", args: ["models", "status", "--agent", "poe"] },
|
||||||
|
{ label: "parent flag", args: ["models", "--agent", "poe", "status"] },
|
||||||
await program.parseAsync(["models", "status", "--agent", "poe"], { from: "user" });
|
])("passes --agent to models status ($label)", async ({ args }) => {
|
||||||
|
await runModelsCommand(args);
|
||||||
expect(modelsStatusCommand).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ agent: "poe" }),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes parent --agent to models status", async () => {
|
|
||||||
const program = createProgram();
|
|
||||||
|
|
||||||
await program.parseAsync(["models", "--agent", "poe", "status"], { from: "user" });
|
|
||||||
|
|
||||||
expect(modelsStatusCommand).toHaveBeenCalledWith(
|
expect(modelsStatusCommand).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ agent: "poe" }),
|
expect.objectContaining({ agent: "poe" }),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ describe("nodes-cli coverage", () => {
|
|||||||
const getNodeInvokeCall = () =>
|
const getNodeInvokeCall = () =>
|
||||||
callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall;
|
callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall;
|
||||||
|
|
||||||
|
const createNodesProgram = () => {
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerNodesCli(program);
|
||||||
|
return program;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runNodesCommand = async (args: string[]) => {
|
||||||
|
const program = createNodesProgram();
|
||||||
|
await program.parseAsync(args, { from: "user" });
|
||||||
|
return getNodeInvokeCall();
|
||||||
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ registerNodesCli } = await import("./nodes-cli.js"));
|
({ registerNodesCli } = await import("./nodes-cli.js"));
|
||||||
});
|
});
|
||||||
@@ -94,32 +107,23 @@ describe("nodes-cli coverage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes system.run with parsed params", async () => {
|
it("invokes system.run with parsed params", async () => {
|
||||||
const program = new Command();
|
const invoke = await runNodesCommand([
|
||||||
program.exitOverride();
|
"nodes",
|
||||||
registerNodesCli(program);
|
"run",
|
||||||
|
"--node",
|
||||||
await program.parseAsync(
|
"mac-1",
|
||||||
[
|
"--cwd",
|
||||||
"nodes",
|
"/tmp",
|
||||||
"run",
|
"--env",
|
||||||
"--node",
|
"FOO=bar",
|
||||||
"mac-1",
|
"--command-timeout",
|
||||||
"--cwd",
|
"1200",
|
||||||
"/tmp",
|
"--needs-screen-recording",
|
||||||
"--env",
|
"--invoke-timeout",
|
||||||
"FOO=bar",
|
"5000",
|
||||||
"--command-timeout",
|
"echo",
|
||||||
"1200",
|
"hi",
|
||||||
"--needs-screen-recording",
|
]);
|
||||||
"--invoke-timeout",
|
|
||||||
"5000",
|
|
||||||
"echo",
|
|
||||||
"hi",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoke = getNodeInvokeCall();
|
|
||||||
|
|
||||||
expect(invoke).toBeTruthy();
|
expect(invoke).toBeTruthy();
|
||||||
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
|
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
|
||||||
@@ -139,16 +143,16 @@ describe("nodes-cli coverage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes system.run with raw command", async () => {
|
it("invokes system.run with raw command", async () => {
|
||||||
const program = new Command();
|
const invoke = await runNodesCommand([
|
||||||
program.exitOverride();
|
"nodes",
|
||||||
registerNodesCli(program);
|
"run",
|
||||||
|
"--agent",
|
||||||
await program.parseAsync(
|
"main",
|
||||||
["nodes", "run", "--agent", "main", "--node", "mac-1", "--raw", "echo hi"],
|
"--node",
|
||||||
{ from: "user" },
|
"mac-1",
|
||||||
);
|
"--raw",
|
||||||
|
"echo hi",
|
||||||
const invoke = getNodeInvokeCall();
|
]);
|
||||||
|
|
||||||
expect(invoke).toBeTruthy();
|
expect(invoke).toBeTruthy();
|
||||||
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
|
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
|
||||||
@@ -164,27 +168,18 @@ describe("nodes-cli coverage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes system.notify with provided fields", async () => {
|
it("invokes system.notify with provided fields", async () => {
|
||||||
const program = new Command();
|
const invoke = await runNodesCommand([
|
||||||
program.exitOverride();
|
"nodes",
|
||||||
registerNodesCli(program);
|
"notify",
|
||||||
|
"--node",
|
||||||
await program.parseAsync(
|
"mac-1",
|
||||||
[
|
"--title",
|
||||||
"nodes",
|
"Ping",
|
||||||
"notify",
|
"--body",
|
||||||
"--node",
|
"Gateway ready",
|
||||||
"mac-1",
|
"--delivery",
|
||||||
"--title",
|
"overlay",
|
||||||
"Ping",
|
]);
|
||||||
"--body",
|
|
||||||
"Gateway ready",
|
|
||||||
"--delivery",
|
|
||||||
"overlay",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoke = getNodeInvokeCall();
|
|
||||||
|
|
||||||
expect(invoke).toBeTruthy();
|
expect(invoke).toBeTruthy();
|
||||||
expect(invoke?.params?.command).toBe("system.notify");
|
expect(invoke?.params?.command).toBe("system.notify");
|
||||||
@@ -198,30 +193,21 @@ describe("nodes-cli coverage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes location.get with params", async () => {
|
it("invokes location.get with params", async () => {
|
||||||
const program = new Command();
|
const invoke = await runNodesCommand([
|
||||||
program.exitOverride();
|
"nodes",
|
||||||
registerNodesCli(program);
|
"location",
|
||||||
|
"get",
|
||||||
await program.parseAsync(
|
"--node",
|
||||||
[
|
"mac-1",
|
||||||
"nodes",
|
"--accuracy",
|
||||||
"location",
|
"precise",
|
||||||
"get",
|
"--max-age",
|
||||||
"--node",
|
"1000",
|
||||||
"mac-1",
|
"--location-timeout",
|
||||||
"--accuracy",
|
"5000",
|
||||||
"precise",
|
"--invoke-timeout",
|
||||||
"--max-age",
|
"6000",
|
||||||
"1000",
|
]);
|
||||||
"--location-timeout",
|
|
||||||
"5000",
|
|
||||||
"--invoke-timeout",
|
|
||||||
"6000",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoke = getNodeInvokeCall();
|
|
||||||
|
|
||||||
expect(invoke).toBeTruthy();
|
expect(invoke).toBeTruthy();
|
||||||
expect(invoke?.params?.command).toBe("location.get");
|
expect(invoke?.params?.command).toBe("location.get");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS } from "../../infra/exec-approvals.js";
|
import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS } from "../../infra/exec-approvals.js";
|
||||||
import { parseTimeoutMs } from "../nodes-run.js";
|
import { parseTimeoutMs } from "../nodes-run.js";
|
||||||
|
|
||||||
@@ -33,14 +33,18 @@ vi.mock("../progress.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("nodes run: approval transport timeout (#12098)", () => {
|
describe("nodes run: approval transport timeout (#12098)", () => {
|
||||||
|
let callGatewayCli: typeof import("./rpc.js").callGatewayCli;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ callGatewayCli } = await import("./rpc.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
callGatewaySpy.mockReset();
|
callGatewaySpy.mockReset();
|
||||||
callGatewaySpy.mockResolvedValue({ decision: "allow-once" });
|
callGatewaySpy.mockResolvedValue({ decision: "allow-once" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("callGatewayCli forwards opts.timeout as the transport timeoutMs", async () => {
|
it("callGatewayCli forwards opts.timeout as the transport timeoutMs", async () => {
|
||||||
const { callGatewayCli } = await import("./rpc.js");
|
|
||||||
|
|
||||||
await callGatewayCli("exec.approval.request", { timeout: "35000" } as never, {
|
await callGatewayCli("exec.approval.request", { timeout: "35000" } as never, {
|
||||||
timeoutMs: 120_000,
|
timeoutMs: 120_000,
|
||||||
});
|
});
|
||||||
@@ -52,8 +56,6 @@ describe("nodes run: approval transport timeout (#12098)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fix: overriding transportTimeoutMs gives the approval enough transport time", async () => {
|
it("fix: overriding transportTimeoutMs gives the approval enough transport time", async () => {
|
||||||
const { callGatewayCli } = await import("./rpc.js");
|
|
||||||
|
|
||||||
const approvalTimeoutMs = 120_000;
|
const approvalTimeoutMs = 120_000;
|
||||||
// Mirror the production code: parseTimeoutMs(opts.timeout) ?? 0
|
// Mirror the production code: parseTimeoutMs(opts.timeout) ?? 0
|
||||||
const transportTimeoutMs = Math.max(parseTimeoutMs("35000") ?? 0, approvalTimeoutMs + 10_000);
|
const transportTimeoutMs = Math.max(parseTimeoutMs("35000") ?? 0, approvalTimeoutMs + 10_000);
|
||||||
@@ -73,8 +75,6 @@ describe("nodes run: approval transport timeout (#12098)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fix: user-specified timeout larger than approval is preserved", async () => {
|
it("fix: user-specified timeout larger than approval is preserved", async () => {
|
||||||
const { callGatewayCli } = await import("./rpc.js");
|
|
||||||
|
|
||||||
const approvalTimeoutMs = 120_000;
|
const approvalTimeoutMs = 120_000;
|
||||||
const userTimeout = 200_000;
|
const userTimeout = 200_000;
|
||||||
// Mirror the production code: parseTimeoutMs preserves valid large values
|
// Mirror the production code: parseTimeoutMs preserves valid large values
|
||||||
@@ -96,8 +96,6 @@ describe("nodes run: approval transport timeout (#12098)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fix: non-numeric timeout falls back to approval floor", async () => {
|
it("fix: non-numeric timeout falls back to approval floor", async () => {
|
||||||
const { callGatewayCli } = await import("./rpc.js");
|
|
||||||
|
|
||||||
const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
|
const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
|
||||||
// parseTimeoutMs returns undefined for garbage input, ?? 0 ensures
|
// parseTimeoutMs returns undefined for garbage input, ?? 0 ensures
|
||||||
// Math.max picks the approval floor instead of producing NaN
|
// Math.max picks the approval floor instead of producing NaN
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const listChannelPairingRequests = vi.fn();
|
const listChannelPairingRequests = vi.fn();
|
||||||
const approveChannelPairingCode = vi.fn();
|
const approveChannelPairingCode = vi.fn();
|
||||||
@@ -45,167 +45,153 @@ vi.mock("../config/config.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("pairing cli", () => {
|
describe("pairing cli", () => {
|
||||||
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
|
let registerPairingCli: typeof import("./pairing-cli.js").registerPairingCli;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerPairingCli } = await import("./pairing-cli.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
listChannelPairingRequests.mockReset();
|
||||||
|
approveChannelPairingCode.mockReset();
|
||||||
|
notifyPairingApproved.mockReset();
|
||||||
|
normalizeChannelId.mockClear();
|
||||||
|
getPairingAdapter.mockClear();
|
||||||
listPairingChannels.mockClear();
|
listPairingChannels.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
function createProgram() {
|
||||||
expect(listPairingChannels).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.name("test");
|
program.name("test");
|
||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPairing(args: string[]) {
|
||||||
|
const program = createProgram();
|
||||||
|
await program.parseAsync(args, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockApprovedPairing() {
|
||||||
|
approveChannelPairingCode.mockResolvedValueOnce({
|
||||||
|
id: "123",
|
||||||
|
entry: {
|
||||||
|
id: "123",
|
||||||
|
code: "ABCDEFGH",
|
||||||
|
createdAt: "2026-01-08T00:00:00Z",
|
||||||
|
lastSeenAt: "2026-01-08T00:00:00Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
|
||||||
|
expect(listPairingChannels).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createProgram();
|
||||||
|
|
||||||
expect(listPairingChannels).toHaveBeenCalledTimes(1);
|
expect(listPairingChannels).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels Telegram ids as telegramUserId", async () => {
|
it.each([
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
{
|
||||||
|
name: "telegram ids",
|
||||||
|
channel: "telegram",
|
||||||
|
id: "123",
|
||||||
|
label: "telegramUserId",
|
||||||
|
meta: { username: "peter" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discord ids",
|
||||||
|
channel: "discord",
|
||||||
|
id: "999",
|
||||||
|
label: "discordUserId",
|
||||||
|
meta: { tag: "Ada#0001" },
|
||||||
|
},
|
||||||
|
])("labels $name correctly", async ({ channel, id, label, meta }) => {
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([
|
listChannelPairingRequests.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
id: "123",
|
id,
|
||||||
code: "ABC123",
|
code: "ABC123",
|
||||||
createdAt: "2026-01-08T00:00:00Z",
|
createdAt: "2026-01-08T00:00:00Z",
|
||||||
lastSeenAt: "2026-01-08T00:00:00Z",
|
lastSeenAt: "2026-01-08T00:00:00Z",
|
||||||
meta: { username: "peter" },
|
meta,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
const program = new Command();
|
try {
|
||||||
program.name("test");
|
await runPairing(["pairing", "list", "--channel", channel]);
|
||||||
registerPairingCli(program);
|
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||||
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
expect(output).toContain(label);
|
||||||
from: "user",
|
expect(output).toContain(id);
|
||||||
});
|
} finally {
|
||||||
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
log.mockRestore();
|
||||||
expect(output).toContain("telegramUserId");
|
}
|
||||||
expect(output).toContain("123");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts channel as positional for list", async () => {
|
it("accepts channel as positional for list", async () => {
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([]);
|
listChannelPairingRequests.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const program = new Command();
|
await runPairing(["pairing", "list", "telegram"]);
|
||||||
program.name("test");
|
|
||||||
registerPairingCli(program);
|
|
||||||
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
|
|
||||||
|
|
||||||
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram");
|
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards --account for list", async () => {
|
it("forwards --account for list", async () => {
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([]);
|
listChannelPairingRequests.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const program = new Command();
|
await runPairing(["pairing", "list", "--channel", "telegram", "--account", "yy"]);
|
||||||
program.name("test");
|
|
||||||
registerPairingCli(program);
|
|
||||||
await program.parseAsync(["pairing", "list", "--channel", "telegram", "--account", "yy"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram", process.env, "yy");
|
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram", process.env, "yy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes channel aliases", async () => {
|
it("normalizes channel aliases", async () => {
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([]);
|
listChannelPairingRequests.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const program = new Command();
|
await runPairing(["pairing", "list", "imsg"]);
|
||||||
program.name("test");
|
|
||||||
registerPairingCli(program);
|
|
||||||
await program.parseAsync(["pairing", "list", "imsg"], { from: "user" });
|
|
||||||
|
|
||||||
expect(normalizeChannelId).toHaveBeenCalledWith("imsg");
|
expect(normalizeChannelId).toHaveBeenCalledWith("imsg");
|
||||||
expect(listChannelPairingRequests).toHaveBeenCalledWith("imessage");
|
expect(listChannelPairingRequests).toHaveBeenCalledWith("imessage");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts extension channels outside the registry", async () => {
|
it("accepts extension channels outside the registry", async () => {
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([]);
|
listChannelPairingRequests.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const program = new Command();
|
await runPairing(["pairing", "list", "zalo"]);
|
||||||
program.name("test");
|
|
||||||
registerPairingCli(program);
|
|
||||||
await program.parseAsync(["pairing", "list", "zalo"], { from: "user" });
|
|
||||||
|
|
||||||
expect(normalizeChannelId).toHaveBeenCalledWith("zalo");
|
expect(normalizeChannelId).toHaveBeenCalledWith("zalo");
|
||||||
expect(listChannelPairingRequests).toHaveBeenCalledWith("zalo");
|
expect(listChannelPairingRequests).toHaveBeenCalledWith("zalo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels Discord ids as discordUserId", async () => {
|
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
|
||||||
listChannelPairingRequests.mockResolvedValueOnce([
|
|
||||||
{
|
|
||||||
id: "999",
|
|
||||||
code: "DEF456",
|
|
||||||
createdAt: "2026-01-08T00:00:00Z",
|
|
||||||
lastSeenAt: "2026-01-08T00:00:00Z",
|
|
||||||
meta: { tag: "Ada#0001" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
const program = new Command();
|
|
||||||
program.name("test");
|
|
||||||
registerPairingCli(program);
|
|
||||||
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
|
||||||
expect(output).toContain("discordUserId");
|
|
||||||
expect(output).toContain("999");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts channel as positional for approve (npm-run compatible)", async () => {
|
it("accepts channel as positional for approve (npm-run compatible)", async () => {
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
mockApprovedPairing();
|
||||||
approveChannelPairingCode.mockResolvedValueOnce({
|
|
||||||
id: "123",
|
|
||||||
entry: {
|
|
||||||
id: "123",
|
|
||||||
code: "ABCDEFGH",
|
|
||||||
createdAt: "2026-01-08T00:00:00Z",
|
|
||||||
lastSeenAt: "2026-01-08T00:00:00Z",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
const program = new Command();
|
try {
|
||||||
program.name("test");
|
await runPairing(["pairing", "approve", "telegram", "ABCDEFGH"]);
|
||||||
registerPairingCli(program);
|
|
||||||
await program.parseAsync(["pairing", "approve", "telegram", "ABCDEFGH"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(approveChannelPairingCode).toHaveBeenCalledWith({
|
expect(approveChannelPairingCode).toHaveBeenCalledWith({
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
code: "ABCDEFGH",
|
code: "ABCDEFGH",
|
||||||
});
|
});
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
|
||||||
|
} finally {
|
||||||
|
log.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards --account for approve", async () => {
|
it("forwards --account for approve", async () => {
|
||||||
const { registerPairingCli } = await import("./pairing-cli.js");
|
mockApprovedPairing();
|
||||||
approveChannelPairingCode.mockResolvedValueOnce({
|
|
||||||
id: "123",
|
|
||||||
entry: {
|
|
||||||
id: "123",
|
|
||||||
code: "ABCDEFGH",
|
|
||||||
createdAt: "2026-01-08T00:00:00Z",
|
|
||||||
lastSeenAt: "2026-01-08T00:00:00Z",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = new Command();
|
await runPairing([
|
||||||
program.name("test");
|
"pairing",
|
||||||
registerPairingCli(program);
|
"approve",
|
||||||
await program.parseAsync(
|
"--channel",
|
||||||
["pairing", "approve", "--channel", "telegram", "--account", "yy", "ABCDEFGH"],
|
"telegram",
|
||||||
{
|
"--account",
|
||||||
from: "user",
|
"yy",
|
||||||
},
|
"ABCDEFGH",
|
||||||
);
|
]);
|
||||||
|
|
||||||
expect(approveChannelPairingCode).toHaveBeenCalledWith({
|
expect(approveChannelPairingCode).toHaveBeenCalledWith({
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
|
|||||||
@@ -42,13 +42,11 @@ describe("parseCliProfileArgs", () => {
|
|||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects combining --dev with --profile (dev first)", () => {
|
it.each([
|
||||||
const res = parseCliProfileArgs(["node", "openclaw", "--dev", "--profile", "work", "status"]);
|
["--dev first", ["node", "openclaw", "--dev", "--profile", "work", "status"]],
|
||||||
expect(res.ok).toBe(false);
|
["--profile first", ["node", "openclaw", "--profile", "work", "--dev", "status"]],
|
||||||
});
|
])("rejects combining --dev with --profile (%s)", (_name, argv) => {
|
||||||
|
const res = parseCliProfileArgs(argv);
|
||||||
it("rejects combining --dev with --profile (profile first)", () => {
|
|
||||||
const res = parseCliProfileArgs(["node", "openclaw", "--profile", "work", "--dev", "status"]);
|
|
||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -103,38 +101,45 @@ describe("applyCliProfileEnv", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("formatCliCommand", () => {
|
describe("formatCliCommand", () => {
|
||||||
it("returns command unchanged when no profile is set", () => {
|
it.each([
|
||||||
expect(formatCliCommand("openclaw doctor --fix", {})).toBe("openclaw doctor --fix");
|
{
|
||||||
});
|
name: "no profile is set",
|
||||||
|
cmd: "openclaw doctor --fix",
|
||||||
it("returns command unchanged when profile is default", () => {
|
env: {},
|
||||||
expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "default" })).toBe(
|
expected: "openclaw doctor --fix",
|
||||||
"openclaw doctor --fix",
|
},
|
||||||
);
|
{
|
||||||
});
|
name: "profile is default",
|
||||||
|
cmd: "openclaw doctor --fix",
|
||||||
it("returns command unchanged when profile is Default (case-insensitive)", () => {
|
env: { OPENCLAW_PROFILE: "default" },
|
||||||
expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "Default" })).toBe(
|
expected: "openclaw doctor --fix",
|
||||||
"openclaw doctor --fix",
|
},
|
||||||
);
|
{
|
||||||
});
|
name: "profile is Default (case-insensitive)",
|
||||||
|
cmd: "openclaw doctor --fix",
|
||||||
it("returns command unchanged when profile is invalid", () => {
|
env: { OPENCLAW_PROFILE: "Default" },
|
||||||
expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "bad profile" })).toBe(
|
expected: "openclaw doctor --fix",
|
||||||
"openclaw doctor --fix",
|
},
|
||||||
);
|
{
|
||||||
});
|
name: "profile is invalid",
|
||||||
|
cmd: "openclaw doctor --fix",
|
||||||
it("returns command unchanged when --profile is already present", () => {
|
env: { OPENCLAW_PROFILE: "bad profile" },
|
||||||
expect(
|
expected: "openclaw doctor --fix",
|
||||||
formatCliCommand("openclaw --profile work doctor --fix", { OPENCLAW_PROFILE: "work" }),
|
},
|
||||||
).toBe("openclaw --profile work doctor --fix");
|
{
|
||||||
});
|
name: "--profile is already present",
|
||||||
|
cmd: "openclaw --profile work doctor --fix",
|
||||||
it("returns command unchanged when --dev is already present", () => {
|
env: { OPENCLAW_PROFILE: "work" },
|
||||||
expect(formatCliCommand("openclaw --dev doctor", { OPENCLAW_PROFILE: "dev" })).toBe(
|
expected: "openclaw --profile work doctor --fix",
|
||||||
"openclaw --dev doctor",
|
},
|
||||||
);
|
{
|
||||||
|
name: "--dev is already present",
|
||||||
|
cmd: "openclaw --dev doctor",
|
||||||
|
env: { OPENCLAW_PROFILE: "dev" },
|
||||||
|
expected: "openclaw --dev doctor",
|
||||||
|
},
|
||||||
|
])("returns command unchanged when $name", ({ cmd, env, expected }) => {
|
||||||
|
expect(formatCliCommand(cmd, env)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inserts --profile flag when profile is set", () => {
|
it("inserts --profile flag when profile is set", () => {
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ function formatRuntimeLogCallArg(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("cli program (nodes basics)", () => {
|
describe("cli program (nodes basics)", () => {
|
||||||
|
function createProgramWithCleanRuntimeLog() {
|
||||||
|
const program = buildProgram();
|
||||||
|
runtime.log.mockClear();
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProgram(argv: string[]) {
|
||||||
|
const program = createProgramWithCleanRuntimeLog();
|
||||||
|
await program.parseAsync(argv, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeOutput() {
|
||||||
|
return runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function mockGatewayWithIosNodeListAnd(method: "node.describe" | "node.invoke", result: unknown) {
|
function mockGatewayWithIosNodeListAnd(method: "node.describe" | "node.invoke", result: unknown) {
|
||||||
callGateway.mockImplementation(async (...args: unknown[]) => {
|
callGateway.mockImplementation(async (...args: unknown[]) => {
|
||||||
const opts = (args[0] ?? {}) as { method?: string };
|
const opts = (args[0] ?? {}) as { method?: string };
|
||||||
@@ -53,9 +68,7 @@ describe("cli program (nodes basics)", () => {
|
|||||||
|
|
||||||
it("runs nodes list and calls node.pair.list", async () => {
|
it("runs nodes list and calls node.pair.list", async () => {
|
||||||
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
||||||
const program = buildProgram();
|
await runProgram(["nodes", "list"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "list"], { from: "user" });
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
|
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
|
||||||
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
|
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
|
||||||
});
|
});
|
||||||
@@ -93,12 +106,10 @@ describe("cli program (nodes basics)", () => {
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
const program = buildProgram();
|
await runProgram(["nodes", "list", "--connected"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "list", "--connected"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
|
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
|
||||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
const output = getRuntimeOutput();
|
||||||
expect(output).toContain("One");
|
expect(output).toContain("One");
|
||||||
expect(output).not.toContain("Two");
|
expect(output).not.toContain("Two");
|
||||||
});
|
});
|
||||||
@@ -127,89 +138,83 @@ describe("cli program (nodes basics)", () => {
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
const program = buildProgram();
|
await runProgram(["nodes", "status", "--last-connected", "24h"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "status", "--last-connected", "24h"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
|
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
|
||||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
const output = getRuntimeOutput();
|
||||||
expect(output).toContain("One");
|
expect(output).toContain("One");
|
||||||
expect(output).not.toContain("Two");
|
expect(output).not.toContain("Two");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs nodes status and calls node.list", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
label: "paired node details",
|
||||||
|
node: {
|
||||||
|
nodeId: "ios-node",
|
||||||
|
displayName: "iOS Node",
|
||||||
|
remoteIp: "192.168.0.88",
|
||||||
|
deviceFamily: "iPad",
|
||||||
|
modelIdentifier: "iPad16,6",
|
||||||
|
caps: ["canvas", "camera"],
|
||||||
|
paired: true,
|
||||||
|
connected: true,
|
||||||
|
},
|
||||||
|
expectedOutput: [
|
||||||
|
"Known: 1 · Paired: 1 · Connected: 1",
|
||||||
|
"iOS Node",
|
||||||
|
"Detail",
|
||||||
|
"device: iPad",
|
||||||
|
"hw: iPad16,6",
|
||||||
|
"Status",
|
||||||
|
"paired",
|
||||||
|
"Caps",
|
||||||
|
"camera",
|
||||||
|
"canvas",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "unpaired node details",
|
||||||
|
node: {
|
||||||
|
nodeId: "android-node",
|
||||||
|
displayName: "Peter's Tab S10 Ultra",
|
||||||
|
remoteIp: "192.168.0.99",
|
||||||
|
deviceFamily: "Android",
|
||||||
|
modelIdentifier: "samsung SM-X926B",
|
||||||
|
caps: ["canvas", "camera"],
|
||||||
|
paired: false,
|
||||||
|
connected: true,
|
||||||
|
},
|
||||||
|
expectedOutput: [
|
||||||
|
"Known: 1 · Paired: 0 · Connected: 1",
|
||||||
|
"Peter's Tab",
|
||||||
|
"S10 Ultra",
|
||||||
|
"Detail",
|
||||||
|
"device: Android",
|
||||||
|
"hw: samsung",
|
||||||
|
"SM-X926B",
|
||||||
|
"Status",
|
||||||
|
"unpaired",
|
||||||
|
"connected",
|
||||||
|
"Caps",
|
||||||
|
"camera",
|
||||||
|
"canvas",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])("runs nodes status and renders $label", async ({ node, expectedOutput }) => {
|
||||||
callGateway.mockResolvedValue({
|
callGateway.mockResolvedValue({
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
nodes: [
|
nodes: [node],
|
||||||
{
|
|
||||||
nodeId: "ios-node",
|
|
||||||
displayName: "iOS Node",
|
|
||||||
remoteIp: "192.168.0.88",
|
|
||||||
deviceFamily: "iPad",
|
|
||||||
modelIdentifier: "iPad16,6",
|
|
||||||
caps: ["canvas", "camera"],
|
|
||||||
paired: true,
|
|
||||||
connected: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const program = buildProgram();
|
await runProgram(["nodes", "status"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "status"], { from: "user" });
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ method: "node.list", params: {} }),
|
expect.objectContaining({ method: "node.list", params: {} }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
const output = getRuntimeOutput();
|
||||||
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
|
for (const expected of expectedOutput) {
|
||||||
expect(output).toContain("iOS Node");
|
expect(output).toContain(expected);
|
||||||
expect(output).toContain("Detail");
|
}
|
||||||
expect(output).toContain("device: iPad");
|
|
||||||
expect(output).toContain("hw: iPad16,6");
|
|
||||||
expect(output).toContain("Status");
|
|
||||||
expect(output).toContain("paired");
|
|
||||||
expect(output).toContain("Caps");
|
|
||||||
expect(output).toContain("camera");
|
|
||||||
expect(output).toContain("canvas");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs nodes status and shows unpaired nodes", async () => {
|
|
||||||
callGateway.mockResolvedValue({
|
|
||||||
ts: Date.now(),
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
nodeId: "android-node",
|
|
||||||
displayName: "Peter's Tab S10 Ultra",
|
|
||||||
remoteIp: "192.168.0.99",
|
|
||||||
deviceFamily: "Android",
|
|
||||||
modelIdentifier: "samsung SM-X926B",
|
|
||||||
caps: ["canvas", "camera"],
|
|
||||||
paired: false,
|
|
||||||
connected: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const program = buildProgram();
|
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "status"], { from: "user" });
|
|
||||||
|
|
||||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
|
||||||
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
|
|
||||||
expect(output).toContain("Peter's Tab");
|
|
||||||
expect(output).toContain("S10 Ultra");
|
|
||||||
expect(output).toContain("Detail");
|
|
||||||
expect(output).toContain("device: Android");
|
|
||||||
expect(output).toContain("hw: samsung");
|
|
||||||
expect(output).toContain("SM-X926B");
|
|
||||||
expect(output).toContain("Status");
|
|
||||||
expect(output).toContain("unpaired");
|
|
||||||
expect(output).toContain("connected");
|
|
||||||
expect(output).toContain("Caps");
|
|
||||||
expect(output).toContain("camera");
|
|
||||||
expect(output).toContain("canvas");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs nodes describe and calls node.describe", async () => {
|
it("runs nodes describe and calls node.describe", async () => {
|
||||||
@@ -222,11 +227,7 @@ describe("cli program (nodes basics)", () => {
|
|||||||
connected: true,
|
connected: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = buildProgram();
|
await runProgram(["nodes", "describe", "--node", "ios-node"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "describe", "--node", "ios-node"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ method: "node.list", params: {} }),
|
expect.objectContaining({ method: "node.list", params: {} }),
|
||||||
@@ -238,7 +239,7 @@ describe("cli program (nodes basics)", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const out = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
const out = getRuntimeOutput();
|
||||||
expect(out).toContain("Commands");
|
expect(out).toContain("Commands");
|
||||||
expect(out).toContain("canvas.eval");
|
expect(out).toContain("canvas.eval");
|
||||||
});
|
});
|
||||||
@@ -248,9 +249,7 @@ describe("cli program (nodes basics)", () => {
|
|||||||
requestId: "r1",
|
requestId: "r1",
|
||||||
node: { nodeId: "n1", token: "t1" },
|
node: { nodeId: "n1", token: "t1" },
|
||||||
});
|
});
|
||||||
const program = buildProgram();
|
await runProgram(["nodes", "approve", "r1"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "approve", "r1"], { from: "user" });
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "node.pair.approve",
|
method: "node.pair.approve",
|
||||||
@@ -268,21 +267,16 @@ describe("cli program (nodes basics)", () => {
|
|||||||
payload: { result: "ok" },
|
payload: { result: "ok" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = buildProgram();
|
await runProgram([
|
||||||
runtime.log.mockClear();
|
"nodes",
|
||||||
await program.parseAsync(
|
"invoke",
|
||||||
[
|
"--node",
|
||||||
"nodes",
|
"ios-node",
|
||||||
"invoke",
|
"--command",
|
||||||
"--node",
|
"canvas.eval",
|
||||||
"ios-node",
|
"--params",
|
||||||
"--command",
|
'{"javaScript":"1+1"}',
|
||||||
"canvas.eval",
|
]);
|
||||||
"--params",
|
|
||||||
'{"javaScript":"1+1"}',
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ method: "node.list", params: {} }),
|
expect.objectContaining({ method: "node.list", params: {} }),
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ async function expectLoggedSingleMediaFile(params?: {
|
|||||||
return mediaPath;
|
return mediaPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectParserAcceptsUrlWithoutBase64(
|
||||||
|
parse: (payload: Record<string, unknown>) => { url?: string; base64?: string },
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
expectedUrl: string,
|
||||||
|
) {
|
||||||
|
const result = parse(payload);
|
||||||
|
expect(result.url).toBe(expectedUrl);
|
||||||
|
expect(result.base64).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectParserRejectsMissingMedia(
|
||||||
|
parse: (payload: Record<string, unknown>) => unknown,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
expectedMessage: string,
|
||||||
|
) {
|
||||||
|
expect(() => parse(payload)).toThrow(expectedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
const IOS_NODE = {
|
const IOS_NODE = {
|
||||||
nodeId: "ios-node",
|
nodeId: "ios-node",
|
||||||
displayName: "iOS Node",
|
displayName: "iOS Node",
|
||||||
@@ -61,6 +79,31 @@ function mockNodeGateway(command?: string, payload?: Record<string, unknown>) {
|
|||||||
const { buildProgram } = await import("./program.js");
|
const { buildProgram } = await import("./program.js");
|
||||||
|
|
||||||
describe("cli program (nodes media)", () => {
|
describe("cli program (nodes media)", () => {
|
||||||
|
function createProgramWithCleanRuntimeLog() {
|
||||||
|
const program = buildProgram();
|
||||||
|
runtime.log.mockClear();
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNodesCommand(argv: string[]) {
|
||||||
|
const program = createProgramWithCleanRuntimeLog();
|
||||||
|
await program.parseAsync(argv, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAndExpectUrlPayloadMediaFile(params: {
|
||||||
|
command: "camera.snap" | "camera.clip";
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
argv: string[];
|
||||||
|
expectedPathPattern: RegExp;
|
||||||
|
}) {
|
||||||
|
mockNodeGateway(params.command, params.payload);
|
||||||
|
await runNodesCommand(params.argv);
|
||||||
|
await expectLoggedSingleMediaFile({
|
||||||
|
expectedPathPattern: params.expectedPathPattern,
|
||||||
|
expectedContent: "url-content",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
runTui.mockResolvedValue(undefined);
|
runTui.mockResolvedValue(undefined);
|
||||||
@@ -69,9 +112,7 @@ describe("cli program (nodes media)", () => {
|
|||||||
it("runs nodes camera snap and prints two MEDIA paths", async () => {
|
it("runs nodes camera snap and prints two MEDIA paths", async () => {
|
||||||
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
|
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
|
||||||
|
|
||||||
const program = buildProgram();
|
await runNodesCommand(["nodes", "camera", "snap", "--node", "ios-node"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node"], { from: "user" });
|
|
||||||
|
|
||||||
const invokeCalls = callGateway.mock.calls
|
const invokeCalls = callGateway.mock.calls
|
||||||
.map((call) => call[0] as { method?: string; params?: Record<string, unknown> })
|
.map((call) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||||
@@ -107,12 +148,7 @@ describe("cli program (nodes media)", () => {
|
|||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = buildProgram();
|
await runNodesCommand(["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(
|
|
||||||
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -140,28 +176,23 @@ describe("cli program (nodes media)", () => {
|
|||||||
it("runs nodes camera snap with facing front and passes params", async () => {
|
it("runs nodes camera snap with facing front and passes params", async () => {
|
||||||
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
|
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
|
||||||
|
|
||||||
const program = buildProgram();
|
await runNodesCommand([
|
||||||
runtime.log.mockClear();
|
"nodes",
|
||||||
await program.parseAsync(
|
"camera",
|
||||||
[
|
"snap",
|
||||||
"nodes",
|
"--node",
|
||||||
"camera",
|
"ios-node",
|
||||||
"snap",
|
"--facing",
|
||||||
"--node",
|
"front",
|
||||||
"ios-node",
|
"--max-width",
|
||||||
"--facing",
|
"640",
|
||||||
"front",
|
"--quality",
|
||||||
"--max-width",
|
"0.8",
|
||||||
"640",
|
"--delay-ms",
|
||||||
"--quality",
|
"2000",
|
||||||
"0.8",
|
"--device-id",
|
||||||
"--delay-ms",
|
"cam-123",
|
||||||
"2000",
|
]);
|
||||||
"--device-id",
|
|
||||||
"cam-123",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -193,23 +224,18 @@ describe("cli program (nodes media)", () => {
|
|||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = buildProgram();
|
await runNodesCommand([
|
||||||
runtime.log.mockClear();
|
"nodes",
|
||||||
await program.parseAsync(
|
"camera",
|
||||||
[
|
"clip",
|
||||||
"nodes",
|
"--node",
|
||||||
"camera",
|
"ios-node",
|
||||||
"clip",
|
"--duration",
|
||||||
"--node",
|
"3000",
|
||||||
"ios-node",
|
"--no-audio",
|
||||||
"--duration",
|
"--device-id",
|
||||||
"3000",
|
"cam-123",
|
||||||
"--no-audio",
|
]);
|
||||||
"--device-id",
|
|
||||||
"cam-123",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -238,12 +264,7 @@ describe("cli program (nodes media)", () => {
|
|||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = buildProgram();
|
await runNodesCommand(["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(
|
|
||||||
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -260,12 +281,7 @@ describe("cli program (nodes media)", () => {
|
|||||||
it("runs nodes canvas snapshot and prints MEDIA path", async () => {
|
it("runs nodes canvas snapshot and prints MEDIA path", async () => {
|
||||||
mockNodeGateway("canvas.snapshot", { format: "png", base64: "aGk=" });
|
mockNodeGateway("canvas.snapshot", { format: "png", base64: "aGk=" });
|
||||||
|
|
||||||
const program = buildProgram();
|
await runNodesCommand(["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"]);
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(
|
|
||||||
["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expectLoggedSingleMediaFile({
|
await expectLoggedSingleMediaFile({
|
||||||
expectedPathPattern: /openclaw-canvas-snapshot-.*\.png$/,
|
expectedPathPattern: /openclaw-canvas-snapshot-.*\.png$/,
|
||||||
@@ -307,62 +323,86 @@ describe("cli program (nodes media)", () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs nodes camera snap with url payload", async () => {
|
it.each([
|
||||||
mockNodeGateway("camera.snap", {
|
{
|
||||||
format: "jpg",
|
label: "runs nodes camera snap with url payload",
|
||||||
url: "https://example.com/photo.jpg",
|
command: "camera.snap" as const,
|
||||||
width: 640,
|
payload: {
|
||||||
height: 480,
|
format: "jpg",
|
||||||
});
|
url: "https://example.com/photo.jpg",
|
||||||
|
width: 640,
|
||||||
const program = buildProgram();
|
height: 480,
|
||||||
runtime.log.mockClear();
|
},
|
||||||
await program.parseAsync(
|
argv: ["nodes", "camera", "snap", "--node", "ios-node", "--facing", "front"],
|
||||||
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "front"],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expectLoggedSingleMediaFile({
|
|
||||||
expectedPathPattern: /openclaw-camera-snap-front-.*\.jpg$/,
|
expectedPathPattern: /openclaw-camera-snap-front-.*\.jpg$/,
|
||||||
expectedContent: "url-content",
|
},
|
||||||
});
|
{
|
||||||
});
|
label: "runs nodes camera clip with url payload",
|
||||||
|
command: "camera.clip" as const,
|
||||||
it("runs nodes camera clip with url payload", async () => {
|
payload: {
|
||||||
mockNodeGateway("camera.clip", {
|
format: "mp4",
|
||||||
format: "mp4",
|
url: "https://example.com/clip.mp4",
|
||||||
url: "https://example.com/clip.mp4",
|
durationMs: 5000,
|
||||||
durationMs: 5000,
|
hasAudio: true,
|
||||||
hasAudio: true,
|
},
|
||||||
});
|
argv: ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "5000"],
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
runtime.log.mockClear();
|
|
||||||
await program.parseAsync(
|
|
||||||
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "5000"],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expectLoggedSingleMediaFile({
|
|
||||||
expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/,
|
expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/,
|
||||||
expectedContent: "url-content",
|
},
|
||||||
|
])("$label", async ({ command, payload, argv, expectedPathPattern }) => {
|
||||||
|
await runAndExpectUrlPayloadMediaFile({
|
||||||
|
command,
|
||||||
|
payload,
|
||||||
|
argv,
|
||||||
|
expectedPathPattern,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseCameraSnapPayload with url", () => {
|
describe("url payload parsers", () => {
|
||||||
it("accepts url without base64", () => {
|
const parserCases = [
|
||||||
const result = parseCameraSnapPayload({
|
{
|
||||||
format: "jpg",
|
label: "camera snap parser",
|
||||||
url: "https://example.com/photo.jpg",
|
parse: (payload: Record<string, unknown>) => parseCameraSnapPayload(payload),
|
||||||
width: 640,
|
validPayload: {
|
||||||
height: 480,
|
format: "jpg",
|
||||||
});
|
url: "https://example.com/photo.jpg",
|
||||||
expect(result.url).toBe("https://example.com/photo.jpg");
|
width: 640,
|
||||||
expect(result.base64).toBeUndefined();
|
height: 480,
|
||||||
});
|
},
|
||||||
|
invalidPayload: { format: "jpg", width: 640, height: 480 },
|
||||||
|
expectedUrl: "https://example.com/photo.jpg",
|
||||||
|
expectedError: "invalid camera.snap payload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "camera clip parser",
|
||||||
|
parse: (payload: Record<string, unknown>) => parseCameraClipPayload(payload),
|
||||||
|
validPayload: {
|
||||||
|
format: "mp4",
|
||||||
|
url: "https://example.com/clip.mp4",
|
||||||
|
durationMs: 3000,
|
||||||
|
hasAudio: true,
|
||||||
|
},
|
||||||
|
invalidPayload: { format: "mp4", durationMs: 3000, hasAudio: true },
|
||||||
|
expectedUrl: "https://example.com/clip.mp4",
|
||||||
|
expectedError: "invalid camera.clip payload",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
it("accepts both base64 and url", () => {
|
it.each(parserCases)(
|
||||||
|
"accepts url without base64: $label",
|
||||||
|
({ parse, validPayload, expectedUrl }) => {
|
||||||
|
expectParserAcceptsUrlWithoutBase64(parse, validPayload, expectedUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(parserCases)(
|
||||||
|
"rejects payload with neither base64 nor url: $label",
|
||||||
|
({ parse, invalidPayload, expectedError }) => {
|
||||||
|
expectParserRejectsMissingMedia(parse, invalidPayload, expectedError);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("snap parser accepts both base64 and url", () => {
|
||||||
const result = parseCameraSnapPayload({
|
const result = parseCameraSnapPayload({
|
||||||
format: "jpg",
|
format: "jpg",
|
||||||
base64: "aGk=",
|
base64: "aGk=",
|
||||||
@@ -373,30 +413,5 @@ describe("cli program (nodes media)", () => {
|
|||||||
expect(result.base64).toBe("aGk=");
|
expect(result.base64).toBe("aGk=");
|
||||||
expect(result.url).toBe("https://example.com/photo.jpg");
|
expect(result.url).toBe("https://example.com/photo.jpg");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects payload with neither base64 nor url", () => {
|
|
||||||
expect(() => parseCameraSnapPayload({ format: "jpg", width: 640, height: 480 })).toThrow(
|
|
||||||
"invalid camera.snap payload",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseCameraClipPayload with url", () => {
|
|
||||||
it("accepts url without base64", () => {
|
|
||||||
const result = parseCameraClipPayload({
|
|
||||||
format: "mp4",
|
|
||||||
url: "https://example.com/clip.mp4",
|
|
||||||
durationMs: 3000,
|
|
||||||
hasAudio: true,
|
|
||||||
});
|
|
||||||
expect(result.url).toBe("https://example.com/clip.mp4");
|
|
||||||
expect(result.base64).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects payload with neither base64 nor url", () => {
|
|
||||||
expect(() =>
|
|
||||||
parseCameraClipPayload({ format: "mp4", durationMs: 3000, hasAudio: true }),
|
|
||||||
).toThrow("invalid camera.clip payload");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,99 +20,108 @@ installSmokeProgramMocks();
|
|||||||
const { buildProgram } = await import("./program.js");
|
const { buildProgram } = await import("./program.js");
|
||||||
|
|
||||||
describe("cli program (smoke)", () => {
|
describe("cli program (smoke)", () => {
|
||||||
|
function createProgram() {
|
||||||
|
return buildProgram();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProgram(argv: string[]) {
|
||||||
|
const program = createProgram();
|
||||||
|
await program.parseAsync(argv, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
runTui.mockResolvedValue(undefined);
|
runTui.mockResolvedValue(undefined);
|
||||||
ensureConfigReady.mockResolvedValue(undefined);
|
ensureConfigReady.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs message with required options", async () => {
|
it.each([
|
||||||
const program = buildProgram();
|
{
|
||||||
await expect(
|
label: "runs message with required options",
|
||||||
program.parseAsync(["message", "send", "--target", "+1", "--message", "hi"], {
|
argv: ["message", "send", "--target", "+1", "--message", "hi"],
|
||||||
from: "user",
|
},
|
||||||
}),
|
{
|
||||||
).rejects.toThrow("exit");
|
label: "runs message react with signal author fields",
|
||||||
expect(messageCommand).toHaveBeenCalled();
|
argv: [
|
||||||
});
|
"message",
|
||||||
|
"react",
|
||||||
it("runs message react with signal author fields", async () => {
|
"--channel",
|
||||||
const program = buildProgram();
|
"signal",
|
||||||
await expect(
|
"--target",
|
||||||
program.parseAsync(
|
"signal:group:abc123",
|
||||||
[
|
"--message-id",
|
||||||
"message",
|
"1737630212345",
|
||||||
"react",
|
"--emoji",
|
||||||
"--channel",
|
"✅",
|
||||||
"signal",
|
"--target-author-uuid",
|
||||||
"--target",
|
"123e4567-e89b-12d3-a456-426614174000",
|
||||||
"signal:group:abc123",
|
],
|
||||||
"--message-id",
|
},
|
||||||
"1737630212345",
|
])("$label", async ({ argv }) => {
|
||||||
"--emoji",
|
await expect(runProgram(argv)).rejects.toThrow("exit");
|
||||||
"✅",
|
|
||||||
"--target-author-uuid",
|
|
||||||
"123e4567-e89b-12d3-a456-426614174000",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
),
|
|
||||||
).rejects.toThrow("exit");
|
|
||||||
expect(messageCommand).toHaveBeenCalled();
|
expect(messageCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs status command", async () => {
|
it("runs status command", async () => {
|
||||||
const program = buildProgram();
|
await runProgram(["status"]);
|
||||||
await program.parseAsync(["status"], { from: "user" });
|
|
||||||
expect(statusCommand).toHaveBeenCalled();
|
expect(statusCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers memory command", () => {
|
it("registers memory command", () => {
|
||||||
const program = buildProgram();
|
const program = createProgram();
|
||||||
const names = program.commands.map((command) => command.name());
|
const names = program.commands.map((command) => command.name());
|
||||||
expect(names).toContain("memory");
|
expect(names).toContain("memory");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs tui without overriding timeout", async () => {
|
it.each([
|
||||||
const program = buildProgram();
|
{
|
||||||
await program.parseAsync(["tui"], { from: "user" });
|
label: "runs tui without overriding timeout",
|
||||||
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: undefined }));
|
argv: ["tui"],
|
||||||
});
|
expectedTimeoutMs: undefined,
|
||||||
|
expectedWarning: undefined,
|
||||||
it("runs tui with explicit timeout override", async () => {
|
},
|
||||||
const program = buildProgram();
|
{
|
||||||
await program.parseAsync(["tui", "--timeout-ms", "45000"], {
|
label: "runs tui with explicit timeout override",
|
||||||
from: "user",
|
argv: ["tui", "--timeout-ms", "45000"],
|
||||||
});
|
expectedTimeoutMs: 45000,
|
||||||
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 45000 }));
|
expectedWarning: undefined,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
it("warns and ignores invalid tui timeout override", async () => {
|
label: "warns and ignores invalid tui timeout override",
|
||||||
const program = buildProgram();
|
argv: ["tui", "--timeout-ms", "nope"],
|
||||||
await program.parseAsync(["tui", "--timeout-ms", "nope"], { from: "user" });
|
expectedTimeoutMs: undefined,
|
||||||
expect(runtime.error).toHaveBeenCalledWith('warning: invalid --timeout-ms "nope"; ignoring');
|
expectedWarning: 'warning: invalid --timeout-ms "nope"; ignoring',
|
||||||
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: undefined }));
|
},
|
||||||
|
])("$label", async ({ argv, expectedTimeoutMs, expectedWarning }) => {
|
||||||
|
await runProgram(argv);
|
||||||
|
if (expectedWarning) {
|
||||||
|
expect(runtime.error).toHaveBeenCalledWith(expectedWarning);
|
||||||
|
}
|
||||||
|
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: expectedTimeoutMs }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs config alias as configure", async () => {
|
it("runs config alias as configure", async () => {
|
||||||
const program = buildProgram();
|
await runProgram(["config"]);
|
||||||
await program.parseAsync(["config"], { from: "user" });
|
|
||||||
expect(configureCommand).toHaveBeenCalled();
|
expect(configureCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs setup without wizard flags", async () => {
|
it.each([
|
||||||
const program = buildProgram();
|
{
|
||||||
await program.parseAsync(["setup"], { from: "user" });
|
label: "runs setup without wizard flags",
|
||||||
expect(setupCommand).toHaveBeenCalled();
|
argv: ["setup"],
|
||||||
expect(onboardCommand).not.toHaveBeenCalled();
|
expectSetupCalled: true,
|
||||||
});
|
expectOnboardCalled: false,
|
||||||
|
},
|
||||||
it("runs setup wizard when wizard flags are present", async () => {
|
{
|
||||||
const program = buildProgram();
|
label: "runs setup wizard when wizard flags are present",
|
||||||
await program.parseAsync(["setup", "--remote-url", "ws://example"], {
|
argv: ["setup", "--remote-url", "ws://example"],
|
||||||
from: "user",
|
expectSetupCalled: false,
|
||||||
});
|
expectOnboardCalled: true,
|
||||||
expect(onboardCommand).toHaveBeenCalled();
|
},
|
||||||
expect(setupCommand).not.toHaveBeenCalled();
|
])("$label", async ({ argv, expectSetupCalled, expectOnboardCalled }) => {
|
||||||
|
await runProgram(argv);
|
||||||
|
expect(setupCommand).toHaveBeenCalledTimes(expectSetupCalled ? 1 : 0);
|
||||||
|
expect(onboardCommand).toHaveBeenCalledTimes(expectOnboardCalled ? 1 : 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes auth api keys to onboard", async () => {
|
it("passes auth api keys to onboard", async () => {
|
||||||
@@ -168,11 +177,14 @@ describe("cli program (smoke)", () => {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const entry of cases) {
|
for (const entry of cases) {
|
||||||
const program = buildProgram();
|
await runProgram([
|
||||||
await program.parseAsync(
|
"onboard",
|
||||||
["onboard", "--non-interactive", "--auth-choice", entry.authChoice, entry.flag, entry.key],
|
"--non-interactive",
|
||||||
{ from: "user" },
|
"--auth-choice",
|
||||||
);
|
entry.authChoice,
|
||||||
|
entry.flag,
|
||||||
|
entry.key,
|
||||||
|
]);
|
||||||
expect(onboardCommand).toHaveBeenCalledWith(
|
expect(onboardCommand).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
nonInteractive: true,
|
nonInteractive: true,
|
||||||
@@ -186,26 +198,22 @@ describe("cli program (smoke)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes custom provider flags to onboard", async () => {
|
it("passes custom provider flags to onboard", async () => {
|
||||||
const program = buildProgram();
|
await runProgram([
|
||||||
await program.parseAsync(
|
"onboard",
|
||||||
[
|
"--non-interactive",
|
||||||
"onboard",
|
"--auth-choice",
|
||||||
"--non-interactive",
|
"custom-api-key",
|
||||||
"--auth-choice",
|
"--custom-base-url",
|
||||||
"custom-api-key",
|
"https://llm.example.com/v1",
|
||||||
"--custom-base-url",
|
"--custom-api-key",
|
||||||
"https://llm.example.com/v1",
|
"sk-custom-test",
|
||||||
"--custom-api-key",
|
"--custom-model-id",
|
||||||
"sk-custom-test",
|
"foo-large",
|
||||||
"--custom-model-id",
|
"--custom-provider-id",
|
||||||
"foo-large",
|
"my-custom",
|
||||||
"--custom-provider-id",
|
"--custom-compatibility",
|
||||||
"my-custom",
|
"anthropic",
|
||||||
"--custom-compatibility",
|
]);
|
||||||
"anthropic",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(onboardCommand).toHaveBeenCalledWith(
|
expect(onboardCommand).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -221,22 +229,27 @@ describe("cli program (smoke)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs channels login", async () => {
|
it.each([
|
||||||
const program = buildProgram();
|
{
|
||||||
await program.parseAsync(["channels", "login", "--account", "work"], {
|
label: "runs channels login",
|
||||||
from: "user",
|
argv: ["channels", "login", "--account", "work"],
|
||||||
});
|
expectCall: () =>
|
||||||
expect(runChannelLogin).toHaveBeenCalledWith(
|
expect(runChannelLogin).toHaveBeenCalledWith(
|
||||||
{ channel: undefined, account: "work", verbose: false },
|
{ channel: undefined, account: "work", verbose: false },
|
||||||
runtime,
|
runtime,
|
||||||
);
|
),
|
||||||
});
|
},
|
||||||
|
{
|
||||||
it("runs channels logout", async () => {
|
label: "runs channels logout",
|
||||||
const program = buildProgram();
|
argv: ["channels", "logout", "--account", "work"],
|
||||||
await program.parseAsync(["channels", "logout", "--account", "work"], {
|
expectCall: () =>
|
||||||
from: "user",
|
expect(runChannelLogout).toHaveBeenCalledWith(
|
||||||
});
|
{ channel: undefined, account: "work" },
|
||||||
expect(runChannelLogout).toHaveBeenCalledWith({ channel: undefined, account: "work" }, runtime);
|
runtime,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
])("$label", async ({ argv, expectCall }) => {
|
||||||
|
await runProgram(argv);
|
||||||
|
expectCall();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ const testProgramContext: ProgramContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("command-registry", () => {
|
describe("command-registry", () => {
|
||||||
|
const createProgram = () => new Command();
|
||||||
|
|
||||||
|
const withProcessArgv = async (argv: string[], run: () => Promise<void>) => {
|
||||||
|
const prevArgv = process.argv;
|
||||||
|
process.argv = argv;
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} finally {
|
||||||
|
process.argv = prevArgv;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
it("includes both agent and agents in core CLI command names", () => {
|
it("includes both agent and agents in core CLI command names", () => {
|
||||||
const names = getCoreCliCommandNames();
|
const names = getCoreCliCommandNames();
|
||||||
expect(names).toContain("agent");
|
expect(names).toContain("agent");
|
||||||
@@ -46,7 +58,7 @@ describe("command-registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registerCoreCliByName resolves agents to the agent entry", async () => {
|
it("registerCoreCliByName resolves agents to the agent entry", async () => {
|
||||||
const program = new Command();
|
const program = createProgram();
|
||||||
const found = await registerCoreCliByName(program, testProgramContext, "agents");
|
const found = await registerCoreCliByName(program, testProgramContext, "agents");
|
||||||
expect(found).toBe(true);
|
expect(found).toBe(true);
|
||||||
const agentsCmd = program.commands.find((c) => c.name() === "agents");
|
const agentsCmd = program.commands.find((c) => c.name() === "agents");
|
||||||
@@ -57,20 +69,20 @@ describe("command-registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registerCoreCliByName returns false for unknown commands", async () => {
|
it("registerCoreCliByName returns false for unknown commands", async () => {
|
||||||
const program = new Command();
|
const program = createProgram();
|
||||||
const found = await registerCoreCliByName(program, testProgramContext, "nonexistent");
|
const found = await registerCoreCliByName(program, testProgramContext, "nonexistent");
|
||||||
expect(found).toBe(false);
|
expect(found).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers doctor placeholder for doctor primary command", () => {
|
it("registers doctor placeholder for doctor primary command", () => {
|
||||||
const program = new Command();
|
const program = createProgram();
|
||||||
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "doctor"]);
|
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "doctor"]);
|
||||||
|
|
||||||
expect(program.commands.map((command) => command.name())).toEqual(["doctor"]);
|
expect(program.commands.map((command) => command.name())).toEqual(["doctor"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats maintenance commands as top-level builtins", async () => {
|
it("treats maintenance commands as top-level builtins", async () => {
|
||||||
const program = new Command();
|
const program = createProgram();
|
||||||
|
|
||||||
expect(await registerCoreCliByName(program, testProgramContext, "doctor")).toBe(true);
|
expect(await registerCoreCliByName(program, testProgramContext, "doctor")).toBe(true);
|
||||||
|
|
||||||
@@ -83,17 +95,12 @@ describe("command-registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers grouped core entry placeholders without duplicate command errors", async () => {
|
it("registers grouped core entry placeholders without duplicate command errors", async () => {
|
||||||
const program = new Command();
|
const program = createProgram();
|
||||||
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "vitest"]);
|
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "vitest"]);
|
||||||
|
program.exitOverride();
|
||||||
const prevArgv = process.argv;
|
await withProcessArgv(["node", "openclaw", "status"], async () => {
|
||||||
process.argv = ["node", "openclaw", "status"];
|
|
||||||
try {
|
|
||||||
program.exitOverride();
|
|
||||||
await program.parseAsync(["node", "openclaw", "status"]);
|
await program.parseAsync(["node", "openclaw", "status"]);
|
||||||
} finally {
|
});
|
||||||
process.argv = prevArgv;
|
|
||||||
}
|
|
||||||
|
|
||||||
const names = program.commands.map((command) => command.name());
|
const names = program.commands.map((command) => command.name());
|
||||||
expect(names).toContain("status");
|
expect(names).toContain("status");
|
||||||
|
|||||||
@@ -29,22 +29,30 @@ function makeRuntime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ensureConfigReady", () => {
|
describe("ensureConfigReady", () => {
|
||||||
|
async function runEnsureConfigReady(commandPath: string[]) {
|
||||||
|
vi.resetModules();
|
||||||
|
const { ensureConfigReady } = await import("./config-guard.js");
|
||||||
|
await ensureConfigReady({ runtime: makeRuntime() as never, commandPath });
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
|
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips doctor flow for read-only fast path commands", async () => {
|
it.each([
|
||||||
vi.resetModules();
|
{
|
||||||
const { ensureConfigReady } = await import("./config-guard.js");
|
name: "skips doctor flow for read-only fast path commands",
|
||||||
await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["status"] });
|
commandPath: ["status"],
|
||||||
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
|
expectedDoctorCalls: 0,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
it("runs doctor flow for commands that may mutate state", async () => {
|
name: "runs doctor flow for commands that may mutate state",
|
||||||
vi.resetModules();
|
commandPath: ["message"],
|
||||||
const { ensureConfigReady } = await import("./config-guard.js");
|
expectedDoctorCalls: 1,
|
||||||
await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["message"] });
|
},
|
||||||
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
|
])("$name", async ({ commandPath, expectedDoctorCalls }) => {
|
||||||
|
await runEnsureConfigReady(commandPath);
|
||||||
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const doctorCommand = vi.fn();
|
const doctorCommand = vi.fn();
|
||||||
const dashboardCommand = vi.fn();
|
const dashboardCommand = vi.fn();
|
||||||
@@ -32,7 +32,19 @@ vi.mock("../../runtime.js", () => ({
|
|||||||
defaultRuntime: runtime,
|
defaultRuntime: runtime,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let registerMaintenanceCommands: typeof import("./register.maintenance.js").registerMaintenanceCommands;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerMaintenanceCommands } = await import("./register.maintenance.js"));
|
||||||
|
});
|
||||||
|
|
||||||
describe("registerMaintenanceCommands doctor action", () => {
|
describe("registerMaintenanceCommands doctor action", () => {
|
||||||
|
async function runMaintenanceCli(args: string[]) {
|
||||||
|
const program = new Command();
|
||||||
|
registerMaintenanceCommands(program);
|
||||||
|
await program.parseAsync(args, { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -40,11 +52,7 @@ describe("registerMaintenanceCommands doctor action", () => {
|
|||||||
it("exits with code 0 after successful doctor run", async () => {
|
it("exits with code 0 after successful doctor run", async () => {
|
||||||
doctorCommand.mockResolvedValue(undefined);
|
doctorCommand.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const { registerMaintenanceCommands } = await import("./register.maintenance.js");
|
await runMaintenanceCli(["doctor", "--non-interactive", "--yes"]);
|
||||||
const program = new Command();
|
|
||||||
registerMaintenanceCommands(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["doctor", "--non-interactive", "--yes"], { from: "user" });
|
|
||||||
|
|
||||||
expect(doctorCommand).toHaveBeenCalledWith(
|
expect(doctorCommand).toHaveBeenCalledWith(
|
||||||
runtime,
|
runtime,
|
||||||
@@ -59,11 +67,7 @@ describe("registerMaintenanceCommands doctor action", () => {
|
|||||||
it("exits with code 1 when doctor fails", async () => {
|
it("exits with code 1 when doctor fails", async () => {
|
||||||
doctorCommand.mockRejectedValue(new Error("doctor failed"));
|
doctorCommand.mockRejectedValue(new Error("doctor failed"));
|
||||||
|
|
||||||
const { registerMaintenanceCommands } = await import("./register.maintenance.js");
|
await runMaintenanceCli(["doctor"]);
|
||||||
const program = new Command();
|
|
||||||
registerMaintenanceCommands(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["doctor"], { from: "user" });
|
|
||||||
|
|
||||||
expect(runtime.error).toHaveBeenCalledWith("Error: doctor failed");
|
expect(runtime.error).toHaveBeenCalledWith("Error: doctor failed");
|
||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ describe("registerSubCliCommands", () => {
|
|||||||
const originalArgv = process.argv;
|
const originalArgv = process.argv;
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
const createRegisteredProgram = (argv: string[], name?: string) => {
|
||||||
|
process.argv = argv;
|
||||||
|
const program = new Command();
|
||||||
|
if (name) {
|
||||||
|
program.name(name);
|
||||||
|
}
|
||||||
|
registerSubCliCommands(program, process.argv);
|
||||||
|
return program;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
delete process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS;
|
delete process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS;
|
||||||
@@ -42,9 +52,7 @@ describe("registerSubCliCommands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers only the primary placeholder and dispatches", async () => {
|
it("registers only the primary placeholder and dispatches", async () => {
|
||||||
process.argv = ["node", "openclaw", "acp"];
|
const program = createRegisteredProgram(["node", "openclaw", "acp"]);
|
||||||
const program = new Command();
|
|
||||||
registerSubCliCommands(program, process.argv);
|
|
||||||
|
|
||||||
expect(program.commands.map((cmd) => cmd.name())).toEqual(["acp"]);
|
expect(program.commands.map((cmd) => cmd.name())).toEqual(["acp"]);
|
||||||
|
|
||||||
@@ -55,9 +63,7 @@ describe("registerSubCliCommands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers placeholders for all subcommands when no primary", () => {
|
it("registers placeholders for all subcommands when no primary", () => {
|
||||||
process.argv = ["node", "openclaw"];
|
const program = createRegisteredProgram(["node", "openclaw"]);
|
||||||
const program = new Command();
|
|
||||||
registerSubCliCommands(program, process.argv);
|
|
||||||
|
|
||||||
const names = program.commands.map((cmd) => cmd.name());
|
const names = program.commands.map((cmd) => cmd.name());
|
||||||
expect(names).toContain("acp");
|
expect(names).toContain("acp");
|
||||||
@@ -67,10 +73,7 @@ describe("registerSubCliCommands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("re-parses argv for lazy subcommands", async () => {
|
it("re-parses argv for lazy subcommands", async () => {
|
||||||
process.argv = ["node", "openclaw", "nodes", "list"];
|
const program = createRegisteredProgram(["node", "openclaw", "nodes", "list"], "openclaw");
|
||||||
const program = new Command();
|
|
||||||
program.name("openclaw");
|
|
||||||
registerSubCliCommands(program, process.argv);
|
|
||||||
|
|
||||||
expect(program.commands.map((cmd) => cmd.name())).toEqual(["nodes"]);
|
expect(program.commands.map((cmd) => cmd.name())).toEqual(["nodes"]);
|
||||||
|
|
||||||
@@ -81,10 +84,7 @@ describe("registerSubCliCommands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("replaces placeholder when registering a subcommand by name", async () => {
|
it("replaces placeholder when registering a subcommand by name", async () => {
|
||||||
process.argv = ["node", "openclaw", "acp", "--help"];
|
const program = createRegisteredProgram(["node", "openclaw", "acp", "--help"], "openclaw");
|
||||||
const program = new Command();
|
|
||||||
program.name("openclaw");
|
|
||||||
registerSubCliCommands(program, process.argv);
|
|
||||||
|
|
||||||
await registerSubCliByName(program, "acp");
|
await registerSubCliByName(program, "acp");
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ function createRemoteQrConfig(params?: { withTailscale?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("registerQrCli", () => {
|
describe("registerQrCli", () => {
|
||||||
|
function createProgram() {
|
||||||
|
const program = new Command();
|
||||||
|
registerQrCli(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQr(args: string[]) {
|
||||||
|
const program = createProgram();
|
||||||
|
await program.parseAsync(["qr", ...args], { from: "user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectQrExit(args: string[]) {
|
||||||
|
await expect(runQr(args)).rejects.toThrow("exit");
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
||||||
@@ -68,10 +83,7 @@ describe("registerQrCli", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = new Command();
|
await runQr(["--setup-code-only"]);
|
||||||
registerQrCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["qr", "--setup-code-only"], { from: "user" });
|
|
||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
@@ -90,10 +102,7 @@ describe("registerQrCli", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = new Command();
|
await runQr([]);
|
||||||
registerQrCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["qr"], { from: "user" });
|
|
||||||
|
|
||||||
expect(qrGenerate).toHaveBeenCalledTimes(1);
|
expect(qrGenerate).toHaveBeenCalledTimes(1);
|
||||||
const output = runtime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
|
const output = runtime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
|
||||||
@@ -111,12 +120,7 @@ describe("registerQrCli", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = new Command();
|
await runQr(["--setup-code-only", "--token", "override-token"]);
|
||||||
registerQrCli(program);
|
|
||||||
|
|
||||||
await program.parseAsync(["qr", "--setup-code-only", "--token", "override-token"], {
|
|
||||||
from: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
@@ -133,10 +137,7 @@ describe("registerQrCli", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = new Command();
|
await expectQrExit([]);
|
||||||
registerQrCli(program);
|
|
||||||
|
|
||||||
await expect(program.parseAsync(["qr"], { from: "user" })).rejects.toThrow("exit");
|
|
||||||
|
|
||||||
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
|
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
|
||||||
expect(output).toContain("only bound to loopback");
|
expect(output).toContain("only bound to loopback");
|
||||||
@@ -144,10 +145,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => {
|
it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => {
|
||||||
loadConfig.mockReturnValue(createRemoteQrConfig());
|
loadConfig.mockReturnValue(createRemoteQrConfig());
|
||||||
|
await runQr(["--setup-code-only", "--remote"]);
|
||||||
const program = new Command();
|
|
||||||
registerQrCli(program);
|
|
||||||
await program.parseAsync(["qr", "--setup-code-only", "--remote"], { from: "user" });
|
|
||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "wss://remote.example.com:444",
|
url: "wss://remote.example.com:444",
|
||||||
@@ -156,12 +154,18 @@ describe("registerQrCli", () => {
|
|||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports gateway.remote.url as source in --remote json output", async () => {
|
it.each([
|
||||||
loadConfig.mockReturnValue(createRemoteQrConfig());
|
{ name: "without tailscale configured", withTailscale: false },
|
||||||
|
{ name: "when tailscale is configured", withTailscale: true },
|
||||||
|
])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => {
|
||||||
|
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale }));
|
||||||
|
runCommandWithTimeout.mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
|
||||||
const program = new Command();
|
await runQr(["--json", "--remote"]);
|
||||||
registerQrCli(program);
|
|
||||||
await program.parseAsync(["qr", "--json", "--remote"], { from: "user" });
|
|
||||||
|
|
||||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
||||||
setupCode?: string;
|
setupCode?: string;
|
||||||
@@ -172,6 +176,7 @@ describe("registerQrCli", () => {
|
|||||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||||
expect(payload.auth).toBe("token");
|
expect(payload.auth).toBe("token");
|
||||||
expect(payload.urlSource).toBe("gateway.remote.url");
|
expect(payload.urlSource).toBe("gateway.remote.url");
|
||||||
|
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("errors when --remote is set but no remote URL is configured", async () => {
|
it("errors when --remote is set but no remote URL is configured", async () => {
|
||||||
@@ -183,33 +188,8 @@ describe("registerQrCli", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const program = new Command();
|
await expectQrExit(["--remote"]);
|
||||||
registerQrCli(program);
|
|
||||||
|
|
||||||
await expect(program.parseAsync(["qr", "--remote"], { from: "user" })).rejects.toThrow("exit");
|
|
||||||
|
|
||||||
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
|
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
|
||||||
expect(output).toContain("qr --remote requires");
|
expect(output).toContain("qr --remote requires");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers gateway.remote.url over tailscale when --remote is set", async () => {
|
|
||||||
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: true }));
|
|
||||||
runCommandWithTimeout.mockResolvedValue({
|
|
||||||
code: 0,
|
|
||||||
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
registerQrCli(program);
|
|
||||||
await program.parseAsync(["qr", "--json", "--remote"], { from: "user" });
|
|
||||||
|
|
||||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
|
||||||
gatewayUrl?: string;
|
|
||||||
urlSource?: string;
|
|
||||||
};
|
|
||||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
|
||||||
expect(payload.urlSource).toBe("gateway.remote.url");
|
|
||||||
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runRegisteredCli } from "../test-utils/command-runner.js";
|
||||||
|
|
||||||
const updateCommand = vi.fn(async (_opts: unknown) => {});
|
const updateCommand = vi.fn(async (_opts: unknown) => {});
|
||||||
const updateStatusCommand = vi.fn(async (_opts: unknown) => {});
|
const updateStatusCommand = vi.fn(async (_opts: unknown) => {});
|
||||||
@@ -28,6 +29,12 @@ vi.mock("../runtime.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("update cli option collisions", () => {
|
describe("update cli option collisions", () => {
|
||||||
|
let registerUpdateCli: typeof import("./update-cli.js").registerUpdateCli;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ registerUpdateCli } = await import("./update-cli.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
updateCommand.mockClear();
|
updateCommand.mockClear();
|
||||||
updateStatusCommand.mockClear();
|
updateStatusCommand.mockClear();
|
||||||
@@ -38,11 +45,10 @@ describe("update cli option collisions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards parent-captured --json/--timeout to `update status`", async () => {
|
it("forwards parent-captured --json/--timeout to `update status`", async () => {
|
||||||
const { registerUpdateCli } = await import("./update-cli.js");
|
await runRegisteredCli({
|
||||||
const program = new Command();
|
register: registerUpdateCli as (program: Command) => void,
|
||||||
registerUpdateCli(program);
|
argv: ["update", "status", "--json", "--timeout", "9"],
|
||||||
|
});
|
||||||
await program.parseAsync(["update", "status", "--json", "--timeout", "9"], { from: "user" });
|
|
||||||
|
|
||||||
expect(updateStatusCommand).toHaveBeenCalledWith(
|
expect(updateStatusCommand).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -53,11 +59,10 @@ describe("update cli option collisions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards parent-captured --timeout to `update wizard`", async () => {
|
it("forwards parent-captured --timeout to `update wizard`", async () => {
|
||||||
const { registerUpdateCli } = await import("./update-cli.js");
|
await runRegisteredCli({
|
||||||
const program = new Command();
|
register: registerUpdateCli as (program: Command) => void,
|
||||||
registerUpdateCli(program);
|
argv: ["update", "wizard", "--timeout", "13"],
|
||||||
|
});
|
||||||
await program.parseAsync(["update", "wizard", "--timeout", "13"], { from: "user" });
|
|
||||||
|
|
||||||
expect(updateWizardCommand).toHaveBeenCalledWith(
|
expect(updateWizardCommand).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
|
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
|
||||||
import type { UpdateRunResult } from "../infra/update-runner.js";
|
import type { UpdateRunResult } from "../infra/update-runner.js";
|
||||||
import { captureEnv } from "../test-utils/env.js";
|
import { captureEnv } from "../test-utils/env.js";
|
||||||
@@ -120,23 +118,15 @@ const { updateCommand, registerUpdateCli, updateStatusCommand, updateWizardComma
|
|||||||
await import("./update-cli.js");
|
await import("./update-cli.js");
|
||||||
|
|
||||||
describe("update-cli", () => {
|
describe("update-cli", () => {
|
||||||
let fixtureRoot = "";
|
const fixtureRoot = "/tmp/openclaw-update-tests";
|
||||||
let fixtureCount = 0;
|
let fixtureCount = 0;
|
||||||
|
|
||||||
const createCaseDir = async (prefix: string) => {
|
const createCaseDir = (prefix: string) => {
|
||||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||||
// Tests only need a stable path; the directory does not have to exist because all I/O is mocked.
|
// Tests only need a stable path; the directory does not have to exist because all I/O is mocked.
|
||||||
return dir;
|
return dir;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-tests-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseConfig = {} as OpenClawConfig;
|
const baseConfig = {} as OpenClawConfig;
|
||||||
const baseSnapshot: ConfigFileSnapshot = {
|
const baseSnapshot: ConfigFileSnapshot = {
|
||||||
path: "/tmp/openclaw-config.json",
|
path: "/tmp/openclaw-config.json",
|
||||||
@@ -186,8 +176,17 @@ describe("update-cli", () => {
|
|||||||
return call;
|
return call;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const makeOkUpdateResult = (overrides: Partial<UpdateRunResult> = {}): UpdateRunResult =>
|
||||||
|
({
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
...overrides,
|
||||||
|
}) as UpdateRunResult;
|
||||||
|
|
||||||
const setupNonInteractiveDowngrade = async () => {
|
const setupNonInteractiveDowngrade = async () => {
|
||||||
const tempDir = await createCaseDir("openclaw-update");
|
const tempDir = createCaseDir("openclaw-update");
|
||||||
setTty(false);
|
setTty(false);
|
||||||
readPackageVersion.mockResolvedValue("2.0.0");
|
readPackageVersion.mockResolvedValue("2.0.0");
|
||||||
|
|
||||||
@@ -332,55 +331,53 @@ describe("update-cli", () => {
|
|||||||
expect(parsed.channel.value).toBe("stable");
|
expect(parsed.channel.value).toBe("stable");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to dev channel for git installs when unset", async () => {
|
it.each([
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
{
|
||||||
status: "ok",
|
name: "defaults to dev channel for git installs when unset",
|
||||||
mode: "git",
|
mode: "git" as const,
|
||||||
steps: [],
|
options: {},
|
||||||
durationMs: 100,
|
prepare: async () => {},
|
||||||
});
|
expectedChannel: "dev" as const,
|
||||||
|
expectedTag: undefined as string | undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "defaults to stable channel for package installs when unset",
|
||||||
|
mode: "npm" as const,
|
||||||
|
options: { yes: true },
|
||||||
|
prepare: async () => {
|
||||||
|
const tempDir = createCaseDir("openclaw-update");
|
||||||
|
mockPackageInstallStatus(tempDir);
|
||||||
|
},
|
||||||
|
expectedChannel: "stable" as const,
|
||||||
|
expectedTag: "latest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uses stored beta channel when configured",
|
||||||
|
mode: "git" as const,
|
||||||
|
options: {},
|
||||||
|
prepare: async () => {
|
||||||
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||||
|
...baseSnapshot,
|
||||||
|
config: { update: { channel: "beta" } } as OpenClawConfig,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
expectedChannel: "beta" as const,
|
||||||
|
expectedTag: undefined as string | undefined,
|
||||||
|
},
|
||||||
|
])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => {
|
||||||
|
await prepare();
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
|
||||||
|
|
||||||
await updateCommand({});
|
await updateCommand(options);
|
||||||
|
|
||||||
expectUpdateCallChannel("dev");
|
const call = expectUpdateCallChannel(expectedChannel);
|
||||||
});
|
if (expectedTag !== undefined) {
|
||||||
|
expect(call?.tag).toBe(expectedTag);
|
||||||
it("defaults to stable channel for package installs when unset", async () => {
|
}
|
||||||
const tempDir = await createCaseDir("openclaw-update");
|
|
||||||
|
|
||||||
mockPackageInstallStatus(tempDir);
|
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
||||||
status: "ok",
|
|
||||||
mode: "npm",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
await updateCommand({ yes: true });
|
|
||||||
|
|
||||||
const call = expectUpdateCallChannel("stable");
|
|
||||||
expect(call?.tag).toBe("latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses stored beta channel when configured", async () => {
|
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
|
||||||
...baseSnapshot,
|
|
||||||
config: { update: { channel: "beta" } } as OpenClawConfig,
|
|
||||||
});
|
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
||||||
status: "ok",
|
|
||||||
mode: "git",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
await updateCommand({});
|
|
||||||
|
|
||||||
expectUpdateCallChannel("beta");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to latest when beta tag is older than release", async () => {
|
it("falls back to latest when beta tag is older than release", async () => {
|
||||||
const tempDir = await createCaseDir("openclaw-update");
|
const tempDir = createCaseDir("openclaw-update");
|
||||||
|
|
||||||
mockPackageInstallStatus(tempDir);
|
mockPackageInstallStatus(tempDir);
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||||
@@ -391,12 +388,11 @@ describe("update-cli", () => {
|
|||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "1.2.3-1",
|
version: "1.2.3-1",
|
||||||
});
|
});
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
vi.mocked(runGatewayUpdate).mockResolvedValue(
|
||||||
status: "ok",
|
makeOkUpdateResult({
|
||||||
mode: "npm",
|
mode: "npm",
|
||||||
steps: [],
|
}),
|
||||||
durationMs: 100,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
await updateCommand({});
|
await updateCommand({});
|
||||||
|
|
||||||
@@ -405,15 +401,14 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("honors --tag override", async () => {
|
it("honors --tag override", async () => {
|
||||||
const tempDir = await createCaseDir("openclaw-update");
|
const tempDir = createCaseDir("openclaw-update");
|
||||||
|
|
||||||
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
vi.mocked(runGatewayUpdate).mockResolvedValue(
|
||||||
status: "ok",
|
makeOkUpdateResult({
|
||||||
mode: "npm",
|
mode: "npm",
|
||||||
steps: [],
|
}),
|
||||||
durationMs: 100,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
await updateCommand({ tag: "next" });
|
await updateCommand({ tag: "next" });
|
||||||
|
|
||||||
@@ -422,14 +417,7 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateCommand outputs JSON when --json is set", async () => {
|
it("updateCommand outputs JSON when --json is set", async () => {
|
||||||
const mockResult: UpdateRunResult = {
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||||
status: "ok",
|
|
||||||
mode: "git",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
||||||
vi.mocked(defaultRuntime.log).mockClear();
|
vi.mocked(defaultRuntime.log).mockClear();
|
||||||
|
|
||||||
await updateCommand({ json: true });
|
await updateCommand({ json: true });
|
||||||
@@ -464,14 +452,7 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateCommand restarts daemon by default", async () => {
|
it("updateCommand restarts daemon by default", async () => {
|
||||||
const mockResult: UpdateRunResult = {
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||||
status: "ok",
|
|
||||||
mode: "git",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
||||||
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
||||||
|
|
||||||
await updateCommand({});
|
await updateCommand({});
|
||||||
@@ -480,18 +461,11 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateCommand continues after doctor sub-step and clears update flag", async () => {
|
it("updateCommand continues after doctor sub-step and clears update flag", async () => {
|
||||||
const mockResult: UpdateRunResult = {
|
|
||||||
status: "ok",
|
|
||||||
mode: "git",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
const envSnapshot = captureEnv(["OPENCLAW_UPDATE_IN_PROGRESS"]);
|
const envSnapshot = captureEnv(["OPENCLAW_UPDATE_IN_PROGRESS"]);
|
||||||
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
||||||
try {
|
try {
|
||||||
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||||
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
||||||
vi.mocked(doctorCommand).mockResolvedValue(undefined);
|
vi.mocked(doctorCommand).mockResolvedValue(undefined);
|
||||||
vi.mocked(defaultRuntime.log).mockClear();
|
vi.mocked(defaultRuntime.log).mockClear();
|
||||||
@@ -515,14 +489,7 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateCommand skips restart when --no-restart is set", async () => {
|
it("updateCommand skips restart when --no-restart is set", async () => {
|
||||||
const mockResult: UpdateRunResult = {
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||||
status: "ok",
|
|
||||||
mode: "git",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
||||||
|
|
||||||
await updateCommand({ restart: false });
|
await updateCommand({ restart: false });
|
||||||
|
|
||||||
@@ -530,14 +497,7 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateCommand skips success message when restart does not run", async () => {
|
it("updateCommand skips success message when restart does not run", async () => {
|
||||||
const mockResult: UpdateRunResult = {
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||||
status: "ok",
|
|
||||||
mode: "git",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
||||||
vi.mocked(runDaemonRestart).mockResolvedValue(false);
|
vi.mocked(runDaemonRestart).mockResolvedValue(false);
|
||||||
vi.mocked(defaultRuntime.log).mockClear();
|
vi.mocked(defaultRuntime.log).mockClear();
|
||||||
|
|
||||||
@@ -547,35 +507,35 @@ describe("update-cli", () => {
|
|||||||
expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false);
|
expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateCommand validates timeout option", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "update command",
|
||||||
|
run: async () => await updateCommand({ timeout: "invalid" }),
|
||||||
|
requireTty: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update status command",
|
||||||
|
run: async () => await updateStatusCommand({ timeout: "invalid" }),
|
||||||
|
requireTty: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update wizard command",
|
||||||
|
run: async () => await updateWizardCommand({ timeout: "invalid" }),
|
||||||
|
requireTty: true,
|
||||||
|
},
|
||||||
|
])("validates timeout option for $name", async ({ run, requireTty }) => {
|
||||||
|
setTty(requireTty);
|
||||||
vi.mocked(defaultRuntime.error).mockClear();
|
vi.mocked(defaultRuntime.error).mockClear();
|
||||||
vi.mocked(defaultRuntime.exit).mockClear();
|
vi.mocked(defaultRuntime.exit).mockClear();
|
||||||
|
|
||||||
await updateCommand({ timeout: "invalid" });
|
await run();
|
||||||
|
|
||||||
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
|
||||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateStatusCommand validates timeout option", async () => {
|
|
||||||
vi.mocked(defaultRuntime.error).mockClear();
|
|
||||||
vi.mocked(defaultRuntime.exit).mockClear();
|
|
||||||
|
|
||||||
await updateStatusCommand({ timeout: "invalid" });
|
|
||||||
|
|
||||||
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
||||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists update channel when --channel is set", async () => {
|
it("persists update channel when --channel is set", async () => {
|
||||||
const mockResult: UpdateRunResult = {
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||||
status: "ok",
|
|
||||||
mode: "git",
|
|
||||||
steps: [],
|
|
||||||
durationMs: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
||||||
|
|
||||||
await updateCommand({ channel: "beta" });
|
await updateCommand({ channel: "beta" });
|
||||||
|
|
||||||
@@ -586,26 +546,31 @@ describe("update-cli", () => {
|
|||||||
expect(call?.update?.channel).toBe("beta");
|
expect(call?.update?.channel).toBe("beta");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires confirmation on downgrade when non-interactive", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "requires confirmation without --yes",
|
||||||
|
options: {},
|
||||||
|
shouldExit: true,
|
||||||
|
shouldRunUpdate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allows downgrade with --yes",
|
||||||
|
options: { yes: true },
|
||||||
|
shouldExit: false,
|
||||||
|
shouldRunUpdate: true,
|
||||||
|
},
|
||||||
|
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunUpdate }) => {
|
||||||
await setupNonInteractiveDowngrade();
|
await setupNonInteractiveDowngrade();
|
||||||
|
await updateCommand(options);
|
||||||
|
|
||||||
await updateCommand({});
|
const downgradeMessageSeen = vi
|
||||||
|
.mocked(defaultRuntime.error)
|
||||||
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
.mock.calls.some((call) => String(call[0]).includes("Downgrade confirmation required."));
|
||||||
expect.stringContaining("Downgrade confirmation required."),
|
expect(downgradeMessageSeen).toBe(shouldExit);
|
||||||
|
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(
|
||||||
|
shouldExit,
|
||||||
);
|
);
|
||||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
|
||||||
});
|
|
||||||
|
|
||||||
it("allows downgrade with --yes in non-interactive mode", async () => {
|
|
||||||
await setupNonInteractiveDowngrade();
|
|
||||||
|
|
||||||
await updateCommand({ yes: true });
|
|
||||||
|
|
||||||
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Downgrade confirmation required."),
|
|
||||||
);
|
|
||||||
expect(runGatewayUpdate).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateWizardCommand requires a TTY", async () => {
|
it("updateWizardCommand requires a TTY", async () => {
|
||||||
@@ -621,19 +586,8 @@ describe("update-cli", () => {
|
|||||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateWizardCommand validates timeout option", async () => {
|
|
||||||
setTty(true);
|
|
||||||
vi.mocked(defaultRuntime.error).mockClear();
|
|
||||||
vi.mocked(defaultRuntime.exit).mockClear();
|
|
||||||
|
|
||||||
await updateWizardCommand({ timeout: "invalid" });
|
|
||||||
|
|
||||||
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
|
||||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateWizardCommand offers dev checkout and forwards selections", async () => {
|
it("updateWizardCommand offers dev checkout and forwards selections", async () => {
|
||||||
const tempDir = await createCaseDir("openclaw-update-wizard");
|
const tempDir = createCaseDir("openclaw-update-wizard");
|
||||||
const envSnapshot = captureEnv(["OPENCLAW_GIT_DIR"]);
|
const envSnapshot = captureEnv(["OPENCLAW_GIT_DIR"]);
|
||||||
try {
|
try {
|
||||||
setTty(true);
|
setTty(true);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
||||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
@@ -99,13 +99,19 @@ async function runSuccessfulTelegramProbe(
|
|||||||
return { calls, telegram };
|
return { calls, telegram };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let createPluginRuntime: typeof import("../plugins/runtime/index.js").createPluginRuntime;
|
||||||
|
let setTelegramRuntime: typeof import("../../extensions/telegram/src/runtime.js").setTelegramRuntime;
|
||||||
|
|
||||||
describe("getHealthSnapshot", () => {
|
describe("getHealthSnapshot", () => {
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
|
({ createPluginRuntime } = await import("../plugins/runtime/index.js"));
|
||||||
|
({ setTelegramRuntime } = await import("../../extensions/telegram/src/runtime.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
|
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
|
||||||
);
|
);
|
||||||
const { createPluginRuntime } = await import("../plugins/runtime/index.js");
|
|
||||||
const { setTelegramRuntime } = await import("../../extensions/telegram/src/runtime.js");
|
|
||||||
setTelegramRuntime(createPluginRuntime());
|
setTelegramRuntime(createPluginRuntime());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
let modelsListCommand: typeof import("./models/list.list-command.js").modelsListCommand;
|
let modelsListCommand: typeof import("./models/list.list-command.js").modelsListCommand;
|
||||||
|
let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegistry;
|
||||||
|
let toModelRow: typeof import("./models/list.registry.js").toModelRow;
|
||||||
|
|
||||||
const loadConfig = vi.fn();
|
const loadConfig = vi.fn();
|
||||||
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
|
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -274,6 +276,7 @@ describe("models list/status", () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ modelsListCommand } = await import("./models/list.list-command.js"));
|
({ modelsListCommand } = await import("./models/list.list-command.js"));
|
||||||
|
({ loadModelRegistry, toModelRow } = await import("./models/list.registry.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("models list syncs auth-profiles into auth.json before availability checks", async () => {
|
it("models list syncs auth-profiles into auth.json before availability checks", async () => {
|
||||||
@@ -309,17 +312,12 @@ describe("models list/status", () => {
|
|||||||
expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7");
|
expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("models list provider filter normalizes z.ai alias", async () => {
|
it.each(["z.ai", "Z.AI", "z-ai"] as const)(
|
||||||
await expectZaiProviderFilter("z.ai");
|
"models list provider filter normalizes %s alias",
|
||||||
});
|
async (provider) => {
|
||||||
|
await expectZaiProviderFilter(provider);
|
||||||
it("models list provider filter normalizes Z.AI alias casing", async () => {
|
},
|
||||||
await expectZaiProviderFilter("Z.AI");
|
);
|
||||||
});
|
|
||||||
|
|
||||||
it("models list provider filter normalizes z-ai alias", async () => {
|
|
||||||
await expectZaiProviderFilter("z-ai");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("models list marks auth as unavailable when ZAI key is missing", async () => {
|
it("models list marks auth as unavailable when ZAI key is missing", async () => {
|
||||||
setDefaultZaiRegistry({ available: false });
|
setDefaultZaiRegistry({ available: false });
|
||||||
@@ -331,57 +329,67 @@ describe("models list/status", () => {
|
|||||||
expect(payload.models[0]?.available).toBe(false);
|
expect(payload.models[0]?.available).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => {
|
it.each([
|
||||||
const payload = await runGoogleAntigravityListCase({
|
{
|
||||||
|
name: "thinking",
|
||||||
configuredModelId: "claude-opus-4-6-thinking",
|
configuredModelId: "claude-opus-4-6-thinking",
|
||||||
templateId: "claude-opus-4-5-thinking",
|
templateId: "claude-opus-4-5-thinking",
|
||||||
templateName: "Claude Opus 4.5 Thinking",
|
templateName: "Claude Opus 4.5 Thinking",
|
||||||
});
|
expectedKey: "google-antigravity/claude-opus-4-6-thinking",
|
||||||
expectAntigravityModel(payload, {
|
},
|
||||||
key: "google-antigravity/claude-opus-4-6-thinking",
|
{
|
||||||
available: false,
|
name: "non-thinking",
|
||||||
includesTags: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => {
|
|
||||||
const payload = await runGoogleAntigravityListCase({
|
|
||||||
configuredModelId: "claude-opus-4-6",
|
configuredModelId: "claude-opus-4-6",
|
||||||
templateId: "claude-opus-4-5",
|
templateId: "claude-opus-4-5",
|
||||||
templateName: "Claude Opus 4.5",
|
templateName: "Claude Opus 4.5",
|
||||||
});
|
expectedKey: "google-antigravity/claude-opus-4-6",
|
||||||
expectAntigravityModel(payload, {
|
},
|
||||||
key: "google-antigravity/claude-opus-4-6",
|
] as const)(
|
||||||
available: false,
|
"models list resolves antigravity opus 4.6 $name from 4.5 template",
|
||||||
includesTags: true,
|
async ({ configuredModelId, templateId, templateName, expectedKey }) => {
|
||||||
});
|
const payload = await runGoogleAntigravityListCase({
|
||||||
});
|
configuredModelId,
|
||||||
|
templateId,
|
||||||
|
templateName,
|
||||||
|
});
|
||||||
|
expectAntigravityModel(payload, {
|
||||||
|
key: expectedKey,
|
||||||
|
available: false,
|
||||||
|
includesTags: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => {
|
it.each([
|
||||||
const payload = await runGoogleAntigravityListCase({
|
{
|
||||||
|
name: "thinking",
|
||||||
configuredModelId: "claude-opus-4-6-thinking",
|
configuredModelId: "claude-opus-4-6-thinking",
|
||||||
templateId: "claude-opus-4-5-thinking",
|
templateId: "claude-opus-4-5-thinking",
|
||||||
templateName: "Claude Opus 4.5 Thinking",
|
templateName: "Claude Opus 4.5 Thinking",
|
||||||
available: true,
|
expectedKey: "google-antigravity/claude-opus-4-6-thinking",
|
||||||
});
|
},
|
||||||
expectAntigravityModel(payload, {
|
{
|
||||||
key: "google-antigravity/claude-opus-4-6-thinking",
|
name: "non-thinking",
|
||||||
available: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => {
|
|
||||||
const payload = await runGoogleAntigravityListCase({
|
|
||||||
configuredModelId: "claude-opus-4-6",
|
configuredModelId: "claude-opus-4-6",
|
||||||
templateId: "claude-opus-4-5",
|
templateId: "claude-opus-4-5",
|
||||||
templateName: "Claude Opus 4.5",
|
templateName: "Claude Opus 4.5",
|
||||||
available: true,
|
expectedKey: "google-antigravity/claude-opus-4-6",
|
||||||
});
|
},
|
||||||
expectAntigravityModel(payload, {
|
] as const)(
|
||||||
key: "google-antigravity/claude-opus-4-6",
|
"models list marks synthesized antigravity opus 4.6 $name as available when template is available",
|
||||||
available: true,
|
async ({ configuredModelId, templateId, templateName, expectedKey }) => {
|
||||||
});
|
const payload = await runGoogleAntigravityListCase({
|
||||||
});
|
configuredModelId,
|
||||||
|
templateId,
|
||||||
|
templateName,
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
expectAntigravityModel(payload, {
|
||||||
|
key: expectedKey,
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("models list prefers registry availability over provider auth heuristics", async () => {
|
it("models list prefers registry availability over provider auth heuristics", async () => {
|
||||||
const payload = await runGoogleAntigravityListCase({
|
const payload = await runGoogleAntigravityListCase({
|
||||||
@@ -472,13 +480,10 @@ describe("models list/status", () => {
|
|||||||
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
|
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { loadModelRegistry } = await import("./models/list.registry.js");
|
|
||||||
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
|
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
|
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
|
||||||
const { toModelRow } = await import("./models/list.registry.js");
|
|
||||||
|
|
||||||
const row = toModelRow({
|
const row = toModelRow({
|
||||||
model: makeGoogleAntigravityTemplate(
|
model: makeGoogleAntigravityTemplate(
|
||||||
"claude-opus-4-6-thinking",
|
"claude-opus-4-6-thinking",
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
|
||||||
|
|
||||||
const createClackPrompterMock = vi.hoisted(() => vi.fn());
|
|
||||||
const runOnboardingWizardMock = vi.hoisted(() => vi.fn());
|
|
||||||
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../wizard/clack-prompter.js", () => ({ createClackPrompter: createClackPrompterMock }));
|
|
||||||
vi.mock("../wizard/onboarding.js", () => ({ runOnboardingWizard: runOnboardingWizardMock }));
|
|
||||||
vi.mock("../terminal/restore.js", () => ({ restoreTerminalState: restoreTerminalStateMock }));
|
|
||||||
|
|
||||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
|
||||||
import { runInteractiveOnboarding } from "./onboard-interactive.js";
|
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
|
||||||
log: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
exit: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("runInteractiveOnboarding", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
createClackPrompterMock.mockReset();
|
|
||||||
runOnboardingWizardMock.mockReset();
|
|
||||||
restoreTerminalStateMock.mockReset();
|
|
||||||
(runtime.log as ReturnType<typeof vi.fn>).mockClear();
|
|
||||||
(runtime.error as ReturnType<typeof vi.fn>).mockClear();
|
|
||||||
(runtime.exit as ReturnType<typeof vi.fn>).mockClear();
|
|
||||||
|
|
||||||
createClackPrompterMock.mockReturnValue({});
|
|
||||||
runOnboardingWizardMock.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exits with code 1 when the wizard is cancelled", async () => {
|
|
||||||
runOnboardingWizardMock.mockRejectedValue(new WizardCancelledError());
|
|
||||||
|
|
||||||
await runInteractiveOnboarding({} as never, runtime);
|
|
||||||
|
|
||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
||||||
expect(restoreTerminalStateMock).toHaveBeenCalledWith("onboarding finish", {
|
|
||||||
resumeStdinIfPaused: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rethrows non-cancel errors", async () => {
|
|
||||||
const err = new Error("boom");
|
|
||||||
runOnboardingWizardMock.mockRejectedValue(err);
|
|
||||||
|
|
||||||
await expect(runInteractiveOnboarding({} as never, runtime)).rejects.toThrow("boom");
|
|
||||||
|
|
||||||
expect(runtime.exit).not.toHaveBeenCalled();
|
|
||||||
expect(restoreTerminalStateMock).toHaveBeenCalledWith("onboarding finish", {
|
|
||||||
resumeStdinIfPaused: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -69,4 +69,17 @@ describe("runInteractiveOnboarding", () => {
|
|||||||
Number.MAX_SAFE_INTEGER;
|
Number.MAX_SAFE_INTEGER;
|
||||||
expect(restoreOrder).toBeLessThan(exitOrder);
|
expect(restoreOrder).toBeLessThan(exitOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rethrows non-cancel errors after restoring terminal state", async () => {
|
||||||
|
const runtime = makeRuntime();
|
||||||
|
const err = new Error("boom");
|
||||||
|
mocks.runOnboardingWizard.mockRejectedValueOnce(err);
|
||||||
|
|
||||||
|
await expect(runInteractiveOnboarding({} as never, runtime)).rejects.toThrow("boom");
|
||||||
|
|
||||||
|
expect(runtime.exit).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", {
|
||||||
|
resumeStdinIfPaused: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,33 +29,29 @@ function asMessage(payload: Record<string, unknown>): Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveDiscordMessageChannelId", () => {
|
describe("resolveDiscordMessageChannelId", () => {
|
||||||
it("uses message.channelId when present", () => {
|
it.each([
|
||||||
const channelId = resolveDiscordMessageChannelId({
|
{
|
||||||
message: asMessage({ channelId: " 123 " }),
|
name: "uses message.channelId when present",
|
||||||
});
|
params: { message: asMessage({ channelId: " 123 " }) },
|
||||||
expect(channelId).toBe("123");
|
expected: "123",
|
||||||
});
|
},
|
||||||
|
{
|
||||||
it("falls back to message.channel_id", () => {
|
name: "falls back to message.channel_id",
|
||||||
const channelId = resolveDiscordMessageChannelId({
|
params: { message: asMessage({ channel_id: " 234 " }) },
|
||||||
message: asMessage({ channel_id: " 234 " }),
|
expected: "234",
|
||||||
});
|
},
|
||||||
expect(channelId).toBe("234");
|
{
|
||||||
});
|
name: "falls back to message.rawData.channel_id",
|
||||||
|
params: { message: asMessage({ rawData: { channel_id: "456" } }) },
|
||||||
it("falls back to message.rawData.channel_id", () => {
|
expected: "456",
|
||||||
const channelId = resolveDiscordMessageChannelId({
|
},
|
||||||
message: asMessage({ rawData: { channel_id: "456" } }),
|
{
|
||||||
});
|
name: "falls back to eventChannelId and coerces numeric values",
|
||||||
expect(channelId).toBe("456");
|
params: { message: asMessage({}), eventChannelId: 789 },
|
||||||
});
|
expected: "789",
|
||||||
|
},
|
||||||
it("falls back to eventChannelId and coerces numeric values", () => {
|
] as const)("$name", ({ params, expected }) => {
|
||||||
const channelId = resolveDiscordMessageChannelId({
|
expect(resolveDiscordMessageChannelId(params)).toBe(expected);
|
||||||
message: asMessage({}),
|
|
||||||
eventChannelId: 789,
|
|
||||||
});
|
|
||||||
expect(channelId).toBe("789");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
GatewayIntents,
|
GatewayIntents,
|
||||||
@@ -70,6 +70,12 @@ vi.mock("ws", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("createDiscordGatewayPlugin", () => {
|
describe("createDiscordGatewayPlugin", () => {
|
||||||
|
let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ createDiscordGatewayPlugin } = await import("./gateway-plugin.js"));
|
||||||
|
});
|
||||||
|
|
||||||
function createRuntime() {
|
function createRuntime() {
|
||||||
return {
|
return {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@@ -87,7 +93,6 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
||||||
const { createDiscordGatewayPlugin } = await import("./gateway-plugin.js");
|
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
|
|
||||||
const plugin = createDiscordGatewayPlugin({
|
const plugin = createDiscordGatewayPlugin({
|
||||||
@@ -111,7 +116,6 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the default gateway plugin when proxy is invalid", async () => {
|
it("falls back to the default gateway plugin when proxy is invalid", async () => {
|
||||||
const { createDiscordGatewayPlugin } = await import("./gateway-plugin.js");
|
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
|
|
||||||
const plugin = createDiscordGatewayPlugin({
|
const plugin = createDiscordGatewayPlugin({
|
||||||
|
|||||||
@@ -63,12 +63,15 @@ describe("runBootOnce", () => {
|
|||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips when BOOT.md is empty", async () => {
|
it.each([
|
||||||
|
{ title: "empty", content: " \n", reason: "empty" as const },
|
||||||
|
{ title: "whitespace-only", content: "\n\t ", reason: "empty" as const },
|
||||||
|
])("skips when BOOT.md is $title", async ({ content, reason }) => {
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-"));
|
||||||
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
|
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
|
||||||
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
reason: "empty",
|
reason,
|
||||||
});
|
});
|
||||||
expect(agentCommand).not.toHaveBeenCalled();
|
expect(agentCommand).not.toHaveBeenCalled();
|
||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ function setGatewayNetworkDefaults(port = 18789) {
|
|||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setLocalLoopbackGatewayConfig(port = 18789) {
|
||||||
|
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } });
|
||||||
|
setGatewayNetworkDefaults(port);
|
||||||
|
}
|
||||||
|
|
||||||
function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword = "from-config") {
|
function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword = "from-config") {
|
||||||
return {
|
return {
|
||||||
gateway: {
|
gateway: {
|
||||||
@@ -109,20 +114,19 @@ describe("callGateway url resolution", () => {
|
|||||||
resetGatewayCallMocks();
|
resetGatewayCallMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps loopback when local bind is auto even if tailnet is present", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
label: "keeps loopback when local bind is auto even if tailnet is present",
|
||||||
|
tailnetIp: "100.64.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "falls back to loopback when local bind is auto without tailnet IP",
|
||||||
|
tailnetIp: undefined,
|
||||||
|
},
|
||||||
|
])("$label", async ({ tailnetIp }) => {
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
|
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
|
||||||
resolveGatewayPort.mockReturnValue(18800);
|
resolveGatewayPort.mockReturnValue(18800);
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp);
|
||||||
|
|
||||||
await callGateway({ method: "health" });
|
|
||||||
|
|
||||||
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to loopback when local bind is auto without tailnet IP", async () => {
|
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
|
|
||||||
resolveGatewayPort.mockReturnValue(18800);
|
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
await callGateway({ method: "health" });
|
await callGateway({ method: "health" });
|
||||||
|
|
||||||
@@ -199,34 +203,25 @@ describe("callGateway url resolution", () => {
|
|||||||
expect(lastClientOptions?.token).toBe("explicit-token");
|
expect(lastClientOptions?.token).toBe("explicit-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses least-privilege scopes by default for non-CLI callers", async () => {
|
it.each([
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } });
|
{
|
||||||
resolveGatewayPort.mockReturnValue(18789);
|
label: "uses least-privilege scopes by default for non-CLI callers",
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
call: () => callGateway({ method: "health" }),
|
||||||
|
expectedScopes: ["operator.read"],
|
||||||
await callGateway({ method: "health" });
|
},
|
||||||
|
{
|
||||||
expect(lastClientOptions?.scopes).toEqual(["operator.read"]);
|
label: "keeps legacy admin scopes for explicit CLI callers",
|
||||||
});
|
call: () => callGatewayCli({ method: "health" }),
|
||||||
|
expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||||
it("keeps legacy admin scopes for explicit CLI callers", async () => {
|
},
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } });
|
])("$label", async ({ call, expectedScopes }) => {
|
||||||
resolveGatewayPort.mockReturnValue(18789);
|
setLocalLoopbackGatewayConfig();
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
await call();
|
||||||
|
expect(lastClientOptions?.scopes).toEqual(expectedScopes);
|
||||||
await callGatewayCli({ method: "health" });
|
|
||||||
|
|
||||||
expect(lastClientOptions?.scopes).toEqual([
|
|
||||||
"operator.admin",
|
|
||||||
"operator.approvals",
|
|
||||||
"operator.pairing",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes explicit scopes through, including empty arrays", async () => {
|
it("passes explicit scopes through, including empty arrays", async () => {
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } });
|
setLocalLoopbackGatewayConfig();
|
||||||
resolveGatewayPort.mockReturnValue(18789);
|
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
await callGatewayScoped({ method: "health", scopes: ["operator.read"] });
|
await callGatewayScoped({ method: "health", scopes: ["operator.read"] });
|
||||||
expect(lastClientOptions?.scopes).toEqual(["operator.read"]);
|
expect(lastClientOptions?.scopes).toEqual(["operator.read"]);
|
||||||
@@ -242,10 +237,7 @@ describe("buildGatewayConnectionDetails", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses explicit url overrides and omits bind details", () => {
|
it("uses explicit url overrides and omits bind details", () => {
|
||||||
loadConfig.mockReturnValue({
|
setLocalLoopbackGatewayConfig(18800);
|
||||||
gateway: { mode: "local", bind: "loopback" },
|
|
||||||
});
|
|
||||||
resolveGatewayPort.mockReturnValue(18800);
|
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
||||||
|
|
||||||
const details = buildGatewayConnectionDetails({
|
const details = buildGatewayConnectionDetails({
|
||||||
@@ -340,11 +332,7 @@ describe("buildGatewayConnectionDetails", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows ws:// for loopback addresses in local mode", () => {
|
it("allows ws:// for loopback addresses in local mode", () => {
|
||||||
loadConfig.mockReturnValue({
|
setLocalLoopbackGatewayConfig();
|
||||||
gateway: { mode: "local", bind: "loopback" },
|
|
||||||
});
|
|
||||||
resolveGatewayPort.mockReturnValue(18789);
|
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
const details = buildGatewayConnectionDetails();
|
const details = buildGatewayConnectionDetails();
|
||||||
|
|
||||||
@@ -365,11 +353,7 @@ describe("callGateway error details", () => {
|
|||||||
startMode = "close";
|
startMode = "close";
|
||||||
closeCode = 1006;
|
closeCode = 1006;
|
||||||
closeReason = "";
|
closeReason = "";
|
||||||
loadConfig.mockReturnValue({
|
setLocalLoopbackGatewayConfig();
|
||||||
gateway: { mode: "local", bind: "loopback" },
|
|
||||||
});
|
|
||||||
resolveGatewayPort.mockReturnValue(18789);
|
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
let err: Error | null = null;
|
let err: Error | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -386,11 +370,7 @@ describe("callGateway error details", () => {
|
|||||||
|
|
||||||
it("includes connection details on timeout", async () => {
|
it("includes connection details on timeout", async () => {
|
||||||
startMode = "silent";
|
startMode = "silent";
|
||||||
loadConfig.mockReturnValue({
|
setLocalLoopbackGatewayConfig();
|
||||||
gateway: { mode: "local", bind: "loopback" },
|
|
||||||
});
|
|
||||||
resolveGatewayPort.mockReturnValue(18789);
|
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
let errMessage = "";
|
let errMessage = "";
|
||||||
@@ -409,11 +389,7 @@ describe("callGateway error details", () => {
|
|||||||
|
|
||||||
it("does not overflow very large timeout values", async () => {
|
it("does not overflow very large timeout values", async () => {
|
||||||
startMode = "silent";
|
startMode = "silent";
|
||||||
loadConfig.mockReturnValue({
|
setLocalLoopbackGatewayConfig();
|
||||||
gateway: { mode: "local", bind: "loopback" },
|
|
||||||
});
|
|
||||||
resolveGatewayPort.mockReturnValue(18789);
|
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
let errMessage = "";
|
let errMessage = "";
|
||||||
@@ -474,89 +450,29 @@ describe("callGateway url override auth requirements", () => {
|
|||||||
|
|
||||||
describe("callGateway password resolution", () => {
|
describe("callGateway password resolution", () => {
|
||||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||||
|
const explicitAuthCases = [
|
||||||
|
{
|
||||||
|
label: "password",
|
||||||
|
authKey: "password",
|
||||||
|
envKey: "OPENCLAW_GATEWAY_PASSWORD",
|
||||||
|
envValue: "from-env",
|
||||||
|
configValue: "from-config",
|
||||||
|
explicitValue: "explicit-password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "token",
|
||||||
|
authKey: "token",
|
||||||
|
envKey: "OPENCLAW_GATEWAY_TOKEN",
|
||||||
|
envValue: "env-token",
|
||||||
|
configValue: "local-token",
|
||||||
|
explicitValue: "explicit-token",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PASSWORD"]);
|
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PASSWORD", "OPENCLAW_GATEWAY_TOKEN"]);
|
||||||
resetGatewayCallMocks();
|
resetGatewayCallMocks();
|
||||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||||
setGatewayNetworkDefaults(18789);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
envSnapshot.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses local config password when env is unset", async () => {
|
|
||||||
loadConfig.mockReturnValue({
|
|
||||||
gateway: {
|
|
||||||
mode: "local",
|
|
||||||
bind: "loopback",
|
|
||||||
auth: { password: "secret" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await callGateway({ method: "health" });
|
|
||||||
|
|
||||||
expect(lastClientOptions?.password).toBe("secret");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers env password over local config password", async () => {
|
|
||||||
process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env";
|
|
||||||
loadConfig.mockReturnValue({
|
|
||||||
gateway: {
|
|
||||||
mode: "local",
|
|
||||||
bind: "loopback",
|
|
||||||
auth: { password: "from-config" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await callGateway({ method: "health" });
|
|
||||||
|
|
||||||
expect(lastClientOptions?.password).toBe("from-env");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses remote password in remote mode when env is unset", async () => {
|
|
||||||
loadConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-secret"));
|
|
||||||
|
|
||||||
await callGateway({ method: "health" });
|
|
||||||
|
|
||||||
expect(lastClientOptions?.password).toBe("remote-secret");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers env password over remote password in remote mode", async () => {
|
|
||||||
process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env";
|
|
||||||
loadConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-secret"));
|
|
||||||
|
|
||||||
await callGateway({ method: "health" });
|
|
||||||
|
|
||||||
expect(lastClientOptions?.password).toBe("from-env");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses explicit password when url override is set", async () => {
|
|
||||||
process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env";
|
|
||||||
loadConfig.mockReturnValue({
|
|
||||||
gateway: {
|
|
||||||
mode: "local",
|
|
||||||
auth: { password: "from-config" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await callGateway({
|
|
||||||
method: "health",
|
|
||||||
url: "wss://override.example/ws",
|
|
||||||
password: "explicit-password",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lastClientOptions?.password).toBe("explicit-password");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("callGateway token resolution", () => {
|
|
||||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]);
|
|
||||||
resetGatewayCallMocks();
|
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||||
setGatewayNetworkDefaults(18789);
|
setGatewayNetworkDefaults(18789);
|
||||||
});
|
});
|
||||||
@@ -565,21 +481,73 @@ describe("callGateway token resolution", () => {
|
|||||||
envSnapshot.restore();
|
envSnapshot.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses explicit token when url override is set", async () => {
|
it.each([
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
|
{
|
||||||
|
label: "uses local config password when env is unset",
|
||||||
|
envPassword: undefined,
|
||||||
|
config: {
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
bind: "loopback",
|
||||||
|
auth: { password: "secret" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPassword: "secret",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "prefers env password over local config password",
|
||||||
|
envPassword: "from-env",
|
||||||
|
config: {
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
bind: "loopback",
|
||||||
|
auth: { password: "from-config" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPassword: "from-env",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "uses remote password in remote mode when env is unset",
|
||||||
|
envPassword: undefined,
|
||||||
|
config: makeRemotePasswordGatewayConfig("remote-secret"),
|
||||||
|
expectedPassword: "remote-secret",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "prefers env password over remote password in remote mode",
|
||||||
|
envPassword: "from-env",
|
||||||
|
config: makeRemotePasswordGatewayConfig("remote-secret"),
|
||||||
|
expectedPassword: "from-env",
|
||||||
|
},
|
||||||
|
])("$label", async ({ envPassword, config, expectedPassword }) => {
|
||||||
|
if (envPassword !== undefined) {
|
||||||
|
process.env.OPENCLAW_GATEWAY_PASSWORD = envPassword;
|
||||||
|
}
|
||||||
|
loadConfig.mockReturnValue(config);
|
||||||
|
|
||||||
|
await callGateway({ method: "health" });
|
||||||
|
|
||||||
|
expect(lastClientOptions?.password).toBe(expectedPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(explicitAuthCases)("uses explicit $label when url override is set", async (testCase) => {
|
||||||
|
process.env[testCase.envKey] = testCase.envValue;
|
||||||
|
const auth = { [testCase.authKey]: testCase.configValue } as {
|
||||||
|
password?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
gateway: {
|
gateway: {
|
||||||
mode: "local",
|
mode: "local",
|
||||||
auth: { token: "local-token" },
|
auth,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await callGateway({
|
await callGateway({
|
||||||
method: "health",
|
method: "health",
|
||||||
url: "wss://override.example/ws",
|
url: "wss://override.example/ws",
|
||||||
token: "explicit-token",
|
[testCase.authKey]: testCase.explicitValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(lastClientOptions?.token).toBe("explicit-token");
|
expect(lastClientOptions?.[testCase.authKey]).toBe(testCase.explicitValue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fsPromises from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { emitAgentEvent } from "../../infra/agent-events.js";
|
import { emitAgentEvent } from "../../infra/agent-events.js";
|
||||||
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
|
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
|
||||||
import { resetLogger, setLoggerOverride } from "../../logging.js";
|
import { resetLogger, setLoggerOverride } from "../../logging.js";
|
||||||
@@ -462,15 +462,20 @@ describe("exec approval handlers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("gateway healthHandlers.status scope handling", () => {
|
describe("gateway healthHandlers.status scope handling", () => {
|
||||||
beforeEach(async () => {
|
let statusModule: typeof import("../../commands/status.js");
|
||||||
const status = await import("../../commands/status.js");
|
let healthHandlers: typeof import("./health.js").healthHandlers;
|
||||||
vi.mocked(status.getStatusSummary).mockClear();
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
statusModule = await import("../../commands/status.js");
|
||||||
|
({ healthHandlers } = await import("./health.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(statusModule.getStatusSummary).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runHealthStatus(scopes: string[]) {
|
async function runHealthStatus(scopes: string[]) {
|
||||||
const respond = vi.fn();
|
const respond = vi.fn();
|
||||||
const status = await import("../../commands/status.js");
|
|
||||||
const { healthHandlers } = await import("./health.js");
|
|
||||||
|
|
||||||
await healthHandlers.status({
|
await healthHandlers.status({
|
||||||
req: {} as never,
|
req: {} as never,
|
||||||
@@ -481,22 +486,21 @@ describe("gateway healthHandlers.status scope handling", () => {
|
|||||||
isWebchatConnect: () => false,
|
isWebchatConnect: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { respond, status };
|
return respond;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("requests redacted status for non-admin clients", async () => {
|
it.each([
|
||||||
const { respond, status } = await runHealthStatus(["operator.read"]);
|
{ scopes: ["operator.read"], includeSensitive: false },
|
||||||
|
{ scopes: ["operator.admin"], includeSensitive: true },
|
||||||
|
])(
|
||||||
|
"requests includeSensitive=$includeSensitive for scopes $scopes",
|
||||||
|
async ({ scopes, includeSensitive }) => {
|
||||||
|
const respond = await runHealthStatus(scopes);
|
||||||
|
|
||||||
expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: false });
|
expect(vi.mocked(statusModule.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive });
|
||||||
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
it("requests full status for admin clients", async () => {
|
|
||||||
const { respond, status } = await runHealthStatus(["operator.admin"]);
|
|
||||||
|
|
||||||
expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: true });
|
|
||||||
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("logs.tail", () => {
|
describe("logs.tail", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
|
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
|
||||||
|
|
||||||
@@ -73,16 +73,32 @@ vi.mock("./openclaw-root.js", () => ({
|
|||||||
resolveOpenClawPackageRootSync: vi.fn(() => null),
|
resolveOpenClawPackageRootSync: vi.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let resolveControlUiRepoRoot: typeof import("./control-ui-assets.js").resolveControlUiRepoRoot;
|
||||||
|
let resolveControlUiDistIndexPath: typeof import("./control-ui-assets.js").resolveControlUiDistIndexPath;
|
||||||
|
let resolveControlUiDistIndexHealth: typeof import("./control-ui-assets.js").resolveControlUiDistIndexHealth;
|
||||||
|
let resolveControlUiRootOverrideSync: typeof import("./control-ui-assets.js").resolveControlUiRootOverrideSync;
|
||||||
|
let resolveControlUiRootSync: typeof import("./control-ui-assets.js").resolveControlUiRootSync;
|
||||||
|
let openclawRoot: typeof import("./openclaw-root.js");
|
||||||
|
|
||||||
describe("control UI assets helpers (fs-mocked)", () => {
|
describe("control UI assets helpers (fs-mocked)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
({
|
||||||
|
resolveControlUiRepoRoot,
|
||||||
|
resolveControlUiDistIndexPath,
|
||||||
|
resolveControlUiDistIndexHealth,
|
||||||
|
resolveControlUiRootOverrideSync,
|
||||||
|
resolveControlUiRootSync,
|
||||||
|
} = await import("./control-ui-assets.js"));
|
||||||
|
openclawRoot = await import("./openclaw-root.js");
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.entries.clear();
|
state.entries.clear();
|
||||||
state.realpaths.clear();
|
state.realpaths.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves repo root from src argv1", async () => {
|
it("resolves repo root from src argv1", () => {
|
||||||
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const root = abs("fixtures/ui-src");
|
const root = abs("fixtures/ui-src");
|
||||||
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
|
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
|
||||||
|
|
||||||
@@ -90,9 +106,7 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
expect(resolveControlUiRepoRoot(argv1)).toBe(root);
|
expect(resolveControlUiRepoRoot(argv1)).toBe(root);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves repo root by traversing up (dist argv1)", async () => {
|
it("resolves repo root by traversing up (dist argv1)", () => {
|
||||||
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const root = abs("fixtures/ui-dist");
|
const root = abs("fixtures/ui-dist");
|
||||||
setFile(path.join(root, "package.json"), "{}\n");
|
setFile(path.join(root, "package.json"), "{}\n");
|
||||||
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
|
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
|
||||||
@@ -102,8 +116,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves dist control-ui index path for dist argv1", async () => {
|
it("resolves dist control-ui index path for dist argv1", async () => {
|
||||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js"));
|
const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js"));
|
||||||
const distDir = path.dirname(argv1);
|
const distDir = path.dirname(argv1);
|
||||||
await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe(
|
await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe(
|
||||||
@@ -112,9 +124,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses resolveOpenClawPackageRoot when available", async () => {
|
it("uses resolveOpenClawPackageRoot when available", async () => {
|
||||||
const openclawRoot = await import("./openclaw-root.js");
|
|
||||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const pkgRoot = abs("fixtures/openclaw");
|
const pkgRoot = abs("fixtures/openclaw");
|
||||||
(
|
(
|
||||||
openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType<typeof vi.fn>
|
openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType<typeof vi.fn>
|
||||||
@@ -126,8 +135,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to package.json name matching when root resolution fails", async () => {
|
it("falls back to package.json name matching when root resolution fails", async () => {
|
||||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const root = abs("fixtures/fallback");
|
const root = abs("fixtures/fallback");
|
||||||
setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
|
setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||||
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
|
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||||
@@ -138,8 +145,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when fallback package name does not match", async () => {
|
it("returns null when fallback package name does not match", async () => {
|
||||||
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const root = abs("fixtures/not-openclaw");
|
const root = abs("fixtures/not-openclaw");
|
||||||
setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" }));
|
setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" }));
|
||||||
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
|
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||||
@@ -148,8 +153,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports health for missing + existing dist assets", async () => {
|
it("reports health for missing + existing dist assets", async () => {
|
||||||
const { resolveControlUiDistIndexHealth } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const root = abs("fixtures/health");
|
const root = abs("fixtures/health");
|
||||||
const indexPath = path.join(root, "dist", "control-ui", "index.html");
|
const indexPath = path.join(root, "dist", "control-ui", "index.html");
|
||||||
|
|
||||||
@@ -165,9 +168,7 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves control-ui root from override file or directory", async () => {
|
it("resolves control-ui root from override file or directory", () => {
|
||||||
const { resolveControlUiRootOverrideSync } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const root = abs("fixtures/override");
|
const root = abs("fixtures/override");
|
||||||
const uiDir = path.join(root, "dist", "control-ui");
|
const uiDir = path.join(root, "dist", "control-ui");
|
||||||
const indexPath = path.join(uiDir, "index.html");
|
const indexPath = path.join(uiDir, "index.html");
|
||||||
@@ -181,9 +182,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves control-ui root for dist bundle argv1 and moduleUrl candidates", async () => {
|
it("resolves control-ui root for dist bundle argv1 and moduleUrl candidates", async () => {
|
||||||
const openclawRoot = await import("./openclaw-root.js");
|
|
||||||
const { resolveControlUiRootSync } = await import("./control-ui-assets.js");
|
|
||||||
|
|
||||||
const pkgRoot = abs("fixtures/openclaw-bundle");
|
const pkgRoot = abs("fixtures/openclaw-bundle");
|
||||||
(
|
(
|
||||||
openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType<typeof vi.fn>
|
openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
|
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
|
||||||
|
|
||||||
@@ -90,6 +90,14 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveOpenClawPackageRoot", () => {
|
describe("resolveOpenClawPackageRoot", () => {
|
||||||
|
let resolveOpenClawPackageRoot: typeof import("./openclaw-root.js").resolveOpenClawPackageRoot;
|
||||||
|
let resolveOpenClawPackageRootSync: typeof import("./openclaw-root.js").resolveOpenClawPackageRootSync;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } =
|
||||||
|
await import("./openclaw-root.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.entries.clear();
|
state.entries.clear();
|
||||||
state.realpaths.clear();
|
state.realpaths.clear();
|
||||||
@@ -97,8 +105,6 @@ describe("resolveOpenClawPackageRoot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves package root from .bin argv1", async () => {
|
it("resolves package root from .bin argv1", async () => {
|
||||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
|
||||||
|
|
||||||
const project = fx("bin-scenario");
|
const project = fx("bin-scenario");
|
||||||
const argv1 = path.join(project, "node_modules", ".bin", "openclaw");
|
const argv1 = path.join(project, "node_modules", ".bin", "openclaw");
|
||||||
const pkgRoot = path.join(project, "node_modules", "openclaw");
|
const pkgRoot = path.join(project, "node_modules", "openclaw");
|
||||||
@@ -108,8 +114,6 @@ describe("resolveOpenClawPackageRoot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves package root via symlinked argv1", async () => {
|
it("resolves package root via symlinked argv1", async () => {
|
||||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
|
||||||
|
|
||||||
const project = fx("symlink-scenario");
|
const project = fx("symlink-scenario");
|
||||||
const bin = path.join(project, "bin", "openclaw");
|
const bin = path.join(project, "bin", "openclaw");
|
||||||
const realPkg = path.join(project, "real-pkg");
|
const realPkg = path.join(project, "real-pkg");
|
||||||
@@ -132,8 +136,6 @@ describe("resolveOpenClawPackageRoot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prefers moduleUrl candidates", async () => {
|
it("prefers moduleUrl candidates", async () => {
|
||||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
|
||||||
|
|
||||||
const pkgRoot = fx("moduleurl");
|
const pkgRoot = fx("moduleurl");
|
||||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||||
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "index.js")).toString();
|
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "index.js")).toString();
|
||||||
@@ -142,8 +144,6 @@ describe("resolveOpenClawPackageRoot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-openclaw package roots", async () => {
|
it("returns null for non-openclaw package roots", async () => {
|
||||||
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
|
|
||||||
|
|
||||||
const pkgRoot = fx("not-openclaw");
|
const pkgRoot = fx("not-openclaw");
|
||||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));
|
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));
|
||||||
|
|
||||||
@@ -151,8 +151,6 @@ describe("resolveOpenClawPackageRoot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("async resolver matches sync behavior", async () => {
|
it("async resolver matches sync behavior", async () => {
|
||||||
const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js");
|
|
||||||
|
|
||||||
const pkgRoot = fx("async");
|
const pkgRoot = fx("async");
|
||||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
||||||
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
||||||
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
|
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
|
||||||
@@ -86,16 +86,32 @@ function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("runMessageAction context isolation", () => {
|
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
|
||||||
beforeEach(async () => {
|
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
|
||||||
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
|
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
|
||||||
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
|
let setWhatsAppRuntime: typeof import("../../../extensions/whatsapp/src/runtime.js").setWhatsAppRuntime;
|
||||||
const { setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js");
|
|
||||||
const { setWhatsAppRuntime } = await import("../../../extensions/whatsapp/src/runtime.js");
|
function installChannelRuntimes(params?: { includeTelegram?: boolean; includeWhatsApp?: boolean }) {
|
||||||
const runtime = createPluginRuntime();
|
const runtime = createPluginRuntime();
|
||||||
setSlackRuntime(runtime);
|
setSlackRuntime(runtime);
|
||||||
|
if (params?.includeTelegram !== false) {
|
||||||
setTelegramRuntime(runtime);
|
setTelegramRuntime(runtime);
|
||||||
|
}
|
||||||
|
if (params?.includeWhatsApp !== false) {
|
||||||
setWhatsAppRuntime(runtime);
|
setWhatsAppRuntime(runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runMessageAction context isolation", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
|
||||||
|
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
|
||||||
|
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
|
||||||
|
({ setWhatsAppRuntime } = await import("../../../extensions/whatsapp/src/runtime.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installChannelRuntimes();
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
createTestRegistry([
|
createTestRegistry([
|
||||||
{
|
{
|
||||||
@@ -222,59 +238,59 @@ describe("runMessageAction context isolation", () => {
|
|||||||
expect(result.kind).toBe("action");
|
expect(result.kind).toBe("action");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows WhatsApp send when target matches current chat", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "whatsapp",
|
||||||
|
channel: "whatsapp",
|
||||||
|
target: "123@g.us",
|
||||||
|
currentChannelId: "123@g.us",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "imessage",
|
||||||
|
channel: "imessage",
|
||||||
|
target: "imessage:+15551234567",
|
||||||
|
currentChannelId: "imessage:+15551234567",
|
||||||
|
},
|
||||||
|
] as const)("allows $name send when target matches current context", async (testCase) => {
|
||||||
const result = await runDrySend({
|
const result = await runDrySend({
|
||||||
cfg: whatsappConfig,
|
cfg: whatsappConfig,
|
||||||
actionParams: {
|
actionParams: {
|
||||||
channel: "whatsapp",
|
channel: testCase.channel,
|
||||||
target: "123@g.us",
|
target: testCase.target,
|
||||||
message: "hi",
|
message: "hi",
|
||||||
},
|
},
|
||||||
toolContext: { currentChannelId: "123@g.us" },
|
toolContext: { currentChannelId: testCase.currentChannelId },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.kind).toBe("send");
|
expect(result.kind).toBe("send");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks WhatsApp send when target differs from current chat", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "whatsapp",
|
||||||
|
channel: "whatsapp",
|
||||||
|
target: "456@g.us",
|
||||||
|
currentChannelId: "123@g.us",
|
||||||
|
currentChannelProvider: "whatsapp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "imessage",
|
||||||
|
channel: "imessage",
|
||||||
|
target: "imessage:+15551230000",
|
||||||
|
currentChannelId: "imessage:+15551234567",
|
||||||
|
currentChannelProvider: "imessage",
|
||||||
|
},
|
||||||
|
] as const)("blocks $name send when target differs from current context", async (testCase) => {
|
||||||
const result = await runDrySend({
|
const result = await runDrySend({
|
||||||
cfg: whatsappConfig,
|
cfg: whatsappConfig,
|
||||||
actionParams: {
|
actionParams: {
|
||||||
channel: "whatsapp",
|
channel: testCase.channel,
|
||||||
target: "456@g.us",
|
target: testCase.target,
|
||||||
message: "hi",
|
|
||||||
},
|
|
||||||
toolContext: { currentChannelId: "123@g.us", currentChannelProvider: "whatsapp" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.kind).toBe("send");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows iMessage send when target matches current handle", async () => {
|
|
||||||
const result = await runDrySend({
|
|
||||||
cfg: whatsappConfig,
|
|
||||||
actionParams: {
|
|
||||||
channel: "imessage",
|
|
||||||
target: "imessage:+15551234567",
|
|
||||||
message: "hi",
|
|
||||||
},
|
|
||||||
toolContext: { currentChannelId: "imessage:+15551234567" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.kind).toBe("send");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks iMessage send when target differs from current handle", async () => {
|
|
||||||
const result = await runDrySend({
|
|
||||||
cfg: whatsappConfig,
|
|
||||||
actionParams: {
|
|
||||||
channel: "imessage",
|
|
||||||
target: "imessage:+15551230000",
|
|
||||||
message: "hi",
|
message: "hi",
|
||||||
},
|
},
|
||||||
toolContext: {
|
toolContext: {
|
||||||
currentChannelId: "imessage:+15551234567",
|
currentChannelId: testCase.currentChannelId,
|
||||||
currentChannelProvider: "imessage",
|
currentChannelProvider: testCase.currentChannelProvider,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -498,11 +514,8 @@ describe("runMessageAction sendAttachment hydration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("runMessageAction sandboxed media validation", () => {
|
describe("runMessageAction sandboxed media validation", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
|
installChannelRuntimes({ includeTelegram: false, includeWhatsApp: false });
|
||||||
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
|
|
||||||
const runtime = createPluginRuntime();
|
|
||||||
setSlackRuntime(runtime);
|
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
createTestRegistry([
|
createTestRegistry([
|
||||||
{
|
{
|
||||||
@@ -518,38 +531,38 @@ describe("runMessageAction sandboxed media validation", () => {
|
|||||||
setActivePluginRegistry(createTestRegistry([]));
|
setActivePluginRegistry(createTestRegistry([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects media outside the sandbox root", async () => {
|
it.each(["/etc/passwd", "file:///etc/passwd"])(
|
||||||
await withSandbox(async (sandboxDir) => {
|
"rejects out-of-sandbox media reference: %s",
|
||||||
await expect(
|
async (media) => {
|
||||||
runDrySend({
|
await withSandbox(async (sandboxDir) => {
|
||||||
cfg: slackConfig,
|
await expect(
|
||||||
actionParams: {
|
runDrySend({
|
||||||
channel: "slack",
|
cfg: slackConfig,
|
||||||
target: "#C12345678",
|
actionParams: {
|
||||||
media: "/etc/passwd",
|
channel: "slack",
|
||||||
message: "",
|
target: "#C12345678",
|
||||||
},
|
media,
|
||||||
sandboxRoot: sandboxDir,
|
message: "",
|
||||||
}),
|
},
|
||||||
).rejects.toThrow(/sandbox/i);
|
sandboxRoot: sandboxDir,
|
||||||
});
|
}),
|
||||||
});
|
).rejects.toThrow(/sandbox/i);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("rejects file:// media outside the sandbox root", async () => {
|
it("rejects data URLs in media params", async () => {
|
||||||
await withSandbox(async (sandboxDir) => {
|
await expect(
|
||||||
await expect(
|
runDrySend({
|
||||||
runDrySend({
|
cfg: slackConfig,
|
||||||
cfg: slackConfig,
|
actionParams: {
|
||||||
actionParams: {
|
channel: "slack",
|
||||||
channel: "slack",
|
target: "#C12345678",
|
||||||
target: "#C12345678",
|
media: "data:image/png;base64,abcd",
|
||||||
media: "file:///etc/passwd",
|
message: "",
|
||||||
message: "",
|
},
|
||||||
},
|
}),
|
||||||
sandboxRoot: sandboxDir,
|
).rejects.toThrow(/data:/i);
|
||||||
}),
|
|
||||||
).rejects.toThrow(/sandbox/i);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rewrites sandbox-relative media paths", async () => {
|
it("rewrites sandbox-relative media paths", async () => {
|
||||||
@@ -592,20 +605,6 @@ describe("runMessageAction sandboxed media validation", () => {
|
|||||||
expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "note.ogg"));
|
expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "note.ogg"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects data URLs in media params", async () => {
|
|
||||||
await expect(
|
|
||||||
runDrySend({
|
|
||||||
cfg: slackConfig,
|
|
||||||
actionParams: {
|
|
||||||
channel: "slack",
|
|
||||||
target: "#C12345678",
|
|
||||||
media: "data:image/png;base64,abcd",
|
|
||||||
message: "",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/data:/i);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runMessageAction media caption behavior", () => {
|
describe("runMessageAction media caption behavior", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
|
||||||
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
@@ -80,11 +80,18 @@ const defaultTelegramToolContext = {
|
|||||||
currentThreadTs: "42",
|
currentThreadTs: "42",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
|
||||||
|
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
|
||||||
|
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
|
||||||
|
|
||||||
describe("runMessageAction threading auto-injection", () => {
|
describe("runMessageAction threading auto-injection", () => {
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
|
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
|
||||||
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
|
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
|
||||||
const { setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js");
|
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
const runtime = createPluginRuntime();
|
const runtime = createPluginRuntime();
|
||||||
setSlackRuntime(runtime);
|
setSlackRuntime(runtime);
|
||||||
setTelegramRuntime(runtime);
|
setTelegramRuntime(runtime);
|
||||||
@@ -110,94 +117,73 @@ describe("runMessageAction threading auto-injection", () => {
|
|||||||
mocks.recordSessionMetaFromInbound.mockReset();
|
mocks.recordSessionMetaFromInbound.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses toolContext thread when auto-threading is active", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "exact channel id",
|
||||||
|
target: "channel:C123",
|
||||||
|
threadTs: "111.222",
|
||||||
|
expectedSessionKey: "agent:main:slack:channel:c123:thread:111.222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case-insensitive channel id",
|
||||||
|
target: "channel:c123",
|
||||||
|
threadTs: "333.444",
|
||||||
|
expectedSessionKey: "agent:main:slack:channel:c123:thread:333.444",
|
||||||
|
},
|
||||||
|
] as const)("auto-threads slack using $name", async (testCase) => {
|
||||||
mockHandledSendAction();
|
mockHandledSendAction();
|
||||||
|
|
||||||
const call = await runThreadingAction({
|
const call = await runThreadingAction({
|
||||||
cfg: slackConfig,
|
cfg: slackConfig,
|
||||||
actionParams: {
|
actionParams: {
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
target: "channel:C123",
|
target: testCase.target,
|
||||||
message: "hi",
|
message: "hi",
|
||||||
},
|
},
|
||||||
toolContext: {
|
toolContext: {
|
||||||
currentChannelId: "C123",
|
currentChannelId: "C123",
|
||||||
currentThreadTs: "111.222",
|
currentThreadTs: testCase.threadTs,
|
||||||
replyToMode: "all",
|
replyToMode: "all",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(call?.ctx?.agentId).toBe("main");
|
expect(call?.ctx?.agentId).toBe("main");
|
||||||
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222");
|
expect(call?.ctx?.mirror?.sessionKey).toBe(testCase.expectedSessionKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches auto-threading when channel ids differ in case", async () => {
|
it.each([
|
||||||
mockHandledSendAction();
|
{
|
||||||
|
name: "injects threadId for matching target",
|
||||||
const call = await runThreadingAction({
|
target: "telegram:123",
|
||||||
cfg: slackConfig,
|
expectedThreadId: "42",
|
||||||
actionParams: {
|
},
|
||||||
channel: "slack",
|
{
|
||||||
target: "channel:c123",
|
name: "injects threadId for prefixed group target",
|
||||||
message: "hi",
|
target: "telegram:group:123",
|
||||||
},
|
expectedThreadId: "42",
|
||||||
toolContext: {
|
},
|
||||||
currentChannelId: "C123",
|
{
|
||||||
currentThreadTs: "333.444",
|
name: "skips threadId when target chat differs",
|
||||||
replyToMode: "all",
|
target: "telegram:999",
|
||||||
},
|
expectedThreadId: undefined,
|
||||||
});
|
},
|
||||||
|
] as const)("telegram auto-threading: $name", async (testCase) => {
|
||||||
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:333.444");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-injects telegram threadId from toolContext when omitted", async () => {
|
|
||||||
mockHandledSendAction();
|
mockHandledSendAction();
|
||||||
|
|
||||||
const call = await runThreadingAction({
|
const call = await runThreadingAction({
|
||||||
cfg: telegramConfig,
|
cfg: telegramConfig,
|
||||||
actionParams: {
|
actionParams: {
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
target: "telegram:123",
|
target: testCase.target,
|
||||||
message: "hi",
|
message: "hi",
|
||||||
},
|
},
|
||||||
toolContext: defaultTelegramToolContext,
|
toolContext: defaultTelegramToolContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(call?.threadId).toBe("42");
|
expect(call?.ctx?.params?.threadId).toBe(testCase.expectedThreadId);
|
||||||
expect(call?.ctx?.params?.threadId).toBe("42");
|
if (testCase.expectedThreadId !== undefined) {
|
||||||
});
|
expect(call?.threadId).toBe(testCase.expectedThreadId);
|
||||||
|
}
|
||||||
it("skips telegram auto-threading when target chat differs", async () => {
|
|
||||||
mockHandledSendAction();
|
|
||||||
|
|
||||||
const call = await runThreadingAction({
|
|
||||||
cfg: telegramConfig,
|
|
||||||
actionParams: {
|
|
||||||
channel: "telegram",
|
|
||||||
target: "telegram:999",
|
|
||||||
message: "hi",
|
|
||||||
},
|
|
||||||
toolContext: defaultTelegramToolContext,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(call?.ctx?.params?.threadId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches telegram target with internal prefix variations", async () => {
|
|
||||||
mockHandledSendAction();
|
|
||||||
|
|
||||||
const call = await runThreadingAction({
|
|
||||||
cfg: telegramConfig,
|
|
||||||
actionParams: {
|
|
||||||
channel: "telegram",
|
|
||||||
target: "telegram:group:123",
|
|
||||||
message: "hi",
|
|
||||||
},
|
|
||||||
toolContext: defaultTelegramToolContext,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(call?.ctx?.params?.threadId).toBe("42");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses explicit telegram threadId when provided", async () => {
|
it("uses explicit telegram threadId when provided", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type MockSpawnChild = EventEmitter & {
|
type MockSpawnChild = EventEmitter & {
|
||||||
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
|
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
|
||||||
@@ -40,9 +40,15 @@ vi.mock("node:child_process", () => {
|
|||||||
|
|
||||||
const spawnMock = vi.mocked(spawn);
|
const spawnMock = vi.mocked(spawn);
|
||||||
|
|
||||||
|
let parseSshConfigOutput: typeof import("./ssh-config.js").parseSshConfigOutput;
|
||||||
|
let resolveSshConfig: typeof import("./ssh-config.js").resolveSshConfig;
|
||||||
|
|
||||||
describe("ssh-config", () => {
|
describe("ssh-config", () => {
|
||||||
it("parses ssh -G output", async () => {
|
beforeAll(async () => {
|
||||||
const { parseSshConfigOutput } = await import("./ssh-config.js");
|
({ parseSshConfigOutput, resolveSshConfig } = await import("./ssh-config.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses ssh -G output", () => {
|
||||||
const parsed = parseSshConfigOutput(
|
const parsed = parseSshConfigOutput(
|
||||||
"user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n",
|
"user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n",
|
||||||
);
|
);
|
||||||
@@ -53,7 +59,6 @@ describe("ssh-config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves ssh config via ssh -G", async () => {
|
it("resolves ssh config via ssh -G", async () => {
|
||||||
const { resolveSshConfig } = await import("./ssh-config.js");
|
|
||||||
const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 });
|
const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 });
|
||||||
expect(config?.user).toBe("steipete");
|
expect(config?.user).toBe("steipete");
|
||||||
expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net");
|
expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net");
|
||||||
@@ -74,7 +79,6 @@ describe("ssh-config", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { resolveSshConfig } = await import("./ssh-config.js");
|
|
||||||
const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 });
|
const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 });
|
||||||
expect(config).toBeNull();
|
expect(config).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ vi.mock("./backoff.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function createRuntime() {
|
||||||
|
return { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
describe("waitForTransportReady", () => {
|
describe("waitForTransportReady", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -22,7 +26,7 @@ describe("waitForTransportReady", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns when the check succeeds and logs after the delay", async () => {
|
it("returns when the check succeeds and logs after the delay", async () => {
|
||||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
const runtime = createRuntime();
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const readyPromise = waitForTransportReady({
|
const readyPromise = waitForTransportReady({
|
||||||
label: "test transport",
|
label: "test transport",
|
||||||
@@ -48,7 +52,7 @@ describe("waitForTransportReady", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws after the timeout", async () => {
|
it("throws after the timeout", async () => {
|
||||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
const runtime = createRuntime();
|
||||||
const waitPromise = waitForTransportReady({
|
const waitPromise = waitForTransportReady({
|
||||||
label: "test transport",
|
label: "test transport",
|
||||||
timeoutMs: 110,
|
timeoutMs: 110,
|
||||||
@@ -65,7 +69,7 @@ describe("waitForTransportReady", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when aborted", async () => {
|
it("returns early when aborted", async () => {
|
||||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
const runtime = createRuntime();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
controller.abort();
|
controller.abort();
|
||||||
await waitForTransportReady({
|
await waitForTransportReady({
|
||||||
|
|||||||
@@ -147,22 +147,23 @@ describe("update-startup", () => {
|
|||||||
return { log, parsed };
|
return { log, parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("logs update hint for npm installs when newer tag exists", async () => {
|
it.each([
|
||||||
const { log, parsed } = await runUpdateCheckAndReadState("stable");
|
{
|
||||||
|
name: "stable channel",
|
||||||
|
channel: "stable" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "beta channel with older beta tag",
|
||||||
|
channel: "beta" as const,
|
||||||
|
},
|
||||||
|
])("logs latest update hint for $name", async ({ channel }) => {
|
||||||
|
const { log, parsed } = await runUpdateCheckAndReadState(channel);
|
||||||
|
|
||||||
expect(log.info).toHaveBeenCalledWith(
|
expect(log.info).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("update available (latest): v2.0.0"),
|
expect.stringContaining("update available (latest): v2.0.0"),
|
||||||
);
|
);
|
||||||
expect(parsed.lastNotifiedVersion).toBe("2.0.0");
|
expect(parsed.lastNotifiedVersion).toBe("2.0.0");
|
||||||
expect(parsed.lastAvailableVersion).toBe("2.0.0");
|
expect(parsed.lastAvailableVersion).toBe("2.0.0");
|
||||||
});
|
|
||||||
|
|
||||||
it("uses latest when beta tag is older than release", async () => {
|
|
||||||
const { log, parsed } = await runUpdateCheckAndReadState("beta");
|
|
||||||
|
|
||||||
expect(log.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("update available (latest): v2.0.0"),
|
|
||||||
);
|
|
||||||
expect(parsed.lastNotifiedTag).toBe("latest");
|
expect(parsed.lastNotifiedTag).toBe("latest");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,26 +8,8 @@ import {
|
|||||||
createImageCarouselColumn,
|
createImageCarouselColumn,
|
||||||
createProductCarousel,
|
createProductCarousel,
|
||||||
messageAction,
|
messageAction,
|
||||||
postbackAction,
|
|
||||||
} from "./template-messages.js";
|
} from "./template-messages.js";
|
||||||
|
|
||||||
describe("messageAction", () => {
|
|
||||||
it("truncates label to 20 characters", () => {
|
|
||||||
const action = messageAction("This is a very long label that exceeds the limit");
|
|
||||||
|
|
||||||
expect(action.label).toBe("This is a very long ");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("postbackAction", () => {
|
|
||||||
it("truncates data to 300 characters", () => {
|
|
||||||
const longData = "x".repeat(400);
|
|
||||||
const action = postbackAction("Test", longData);
|
|
||||||
|
|
||||||
expect((action as { data: string }).data.length).toBe(300);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createConfirmTemplate", () => {
|
describe("createConfirmTemplate", () => {
|
||||||
it("truncates text to 240 characters", () => {
|
it("truncates text to 240 characters", () => {
|
||||||
const longText = "x".repeat(300);
|
const longText = "x".repeat(300);
|
||||||
@@ -118,33 +100,25 @@ describe("carousel column limits", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createProductCarousel", () => {
|
describe("createProductCarousel", () => {
|
||||||
it("uses URI action when actionUrl provided", () => {
|
it.each([
|
||||||
const template = createProductCarousel([
|
{
|
||||||
{
|
title: "Product",
|
||||||
title: "Product",
|
description: "Desc",
|
||||||
description: "Desc",
|
actionLabel: "Buy",
|
||||||
actionLabel: "Buy",
|
actionUrl: "https://shop.com/buy",
|
||||||
actionUrl: "https://shop.com/buy",
|
expectedType: "uri",
|
||||||
},
|
},
|
||||||
]);
|
{
|
||||||
|
title: "Product",
|
||||||
|
description: "Desc",
|
||||||
|
actionLabel: "Select",
|
||||||
|
actionData: "product_id=123",
|
||||||
|
expectedType: "postback",
|
||||||
|
},
|
||||||
|
])("uses expected action type for product action", ({ expectedType, ...item }) => {
|
||||||
|
const template = createProductCarousel([item]);
|
||||||
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
|
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
|
||||||
.columns;
|
.columns;
|
||||||
expect(columns[0].actions[0].type).toBe("uri");
|
expect(columns[0].actions[0].type).toBe(expectedType);
|
||||||
});
|
|
||||||
|
|
||||||
it("uses postback action when actionData provided", () => {
|
|
||||||
const template = createProductCarousel([
|
|
||||||
{
|
|
||||||
title: "Product",
|
|
||||||
description: "Desc",
|
|
||||||
actionLabel: "Select",
|
|
||||||
actionData: "product_id=123",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
|
|
||||||
.columns;
|
|
||||||
expect(columns[0].actions[0].type).toBe("postback");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("./config.js", () => ({
|
vi.mock("./config.js", () => ({
|
||||||
readLoggingConfig: () => undefined,
|
readLoggingConfig: () => undefined,
|
||||||
@@ -27,6 +27,13 @@ type ConsoleSnapshot = {
|
|||||||
|
|
||||||
let originalIsTty: boolean | undefined;
|
let originalIsTty: boolean | undefined;
|
||||||
let snapshot: ConsoleSnapshot;
|
let snapshot: ConsoleSnapshot;
|
||||||
|
let logging: typeof import("../logging.js");
|
||||||
|
let state: typeof import("./state.js");
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
logging = await import("../logging.js");
|
||||||
|
state = await import("./state.js");
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadConfigCalls = 0;
|
loadConfigCalls = 0;
|
||||||
@@ -42,7 +49,7 @@ beforeEach(() => {
|
|||||||
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(() => {
|
||||||
console.log = snapshot.log;
|
console.log = snapshot.log;
|
||||||
console.info = snapshot.info;
|
console.info = snapshot.info;
|
||||||
console.warn = snapshot.warn;
|
console.warn = snapshot.warn;
|
||||||
@@ -50,14 +57,11 @@ afterEach(async () => {
|
|||||||
console.debug = snapshot.debug;
|
console.debug = snapshot.debug;
|
||||||
console.trace = snapshot.trace;
|
console.trace = snapshot.trace;
|
||||||
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true });
|
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true });
|
||||||
const logging = await import("../logging.js");
|
|
||||||
logging.setConsoleConfigLoaderForTests();
|
logging.setConsoleConfigLoaderForTests();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadLogging() {
|
function loadLogging() {
|
||||||
const logging = await import("../logging.js");
|
|
||||||
const state = await import("./state.js");
|
|
||||||
state.loggingState.cachedConsoleSettings = null;
|
state.loggingState.cachedConsoleSettings = null;
|
||||||
logging.setConsoleConfigLoaderForTests(() => {
|
logging.setConsoleConfigLoaderForTests(() => {
|
||||||
loadConfigCalls += 1;
|
loadConfigCalls += 1;
|
||||||
@@ -71,8 +75,8 @@ async function loadLogging() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getConsoleSettings", () => {
|
describe("getConsoleSettings", () => {
|
||||||
it("does not recurse when loadConfig logs during resolution", async () => {
|
it("does not recurse when loadConfig logs during resolution", () => {
|
||||||
const { logging } = await loadLogging();
|
const { logging } = loadLogging();
|
||||||
logging.setConsoleTimestampPrefix(true);
|
logging.setConsoleTimestampPrefix(true);
|
||||||
logging.enableConsoleCapture();
|
logging.enableConsoleCapture();
|
||||||
const { getConsoleSettings } = logging;
|
const { getConsoleSettings } = logging;
|
||||||
@@ -80,8 +84,8 @@ describe("getConsoleSettings", () => {
|
|||||||
expect(loadConfigCalls).toBe(1);
|
expect(loadConfigCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips config fallback during re-entrant resolution", async () => {
|
it("skips config fallback during re-entrant resolution", () => {
|
||||||
const { logging, state } = await loadLogging();
|
const { logging, state } = loadLogging();
|
||||||
state.loggingState.resolvingConsoleSettings = true;
|
state.loggingState.resolvingConsoleSettings = true;
|
||||||
logging.setConsoleTimestampPrefix(true);
|
logging.setConsoleTimestampPrefix(true);
|
||||||
logging.enableConsoleCapture();
|
logging.enableConsoleCapture();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const fetchWithSsrFGuardMock = vi.fn();
|
const fetchWithSsrFGuardMock = vi.fn();
|
||||||
|
|
||||||
@@ -10,6 +10,15 @@ async function waitForMicrotaskTurn(): Promise<void> {
|
|||||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fetchWithGuard: typeof import("./input-files.js").fetchWithGuard;
|
||||||
|
let extractImageContentFromSource: typeof import("./input-files.js").extractImageContentFromSource;
|
||||||
|
let extractFileContentFromSource: typeof import("./input-files.js").extractFileContentFromSource;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ fetchWithGuard, extractImageContentFromSource, extractFileContentFromSource } =
|
||||||
|
await import("./input-files.js"));
|
||||||
|
});
|
||||||
|
|
||||||
describe("fetchWithGuard", () => {
|
describe("fetchWithGuard", () => {
|
||||||
it("rejects oversized streamed payloads and cancels the stream", async () => {
|
it("rejects oversized streamed payloads and cancels the stream", async () => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
@@ -40,7 +49,6 @@ describe("fetchWithGuard", () => {
|
|||||||
finalUrl: "https://example.com/file.bin",
|
finalUrl: "https://example.com/file.bin",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { fetchWithGuard } = await import("./input-files.js");
|
|
||||||
await expect(
|
await expect(
|
||||||
fetchWithGuard({
|
fetchWithGuard({
|
||||||
url: "https://example.com/file.bin",
|
url: "https://example.com/file.bin",
|
||||||
@@ -64,7 +72,6 @@ describe("base64 size guards", () => {
|
|||||||
kind: "images",
|
kind: "images",
|
||||||
expectedError: "Image too large",
|
expectedError: "Image too large",
|
||||||
run: async (data: string) => {
|
run: async (data: string) => {
|
||||||
const { extractImageContentFromSource } = await import("./input-files.js");
|
|
||||||
return await extractImageContentFromSource(
|
return await extractImageContentFromSource(
|
||||||
{ type: "base64", data, mediaType: "image/png" },
|
{ type: "base64", data, mediaType: "image/png" },
|
||||||
{
|
{
|
||||||
@@ -81,7 +88,6 @@ describe("base64 size guards", () => {
|
|||||||
kind: "files",
|
kind: "files",
|
||||||
expectedError: "File too large",
|
expectedError: "File too large",
|
||||||
run: async (data: string) => {
|
run: async (data: string) => {
|
||||||
const { extractFileContentFromSource } = await import("./input-files.js");
|
|
||||||
return await extractFileContentFromSource({
|
return await extractFileContentFromSource({
|
||||||
source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" },
|
source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" },
|
||||||
limits: {
|
limits: {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import JSZip from "jszip";
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createPinnedLookup } from "../infra/net/ssrf.js";
|
import { createPinnedLookup } from "../infra/net/ssrf.js";
|
||||||
import { captureEnv } from "../test-utils/env.js";
|
import { captureEnv } from "../test-utils/env.js";
|
||||||
@@ -92,41 +91,6 @@ describe("media store redirects", () => {
|
|||||||
expect(await fs.readFile(saved.path, "utf8")).toBe("redirected");
|
expect(await fs.readFile(saved.path, "utf8")).toBe("redirected");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sniffs xlsx from zip content when headers and url extension are missing", async () => {
|
|
||||||
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
|
|
||||||
const { req, res } = createMockHttpExchange();
|
|
||||||
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.headers = {};
|
|
||||||
setImmediate(() => {
|
|
||||||
cb(res as unknown);
|
|
||||||
const zip = new JSZip();
|
|
||||||
zip.file(
|
|
||||||
"[Content_Types].xml",
|
|
||||||
'<Types><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>',
|
|
||||||
);
|
|
||||||
zip.file("xl/workbook.xml", "<workbook/>");
|
|
||||||
void zip
|
|
||||||
.generateAsync({ type: "nodebuffer" })
|
|
||||||
.then((buf) => {
|
|
||||||
res.write(buf);
|
|
||||||
res.end();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
res.destroy(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return req;
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await saveMediaSource("https://example.com/download");
|
|
||||||
expect(saved.contentType).toBe(
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
);
|
|
||||||
expect(path.extname(saved.path)).toBe(".xlsx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails when redirect response omits location header", async () => {
|
it("fails when redirect response omits location header", async () => {
|
||||||
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
|
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
|
||||||
const { req, res } = createMockHttpExchange();
|
const { req, res } = createMockHttpExchange();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReadableStream } from "node:stream/web";
|
import { ReadableStream } from "node:stream/web";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js";
|
import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js";
|
||||||
import type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
|
import type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
|
||||||
|
|
||||||
@@ -10,6 +10,12 @@ vi.mock("../infra/retry.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("runVoyageEmbeddingBatches", () => {
|
describe("runVoyageEmbeddingBatches", () => {
|
||||||
|
let runVoyageEmbeddingBatches: typeof import("./batch-voyage.js").runVoyageEmbeddingBatches;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ runVoyageEmbeddingBatches } = await import("./batch-voyage.js"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -84,8 +90,6 @@ describe("runVoyageEmbeddingBatches", () => {
|
|||||||
body: stream,
|
body: stream,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { runVoyageEmbeddingBatches } = await import("./batch-voyage.js");
|
|
||||||
|
|
||||||
const results = await runVoyageEmbeddingBatches({
|
const results = await runVoyageEmbeddingBatches({
|
||||||
client: mockClient,
|
client: mockClient,
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -156,8 +160,6 @@ describe("runVoyageEmbeddingBatches", () => {
|
|||||||
|
|
||||||
fetchMock.mockResolvedValueOnce({ ok: true, body: stream });
|
fetchMock.mockResolvedValueOnce({ ok: true, body: stream });
|
||||||
|
|
||||||
const { runVoyageEmbeddingBatches } = await import("./batch-voyage.js");
|
|
||||||
|
|
||||||
const results = await runVoyageEmbeddingBatches({
|
const results = await runVoyageEmbeddingBatches({
|
||||||
client: mockClient,
|
client: mockClient,
|
||||||
agentId: "a1",
|
agentId: "a1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Test: before_compaction & after_compaction hook wiring
|
* Test: before_compaction & after_compaction hook wiring
|
||||||
*/
|
*/
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const hookMocks = vi.hoisted(() => ({
|
const hookMocks = vi.hoisted(() => ({
|
||||||
runner: {
|
runner: {
|
||||||
@@ -20,6 +20,14 @@ vi.mock("../infra/agent-events.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("compaction hook wiring", () => {
|
describe("compaction hook wiring", () => {
|
||||||
|
let handleAutoCompactionStart: typeof import("../agents/pi-embedded-subscribe.handlers.compaction.js").handleAutoCompactionStart;
|
||||||
|
let handleAutoCompactionEnd: typeof import("../agents/pi-embedded-subscribe.handlers.compaction.js").handleAutoCompactionEnd;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ handleAutoCompactionStart, handleAutoCompactionEnd } =
|
||||||
|
await import("../agents/pi-embedded-subscribe.handlers.compaction.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hookMocks.runner.hasHooks.mockReset();
|
hookMocks.runner.hasHooks.mockReset();
|
||||||
hookMocks.runner.hasHooks.mockReturnValue(false);
|
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||||
@@ -29,12 +37,9 @@ describe("compaction hook wiring", () => {
|
|||||||
hookMocks.runner.runAfterCompaction.mockResolvedValue(undefined);
|
hookMocks.runner.runAfterCompaction.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls runBeforeCompaction in handleAutoCompactionStart", async () => {
|
it("calls runBeforeCompaction in handleAutoCompactionStart", () => {
|
||||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
const { handleAutoCompactionStart } =
|
|
||||||
await import("../agents/pi-embedded-subscribe.handlers.compaction.js");
|
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
params: { runId: "r1", session: { messages: [1, 2, 3] } },
|
params: { runId: "r1", session: { messages: [1, 2, 3] } },
|
||||||
state: { compactionInFlight: false },
|
state: { compactionInFlight: false },
|
||||||
@@ -54,12 +59,9 @@ describe("compaction hook wiring", () => {
|
|||||||
expect(event?.messageCount).toBe(3);
|
expect(event?.messageCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls runAfterCompaction when willRetry is false", async () => {
|
it("calls runAfterCompaction when willRetry is false", () => {
|
||||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
const { handleAutoCompactionEnd } =
|
|
||||||
await import("../agents/pi-embedded-subscribe.handlers.compaction.js");
|
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
params: { runId: "r2", session: { messages: [1, 2] } },
|
params: { runId: "r2", session: { messages: [1, 2] } },
|
||||||
state: { compactionInFlight: true },
|
state: { compactionInFlight: true },
|
||||||
@@ -88,12 +90,9 @@ describe("compaction hook wiring", () => {
|
|||||||
expect(event?.compactedCount).toBe(1);
|
expect(event?.compactedCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not call runAfterCompaction when willRetry is true", async () => {
|
it("does not call runAfterCompaction when willRetry is true", () => {
|
||||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||||
|
|
||||||
const { handleAutoCompactionEnd } =
|
|
||||||
await import("../agents/pi-embedded-subscribe.handlers.compaction.js");
|
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
params: { runId: "r3", session: { messages: [] } },
|
params: { runId: "r3", session: { messages: [] } },
|
||||||
state: { compactionInFlight: true },
|
state: { compactionInFlight: true },
|
||||||
|
|||||||
@@ -89,11 +89,11 @@ describe("attachChildProcessBridge", () => {
|
|||||||
addedSigterm("SIGTERM");
|
addedSigterm("SIGTERM");
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => reject(new Error("timeout waiting for child exit")), 10_000);
|
const timeout = setTimeout(() => reject(new Error("timeout waiting for child exit")), 2_000);
|
||||||
child.once("exit", () => {
|
child.once("exit", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, 20_000);
|
}, 5_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("runCommandWithTimeout", () => {
|
|||||||
|
|
||||||
it("kills command when no output timeout elapses", async () => {
|
it("kills command when no output timeout elapses", async () => {
|
||||||
const result = await runCommandWithTimeout(
|
const result = await runCommandWithTimeout(
|
||||||
[process.execPath, "-e", "setTimeout(() => {}, 1_000)"],
|
[process.execPath, "-e", "setTimeout(() => {}, 120)"],
|
||||||
{
|
{
|
||||||
timeoutMs: 1_000,
|
timeoutMs: 1_000,
|
||||||
noOutputTimeoutMs: 35,
|
noOutputTimeoutMs: 35,
|
||||||
@@ -55,11 +55,11 @@ describe("runCommandWithTimeout", () => {
|
|||||||
[
|
[
|
||||||
process.execPath,
|
process.execPath,
|
||||||
"-e",
|
"-e",
|
||||||
'let i=0; const t=setInterval(() => { process.stdout.write("."); i += 1; if (i >= 2) { clearInterval(t); process.exit(0); } }, 5);',
|
'process.stdout.write("."); setTimeout(() => process.stdout.write("."), 30); setTimeout(() => process.exit(0), 60);',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
timeoutMs: 1_000,
|
timeoutMs: 1_000,
|
||||||
noOutputTimeoutMs: 120,
|
noOutputTimeoutMs: 500,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ describe("runCommandWithTimeout", () => {
|
|||||||
|
|
||||||
it("reports global timeout termination when overall timeout elapses", async () => {
|
it("reports global timeout termination when overall timeout elapses", async () => {
|
||||||
const result = await runCommandWithTimeout(
|
const result = await runCommandWithTimeout(
|
||||||
[process.execPath, "-e", "setTimeout(() => {}, 1_000)"],
|
[process.execPath, "-e", "setTimeout(() => {}, 120)"],
|
||||||
{
|
{
|
||||||
timeoutMs: 15,
|
timeoutMs: 15,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { killProcessTree } from "./kill-tree.js";
|
||||||
|
|
||||||
const { spawnMock } = vi.hoisted(() => ({
|
const { spawnMock } = vi.hoisted(() => ({
|
||||||
spawnMock: vi.fn(),
|
spawnMock: vi.fn(),
|
||||||
@@ -32,7 +33,6 @@ describe("killProcessTree", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
killSpy.mockRestore();
|
killSpy.mockRestore();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +45,6 @@ describe("killProcessTree", () => {
|
|||||||
}) as typeof process.kill);
|
}) as typeof process.kill);
|
||||||
|
|
||||||
await withPlatform("win32", async () => {
|
await withPlatform("win32", async () => {
|
||||||
const { killProcessTree } = await import("./kill-tree.js");
|
|
||||||
killProcessTree(4242, { graceMs: 25 });
|
killProcessTree(4242, { graceMs: 25 });
|
||||||
|
|
||||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -70,7 +69,6 @@ describe("killProcessTree", () => {
|
|||||||
}) as typeof process.kill);
|
}) as typeof process.kill);
|
||||||
|
|
||||||
await withPlatform("win32", async () => {
|
await withPlatform("win32", async () => {
|
||||||
const { killProcessTree } = await import("./kill-tree.js");
|
|
||||||
killProcessTree(5252, { graceMs: 10 });
|
killProcessTree(5252, { graceMs: 10 });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
@@ -103,7 +101,6 @@ describe("killProcessTree", () => {
|
|||||||
}) as typeof process.kill);
|
}) as typeof process.kill);
|
||||||
|
|
||||||
await withPlatform("linux", async () => {
|
await withPlatform("linux", async () => {
|
||||||
const { killProcessTree } = await import("./kill-tree.js");
|
|
||||||
killProcessTree(3333, { graceMs: 10 });
|
killProcessTree(3333, { graceMs: 10 });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
@@ -123,7 +120,6 @@ describe("killProcessTree", () => {
|
|||||||
}) as typeof process.kill);
|
}) as typeof process.kill);
|
||||||
|
|
||||||
await withPlatform("linux", async () => {
|
await withPlatform("linux", async () => {
|
||||||
const { killProcessTree } = await import("./kill-tree.js");
|
|
||||||
killProcessTree(4444, { graceMs: 5 });
|
killProcessTree(4444, { graceMs: 5 });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(5);
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({
|
const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({
|
||||||
spawnWithFallbackMock: vi.fn(),
|
spawnWithFallbackMock: vi.fn(),
|
||||||
@@ -16,6 +16,8 @@ vi.mock("../../kill-tree.js", () => ({
|
|||||||
killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args),
|
killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let createChildAdapter: typeof import("./child.js").createChildAdapter;
|
||||||
|
|
||||||
function createStubChild(pid = 1234) {
|
function createStubChild(pid = 1234) {
|
||||||
const child = new EventEmitter() as ChildProcess;
|
const child = new EventEmitter() as ChildProcess;
|
||||||
child.stdin = new PassThrough() as ChildProcess["stdin"];
|
child.stdin = new PassThrough() as ChildProcess["stdin"];
|
||||||
@@ -33,7 +35,6 @@ async function createAdapterHarness(params?: {
|
|||||||
argv?: string[];
|
argv?: string[];
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}) {
|
}) {
|
||||||
const { createChildAdapter } = await import("./child.js");
|
|
||||||
const { child, killMock } = createStubChild(params?.pid);
|
const { child, killMock } = createStubChild(params?.pid);
|
||||||
spawnWithFallbackMock.mockResolvedValue({
|
spawnWithFallbackMock.mockResolvedValue({
|
||||||
child,
|
child,
|
||||||
@@ -48,6 +49,10 @@ async function createAdapterHarness(params?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createChildAdapter", () => {
|
describe("createChildAdapter", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ createChildAdapter } = await import("./child.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spawnWithFallbackMock.mockReset();
|
spawnWithFallbackMock.mockReset();
|
||||||
killProcessTreeMock.mockReset();
|
killProcessTreeMock.mockReset();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { spawnMock, ptyKillMock, killProcessTreeMock } = vi.hoisted(() => ({
|
const { spawnMock, ptyKillMock, killProcessTreeMock } = vi.hoisted(() => ({
|
||||||
spawnMock: vi.fn(),
|
spawnMock: vi.fn(),
|
||||||
@@ -32,6 +32,12 @@ function createStubPty(pid = 1234) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createPtyAdapter", () => {
|
describe("createPtyAdapter", () => {
|
||||||
|
let createPtyAdapter: typeof import("./pty.js").createPtyAdapter;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ createPtyAdapter } = await import("./pty.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spawnMock.mockReset();
|
spawnMock.mockReset();
|
||||||
ptyKillMock.mockReset();
|
ptyKillMock.mockReset();
|
||||||
@@ -41,7 +47,6 @@ describe("createPtyAdapter", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +55,6 @@ describe("createPtyAdapter", () => {
|
|||||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||||
try {
|
try {
|
||||||
spawnMock.mockReturnValue(createStubPty());
|
spawnMock.mockReturnValue(createStubPty());
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
const adapter = await createPtyAdapter({
|
const adapter = await createPtyAdapter({
|
||||||
shell: "bash",
|
shell: "bash",
|
||||||
@@ -69,7 +73,6 @@ describe("createPtyAdapter", () => {
|
|||||||
|
|
||||||
it("uses process-tree kill for SIGKILL by default", async () => {
|
it("uses process-tree kill for SIGKILL by default", async () => {
|
||||||
spawnMock.mockReturnValue(createStubPty());
|
spawnMock.mockReturnValue(createStubPty());
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
const adapter = await createPtyAdapter({
|
const adapter = await createPtyAdapter({
|
||||||
shell: "bash",
|
shell: "bash",
|
||||||
@@ -84,7 +87,6 @@ describe("createPtyAdapter", () => {
|
|||||||
it("wait does not settle immediately on SIGKILL", async () => {
|
it("wait does not settle immediately on SIGKILL", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
spawnMock.mockReturnValue(createStubPty());
|
spawnMock.mockReturnValue(createStubPty());
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
const adapter = await createPtyAdapter({
|
const adapter = await createPtyAdapter({
|
||||||
shell: "bash",
|
shell: "bash",
|
||||||
@@ -111,7 +113,6 @@ describe("createPtyAdapter", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const stub = createStubPty();
|
const stub = createStubPty();
|
||||||
spawnMock.mockReturnValue(stub);
|
spawnMock.mockReturnValue(stub);
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
const adapter = await createPtyAdapter({
|
const adapter = await createPtyAdapter({
|
||||||
shell: "bash",
|
shell: "bash",
|
||||||
@@ -131,7 +132,6 @@ describe("createPtyAdapter", () => {
|
|||||||
it("resolves wait when exit fires before wait is called", async () => {
|
it("resolves wait when exit fires before wait is called", async () => {
|
||||||
const stub = createStubPty();
|
const stub = createStubPty();
|
||||||
spawnMock.mockReturnValue(stub);
|
spawnMock.mockReturnValue(stub);
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
const adapter = await createPtyAdapter({
|
const adapter = await createPtyAdapter({
|
||||||
shell: "bash",
|
shell: "bash",
|
||||||
@@ -146,7 +146,6 @@ describe("createPtyAdapter", () => {
|
|||||||
it("keeps inherited env when no override env is provided", async () => {
|
it("keeps inherited env when no override env is provided", async () => {
|
||||||
const stub = createStubPty();
|
const stub = createStubPty();
|
||||||
spawnMock.mockReturnValue(stub);
|
spawnMock.mockReturnValue(stub);
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
await createPtyAdapter({
|
await createPtyAdapter({
|
||||||
shell: "bash",
|
shell: "bash",
|
||||||
@@ -160,7 +159,6 @@ describe("createPtyAdapter", () => {
|
|||||||
it("passes explicit env overrides as strings", async () => {
|
it("passes explicit env overrides as strings", async () => {
|
||||||
const stub = createStubPty();
|
const stub = createStubPty();
|
||||||
spawnMock.mockReturnValue(stub);
|
spawnMock.mockReturnValue(stub);
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
await createPtyAdapter({
|
await createPtyAdapter({
|
||||||
shell: "bash",
|
shell: "bash",
|
||||||
@@ -177,7 +175,6 @@ describe("createPtyAdapter", () => {
|
|||||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||||
try {
|
try {
|
||||||
spawnMock.mockReturnValue(createStubPty());
|
spawnMock.mockReturnValue(createStubPty());
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
const adapter = await createPtyAdapter({
|
const adapter = await createPtyAdapter({
|
||||||
shell: "powershell.exe",
|
shell: "powershell.exe",
|
||||||
@@ -199,7 +196,6 @@ describe("createPtyAdapter", () => {
|
|||||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||||
try {
|
try {
|
||||||
spawnMock.mockReturnValue(createStubPty(4567));
|
spawnMock.mockReturnValue(createStubPty(4567));
|
||||||
const { createPtyAdapter } = await import("./pty.js");
|
|
||||||
|
|
||||||
const adapter = await createPtyAdapter({
|
const adapter = await createPtyAdapter({
|
||||||
shell: "powershell.exe",
|
shell: "powershell.exe",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { createPtyAdapterMock } = vi.hoisted(() => ({
|
const { createPtyAdapterMock } = vi.hoisted(() => ({
|
||||||
createPtyAdapterMock: vi.fn(),
|
createPtyAdapterMock: vi.fn(),
|
||||||
@@ -33,13 +33,18 @@ function createStubPtyAdapter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("process supervisor PTY command contract", () => {
|
describe("process supervisor PTY command contract", () => {
|
||||||
|
let createProcessSupervisor: typeof import("./supervisor.js").createProcessSupervisor;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ createProcessSupervisor } = await import("./supervisor.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createPtyAdapterMock.mockReset();
|
createPtyAdapterMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes PTY command verbatim to shell args", async () => {
|
it("passes PTY command verbatim to shell args", async () => {
|
||||||
createPtyAdapterMock.mockResolvedValue(createStubPtyAdapter());
|
createPtyAdapterMock.mockResolvedValue(createStubPtyAdapter());
|
||||||
const { createProcessSupervisor } = await import("./supervisor.js");
|
|
||||||
const supervisor = createProcessSupervisor();
|
const supervisor = createProcessSupervisor();
|
||||||
const command = `printf '%s\\n' "a b" && printf '%s\\n' '$HOME'`;
|
const command = `printf '%s\\n' "a b" && printf '%s\\n' '$HOME'`;
|
||||||
|
|
||||||
@@ -60,7 +65,6 @@ describe("process supervisor PTY command contract", () => {
|
|||||||
|
|
||||||
it("rejects empty PTY command", async () => {
|
it("rejects empty PTY command", async () => {
|
||||||
createPtyAdapterMock.mockResolvedValue(createStubPtyAdapter());
|
createPtyAdapterMock.mockResolvedValue(createStubPtyAdapter());
|
||||||
const { createProcessSupervisor } = await import("./supervisor.js");
|
|
||||||
const supervisor = createProcessSupervisor();
|
const supervisor = createProcessSupervisor();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("process supervisor", () => {
|
|||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
backendId: "test",
|
backendId: "test",
|
||||||
mode: "child",
|
mode: "child",
|
||||||
argv: [process.execPath, "-e", "setTimeout(() => {}, 1_000)"],
|
argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"],
|
||||||
timeoutMs: 1_000,
|
timeoutMs: 1_000,
|
||||||
noOutputTimeoutMs: 20,
|
noOutputTimeoutMs: 20,
|
||||||
stdinMode: "pipe-closed",
|
stdinMode: "pipe-closed",
|
||||||
@@ -42,7 +42,7 @@ describe("process supervisor", () => {
|
|||||||
backendId: "test",
|
backendId: "test",
|
||||||
scopeKey: "scope:a",
|
scopeKey: "scope:a",
|
||||||
mode: "child",
|
mode: "child",
|
||||||
argv: [process.execPath, "-e", "setTimeout(() => {}, 1_000)"],
|
argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"],
|
||||||
timeoutMs: 1_000,
|
timeoutMs: 1_000,
|
||||||
stdinMode: "pipe-open",
|
stdinMode: "pipe-open",
|
||||||
});
|
});
|
||||||
@@ -71,7 +71,7 @@ describe("process supervisor", () => {
|
|||||||
sessionId: "s-timeout",
|
sessionId: "s-timeout",
|
||||||
backendId: "test",
|
backendId: "test",
|
||||||
mode: "child",
|
mode: "child",
|
||||||
argv: [process.execPath, "-e", "setTimeout(() => {}, 1_000)"],
|
argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"],
|
||||||
timeoutMs: 1,
|
timeoutMs: 1,
|
||||||
stdinMode: "pipe-closed",
|
stdinMode: "pipe-closed",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
|
||||||
|
let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership;
|
||||||
|
|
||||||
describe("telegram audit", () => {
|
describe("telegram audit", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } =
|
||||||
|
await import("./audit.js"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collects unmentioned numeric group ids and flags wildcard", async () => {
|
it("collects unmentioned numeric group ids and flags wildcard", async () => {
|
||||||
const { collectTelegramUnmentionedGroupIds } = await import("./audit.js");
|
|
||||||
const res = collectTelegramUnmentionedGroupIds({
|
const res = collectTelegramUnmentionedGroupIds({
|
||||||
"*": { requireMention: false },
|
"*": { requireMention: false },
|
||||||
"-1001": { requireMention: false },
|
"-1001": { requireMention: false },
|
||||||
@@ -20,7 +27,6 @@ describe("telegram audit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("audits membership via getChatMember", async () => {
|
it("audits membership via getChatMember", async () => {
|
||||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn().mockResolvedValueOnce(
|
vi.fn().mockResolvedValueOnce(
|
||||||
@@ -42,7 +48,6 @@ describe("telegram audit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports bot not in group when status is left", async () => {
|
it("reports bot not in group when status is left", async () => {
|
||||||
const { auditTelegramGroupMembership } = await import("./audit.js");
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn().mockResolvedValueOnce(
|
vi.fn().mockResolvedValueOnce(
|
||||||
|
|||||||
@@ -356,22 +356,35 @@ describe("createTelegramBot", () => {
|
|||||||
expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined);
|
expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dedupes duplicate callback_query updates by update_id", async () => {
|
it("dedupes duplicate updates for callback_query, message, and channel_post", async () => {
|
||||||
onSpy.mockReset();
|
|
||||||
replySpy.mockReset();
|
|
||||||
|
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
telegram: {
|
||||||
|
dmPolicy: "open",
|
||||||
|
allowFrom: ["*"],
|
||||||
|
groupPolicy: "open",
|
||||||
|
groups: {
|
||||||
|
"-100777111222": {
|
||||||
|
enabled: true,
|
||||||
|
requireMention: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = getOnHandler("callback_query") as (
|
const callbackHandler = getOnHandler("callback_query") as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
const messageHandler = getOnHandler("message") as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
const channelPostHandler = getOnHandler("channel_post") as (
|
||||||
ctx: Record<string, unknown>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
const ctx = {
|
await callbackHandler({
|
||||||
update: { update_id: 222 },
|
update: { update_id: 222 },
|
||||||
callbackQuery: {
|
callbackQuery: {
|
||||||
id: "cb-1",
|
id: "cb-1",
|
||||||
@@ -385,11 +398,76 @@ describe("createTelegramBot", () => {
|
|||||||
},
|
},
|
||||||
me: { username: "openclaw_bot" },
|
me: { username: "openclaw_bot" },
|
||||||
getFile: async () => ({}),
|
getFile: async () => ({}),
|
||||||
};
|
});
|
||||||
|
await callbackHandler({
|
||||||
|
update: { update_id: 222 },
|
||||||
|
callbackQuery: {
|
||||||
|
id: "cb-1",
|
||||||
|
data: "ping",
|
||||||
|
from: { id: 789, username: "testuser" },
|
||||||
|
message: {
|
||||||
|
chat: { id: 123, type: "private" },
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 9001,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({}),
|
||||||
|
});
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await handler(ctx);
|
replySpy.mockClear();
|
||||||
await handler(ctx);
|
|
||||||
|
|
||||||
|
await messageHandler({
|
||||||
|
update: { update_id: 111 },
|
||||||
|
message: {
|
||||||
|
chat: { id: 123, type: "private" },
|
||||||
|
from: { id: 456, username: "testuser" },
|
||||||
|
text: "hello",
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 42,
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
await messageHandler({
|
||||||
|
update: { update_id: 111 },
|
||||||
|
message: {
|
||||||
|
chat: { id: 123, type: "private" },
|
||||||
|
from: { id: 456, username: "testuser" },
|
||||||
|
text: "hello",
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 42,
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
replySpy.mockClear();
|
||||||
|
|
||||||
|
await channelPostHandler({
|
||||||
|
channelPost: {
|
||||||
|
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
|
||||||
|
from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" },
|
||||||
|
message_id: 777,
|
||||||
|
text: "wake check",
|
||||||
|
date: 1736380800,
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({}),
|
||||||
|
});
|
||||||
|
await channelPostHandler({
|
||||||
|
channelPost: {
|
||||||
|
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
|
||||||
|
from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" },
|
||||||
|
message_id: 777,
|
||||||
|
text: "wake check",
|
||||||
|
date: 1736380800,
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({}),
|
||||||
|
});
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
it("allows distinct callback_query ids without update_id", async () => {
|
it("allows distinct callback_query ids without update_id", async () => {
|
||||||
@@ -1975,73 +2053,4 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).not.toHaveBeenCalled();
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
fetchSpy.mockRestore();
|
fetchSpy.mockRestore();
|
||||||
});
|
});
|
||||||
it("dedupes duplicate message updates by update_id", async () => {
|
|
||||||
onSpy.mockReset();
|
|
||||||
replySpy.mockReset();
|
|
||||||
|
|
||||||
loadConfig.mockReturnValue({
|
|
||||||
channels: {
|
|
||||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
|
||||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
update: { update_id: 111 },
|
|
||||||
message: {
|
|
||||||
chat: { id: 123, type: "private" },
|
|
||||||
from: { id: 456, username: "testuser" },
|
|
||||||
text: "hello",
|
|
||||||
date: 1736380800,
|
|
||||||
message_id: 42,
|
|
||||||
},
|
|
||||||
me: { username: "openclaw_bot" },
|
|
||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
|
||||||
};
|
|
||||||
|
|
||||||
await handler(ctx);
|
|
||||||
await handler(ctx);
|
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
it("dedupes duplicate channel_post updates by chat/message key", async () => {
|
|
||||||
onSpy.mockReset();
|
|
||||||
replySpy.mockReset();
|
|
||||||
|
|
||||||
loadConfig.mockReturnValue({
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
groupPolicy: "open",
|
|
||||||
groups: {
|
|
||||||
"-100777111222": {
|
|
||||||
enabled: true,
|
|
||||||
requireMention: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
|
||||||
const handler = getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
channelPost: {
|
|
||||||
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
|
|
||||||
from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" },
|
|
||||||
message_id: 777,
|
|
||||||
text: "wake check",
|
|
||||||
date: 1736380800,
|
|
||||||
},
|
|
||||||
me: { username: "openclaw_bot" },
|
|
||||||
getFile: async () => ({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await handler(ctx);
|
|
||||||
await handler(ctx);
|
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
10
src/test-utils/command-runner.ts
Normal file
10
src/test-utils/command-runner.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
export async function runRegisteredCli(params: {
|
||||||
|
register: (program: Command) => void;
|
||||||
|
argv: string[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const program = new Command();
|
||||||
|
params.register(program);
|
||||||
|
await program.parseAsync(params.argv, { from: "user" });
|
||||||
|
}
|
||||||
@@ -68,33 +68,28 @@ describe("resolveGatewayConnection", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses explicit token when url override is set", () => {
|
it.each([
|
||||||
|
{
|
||||||
|
label: "token",
|
||||||
|
auth: { token: "explicit-token" },
|
||||||
|
expected: { token: "explicit-token", password: undefined },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "password",
|
||||||
|
auth: { password: "explicit-password" },
|
||||||
|
expected: { token: undefined, password: "explicit-password" },
|
||||||
|
},
|
||||||
|
])("uses explicit $label when url override is set", ({ auth, expected }) => {
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
|
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
|
||||||
|
|
||||||
const result = resolveGatewayConnection({
|
const result = resolveGatewayConnection({
|
||||||
url: "wss://override.example/ws",
|
url: "wss://override.example/ws",
|
||||||
token: "explicit-token",
|
...auth,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
url: "wss://override.example/ws",
|
url: "wss://override.example/ws",
|
||||||
token: "explicit-token",
|
...expected,
|
||||||
password: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses explicit password when url override is set", () => {
|
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
|
|
||||||
|
|
||||||
const result = resolveGatewayConnection({
|
|
||||||
url: "wss://override.example/ws",
|
|
||||||
password: "explicit-password",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
url: "wss://override.example/ws",
|
|
||||||
token: undefined,
|
|
||||||
password: "explicit-password",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,18 +36,10 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
expect(editor.addToHistory).toHaveBeenCalledWith("hi");
|
expect(editor.addToHistory).toHaveBeenCalledWith("hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add empty-string submissions to history", () => {
|
it.each(["", " "])("does not add blank submissions to history", (text) => {
|
||||||
const { editor, handler } = createSubmitHarness();
|
const { editor, handler } = createSubmitHarness();
|
||||||
|
|
||||||
handler("");
|
handler(text);
|
||||||
|
|
||||||
expect(editor.addToHistory).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not add whitespace-only submissions to history", () => {
|
|
||||||
const { editor, handler } = createSubmitHarness();
|
|
||||||
|
|
||||||
handler(" ");
|
|
||||||
|
|
||||||
expect(editor.addToHistory).not.toHaveBeenCalled();
|
expect(editor.addToHistory).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -79,12 +71,4 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
|
|
||||||
expect(handleBangLine).toHaveBeenCalledWith("!ls");
|
expect(handleBangLine).toHaveBeenCalledWith("!ls");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a lone ! as a normal message", () => {
|
|
||||||
const { sendMessage, handler } = createSubmitHarness();
|
|
||||||
|
|
||||||
handler("!");
|
|
||||||
|
|
||||||
expect(sendMessage).toHaveBeenCalledWith("!");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { runTasksWithConcurrency } from "./run-with-concurrency.js";
|
|||||||
|
|
||||||
describe("runTasksWithConcurrency", () => {
|
describe("runTasksWithConcurrency", () => {
|
||||||
it("preserves task order with bounded worker count", async () => {
|
it("preserves task order with bounded worker count", async () => {
|
||||||
|
const flushMicrotasks = async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
let running = 0;
|
let running = 0;
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
const resolvers: Array<(() => void) | undefined> = [];
|
const resolvers: Array<(() => void) | undefined> = [];
|
||||||
@@ -17,18 +21,18 @@ describe("runTasksWithConcurrency", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resultPromise = runTasksWithConcurrency({ tasks, limit: 2 });
|
const resultPromise = runTasksWithConcurrency({ tasks, limit: 2 });
|
||||||
await vi.waitFor(() => {
|
await flushMicrotasks();
|
||||||
expect(typeof resolvers[0]).toBe("function");
|
expect(typeof resolvers[0]).toBe("function");
|
||||||
expect(typeof resolvers[1]).toBe("function");
|
expect(typeof resolvers[1]).toBe("function");
|
||||||
});
|
|
||||||
resolvers[1]?.();
|
resolvers[1]?.();
|
||||||
await vi.waitFor(() => {
|
await flushMicrotasks();
|
||||||
expect(typeof resolvers[2]).toBe("function");
|
expect(typeof resolvers[2]).toBe("function");
|
||||||
});
|
|
||||||
resolvers[0]?.();
|
resolvers[0]?.();
|
||||||
await vi.waitFor(() => {
|
await flushMicrotasks();
|
||||||
expect(typeof resolvers[3]).toBe("function");
|
expect(typeof resolvers[3]).toBe("function");
|
||||||
});
|
|
||||||
resolvers[2]?.();
|
resolvers[2]?.();
|
||||||
resolvers[3]?.();
|
resolvers[3]?.();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { sleep } from "../../utils.js";
|
||||||
|
import { loadWebMedia } from "../media.js";
|
||||||
import { deliverWebReply } from "./deliver-reply.js";
|
import { deliverWebReply } from "./deliver-reply.js";
|
||||||
import type { WebInboundMsg } from "./types.js";
|
import type { WebInboundMsg } from "./types.js";
|
||||||
|
|
||||||
@@ -23,10 +26,6 @@ vi.mock("../../utils.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loadWebMedia } = await import("../media.js");
|
|
||||||
const { sleep } = await import("../../utils.js");
|
|
||||||
const { logVerbose } = await import("../../globals.js");
|
|
||||||
|
|
||||||
function makeMsg(): WebInboundMsg {
|
function makeMsg(): WebInboundMsg {
|
||||||
return {
|
return {
|
||||||
from: "+10000000000",
|
from: "+10000000000",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { saveSessionStore } from "../../config/sessions.js";
|
import { saveSessionStore } from "../../config/sessions.js";
|
||||||
import { isBotMentionedFromTargets, resolveMentionTargets } from "./mentions.js";
|
import { isBotMentionedFromTargets, resolveMentionTargets } from "./mentions.js";
|
||||||
import { getSessionSnapshot } from "./session-snapshot.js";
|
import { getSessionSnapshot } from "./session-snapshot.js";
|
||||||
@@ -81,42 +81,41 @@ describe("isBotMentionedFromTargets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveMentionTargets with @lid mapping", () => {
|
describe("resolveMentionTargets with @lid mapping", () => {
|
||||||
it("resolves mentionedJids via lid reverse mapping in authDir", async () => {
|
let authDir = "";
|
||||||
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-"));
|
|
||||||
try {
|
beforeAll(async () => {
|
||||||
await fs.writeFile(
|
authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-"));
|
||||||
path.join(authDir, "lid-mapping-777_reverse.json"),
|
await fs.writeFile(path.join(authDir, "lid-mapping-777_reverse.json"), JSON.stringify("+1777"));
|
||||||
JSON.stringify("+1777"),
|
});
|
||||||
);
|
|
||||||
const msg = makeMsg({
|
afterAll(async () => {
|
||||||
|
if (!authDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.rm(authDir, { recursive: true, force: true });
|
||||||
|
authDir = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses @lid reverse mapping for mentions and self identity", () => {
|
||||||
|
const mentionTargets = resolveMentionTargets(
|
||||||
|
makeMsg({
|
||||||
body: "ping",
|
body: "ping",
|
||||||
mentionedJids: ["777@lid"],
|
mentionedJids: ["777@lid"],
|
||||||
selfE164: "+15551234567",
|
selfE164: "+15551234567",
|
||||||
selfJid: "15551234567@s.whatsapp.net",
|
selfJid: "15551234567@s.whatsapp.net",
|
||||||
});
|
}),
|
||||||
const targets = resolveMentionTargets(msg, authDir);
|
authDir,
|
||||||
expect(targets.normalizedMentions).toContain("+1777");
|
);
|
||||||
} finally {
|
expect(mentionTargets.normalizedMentions).toContain("+1777");
|
||||||
await fs.rm(authDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives selfE164 from selfJid when selfJid is @lid and mapping exists", async () => {
|
const selfTargets = resolveMentionTargets(
|
||||||
const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-"));
|
makeMsg({
|
||||||
try {
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(authDir, "lid-mapping-777_reverse.json"),
|
|
||||||
JSON.stringify("+1777"),
|
|
||||||
);
|
|
||||||
const msg = makeMsg({
|
|
||||||
body: "ping",
|
body: "ping",
|
||||||
selfJid: "777@lid",
|
selfJid: "777@lid",
|
||||||
});
|
}),
|
||||||
const targets = resolveMentionTargets(msg, authDir);
|
authDir,
|
||||||
expect(targets.selfE164).toBe("+1777");
|
);
|
||||||
} finally {
|
expect(selfTargets.selfE164).toBe("+1777");
|
||||||
await fs.rm(authDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ vi.mock("./session.js", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
||||||
|
let createWaSocket: typeof import("./session.js").createWaSocket;
|
||||||
|
|
||||||
async function waitForMessage(onMessage: ReturnType<typeof vi.fn>) {
|
async function waitForMessage(onMessage: ReturnType<typeof vi.fn>) {
|
||||||
await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1), {
|
await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1), {
|
||||||
@@ -97,12 +98,19 @@ async function waitForMessage(onMessage: ReturnType<typeof vi.fn>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("web inbound media saves with extension", () => {
|
describe("web inbound media saves with extension", () => {
|
||||||
|
async function getMockSocket() {
|
||||||
|
return (await createWaSocket(false, false)) as unknown as {
|
||||||
|
ev: import("node:events").EventEmitter;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
saveMediaBufferSpy.mockClear();
|
saveMediaBufferSpy.mockClear();
|
||||||
resetWebInboundDedupe();
|
resetWebInboundDedupe();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
({ createWaSocket } = await import("./session.js"));
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,12 +126,7 @@ describe("web inbound media saves with extension", () => {
|
|||||||
accountId: "default",
|
accountId: "default",
|
||||||
authDir: path.join(HOME, "wa-auth"),
|
authDir: path.join(HOME, "wa-auth"),
|
||||||
});
|
});
|
||||||
const { createWaSocket } = await import("./session.js");
|
const realSock = await getMockSocket();
|
||||||
const realSock = await (
|
|
||||||
createWaSocket as unknown as () => Promise<{
|
|
||||||
ev: import("node:events").EventEmitter;
|
|
||||||
}>
|
|
||||||
)();
|
|
||||||
|
|
||||||
realSock.ev.emit("messages.upsert", {
|
realSock.ev.emit("messages.upsert", {
|
||||||
type: "notify",
|
type: "notify",
|
||||||
@@ -202,12 +205,7 @@ describe("web inbound media saves with extension", () => {
|
|||||||
accountId: "default",
|
accountId: "default",
|
||||||
authDir: path.join(HOME, "wa-auth"),
|
authDir: path.join(HOME, "wa-auth"),
|
||||||
});
|
});
|
||||||
const { createWaSocket } = await import("./session.js");
|
const realSock = await getMockSocket();
|
||||||
const realSock = await (
|
|
||||||
createWaSocket as unknown as () => Promise<{
|
|
||||||
ev: import("node:events").EventEmitter;
|
|
||||||
}>
|
|
||||||
)();
|
|
||||||
|
|
||||||
const upsert = {
|
const upsert = {
|
||||||
type: "notify",
|
type: "notify",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
|
||||||
|
import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js";
|
||||||
|
|
||||||
vi.mock("./session.js", () => {
|
vi.mock("./session.js", () => {
|
||||||
const createWaSocket = vi.fn(
|
const createWaSocket = vi.fn(
|
||||||
@@ -35,8 +37,6 @@ vi.mock("./qr-image.js", () => ({
|
|||||||
renderQrPngBase64: vi.fn(async () => "base64"),
|
renderQrPngBase64: vi.fn(async () => "base64"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js");
|
|
||||||
const { createWaSocket, waitForWaConnection, logoutWeb } = await import("./session.js");
|
|
||||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||||
const logoutWebMock = vi.mocked(logoutWeb);
|
const logoutWebMock = vi.mocked(logoutWeb);
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DisconnectReason } from "@whiskeysockets/baileys";
|
import { DisconnectReason } from "@whiskeysockets/baileys";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { loginWeb } from "./login.js";
|
||||||
|
import { createWaSocket, formatError, waitForWaConnection } from "./session.js";
|
||||||
|
|
||||||
const rmMock = vi.spyOn(fs, "rm");
|
const rmMock = vi.spyOn(fs, "rm");
|
||||||
|
|
||||||
const authDir = path.join(os.tmpdir(), "wa-creds");
|
function resolveTestAuthDir() {
|
||||||
|
return path.join(os.tmpdir(), "wa-creds");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDir = resolveTestAuthDir();
|
||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
loadConfig: () =>
|
loadConfig: () =>
|
||||||
@@ -14,7 +20,7 @@ vi.mock("../config/config.js", () => ({
|
|||||||
channels: {
|
channels: {
|
||||||
whatsapp: {
|
whatsapp: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: { enabled: true, authDir },
|
default: { enabled: true, authDir: resolveTestAuthDir() },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -22,6 +28,7 @@ vi.mock("../config/config.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./session.js", () => {
|
vi.mock("./session.js", () => {
|
||||||
|
const authDir = resolveTestAuthDir();
|
||||||
const sockA = { ws: { close: vi.fn() } };
|
const sockA = { ws: { close: vi.fn() } };
|
||||||
const sockB = { ws: { close: vi.fn() } };
|
const sockB = { ws: { close: vi.fn() } };
|
||||||
let call = 0;
|
let call = 0;
|
||||||
@@ -43,11 +50,9 @@ vi.mock("./session.js", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { createWaSocket, waitForWaConnection, formatError } = await import("./session.js");
|
|
||||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||||
const formatErrorMock = vi.mocked(formatError);
|
const formatErrorMock = vi.mocked(formatError);
|
||||||
const { loginWeb } = await import("./login.js");
|
|
||||||
|
|
||||||
describe("loginWeb coverage", () => {
|
describe("loginWeb coverage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { sendVoiceMessageDiscord } from "../discord/send.js";
|
import { sendVoiceMessageDiscord } from "../discord/send.js";
|
||||||
import * as ssrf from "../infra/net/ssrf.js";
|
import * as ssrf from "../infra/net/ssrf.js";
|
||||||
import { optimizeImageToPng } from "../media/image-ops.js";
|
import { optimizeImageToPng } from "../media/image-ops.js";
|
||||||
@@ -52,8 +53,8 @@ beforeAll(async () => {
|
|||||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-"));
|
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-"));
|
||||||
largeJpegBuffer = await sharp({
|
largeJpegBuffer = await sharp({
|
||||||
create: {
|
create: {
|
||||||
width: 800,
|
width: 400,
|
||||||
height: 800,
|
height: 400,
|
||||||
channels: 3,
|
channels: 3,
|
||||||
background: "#ff0000",
|
background: "#ff0000",
|
||||||
},
|
},
|
||||||
@@ -79,7 +80,8 @@ beforeAll(async () => {
|
|||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
alphaPngFile = await writeTempFile(alphaPngBuffer, ".png");
|
alphaPngFile = await writeTempFile(alphaPngBuffer, ".png");
|
||||||
const size = 72;
|
// Keep this small so the alpha-fallback test stays deterministic but fast.
|
||||||
|
const size = 24;
|
||||||
const raw = buildDeterministicBytes(size * size * 4);
|
const raw = buildDeterministicBytes(size * size * 4);
|
||||||
fallbackPngBuffer = await sharp(raw, { raw: { width: size, height: size, channels: 4 } })
|
fallbackPngBuffer = await sharp(raw, { raw: { width: size, height: size, channels: 4 } })
|
||||||
.png()
|
.png()
|
||||||
@@ -132,18 +134,12 @@ describe("web media loading", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips MEDIA: prefix before reading local file", async () => {
|
it("strips MEDIA: prefix before reading local file (including whitespace variants)", async () => {
|
||||||
const result = await loadWebMedia(`MEDIA:${tinyPngFile}`, 1024 * 1024);
|
for (const input of [`MEDIA:${tinyPngFile}`, ` MEDIA : ${tinyPngFile}`]) {
|
||||||
|
const result = await loadWebMedia(input, 1024 * 1024);
|
||||||
expect(result.kind).toBe("image");
|
expect(result.kind).toBe("image");
|
||||||
expect(result.buffer.length).toBeGreaterThan(0);
|
expect(result.buffer.length).toBeGreaterThan(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
it("strips MEDIA: prefix with extra whitespace (LLM-friendly)", async () => {
|
|
||||||
const result = await loadWebMedia(` MEDIA : ${tinyPngFile}`, 1024 * 1024);
|
|
||||||
|
|
||||||
expect(result.kind).toBe("image");
|
|
||||||
expect(result.buffer.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("compresses large local images under the provided cap", async () => {
|
it("compresses large local images under the provided cap", async () => {
|
||||||
@@ -375,7 +371,6 @@ describe("local media root guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows default OpenClaw state workspace and sandbox roots", async () => {
|
it("allows default OpenClaw state workspace and sandbox roots", async () => {
|
||||||
const { resolveStateDir } = await import("../config/paths.js");
|
|
||||||
const stateDir = resolveStateDir();
|
const stateDir = resolveStateDir();
|
||||||
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
||||||
|
|
||||||
@@ -403,7 +398,6 @@ describe("local media root guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects default OpenClaw state per-agent workspace-* roots without explicit local roots", async () => {
|
it("rejects default OpenClaw state per-agent workspace-* roots without explicit local roots", async () => {
|
||||||
const { resolveStateDir } = await import("../config/paths.js");
|
|
||||||
const stateDir = resolveStateDir();
|
const stateDir = resolveStateDir();
|
||||||
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
||||||
|
|
||||||
@@ -416,7 +410,6 @@ describe("local media root guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows per-agent workspace-* paths with explicit local roots", async () => {
|
it("allows per-agent workspace-* paths with explicit local roots", async () => {
|
||||||
const { resolveStateDir } = await import("../config/paths.js");
|
|
||||||
const stateDir = resolveStateDir();
|
const stateDir = resolveStateDir();
|
||||||
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
||||||
const agentWorkspaceDir = path.join(stateDir, "workspace-clawdy");
|
const agentWorkspaceDir = path.join(stateDir, "workspace-clawdy");
|
||||||
|
|||||||
Reference in New Issue
Block a user