diff --git a/extensions/irc/src/channel.startup.test.ts b/extensions/irc/src/channel.startup.test.ts deleted file mode 100644 index 9be684b648e..00000000000 --- a/extensions/irc/src/channel.startup.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - expectStopPendingUntilAbort, - startAccountAndTrackLifecycle, - waitForStartedMocks, -} from "../../../test/helpers/extensions/start-account-lifecycle.js"; -import type { ResolvedIrcAccount } from "./accounts.js"; - -const hoisted = vi.hoisted(() => ({ - monitorIrcProvider: vi.fn(), -})); - -vi.mock("./monitor.js", async () => { - const actual = await vi.importActual("./monitor.js"); - return { - ...actual, - monitorIrcProvider: hoisted.monitorIrcProvider, - }; -}); - -import { ircPlugin } from "./channel.js"; - -function buildAccount(): ResolvedIrcAccount { - return { - accountId: "default", - enabled: true, - name: "default", - configured: true, - host: "irc.example.com", - port: 6697, - tls: true, - nick: "openclaw", - username: "openclaw", - realname: "OpenClaw", - password: "", - passwordSource: "none", - config: {} as ResolvedIrcAccount["config"], - }; -} - -describe("ircPlugin gateway.startAccount", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("keeps startAccount pending until abort, then stops the monitor", async () => { - const stop = vi.fn(); - hoisted.monitorIrcProvider.mockResolvedValue({ stop }); - - const { abort, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: ircPlugin.gateway!.startAccount!, - account: buildAccount(), - }); - - await expectStopPendingUntilAbort({ - waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider), - isSettled, - abort, - task, - stop, - }); - }); -}); diff --git a/extensions/irc/src/setup-core.test.ts b/extensions/irc/src/setup-core.test.ts deleted file mode 100644 index 33d87143beb..00000000000 --- a/extensions/irc/src/setup-core.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - ircSetupAdapter, - parsePort, - setIrcAllowFrom, - setIrcDmPolicy, - setIrcGroupAccess, - setIrcNickServ, - updateIrcAccountConfig, -} from "./setup-core.js"; -import type { CoreConfig } from "./types.js"; - -describe("irc setup core", () => { - it("parses valid ports and falls back for invalid values", () => { - expect(parsePort("6697", 6667)).toBe(6697); - expect(parsePort(" 7000 ", 6667)).toBe(7000); - expect(parsePort("", 6667)).toBe(6667); - expect(parsePort("70000", 6667)).toBe(6667); - expect(parsePort("abc", 6667)).toBe(6667); - }); - - it("updates top-level dm policy and allowlist", () => { - const cfg: CoreConfig = { channels: { irc: {} } }; - - expect(setIrcDmPolicy(cfg, "open")).toMatchObject({ - channels: { - irc: { - dmPolicy: "open", - }, - }, - }); - - expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toMatchObject({ - channels: { - irc: { - allowFrom: ["alice", "bob"], - }, - }, - }); - }); - - it("stores nickserv and account config patches on the scoped account", () => { - const cfg: CoreConfig = { channels: { irc: {} } }; - - expect( - setIrcNickServ(cfg, "work", { - enabled: true, - service: "NickServ", - }), - ).toMatchObject({ - channels: { - irc: { - accounts: { - work: { - nickserv: { - enabled: true, - service: "NickServ", - }, - }, - }, - }, - }, - }); - - expect( - updateIrcAccountConfig(cfg, "work", { - host: "irc.libera.chat", - nick: "openclaw-work", - }), - ).toMatchObject({ - channels: { - irc: { - accounts: { - work: { - host: "irc.libera.chat", - nick: "openclaw-work", - }, - }, - }, - }, - }); - }); - - it("normalizes allowlist groups and handles non-allowlist policies", () => { - const cfg: CoreConfig = { channels: { irc: {} } }; - - expect( - setIrcGroupAccess( - cfg, - "default", - "allowlist", - ["openclaw", "#ops", "openclaw", "*"], - (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - if (trimmed === "*") { - return "*"; - } - return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; - }, - ), - ).toMatchObject({ - channels: { - irc: { - enabled: true, - groupPolicy: "allowlist", - groups: { - "#openclaw": {}, - "#ops": {}, - "*": {}, - }, - }, - }, - }); - - expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toMatchObject({ - channels: { - irc: { - enabled: true, - groupPolicy: "disabled", - }, - }, - }); - }); - - it("validates required input and applies normalized account config", () => { - const validateInput = ircSetupAdapter.validateInput; - const applyAccountConfig = ircSetupAdapter.applyAccountConfig; - expect(validateInput).toBeTypeOf("function"); - expect(applyAccountConfig).toBeTypeOf("function"); - - expect( - validateInput!({ - input: { host: "", nick: "openclaw" }, - } as never), - ).toBe("IRC requires host."); - - expect( - validateInput!({ - input: { host: "irc.libera.chat", nick: "" }, - } as never), - ).toBe("IRC requires nick."); - - expect( - validateInput!({ - input: { host: "irc.libera.chat", nick: "openclaw" }, - } as never), - ).toBeNull(); - - expect( - applyAccountConfig!({ - cfg: { channels: { irc: {} } }, - accountId: "default", - input: { - name: "Default", - host: " irc.libera.chat ", - port: "7000", - tls: true, - nick: " openclaw ", - username: " claw ", - realname: " OpenClaw Bot ", - password: " secret ", - channels: ["#openclaw"], - }, - } as never), - ).toEqual({ - channels: { - irc: { - enabled: true, - name: "Default", - host: "irc.libera.chat", - port: 7000, - tls: true, - nick: "openclaw", - username: "claw", - realname: "OpenClaw Bot", - password: "secret", - channels: ["#openclaw"], - }, - }, - }); - }); -}); diff --git a/extensions/irc/src/setup-surface.test.ts b/extensions/irc/src/setup-surface.test.ts deleted file mode 100644 index 3765285ac41..00000000000 --- a/extensions/irc/src/setup-surface.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createPluginSetupWizardAdapter, - createTestWizardPrompter, - promptSetupWizardAllowFrom, - runSetupWizardConfigure, - type WizardPrompter, -} from "../../../test/helpers/extensions/setup-wizard.js"; -import { ircPlugin } from "./channel.js"; -import type { CoreConfig } from "./types.js"; - -const ircConfigureAdapter = createPluginSetupWizardAdapter(ircPlugin); - -describe("irc setup wizard", () => { - it("configures host and nick via setup prompts", async () => { - const prompter = createTestWizardPrompter({ - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "IRC server host") { - return "irc.libera.chat"; - } - if (message === "IRC server port") { - return "6697"; - } - if (message === "IRC nick") { - return "openclaw-bot"; - } - if (message === "IRC username") { - return "openclaw"; - } - if (message === "IRC real name") { - return "OpenClaw Bot"; - } - if (message.startsWith("Auto-join IRC channels")) { - return "#openclaw, #ops"; - } - if (message.startsWith("IRC channels allowlist")) { - return "#openclaw, #ops"; - } - throw new Error(`Unexpected prompt: ${message}`); - }) as WizardPrompter["text"], - confirm: vi.fn(async ({ message }: { message: string }) => { - if (message === "Use TLS for IRC?") { - return true; - } - if (message === "Configure IRC channels access?") { - return true; - } - return false; - }), - }); - - const result = await runSetupWizardConfigure({ - configure: ircConfigureAdapter.configure, - cfg: {} as CoreConfig, - prompter, - options: {}, - }); - - expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.irc?.enabled).toBe(true); - expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat"); - expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot"); - expect(result.cfg.channels?.irc?.tls).toBe(true); - expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]); - expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist"); - expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]); - }); - - it("writes DM allowFrom to top-level config for non-default account prompts", async () => { - const prompter = createTestWizardPrompter({ - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "IRC allowFrom (nick or nick!user@host)") { - return "Alice, Bob!ident@example.org"; - } - throw new Error(`Unexpected prompt: ${message}`); - }) as WizardPrompter["text"], - confirm: vi.fn(async () => false), - }); - - const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom; - if (!promptAllowFrom) { - throw new Error("promptAllowFrom unavailable"); - } - - const cfg: CoreConfig = { - channels: { - irc: { - accounts: { - work: { - host: "irc.libera.chat", - nick: "openclaw-work", - }, - }, - }, - }, - }; - - const updated = (await promptSetupWizardAllowFrom({ - promptAllowFrom, - cfg, - prompter, - accountId: "work", - })) as CoreConfig; - - expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]); - expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined(); - }); -}); diff --git a/extensions/irc/src/setup.test.ts b/extensions/irc/src/setup.test.ts new file mode 100644 index 00000000000..cd73e3408aa --- /dev/null +++ b/extensions/irc/src/setup.test.ts @@ -0,0 +1,347 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createPluginSetupWizardAdapter, + createTestWizardPrompter, + promptSetupWizardAllowFrom, + runSetupWizardConfigure, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; +import { + expectStopPendingUntilAbort, + startAccountAndTrackLifecycle, + waitForStartedMocks, +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; +import type { ResolvedIrcAccount } from "./accounts.js"; +import { ircPlugin } from "./channel.js"; +import { + ircSetupAdapter, + parsePort, + setIrcAllowFrom, + setIrcDmPolicy, + setIrcGroupAccess, + setIrcNickServ, + updateIrcAccountConfig, +} from "./setup-core.js"; +import type { CoreConfig } from "./types.js"; + +const hoisted = vi.hoisted(() => ({ + monitorIrcProvider: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + monitorIrcProvider: hoisted.monitorIrcProvider, + }; +}); + +const ircConfigureAdapter = createPluginSetupWizardAdapter(ircPlugin); + +function buildAccount(): ResolvedIrcAccount { + return { + accountId: "default", + enabled: true, + name: "default", + configured: true, + host: "irc.example.com", + port: 6697, + tls: true, + nick: "openclaw", + username: "openclaw", + realname: "OpenClaw", + password: "", + passwordSource: "none", + config: {} as ResolvedIrcAccount["config"], + }; +} + +describe("irc setup", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("parses valid ports and falls back for invalid values", () => { + expect(parsePort("6697", 6667)).toBe(6697); + expect(parsePort(" 7000 ", 6667)).toBe(7000); + expect(parsePort("", 6667)).toBe(6667); + expect(parsePort("70000", 6667)).toBe(6667); + expect(parsePort("abc", 6667)).toBe(6667); + }); + + it("updates top-level dm policy and allowlist", () => { + const cfg: CoreConfig = { channels: { irc: {} } }; + + expect(setIrcDmPolicy(cfg, "open")).toMatchObject({ + channels: { + irc: { + dmPolicy: "open", + }, + }, + }); + + expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toMatchObject({ + channels: { + irc: { + allowFrom: ["alice", "bob"], + }, + }, + }); + }); + + it("stores nickserv and account config patches on the scoped account", () => { + const cfg: CoreConfig = { channels: { irc: {} } }; + + expect( + setIrcNickServ(cfg, "work", { + enabled: true, + service: "NickServ", + }), + ).toMatchObject({ + channels: { + irc: { + accounts: { + work: { + nickserv: { + enabled: true, + service: "NickServ", + }, + }, + }, + }, + }, + }); + + expect( + updateIrcAccountConfig(cfg, "work", { + host: "irc.libera.chat", + nick: "openclaw-work", + }), + ).toMatchObject({ + channels: { + irc: { + accounts: { + work: { + host: "irc.libera.chat", + nick: "openclaw-work", + }, + }, + }, + }, + }); + }); + + it("normalizes allowlist groups and handles non-allowlist policies", () => { + const cfg: CoreConfig = { channels: { irc: {} } }; + + expect( + setIrcGroupAccess( + cfg, + "default", + "allowlist", + ["openclaw", "#ops", "openclaw", "*"], + (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + }, + ), + ).toMatchObject({ + channels: { + irc: { + enabled: true, + groupPolicy: "allowlist", + groups: { + "#openclaw": {}, + "#ops": {}, + "*": {}, + }, + }, + }, + }); + + expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toMatchObject({ + channels: { + irc: { + enabled: true, + groupPolicy: "disabled", + }, + }, + }); + }); + + it("validates required input and applies normalized account config", () => { + const validateInput = ircSetupAdapter.validateInput; + const applyAccountConfig = ircSetupAdapter.applyAccountConfig; + expect(validateInput).toBeTypeOf("function"); + expect(applyAccountConfig).toBeTypeOf("function"); + + expect( + validateInput!({ + input: { host: "", nick: "openclaw" }, + } as never), + ).toBe("IRC requires host."); + + expect( + validateInput!({ + input: { host: "irc.libera.chat", nick: "" }, + } as never), + ).toBe("IRC requires nick."); + + expect( + validateInput!({ + input: { host: "irc.libera.chat", nick: "openclaw" }, + } as never), + ).toBeNull(); + + expect( + applyAccountConfig!({ + cfg: { channels: { irc: {} } }, + accountId: "default", + input: { + name: "Default", + host: " irc.libera.chat ", + port: "7000", + tls: true, + nick: " openclaw ", + username: " claw ", + realname: " OpenClaw Bot ", + password: " secret ", + channels: ["#openclaw"], + }, + } as never), + ).toEqual({ + channels: { + irc: { + enabled: true, + name: "Default", + host: "irc.libera.chat", + port: 7000, + tls: true, + nick: "openclaw", + username: "claw", + realname: "OpenClaw Bot", + password: "secret", + channels: ["#openclaw"], + }, + }, + }); + }); + + it("configures host and nick via setup prompts", async () => { + const prompter = createTestWizardPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "IRC server host") { + return "irc.libera.chat"; + } + if (message === "IRC server port") { + return "6697"; + } + if (message === "IRC nick") { + return "openclaw-bot"; + } + if (message === "IRC username") { + return "openclaw"; + } + if (message === "IRC real name") { + return "OpenClaw Bot"; + } + if (message.startsWith("Auto-join IRC channels")) { + return "#openclaw, #ops"; + } + if (message.startsWith("IRC channels allowlist")) { + return "#openclaw, #ops"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Use TLS for IRC?") { + return true; + } + if (message === "Configure IRC channels access?") { + return true; + } + return false; + }), + }); + + const result = await runSetupWizardConfigure({ + configure: ircConfigureAdapter.configure, + cfg: {} as CoreConfig, + prompter, + options: {}, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.irc?.enabled).toBe(true); + expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat"); + expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot"); + expect(result.cfg.channels?.irc?.tls).toBe(true); + expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]); + expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist"); + expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]); + }); + + it("writes DM allowFrom to top-level config for non-default account prompts", async () => { + const prompter = createTestWizardPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "IRC allowFrom (nick or nick!user@host)") { + return "Alice, Bob!ident@example.org"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async () => false), + }); + + const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom; + if (!promptAllowFrom) { + throw new Error("promptAllowFrom unavailable"); + } + + const cfg: CoreConfig = { + channels: { + irc: { + accounts: { + work: { + host: "irc.libera.chat", + nick: "openclaw-work", + }, + }, + }, + }, + }; + + const updated = (await promptSetupWizardAllowFrom({ + promptAllowFrom, + cfg, + prompter, + accountId: "work", + })) as CoreConfig; + + expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]); + expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined(); + }); + + it("keeps startAccount pending until abort, then stops the monitor", async () => { + const stop = vi.fn(); + hoisted.monitorIrcProvider.mockResolvedValue({ stop }); + + const { abort, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: ircPlugin.gateway!.startAccount!, + account: buildAccount(), + }); + + await expectStopPendingUntilAbort({ + waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider), + isSettled, + abort, + task, + stop, + }); + }); +}); diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts deleted file mode 100644 index 8b6368b1bb1..00000000000 --- a/extensions/nextcloud-talk/src/channel.startup.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; -import { - expectStopPendingUntilAbort, - startAccountAndTrackLifecycle, - waitForStartedMocks, -} from "../../../test/helpers/extensions/start-account-lifecycle.js"; -import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; - -const hoisted = vi.hoisted(() => ({ - monitorNextcloudTalkProvider: vi.fn(), -})); - -vi.mock("./monitor.js", async () => { - const actual = await vi.importActual("./monitor.js"); - return { - ...actual, - monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, - }; -}); - -import { nextcloudTalkPlugin } from "./channel.js"; - -function buildAccount(): ResolvedNextcloudTalkAccount { - return { - accountId: "default", - enabled: true, - baseUrl: "https://nextcloud.example.com", - secret: "secret", // pragma: allowlist secret - secretSource: "config", // pragma: allowlist secret - config: { - baseUrl: "https://nextcloud.example.com", - botSecret: "secret", // pragma: allowlist secret - webhookPath: "/nextcloud-talk-webhook", - webhookPort: 8788, - }, - }; -} - -function mockStartedMonitor() { - const stop = vi.fn(); - hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); - return stop; -} - -function startNextcloudAccount(abortSignal?: AbortSignal) { - return nextcloudTalkPlugin.gateway!.startAccount!( - createStartAccountContext({ - account: buildAccount(), - abortSignal, - }), - ); -} - -describe("nextcloudTalkPlugin gateway.startAccount", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("keeps startAccount pending until abort, then stops the monitor", async () => { - const stop = mockStartedMonitor(); - const { abort, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: nextcloudTalkPlugin.gateway!.startAccount!, - account: buildAccount(), - }); - await expectStopPendingUntilAbort({ - waitForStarted: waitForStartedMocks(hoisted.monitorNextcloudTalkProvider), - isSettled, - abort, - task, - stop, - }); - }); - - it("stops immediately when startAccount receives an already-aborted signal", async () => { - const stop = mockStartedMonitor(); - const abort = new AbortController(); - abort.abort(); - - await startNextcloudAccount(abort.signal); - - expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); - expect(stop).toHaveBeenCalledOnce(); - }); -}); diff --git a/extensions/nextcloud-talk/src/config-schema.test.ts b/extensions/nextcloud-talk/src/config-schema.test.ts deleted file mode 100644 index 3841e8a4a9b..00000000000 --- a/extensions/nextcloud-talk/src/config-schema.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { NextcloudTalkConfigSchema } from "./config-schema.js"; - -describe("NextcloudTalkConfigSchema SecretInput", () => { - it("accepts SecretRef botSecret and apiPassword at top-level", () => { - const result = NextcloudTalkConfigSchema.safeParse({ - baseUrl: "https://cloud.example.com", - botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_BOT_SECRET" }, - apiUser: "bot", - apiPassword: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_API_PASSWORD" }, - }); - expect(result.success).toBe(true); - }); - - it("accepts SecretRef botSecret and apiPassword on account", () => { - const result = NextcloudTalkConfigSchema.safeParse({ - accounts: { - main: { - baseUrl: "https://cloud.example.com", - botSecret: { - source: "env", - provider: "default", - id: "NEXTCLOUD_TALK_MAIN_BOT_SECRET", - }, - apiUser: "bot", - apiPassword: { - source: "env", - provider: "default", - id: "NEXTCLOUD_TALK_MAIN_API_PASSWORD", - }, - }, - }, - }); - expect(result.success).toBe(true); - }); -}); diff --git a/extensions/nextcloud-talk/src/core.test.ts b/extensions/nextcloud-talk/src/core.test.ts new file mode 100644 index 00000000000..fe03aaffe0f --- /dev/null +++ b/extensions/nextcloud-talk/src/core.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { NextcloudTalkConfigSchema } from "./config-schema.js"; +import { + escapeNextcloudTalkMarkdown, + formatNextcloudTalkCodeBlock, + formatNextcloudTalkInlineCode, + formatNextcloudTalkMention, + markdownToNextcloudTalk, + stripNextcloudTalkFormatting, + truncateNextcloudTalkText, +} from "./format.js"; +import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js"; + +describe("nextcloud talk core", () => { + it("accepts SecretRef botSecret and apiPassword at top-level", () => { + const result = NextcloudTalkConfigSchema.safeParse({ + baseUrl: "https://cloud.example.com", + botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_BOT_SECRET" }, + apiUser: "bot", + apiPassword: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_API_PASSWORD" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts SecretRef botSecret and apiPassword on account", () => { + const result = NextcloudTalkConfigSchema.safeParse({ + accounts: { + main: { + baseUrl: "https://cloud.example.com", + botSecret: { + source: "env", + provider: "default", + id: "NEXTCLOUD_TALK_MAIN_BOT_SECRET", + }, + apiUser: "bot", + apiPassword: { + source: "env", + provider: "default", + id: "NEXTCLOUD_TALK_MAIN_API_PASSWORD", + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("keeps markdown mostly intact while trimming outer whitespace", () => { + expect(markdownToNextcloudTalk(" **hello** ")).toBe("**hello**"); + }); + + it("escapes markdown-sensitive characters", () => { + expect(escapeNextcloudTalkMarkdown("*hello* [x](y)")).toBe("\\*hello\\* \\[x\\]\\(y\\)"); + }); + + it("formats mentions and code consistently", () => { + expect(formatNextcloudTalkMention("@alice")).toBe("@alice"); + expect(formatNextcloudTalkMention("bob")).toBe("@bob"); + expect(formatNextcloudTalkCodeBlock("const x = 1;", "ts")).toBe("```ts\nconst x = 1;\n```"); + expect(formatNextcloudTalkInlineCode("x")).toBe("`x`"); + expect(formatNextcloudTalkInlineCode("x ` y")).toBe("`` x ` y ``"); + }); + + it("strips markdown formatting and truncates on word boundaries", () => { + expect(stripNextcloudTalkFormatting("**bold** [link](https://example.com) `code`")).toBe( + "bold link", + ); + expect(truncateNextcloudTalkText("alpha beta gamma delta", 14)).toBe("alpha beta..."); + expect(truncateNextcloudTalkText("short", 14)).toBe("short"); + }); + + it("builds an outbound session route for normalized room targets", () => { + const route = resolveNextcloudTalkOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "acct-1", + target: "nextcloud-talk:room-123", + }); + + expect(route).toMatchObject({ + peer: { + kind: "group", + id: "room-123", + }, + from: "nextcloud-talk:room:room-123", + to: "nextcloud-talk:room-123", + }); + }); + + it("returns null when the target cannot be normalized to a room id", () => { + expect( + resolveNextcloudTalkOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "acct-1", + target: "", + }), + ).toBeNull(); + }); +}); diff --git a/extensions/nextcloud-talk/src/format.test.ts b/extensions/nextcloud-talk/src/format.test.ts deleted file mode 100644 index d4c0e110ac7..00000000000 --- a/extensions/nextcloud-talk/src/format.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - escapeNextcloudTalkMarkdown, - formatNextcloudTalkCodeBlock, - formatNextcloudTalkInlineCode, - formatNextcloudTalkMention, - markdownToNextcloudTalk, - stripNextcloudTalkFormatting, - truncateNextcloudTalkText, -} from "./format.js"; - -describe("nextcloud talk format helpers", () => { - it("keeps markdown mostly intact while trimming outer whitespace", () => { - expect(markdownToNextcloudTalk(" **hello** ")).toBe("**hello**"); - }); - - it("escapes markdown-sensitive characters", () => { - expect(escapeNextcloudTalkMarkdown("*hello* [x](y)")).toBe("\\*hello\\* \\[x\\]\\(y\\)"); - }); - - it("formats mentions and code consistently", () => { - expect(formatNextcloudTalkMention("@alice")).toBe("@alice"); - expect(formatNextcloudTalkMention("bob")).toBe("@bob"); - expect(formatNextcloudTalkCodeBlock("const x = 1;", "ts")).toBe("```ts\nconst x = 1;\n```"); - expect(formatNextcloudTalkInlineCode("x")).toBe("`x`"); - expect(formatNextcloudTalkInlineCode("x ` y")).toBe("`` x ` y ``"); - }); - - it("strips markdown formatting and truncates on word boundaries", () => { - expect(stripNextcloudTalkFormatting("**bold** [link](https://example.com) `code`")).toBe( - "bold link", - ); - expect(truncateNextcloudTalkText("alpha beta gamma delta", 14)).toBe("alpha beta..."); - expect(truncateNextcloudTalkText("short", 14)).toBe("short"); - }); -}); diff --git a/extensions/nextcloud-talk/src/session-route.test.ts b/extensions/nextcloud-talk/src/session-route.test.ts deleted file mode 100644 index 6c7282cdb73..00000000000 --- a/extensions/nextcloud-talk/src/session-route.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js"; - -describe("nextcloud talk session route", () => { - it("builds an outbound session route for normalized room targets", () => { - const route = resolveNextcloudTalkOutboundSessionRoute({ - cfg: {}, - agentId: "main", - accountId: "acct-1", - target: "nextcloud-talk:room-123", - }); - - expect(route).toMatchObject({ - peer: { - kind: "group", - id: "room-123", - }, - from: "nextcloud-talk:room:room-123", - to: "nextcloud-talk:room-123", - }); - }); - - it("returns null when the target cannot be normalized to a room id", () => { - expect( - resolveNextcloudTalkOutboundSessionRoute({ - cfg: {}, - agentId: "main", - accountId: "acct-1", - target: "", - }), - ).toBeNull(); - }); -}); diff --git a/extensions/nextcloud-talk/src/setup-surface.test.ts b/extensions/nextcloud-talk/src/setup-surface.test.ts deleted file mode 100644 index 3889cc7ff8a..00000000000 --- a/extensions/nextcloud-talk/src/setup-surface.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { nextcloudTalkSetupAdapter, nextcloudTalkSetupWizard } from "./setup-surface.js"; - -describe("nextcloudTalk setup surface", () => { - it("clears stored bot secret fields when switching the default account to env", () => { - type ApplyAccountConfigContext = Parameters< - typeof nextcloudTalkSetupAdapter.applyAccountConfig - >[0]; - - const next = nextcloudTalkSetupAdapter.applyAccountConfig({ - cfg: { - channels: { - "nextcloud-talk": { - enabled: true, - baseUrl: "https://cloud.old.example", - botSecret: "stored-secret", - botSecretFile: "/tmp/secret.txt", - }, - }, - }, - accountId: DEFAULT_ACCOUNT_ID, - input: { - baseUrl: "https://cloud.example.com", - useEnv: true, - }, - } as unknown as ApplyAccountConfigContext); - - expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com"); - expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); - expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); - }); - - it("clears stored bot secret fields when the wizard switches to env", async () => { - const credential = nextcloudTalkSetupWizard.credentials[0]; - const next = await credential.applyUseEnv?.({ - cfg: { - channels: { - "nextcloud-talk": { - enabled: true, - baseUrl: "https://cloud.example.com", - botSecret: "stored-secret", - botSecretFile: "/tmp/secret.txt", - }, - }, - }, - accountId: DEFAULT_ACCOUNT_ID, - }); - - expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); - expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); - }); -}); diff --git a/extensions/nextcloud-talk/src/setup-core.test.ts b/extensions/nextcloud-talk/src/setup.test.ts similarity index 54% rename from extensions/nextcloud-talk/src/setup-core.test.ts rename to extensions/nextcloud-talk/src/setup.test.ts index dcc1b031d4a..410a0223e35 100644 --- a/extensions/nextcloud-talk/src/setup-core.test.ts +++ b/extensions/nextcloud-talk/src/setup.test.ts @@ -1,5 +1,13 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; +import { + expectStopPendingUntilAbort, + startAccountAndTrackLifecycle, + waitForStartedMocks, +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; +import { nextcloudTalkPlugin } from "./channel.js"; import { clearNextcloudTalkAccountFields, nextcloudTalkDmPolicy, @@ -8,9 +16,57 @@ import { setNextcloudTalkAccountConfig, validateNextcloudTalkBaseUrl, } from "./setup-core.js"; +import { nextcloudTalkSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; -describe("nextcloud talk setup core", () => { +const hoisted = vi.hoisted(() => ({ + monitorNextcloudTalkProvider: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, + }; +}); + +function buildAccount(): ResolvedNextcloudTalkAccount { + return { + accountId: "default", + enabled: true, + baseUrl: "https://nextcloud.example.com", + secret: "secret", // pragma: allowlist secret + secretSource: "config", // pragma: allowlist secret + config: { + baseUrl: "https://nextcloud.example.com", + botSecret: "secret", // pragma: allowlist secret + webhookPath: "/nextcloud-talk-webhook", + webhookPort: 8788, + }, + }; +} + +function mockStartedMonitor() { + const stop = vi.fn(); + hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); + return stop; +} + +function startNextcloudAccount(abortSignal?: AbortSignal) { + return nextcloudTalkPlugin.gateway!.startAccount!( + createStartAccountContext({ + account: buildAccount(), + abortSignal, + }), + ); +} + +describe("nextcloud talk setup", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + it("normalizes and validates base urls", () => { expect(normalizeNextcloudTalkBaseUrl(" https://cloud.example.com/// ")).toBe( "https://cloud.example.com", @@ -188,4 +244,78 @@ describe("nextcloud talk setup core", () => { }, }); }); + + it("clears stored bot secret fields when switching the default account to env", () => { + type ApplyAccountConfigContext = Parameters< + typeof nextcloudTalkSetupAdapter.applyAccountConfig + >[0]; + + const next = nextcloudTalkSetupAdapter.applyAccountConfig({ + cfg: { + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.old.example", + botSecret: "stored-secret", + botSecretFile: "/tmp/secret.txt", + }, + }, + }, + accountId: DEFAULT_ACCOUNT_ID, + input: { + baseUrl: "https://cloud.example.com", + useEnv: true, + }, + } as unknown as ApplyAccountConfigContext); + + expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com"); + expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); + expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); + }); + + it("clears stored bot secret fields when the wizard switches to env", async () => { + const credential = nextcloudTalkSetupWizard.credentials[0]; + const next = await credential.applyUseEnv?.({ + cfg: { + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.example.com", + botSecret: "stored-secret", + botSecretFile: "/tmp/secret.txt", + }, + }, + }, + accountId: DEFAULT_ACCOUNT_ID, + }); + + expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); + expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); + }); + + it("keeps startAccount pending until abort, then stops the monitor", async () => { + const stop = mockStartedMonitor(); + const { abort, task, isSettled } = startAccountAndTrackLifecycle({ + startAccount: nextcloudTalkPlugin.gateway!.startAccount!, + account: buildAccount(), + }); + await expectStopPendingUntilAbort({ + waitForStarted: waitForStartedMocks(hoisted.monitorNextcloudTalkProvider), + isSettled, + abort, + task, + stop, + }); + }); + + it("stops immediately when startAccount receives an already-aborted signal", async () => { + const stop = mockStartedMonitor(); + const abort = new AbortController(); + abort.abort(); + + await startNextcloudAccount(abort.signal); + + expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); }); diff --git a/extensions/synology-chat/src/config-schema.test.ts b/extensions/synology-chat/src/config-schema.test.ts deleted file mode 100644 index 45cc96f20b1..00000000000 --- a/extensions/synology-chat/src/config-schema.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { SynologyChatChannelConfigSchema } from "./config-schema.js"; - -describe("SynologyChatChannelConfigSchema", () => { - it("exports dangerouslyAllowNameMatching in the JSON schema", () => { - const properties = (SynologyChatChannelConfigSchema.schema.properties ?? {}) as Record< - string, - { type?: string } - >; - - expect(properties.dangerouslyAllowNameMatching?.type).toBe("boolean"); - }); - - it("keeps the schema open for plugin-specific passthrough fields", () => { - expect([true, {}]).toContainEqual(SynologyChatChannelConfigSchema.schema.additionalProperties); - }); -}); diff --git a/extensions/synology-chat/src/setup-surface.test.ts b/extensions/synology-chat/src/core.test.ts similarity index 65% rename from extensions/synology-chat/src/setup-surface.test.ts rename to extensions/synology-chat/src/core.test.ts index 49b16a17dc2..c370e91ea3c 100644 --- a/extensions/synology-chat/src/setup-surface.test.ts +++ b/extensions/synology-chat/src/core.test.ts @@ -7,10 +7,49 @@ import { type WizardPrompter, } from "../../../test/helpers/extensions/setup-wizard.js"; import { synologyChatPlugin } from "./channel.js"; +import { SynologyChatChannelConfigSchema } from "./config-schema.js"; +import { buildSynologyChatInboundSessionKey } from "./session-key.js"; const synologyChatConfigure = createPluginSetupWizardConfigure(synologyChatPlugin); -describe("synology-chat setup wizard", () => { +describe("synology-chat core", () => { + it("exports dangerouslyAllowNameMatching in the JSON schema", () => { + const properties = (SynologyChatChannelConfigSchema.schema.properties ?? {}) as Record< + string, + { type?: string } + >; + + expect(properties.dangerouslyAllowNameMatching?.type).toBe("boolean"); + }); + + it("keeps the schema open for plugin-specific passthrough fields", () => { + expect([true, {}]).toContainEqual(SynologyChatChannelConfigSchema.schema.additionalProperties); + }); + + it("isolates direct-message sessions by account and user", () => { + const alpha = buildSynologyChatInboundSessionKey({ + agentId: "main", + accountId: "alpha", + userId: "123", + }); + const beta = buildSynologyChatInboundSessionKey({ + agentId: "main", + accountId: "beta", + userId: "123", + }); + const otherUser = buildSynologyChatInboundSessionKey({ + agentId: "main", + accountId: "alpha", + userId: "456", + }); + + expect(alpha).toBe("agent:main:synology-chat:alpha:direct:123"); + expect(beta).toBe("agent:main:synology-chat:beta:direct:123"); + expect(otherUser).toBe("agent:main:synology-chat:alpha:direct:456"); + expect(alpha).not.toBe(beta); + expect(alpha).not.toBe(otherUser); + }); + it("configures token and incoming webhook for the default account", async () => { const prompter = createTestWizardPrompter({ text: vi.fn(async ({ message }: { message: string }) => { diff --git a/extensions/synology-chat/src/session-key.test.ts b/extensions/synology-chat/src/session-key.test.ts deleted file mode 100644 index ff7d657e0f3..00000000000 --- a/extensions/synology-chat/src/session-key.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildSynologyChatInboundSessionKey } from "./session-key.js"; - -describe("buildSynologyChatInboundSessionKey", () => { - it("isolates direct-message sessions by account and user", () => { - const alpha = buildSynologyChatInboundSessionKey({ - agentId: "main", - accountId: "alpha", - userId: "123", - }); - const beta = buildSynologyChatInboundSessionKey({ - agentId: "main", - accountId: "beta", - userId: "123", - }); - const otherUser = buildSynologyChatInboundSessionKey({ - agentId: "main", - accountId: "alpha", - userId: "456", - }); - - expect(alpha).toBe("agent:main:synology-chat:alpha:direct:123"); - expect(beta).toBe("agent:main:synology-chat:beta:direct:123"); - expect(otherUser).toBe("agent:main:synology-chat:alpha:direct:456"); - expect(alpha).not.toBe(beta); - expect(alpha).not.toBe(otherUser); - }); -}); diff --git a/extensions/tlon/src/channel.test.ts b/extensions/tlon/src/channel.test.ts deleted file mode 100644 index 116b78bf718..00000000000 --- a/extensions/tlon/src/channel.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../api.js"; -import { tlonPlugin } from "./channel.js"; - -describe("tlonPlugin config", () => { - it("formats dm allowlist entries through the shared hybrid adapter", () => { - expect( - tlonPlugin.config.formatAllowFrom?.({ - cfg: {} as OpenClawConfig, - allowFrom: ["zod", " ~nec "], - }), - ).toEqual(["~zod", "~nec"]); - }); - - it("resolves dm allowlist from the default account", () => { - expect( - tlonPlugin.config.resolveAllowFrom?.({ - cfg: { - channels: { - tlon: { - ship: "~sampel-palnet", - url: "https://urbit.example.com", - code: "lidlut-tabwed-pillex-ridrup", - dmAllowlist: ["~zod"], - }, - }, - } as OpenClawConfig, - accountId: "default", - }), - ).toEqual(["~zod"]); - }); -}); diff --git a/extensions/tlon/src/config-schema.test.ts b/extensions/tlon/src/config-schema.test.ts deleted file mode 100644 index fa532331978..00000000000 --- a/extensions/tlon/src/config-schema.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js"; - -describe("Tlon config schema", () => { - it("accepts channelRules with string keys", () => { - const parsed = TlonAuthorizationSchema.parse({ - channelRules: { - "chat/~zod/test": { - mode: "open", - allowedShips: ["~zod"], - }, - }, - }); - - expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open"); - }); - - it("accepts accounts with string keys", () => { - const parsed = TlonConfigSchema.parse({ - accounts: { - primary: { - ship: "~zod", - url: "https://example.com", - code: "code-123", - }, - }, - }); - - expect(parsed.accounts?.primary?.ship).toBe("~zod"); - }); -}); diff --git a/extensions/tlon/src/core.test.ts b/extensions/tlon/src/core.test.ts new file mode 100644 index 00000000000..29360d220be --- /dev/null +++ b/extensions/tlon/src/core.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createPluginSetupWizardConfigure, + createTestWizardPrompter, + runSetupWizardConfigure, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; +import type { OpenClawConfig } from "../api.js"; +import { tlonPlugin } from "./channel.js"; +import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js"; +import { resolveTlonOutboundTarget } from "./targets.js"; +import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; + +const tlonConfigure = createPluginSetupWizardConfigure(tlonPlugin); + +describe("tlon core", () => { + it("formats dm allowlist entries through the shared hybrid adapter", () => { + expect( + tlonPlugin.config.formatAllowFrom?.({ + cfg: {} as OpenClawConfig, + allowFrom: ["zod", " ~nec "], + }), + ).toEqual(["~zod", "~nec"]); + }); + + it("resolves dm allowlist from the default account", () => { + expect( + tlonPlugin.config.resolveAllowFrom?.({ + cfg: { + channels: { + tlon: { + ship: "~sampel-palnet", + url: "https://urbit.example.com", + code: "lidlut-tabwed-pillex-ridrup", + dmAllowlist: ["~zod"], + }, + }, + } as OpenClawConfig, + accountId: "default", + }), + ).toEqual(["~zod"]); + }); + + it("accepts channelRules with string keys", () => { + const parsed = TlonAuthorizationSchema.parse({ + channelRules: { + "chat/~zod/test": { + mode: "open", + allowedShips: ["~zod"], + }, + }, + }); + + expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open"); + }); + + it("accepts accounts with string keys", () => { + const parsed = TlonConfigSchema.parse({ + accounts: { + primary: { + ship: "~zod", + url: "https://example.com", + code: "code-123", + }, + }, + }); + + expect(parsed.accounts?.primary?.ship).toBe("~zod"); + }); + + it("configures ship, auth, and discovery settings", async () => { + const prompter = createTestWizardPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Ship name") { + return "sampel-palnet"; + } + if (message === "Ship URL") { + return "https://urbit.example.com"; + } + if (message === "Login code") { + return "lidlut-tabwed-pillex-ridrup"; + } + if (message === "Group channels (comma-separated)") { + return "chat/~host-ship/general, chat/~host-ship/support"; + } + if (message === "DM allowlist (comma-separated ship names)") { + return "~zod, nec"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Add group channels manually? (optional)") { + return true; + } + if (message === "Restrict DMs with an allowlist?") { + return true; + } + if (message === "Enable auto-discovery of group channels?") { + return true; + } + return false; + }), + }); + + const result = await runSetupWizardConfigure({ + configure: tlonConfigure, + cfg: {} as OpenClawConfig, + prompter, + options: {}, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.tlon?.enabled).toBe(true); + expect(result.cfg.channels?.tlon?.ship).toBe("~sampel-palnet"); + expect(result.cfg.channels?.tlon?.url).toBe("https://urbit.example.com"); + expect(result.cfg.channels?.tlon?.code).toBe("lidlut-tabwed-pillex-ridrup"); + expect(result.cfg.channels?.tlon?.groupChannels).toEqual([ + "chat/~host-ship/general", + "chat/~host-ship/support", + ]); + expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]); + expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true); + expect(result.cfg.channels?.tlon?.allowPrivateNetwork).toBe(false); + }); + + it("resolves dm targets to normalized ships", () => { + expect(resolveTlonOutboundTarget("dm/sampel-palnet")).toEqual({ + ok: true, + to: "~sampel-palnet", + }); + }); + + it("resolves group targets to canonical chat nests", () => { + expect(resolveTlonOutboundTarget("group:host-ship/general")).toEqual({ + ok: true, + to: "chat/~host-ship/general", + }); + }); + + it("returns a helpful error for invalid targets", () => { + const resolved = resolveTlonOutboundTarget("group:bad-target"); + expect(resolved.ok).toBe(false); + if (resolved.ok) { + throw new Error("expected invalid target"); + } + expect(resolved.error.message).toMatch(/invalid tlon target/i); + }); + + it("lists named accounts and the implicit default account", () => { + const cfg = { + channels: { + tlon: { + ship: "~zod", + accounts: { + Work: { ship: "~bus" }, + alerts: { ship: "~nec" }, + }, + }, + }, + } as OpenClawConfig; + + expect(listTlonAccountIds(cfg)).toEqual(["alerts", "default", "work"]); + }); + + it("merges named account config over channel defaults", () => { + const resolved = resolveTlonAccount( + { + channels: { + tlon: { + name: "Base", + ship: "~zod", + url: "https://urbit.example.com", + code: "base-code", + dmAllowlist: ["~nec"], + groupInviteAllowlist: ["~bus"], + defaultAuthorizedShips: ["~marzod"], + accounts: { + Work: { + name: "Work", + code: "work-code", + dmAllowlist: ["~rovnys"], + }, + }, + }, + }, + } as OpenClawConfig, + "work", + ); + + expect(resolved.accountId).toBe("work"); + expect(resolved.name).toBe("Work"); + expect(resolved.ship).toBe("~zod"); + expect(resolved.url).toBe("https://urbit.example.com"); + expect(resolved.code).toBe("work-code"); + expect(resolved.dmAllowlist).toEqual(["~rovnys"]); + expect(resolved.groupInviteAllowlist).toEqual(["~bus"]); + expect(resolved.defaultAuthorizedShips).toEqual(["~marzod"]); + expect(resolved.configured).toBe(true); + }); + + it("keeps the default account on channel-level config only", () => { + const resolved = resolveTlonAccount( + { + channels: { + tlon: { + ship: "~zod", + url: "https://urbit.example.com", + code: "base-code", + accounts: { + default: { + ship: "~ignored", + code: "ignored-code", + }, + }, + }, + }, + } as OpenClawConfig, + "default", + ); + + expect(resolved.ship).toBe("~zod"); + expect(resolved.code).toBe("base-code"); + }); +}); diff --git a/extensions/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts deleted file mode 100644 index 5257f0aa6ce..00000000000 --- a/extensions/tlon/src/setup-surface.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createPluginSetupWizardConfigure, - createTestWizardPrompter, - runSetupWizardConfigure, - type WizardPrompter, -} from "../../../test/helpers/extensions/setup-wizard.js"; -import type { OpenClawConfig } from "../api.js"; -import { tlonPlugin } from "./channel.js"; - -const tlonConfigure = createPluginSetupWizardConfigure(tlonPlugin); - -describe("tlon setup wizard", () => { - it("configures ship, auth, and discovery settings", async () => { - const prompter = createTestWizardPrompter({ - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Ship name") { - return "sampel-palnet"; - } - if (message === "Ship URL") { - return "https://urbit.example.com"; - } - if (message === "Login code") { - return "lidlut-tabwed-pillex-ridrup"; - } - if (message === "Group channels (comma-separated)") { - return "chat/~host-ship/general, chat/~host-ship/support"; - } - if (message === "DM allowlist (comma-separated ship names)") { - return "~zod, nec"; - } - throw new Error(`Unexpected prompt: ${message}`); - }) as WizardPrompter["text"], - confirm: vi.fn(async ({ message }: { message: string }) => { - if (message === "Add group channels manually? (optional)") { - return true; - } - if (message === "Restrict DMs with an allowlist?") { - return true; - } - if (message === "Enable auto-discovery of group channels?") { - return true; - } - return false; - }), - }); - - const result = await runSetupWizardConfigure({ - configure: tlonConfigure, - cfg: {} as OpenClawConfig, - prompter, - options: {}, - }); - - expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.tlon?.enabled).toBe(true); - expect(result.cfg.channels?.tlon?.ship).toBe("~sampel-palnet"); - expect(result.cfg.channels?.tlon?.url).toBe("https://urbit.example.com"); - expect(result.cfg.channels?.tlon?.code).toBe("lidlut-tabwed-pillex-ridrup"); - expect(result.cfg.channels?.tlon?.groupChannels).toEqual([ - "chat/~host-ship/general", - "chat/~host-ship/support", - ]); - expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]); - expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true); - expect(result.cfg.channels?.tlon?.allowPrivateNetwork).toBe(false); - }); -}); diff --git a/extensions/tlon/src/targets.test.ts b/extensions/tlon/src/targets.test.ts deleted file mode 100644 index 3ac4d010f38..00000000000 --- a/extensions/tlon/src/targets.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveTlonOutboundTarget } from "./targets.js"; - -describe("resolveTlonOutboundTarget", () => { - it("resolves dm targets to normalized ships", () => { - expect(resolveTlonOutboundTarget("dm/sampel-palnet")).toEqual({ - ok: true, - to: "~sampel-palnet", - }); - }); - - it("resolves group targets to canonical chat nests", () => { - expect(resolveTlonOutboundTarget("group:host-ship/general")).toEqual({ - ok: true, - to: "chat/~host-ship/general", - }); - }); - - it("returns a helpful error for invalid targets", () => { - const resolved = resolveTlonOutboundTarget("group:bad-target"); - expect(resolved.ok).toBe(false); - if (resolved.ok) { - throw new Error("expected invalid target"); - } - expect(resolved.error.message).toMatch(/invalid tlon target/i); - }); -}); diff --git a/extensions/tlon/src/types.test.ts b/extensions/tlon/src/types.test.ts deleted file mode 100644 index 911facae1f8..00000000000 --- a/extensions/tlon/src/types.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../api.js"; -import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; - -describe("tlon account helpers", () => { - it("lists named accounts and the implicit default account", () => { - const cfg = { - channels: { - tlon: { - ship: "~zod", - accounts: { - Work: { ship: "~bus" }, - alerts: { ship: "~nec" }, - }, - }, - }, - } as OpenClawConfig; - - expect(listTlonAccountIds(cfg)).toEqual(["alerts", "default", "work"]); - }); - - it("merges named account config over channel defaults", () => { - const resolved = resolveTlonAccount( - { - channels: { - tlon: { - name: "Base", - ship: "~zod", - url: "https://urbit.example.com", - code: "base-code", - dmAllowlist: ["~nec"], - groupInviteAllowlist: ["~bus"], - defaultAuthorizedShips: ["~marzod"], - accounts: { - Work: { - name: "Work", - code: "work-code", - dmAllowlist: ["~rovnys"], - }, - }, - }, - }, - } as OpenClawConfig, - "work", - ); - - expect(resolved.accountId).toBe("work"); - expect(resolved.name).toBe("Work"); - expect(resolved.ship).toBe("~zod"); - expect(resolved.url).toBe("https://urbit.example.com"); - expect(resolved.code).toBe("work-code"); - expect(resolved.dmAllowlist).toEqual(["~rovnys"]); - expect(resolved.groupInviteAllowlist).toEqual(["~bus"]); - expect(resolved.defaultAuthorizedShips).toEqual(["~marzod"]); - expect(resolved.configured).toBe(true); - }); - - it("keeps the default account on channel-level config only", () => { - const resolved = resolveTlonAccount( - { - channels: { - tlon: { - ship: "~zod", - url: "https://urbit.example.com", - code: "base-code", - accounts: { - default: { - ship: "~ignored", - code: "ignored-code", - }, - }, - }, - }, - } as OpenClawConfig, - "default", - ); - - expect(resolved.ship).toBe("~zod"); - expect(resolved.code).toBe("base-code"); - }); -});