From 752567dbc8ed9dfbe27633e72274b8ec09d5ebdf Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 15 Jul 2026 14:41:18 +0100 Subject: [PATCH] feat: verify ClickClack after channel setup --- extensions/clickclack/src/setup-core.test.ts | 36 +++- extensions/clickclack/src/setup-core.ts | 8 + .../clickclack/src/setup-verify.test.ts | 174 ++++++++++++++++++ extensions/clickclack/src/setup-verify.ts | 100 +++++++++- 4 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 extensions/clickclack/src/setup-verify.test.ts diff --git a/extensions/clickclack/src/setup-core.test.ts b/extensions/clickclack/src/setup-core.test.ts index d5db0bb50fec..ff6ca6b5fb1f 100644 --- a/extensions/clickclack/src/setup-core.test.ts +++ b/extensions/clickclack/src/setup-core.test.ts @@ -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, + }); + }); }); diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index 1b1ba453e8e5..c6e07c047db0 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -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, + }); + }, }; diff --git a/extensions/clickclack/src/setup-verify.test.ts b/extensions/clickclack/src/setup-verify.test.ts new file mode 100644 index 000000000000..886262f45309 --- /dev/null +++ b/extensions/clickclack/src/setup-verify.test.ts @@ -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> { + 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 }, + ); + }); +}); diff --git a/extensions/clickclack/src/setup-verify.ts b/extensions/clickclack/src/setup-verify.ts index d477c558678a..a00bf9acf8ef 100644 --- a/extensions/clickclack/src/setup-verify.ts +++ b/extensions/clickclack/src/setup-verify.ts @@ -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 { + 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 { + 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); + } +}