Files
openclaw/extensions/twitch/src/setup-surface.test.ts
2026-04-17 13:47:37 -04:00

436 lines
14 KiB
TypeScript

/**
* Tests for setup-surface.ts helpers.
*
* Tests cover:
* - promptToken helper
* - promptUsername helper
* - promptClientId helper
* - promptChannelName helper
* - promptRefreshTokenSetup helper
* - configureWithEnvToken helper
* - setTwitchAccount config updates
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "../api.js";
import {
configureWithEnvToken,
promptChannelName,
promptClientId,
promptRefreshTokenSetup,
promptToken,
promptUsername,
setTwitchAccount,
twitchSetupPlugin,
twitchSetupWizard,
} from "./setup-surface.js";
import type { TwitchAccountConfig } from "./types.js";
// Mock the helpers we're testing
const mockPromptText = vi.fn();
const mockPromptConfirm = vi.fn();
const mockPromptNote = vi.fn();
const mockPrompter: WizardPrompter = {
text: mockPromptText,
confirm: mockPromptConfirm,
note: mockPromptNote,
} as unknown as WizardPrompter;
const originalEnvToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
const mockAccount: TwitchAccountConfig = {
username: "testbot",
accessToken: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
describe("setup surface helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
if (originalEnvToken === undefined) {
delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
} else {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalEnvToken;
}
// Don't restoreAllMocks as it breaks module-level mocks
});
describe("promptToken", () => {
it("should return existing token when user confirms to keep it", async () => {
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 validate token format", async () => {
// 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
if (!capturedValidate) {
throw new Error("promptToken validate callback was not captured");
}
expect(capturedValidate("")).toBe("Required");
expect(capturedValidate("notoauth")).toBe("Token should start with 'oauth:'");
expect(capturedValidate("oauth:goodtoken")).toBeUndefined();
});
});
describe("promptUsername", () => {
it("should prompt for username with validation", async () => {
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),
});
});
});
describe("promptClientId", () => {
it("should prompt for client ID with validation", async () => {
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 require a non-empty channel name", async () => {
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 () => {
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 () => {
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",
});
});
});
describe("configureWithEnvToken", () => {
it("should prompt for username and clientId when using env token", async () => {
// 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<typeof configureWithEnvToken>[0],
mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[5],
);
// Should return config with username and clientId
expect(result).not.toBeNull();
const defaultAccount = result?.cfg.channels?.twitch?.accounts?.default as
| { username?: string; clientId?: string }
| undefined;
expect(defaultAccount?.username).toBe("testbot");
expect(defaultAccount?.clientId).toBe("test-client-id");
});
it("writes env-token setup to the configured default account", async () => {
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
mockPromptText
.mockReset()
.mockResolvedValueOnce("secondary-bot" as never)
.mockResolvedValueOnce("secondary-client" as never);
const result = await configureWithEnvToken(
{
channels: {
twitch: {
defaultAccount: "secondary",
},
},
} as Parameters<typeof configureWithEnvToken>[0],
mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[5],
);
const secondaryAccount = result?.cfg.channels?.twitch?.accounts?.secondary as
| { username?: string; clientId?: string }
| undefined;
expect(secondaryAccount?.username).toBe("secondary-bot");
expect(secondaryAccount?.clientId).toBe("secondary-client");
expect(result?.cfg.channels?.twitch?.accounts?.default).toBeUndefined();
});
});
describe("defaultAccount setup resolution", () => {
it("reports status for the configured default account", async () => {
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
},
} as never);
expect(lines).toEqual(["Twitch (secondary): configured"]);
});
it("reports status for the requested account override", async () => {
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {
channels: {
twitch: {
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
},
accountId: "secondary",
configured: true,
} as never);
expect(lines).toEqual(["Twitch (secondary): configured"]);
});
it("reports env-token default account setup as configured", async () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
const cfg = {
channels: {
twitch: {
accounts: {
default: {
username: "env-bot",
accessToken: "",
clientId: "env-client",
channel: "#env",
},
},
},
},
} as Parameters<NonNullable<typeof twitchSetupWizard.status>["resolveConfigured"]>[0]["cfg"];
expect(twitchSetupWizard.status?.resolveConfigured({ cfg })).toBe(true);
const account = twitchSetupPlugin.config.resolveAccount(cfg, "default");
expect(await twitchSetupPlugin.config.isConfigured?.(account, cfg)).toBe(true);
});
});
describe("setup wizard account routing", () => {
it("normalizes account ids before using them as config keys", () => {
const cfg = setTwitchAccount(
{} as Parameters<typeof setTwitchAccount>[0],
{
username: "normalized-bot",
accessToken: "oauth:normalized",
clientId: "normalized-client",
channel: "#normalized",
},
"__proto__",
);
expect(cfg.channels?.twitch?.accounts?.default?.username).toBe("normalized-bot");
expect(Object.prototype).not.toHaveProperty("username");
expect(
twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {},
accountId: "Alerts\r\n\u001b[31m",
configured: false,
} as never),
).toEqual(["Twitch (alerts-31m): needs username, token, and clientId"]);
});
it("reports account-scoped DM policy config keys", () => {
expect(
twitchSetupWizard.dmPolicy?.resolveConfigKeys?.(
{
channels: {
twitch: {
defaultAccount: "secondary",
},
},
} as Parameters<
NonNullable<NonNullable<typeof twitchSetupWizard.dmPolicy>["resolveConfigKeys"]>
>[0],
undefined,
),
).toEqual({
policyKey: "channels.twitch.accounts.secondary.allowedRoles",
allowFromKey: "channels.twitch.accounts.secondary.allowFrom",
});
expect(twitchSetupWizard.dmPolicy?.resolveConfigKeys?.({} as never, "alerts")).toEqual({
policyKey: "channels.twitch.accounts.alerts.allowedRoles",
allowFromKey: "channels.twitch.accounts.alerts.allowFrom",
});
});
it("writes to the requested account when defaultAccount is not created yet", async () => {
mockPromptText
.mockReset()
.mockResolvedValueOnce("secondary-bot" as never)
.mockResolvedValueOnce("oauth:secondary" as never)
.mockResolvedValueOnce("secondary-client" as never)
.mockResolvedValueOnce("#secondary" as never);
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
const result = await twitchSetupWizard.finalize?.({
cfg: {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
},
},
},
} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["cfg"],
accountId: "secondary",
credentialValues: {},
runtime: {} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["runtime"],
prompter: mockPrompter,
options: {},
forceAllowFrom: false,
});
const twitch = result?.cfg?.channels?.twitch;
expect(twitch?.accounts?.secondary?.username).toBe("secondary-bot");
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:secondary");
expect(twitch?.accounts?.default?.username).toBe("default-bot");
});
});
describe("setup-only plugin config", () => {
it("lists all configured Twitch accounts", () => {
const cfg = {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["default", "secondary"]);
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
});
});
});