diff --git a/extensions/imessage/src/setup-allow-from.test.ts b/extensions/imessage/src/setup-allow-from.test.ts new file mode 100644 index 00000000000..24082342e68 --- /dev/null +++ b/extensions/imessage/src/setup-allow-from.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { parseIMessageAllowFromEntries } from "./setup-surface.js"; + +describe("parseIMessageAllowFromEntries", () => { + it("parses handles and chat targets", () => { + expect(parseIMessageAllowFromEntries("+15555550123, chat_id:123, chat_guid:abc")).toEqual({ + entries: ["+15555550123", "chat_id:123", "chat_guid:abc"], + }); + }); + + it("returns validation errors for invalid chat_id", () => { + expect(parseIMessageAllowFromEntries("chat_id:abc")).toEqual({ + entries: [], + error: "Invalid chat_id: chat_id:abc", + }); + }); + + it("returns validation errors for invalid chat_identifier entries", () => { + expect(parseIMessageAllowFromEntries("chat_identifier:")).toEqual({ + entries: [], + error: "Invalid chat_identifier entry", + }); + }); +}); diff --git a/extensions/mattermost/src/setup-status.test.ts b/extensions/mattermost/src/setup-status.test.ts new file mode 100644 index 00000000000..f1b440315e3 --- /dev/null +++ b/extensions/mattermost/src/setup-status.test.ts @@ -0,0 +1,24 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; +import { describe, expect, it } from "vitest"; +import { mattermostSetupWizard } from "./setup-surface.js"; + +describe("mattermost setup status", () => { + it("treats SecretRef botToken as configured when baseUrl is present", async () => { + const configured = await mattermostSetupWizard.status.resolveConfigured({ + cfg: { + channels: { + mattermost: { + baseUrl: "https://chat.example.test", + botToken: { + source: "env", + provider: "default", + id: "MATTERMOST_BOT_TOKEN", + }, + }, + }, + } as OpenClawConfig, + }); + + expect(configured).toBe(true); + }); +}); diff --git a/extensions/signal/src/setup-allow-from.test.ts b/extensions/signal/src/setup-allow-from.test.ts new file mode 100644 index 00000000000..959082a2582 --- /dev/null +++ b/extensions/signal/src/setup-allow-from.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSignalAccountInput, parseSignalAllowFromEntries } from "./setup-surface.js"; + +describe("normalizeSignalAccountInput", () => { + it("normalizes valid E.164 numbers", () => { + expect(normalizeSignalAccountInput(" +1 (555) 555-0123 ")).toBe("+15555550123"); + }); + + it("rejects invalid values", () => { + expect(normalizeSignalAccountInput("abc")).toBeNull(); + }); +}); + +describe("parseSignalAllowFromEntries", () => { + it("parses e164, uuid and wildcard entries", () => { + expect( + parseSignalAllowFromEntries("+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000, *"), + ).toEqual({ + entries: ["+15555550123", "uuid:123e4567-e89b-12d3-a456-426614174000", "*"], + }); + }); + + it("normalizes bare uuid values", () => { + expect(parseSignalAllowFromEntries("123e4567-e89b-12d3-a456-426614174000")).toEqual({ + entries: ["uuid:123e4567-e89b-12d3-a456-426614174000"], + }); + }); + + it("returns validation errors for invalid entries", () => { + expect(parseSignalAllowFromEntries("uuid:")).toEqual({ + entries: [], + error: "Invalid uuid entry", + }); + expect(parseSignalAllowFromEntries("invalid")).toEqual({ + entries: [], + error: "Invalid entry: invalid", + }); + }); +}); diff --git a/extensions/twitch/src/setup-surface.test.ts b/extensions/twitch/src/setup-surface.test.ts new file mode 100644 index 00000000000..611e0fca66d --- /dev/null +++ b/extensions/twitch/src/setup-surface.test.ts @@ -0,0 +1,311 @@ +/** + * Tests for setup-surface.ts helpers. + * + * Tests cover: + * - promptToken helper + * - promptUsername helper + * - promptClientId helper + * - promptChannelName helper + * - promptRefreshTokenSetup helper + * - configureWithEnvToken helper + * - setTwitchAccount config updates + */ + +import type { WizardPrompter } from "openclaw/plugin-sdk/twitch"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TwitchAccountConfig } from "./types.js"; + +// Mock the helpers we're testing +const mockPromptText = vi.fn(); +const mockPromptConfirm = vi.fn(); +const mockPrompter: WizardPrompter = { + text: mockPromptText, + confirm: mockPromptConfirm, +} as unknown as WizardPrompter; + +const mockAccount: TwitchAccountConfig = { + username: "testbot", + accessToken: "oauth:test123", + clientId: "test-client-id", + channel: "#testchannel", +}; + +describe("setup surface helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Don't restoreAllMocks as it breaks module-level mocks + }); + + describe("promptToken", () => { + it("should return existing token when user confirms to keep it", async () => { + const { promptToken } = await import("./setup-surface.js"); + + mockPromptConfirm.mockResolvedValue(true); + + const result = await promptToken(mockPrompter, mockAccount, undefined); + + expect(result).toBe("oauth:test123"); + expect(mockPromptConfirm).toHaveBeenCalledWith({ + message: "Access token already configured. Keep it?", + initialValue: true, + }); + expect(mockPromptText).not.toHaveBeenCalled(); + }); + + it("should prompt for new token when user doesn't keep existing", async () => { + const { promptToken } = await import("./setup-surface.js"); + + mockPromptConfirm.mockResolvedValue(false); + mockPromptText.mockResolvedValue("oauth:newtoken123"); + + const result = await promptToken(mockPrompter, mockAccount, undefined); + + expect(result).toBe("oauth:newtoken123"); + expect(mockPromptText).toHaveBeenCalledWith({ + message: "Twitch OAuth token (oauth:...)", + initialValue: "", + validate: expect.any(Function), + }); + }); + + it("should use env token as initial value when provided", async () => { + const { promptToken } = await import("./setup-surface.js"); + + mockPromptConfirm.mockResolvedValue(false); + mockPromptText.mockResolvedValue("oauth:fromenv"); + + await promptToken(mockPrompter, null, "oauth:fromenv"); + + expect(mockPromptText).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "oauth:fromenv", + }), + ); + }); + + it("should validate token format", async () => { + const { promptToken } = await import("./setup-surface.js"); + + // Set up mocks - user doesn't want to keep existing token + mockPromptConfirm.mockResolvedValueOnce(false); + + // Track how many times promptText is called + let promptTextCallCount = 0; + let capturedValidate: ((value: string) => string | undefined) | undefined; + + mockPromptText.mockImplementationOnce((_args) => { + promptTextCallCount++; + // Capture the validate function from the first argument + if (_args?.validate) { + capturedValidate = _args.validate; + } + return Promise.resolve("oauth:test123"); + }); + + // Call promptToken + const result = await promptToken(mockPrompter, mockAccount, undefined); + + // Verify promptText was called + expect(promptTextCallCount).toBe(1); + expect(result).toBe("oauth:test123"); + + // Test the validate function + expect(capturedValidate).toBeDefined(); + expect(capturedValidate!("")).toBe("Required"); + expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'"); + }); + + it("should return early when no existing token and no env token", async () => { + const { promptToken } = await import("./setup-surface.js"); + + mockPromptText.mockResolvedValue("oauth:newtoken"); + + const result = await promptToken(mockPrompter, null, undefined); + + expect(result).toBe("oauth:newtoken"); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + }); + }); + + describe("promptUsername", () => { + it("should prompt for username with validation", async () => { + const { promptUsername } = await import("./setup-surface.js"); + + mockPromptText.mockResolvedValue("mybot"); + + const result = await promptUsername(mockPrompter, null); + + expect(result).toBe("mybot"); + expect(mockPromptText).toHaveBeenCalledWith({ + message: "Twitch bot username", + initialValue: "", + validate: expect.any(Function), + }); + }); + + it("should use existing username as initial value", async () => { + const { promptUsername } = await import("./setup-surface.js"); + + mockPromptText.mockResolvedValue("testbot"); + + await promptUsername(mockPrompter, mockAccount); + + expect(mockPromptText).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "testbot", + }), + ); + }); + }); + + describe("promptClientId", () => { + it("should prompt for client ID with validation", async () => { + const { promptClientId } = await import("./setup-surface.js"); + + mockPromptText.mockResolvedValue("abc123xyz"); + + const result = await promptClientId(mockPrompter, null); + + expect(result).toBe("abc123xyz"); + expect(mockPromptText).toHaveBeenCalledWith({ + message: "Twitch Client ID", + initialValue: "", + validate: expect.any(Function), + }); + }); + }); + + describe("promptChannelName", () => { + it("should return channel name when provided", async () => { + const { promptChannelName } = await import("./setup-surface.js"); + + mockPromptText.mockResolvedValue("#mychannel"); + + const result = await promptChannelName(mockPrompter, null); + + expect(result).toBe("#mychannel"); + }); + + it("should require a non-empty channel name", async () => { + const { promptChannelName } = await import("./setup-surface.js"); + + mockPromptText.mockResolvedValue(""); + + await promptChannelName(mockPrompter, null); + + const { validate } = mockPromptText.mock.calls[0]?.[0] ?? {}; + expect(validate?.("")).toBe("Required"); + expect(validate?.(" ")).toBe("Required"); + expect(validate?.("#chan")).toBeUndefined(); + }); + }); + + describe("promptRefreshTokenSetup", () => { + it("should return empty object when user declines", async () => { + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); + + mockPromptConfirm.mockResolvedValue(false); + + const result = await promptRefreshTokenSetup(mockPrompter, mockAccount); + + expect(result).toEqual({}); + expect(mockPromptConfirm).toHaveBeenCalledWith({ + message: "Enable automatic token refresh (requires client secret and refresh token)?", + initialValue: false, + }); + }); + + it("should prompt for credentials when user accepts", async () => { + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); + + mockPromptConfirm + .mockResolvedValueOnce(true) // First call: useRefresh + .mockResolvedValueOnce("secret123") // clientSecret + .mockResolvedValueOnce("refresh123"); // refreshToken + + mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123"); + + const result = await promptRefreshTokenSetup(mockPrompter, null); + + expect(result).toEqual({ + clientSecret: "secret123", + refreshToken: "refresh123", + }); + }); + + it("should use existing values as initial prompts", async () => { + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); + + const accountWithRefresh = { + ...mockAccount, + clientSecret: "existing-secret", + refreshToken: "existing-refresh", + }; + + mockPromptConfirm.mockResolvedValue(true); + mockPromptText + .mockResolvedValueOnce("existing-secret") + .mockResolvedValueOnce("existing-refresh"); + + await promptRefreshTokenSetup(mockPrompter, accountWithRefresh); + + expect(mockPromptConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: true, // Both clientSecret and refreshToken exist + }), + ); + }); + }); + + describe("configureWithEnvToken", () => { + it("should return null when user declines env token", async () => { + const { configureWithEnvToken } = await import("./setup-surface.js"); + + // Reset and set up mock - user declines env token + mockPromptConfirm.mockReset().mockResolvedValue(false as never); + + const result = await configureWithEnvToken( + {} as Parameters[0], + mockPrompter, + null, + "oauth:fromenv", + false, + {} as Parameters[5], + ); + + // Since user declined, should return null without prompting for username/clientId + expect(result).toBeNull(); + expect(mockPromptText).not.toHaveBeenCalled(); + }); + + it("should prompt for username and clientId when using env token", async () => { + const { configureWithEnvToken } = await import("./setup-surface.js"); + + // Reset and set up mocks - user accepts env token + mockPromptConfirm.mockReset().mockResolvedValue(true as never); + + // Set up mocks for username and clientId prompts + mockPromptText + .mockReset() + .mockResolvedValueOnce("testbot" as never) + .mockResolvedValueOnce("test-client-id" as never); + + const result = await configureWithEnvToken( + {} as Parameters[0], + mockPrompter, + null, + "oauth:fromenv", + false, + {} as Parameters[5], + ); + + // Should return config with username and clientId + expect(result).not.toBeNull(); + expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot"); + expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id"); + }); + }); +});