feat: verify ClickClack after channel setup

This commit is contained in:
Shakker
2026-07-15 14:41:18 +01:00
committed by Shakker
parent 3e352d6727
commit 752567dbc8
4 changed files with 316 additions and 2 deletions

View File

@@ -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,
});
});
});

View File

@@ -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,
});
},
};

View 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 },
);
});
});

View File

@@ -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);
}
}