mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 00:41:12 +00:00
feat: verify ClickClack after channel setup
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
// ClickClack tests cover non-interactive setup validation and config writes.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const verifyClickClackAccountAfterSetup = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./setup-verify.js", () => ({
|
||||
verifyClickClackAccountAfterSetup,
|
||||
}));
|
||||
import {
|
||||
applyClickClackCredentialConfig,
|
||||
clickClackSetupAdapter,
|
||||
@@ -295,4 +302,31 @@ describe("ClickClack setup adapter", () => {
|
||||
tokenFile: "/run/secrets/clickclack",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs post-write verification with the saved account config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
clickclack: {
|
||||
baseUrl: "https://clickclack.example",
|
||||
token: "ccb_test",
|
||||
workspace: "default",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const runtime = createNonExitingRuntimeEnv();
|
||||
|
||||
await clickClackSetupAdapter.afterAccountConfigWritten?.({
|
||||
previousCfg: {},
|
||||
cfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {},
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(verifyClickClackAccountAfterSetup).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
runtime,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,4 +211,12 @@ export const clickClackSetupAdapter: ChannelSetupAdapter = {
|
||||
useEnv: input.useEnv,
|
||||
});
|
||||
},
|
||||
afterAccountConfigWritten: async ({ cfg, accountId, runtime }) => {
|
||||
const { verifyClickClackAccountAfterSetup } = await import("./setup-verify.js");
|
||||
await verifyClickClackAccountAfterSetup({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
runtime,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
174
extensions/clickclack/src/setup-verify.test.ts
Normal file
174
extensions/clickclack/src/setup-verify.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// ClickClack tests cover post-write connection verification and gateway guidance.
|
||||
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callGatewayFromCli: vi.fn(),
|
||||
createClient: vi.fn(),
|
||||
me: vi.fn(),
|
||||
resolveWorkspaceId: vi.fn(),
|
||||
workspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
|
||||
callGatewayFromCli: mocks.callGatewayFromCli,
|
||||
}));
|
||||
|
||||
vi.mock("./http-client.js", () => ({
|
||||
createClickClackClient: (options: unknown) => {
|
||||
mocks.createClient(options);
|
||||
return {
|
||||
me: mocks.me,
|
||||
workspaces: mocks.workspaces,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./resolve.js", () => ({
|
||||
resolveWorkspaceId: mocks.resolveWorkspaceId,
|
||||
}));
|
||||
|
||||
import { verifyClickClackAccountAfterSetup } from "./setup-verify.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const configuredAccount = {
|
||||
channels: {
|
||||
clickclack: {
|
||||
baseUrl: "https://clickclack.example",
|
||||
token: "ccb_test",
|
||||
workspace: "default",
|
||||
},
|
||||
},
|
||||
} satisfies CoreConfig;
|
||||
|
||||
function createRuntime() {
|
||||
return createNonExitingRuntimeEnv();
|
||||
}
|
||||
|
||||
async function verify(
|
||||
cfg: CoreConfig = configuredAccount,
|
||||
runtime = createRuntime(),
|
||||
): Promise<ReturnType<typeof createRuntime>> {
|
||||
await verifyClickClackAccountAfterSetup({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
runtime,
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
describe("ClickClack post-write setup verification", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
mocks.callGatewayFromCli.mockReset().mockResolvedValue({ ok: true });
|
||||
mocks.createClient.mockReset();
|
||||
mocks.me.mockReset().mockResolvedValue({ id: "usr_bot", handle: "openclaw" });
|
||||
mocks.resolveWorkspaceId.mockReset().mockResolvedValue("wsp_default");
|
||||
mocks.workspaces
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
{ id: "wsp_default", name: "clickclack", slug: "default", created_at: "2026-01-01" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prints the resolved bot and workspace without blocking setup", async () => {
|
||||
const runtime = await verify();
|
||||
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"Connected as @openclaw — workspace clickclack resolved.",
|
||||
);
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"OpenClaw is running — ClickClack will connect automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "an invalid token",
|
||||
arrange: () => mocks.me.mockRejectedValue({ status: 401 }),
|
||||
expected: "ClickClack rejected the bot token (401). Copy a current token and rerun setup.",
|
||||
},
|
||||
{
|
||||
name: "a missing workspace",
|
||||
arrange: () => {
|
||||
mocks.resolveWorkspaceId.mockResolvedValue("wsp_missing");
|
||||
mocks.workspaces.mockResolvedValue([]);
|
||||
},
|
||||
expected:
|
||||
'Workspace "default" was not found. Check the id, slug, or name, list available workspaces, and rerun setup.',
|
||||
},
|
||||
{
|
||||
name: "a network failure",
|
||||
arrange: () => mocks.me.mockRejectedValue(new Error("network unavailable")),
|
||||
expected:
|
||||
"Connection check failed: network unavailable. Setup was saved; fix the connection and rerun setup.",
|
||||
},
|
||||
])("logs a warning for $name and continues", async ({ arrange, expected }) => {
|
||||
arrange();
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(verify(configuredAccount, runtime)).resolves.toBe(runtime);
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(1, expected);
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"OpenClaw is running — ClickClack will connect automatically.",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips client verification when the implicit env token is unavailable", async () => {
|
||||
vi.stubEnv("CLICKCLACK_BOT_TOKEN", "");
|
||||
const runtime = await verify({
|
||||
channels: {
|
||||
clickclack: {
|
||||
baseUrl: "https://clickclack.example",
|
||||
workspace: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig);
|
||||
|
||||
expect(mocks.createClient).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"Token comes from CLICKCLACK_BOT_TOKEN; verification skipped.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "running",
|
||||
arrange: () => mocks.callGatewayFromCli.mockResolvedValue({ ok: true }),
|
||||
expected: "OpenClaw is running — ClickClack will connect automatically.",
|
||||
},
|
||||
{
|
||||
name: "not running",
|
||||
arrange: () =>
|
||||
mocks.callGatewayFromCli.mockRejectedValue(
|
||||
Object.assign(new Error("gateway closed (1006): no listener"), {
|
||||
name: "GatewayTransportError",
|
||||
kind: "closed",
|
||||
code: 1006,
|
||||
}),
|
||||
),
|
||||
expected: "Start OpenClaw to connect: openclaw gateway",
|
||||
},
|
||||
{
|
||||
name: "unavailable",
|
||||
arrange: () => mocks.callGatewayFromCli.mockRejectedValue(new Error("probe failed")),
|
||||
expected:
|
||||
"If OpenClaw is running it connects automatically; otherwise start it with: openclaw gateway",
|
||||
},
|
||||
])("prints the gateway next step when status is $name", async ({ arrange, expected }) => {
|
||||
arrange();
|
||||
const runtime = await verify();
|
||||
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(2, expected);
|
||||
expect(mocks.callGatewayFromCli).toHaveBeenCalledWith(
|
||||
"health",
|
||||
{ timeout: "1000", json: true },
|
||||
undefined,
|
||||
{ expectFinal: false, progress: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
// ClickClack plugin module implements shared setup connection verification.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
|
||||
import { resolveClickClackAccount } from "./accounts.js";
|
||||
import { createClickClackClient } from "./http-client.js";
|
||||
import { resolveWorkspaceId } from "./resolve.js";
|
||||
import type { CoreConfig, ResolvedClickClackAccount } from "./types.js";
|
||||
|
||||
export type ClickClackSetupConnectionResult =
|
||||
type ClickClackSetupConnectionResult =
|
||||
| { status: "connected"; handle: string; workspaceName: string }
|
||||
| { status: "invalid-token" }
|
||||
| { status: "workspace-not-found"; workspace: string }
|
||||
@@ -15,6 +16,13 @@ export type ClickClackSetupConnectionResult =
|
||||
| { status: "skipped-env-token" }
|
||||
| { status: "skipped-unconfigured" };
|
||||
|
||||
type ClickClackGatewayStatus = "running" | "not-running" | "unavailable";
|
||||
|
||||
const GATEWAY_RUNNING_MESSAGE = "OpenClaw is running — ClickClack will connect automatically.";
|
||||
const GATEWAY_NOT_RUNNING_MESSAGE = "Start OpenClaw to connect: openclaw gateway";
|
||||
const GATEWAY_UNKNOWN_MESSAGE =
|
||||
"If OpenClaw is running it connects automatically; otherwise start it with: openclaw gateway";
|
||||
|
||||
function isHttpStatus(error: unknown, status: number): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -94,3 +102,93 @@ export async function checkClickClackSetupConnection(params: {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isGatewayNotRunningError(error: unknown): boolean {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
"kind" in error &&
|
||||
"code" in error &&
|
||||
(error as { name?: unknown }).name === "GatewayTransportError" &&
|
||||
(error as { kind?: unknown }).kind === "closed" &&
|
||||
(error as { code?: unknown }).code === 1006
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const message = formatErrorMessage(error).toLowerCase();
|
||||
return message.includes("econnrefused") || message.includes("connection refused");
|
||||
}
|
||||
|
||||
async function probeClickClackGatewayStatus(): Promise<ClickClackGatewayStatus> {
|
||||
try {
|
||||
const { callGatewayFromCli } = await import("openclaw/plugin-sdk/gateway-runtime");
|
||||
await callGatewayFromCli("health", { timeout: "1000", json: true }, undefined, {
|
||||
expectFinal: false,
|
||||
progress: false,
|
||||
});
|
||||
return "running";
|
||||
} catch (error) {
|
||||
return isGatewayNotRunningError(error) ? "not-running" : "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
function formatClickClackConnectionLog(
|
||||
result: ClickClackSetupConnectionResult,
|
||||
): string | undefined {
|
||||
switch (result.status) {
|
||||
case "connected":
|
||||
return `Connected as @${result.handle} — workspace ${result.workspaceName} resolved.`;
|
||||
case "invalid-token":
|
||||
return "ClickClack rejected the bot token (401). Copy a current token and rerun setup.";
|
||||
case "workspace-not-found":
|
||||
return `Workspace "${result.workspace}" was not found. Check the id, slug, or name, list available workspaces, and rerun setup.`;
|
||||
case "failed":
|
||||
return `Connection check failed: ${result.error}. Setup was saved; fix the connection and rerun setup.`;
|
||||
case "skipped-env-token":
|
||||
return "Token comes from CLICKCLACK_BOT_TOKEN; verification skipped.";
|
||||
case "skipped-unconfigured":
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatClickClackGatewayLog(status: ClickClackGatewayStatus): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return GATEWAY_RUNNING_MESSAGE;
|
||||
case "not-running":
|
||||
return GATEWAY_NOT_RUNNING_MESSAGE;
|
||||
case "unavailable":
|
||||
return GATEWAY_UNKNOWN_MESSAGE;
|
||||
}
|
||||
return GATEWAY_UNKNOWN_MESSAGE;
|
||||
}
|
||||
|
||||
export async function verifyClickClackAccountAfterSetup(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const result = await checkClickClackSetupConnection({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const message = formatClickClackConnectionLog(result);
|
||||
if (message) {
|
||||
params.runtime.log(message);
|
||||
}
|
||||
} catch (error) {
|
||||
params.runtime.log(
|
||||
`Connection check failed: ${formatErrorMessage(error)}. Setup was saved; fix the connection and rerun setup.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await probeClickClackGatewayStatus();
|
||||
params.runtime.log(formatClickClackGatewayLog(status));
|
||||
} catch {
|
||||
params.runtime.log(GATEWAY_UNKNOWN_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user