Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

555 lines
17 KiB
TypeScript

/**
* Exercises the Twitch plugin surface and its chat-formatting helpers (channel
* normalization, message splitting/length caps, markdown stripping) against a
* mocked runtime — no live Twitch/IRC connection.
*/
import type {
Content,
IAgentRuntime,
Memory,
MessageConnectorRegistration,
State,
} from "@elizaos/core";
import { describe, expect, test, vi } from "vitest";
import twitchPlugin, {
formatChannelForDisplay,
getTwitchUserDisplayName,
MAX_TWITCH_MESSAGE_LENGTH,
normalizeChannel,
splitMessageForTwitch,
stripMarkdownForTwitch,
TwitchApiError,
TwitchConfigurationError,
type TwitchMessage,
type TwitchMessageSendOptions,
TwitchNotConnectedError,
TwitchPluginError,
type TwitchSendResult,
TwitchService,
TwitchServiceNotInitializedError,
type TwitchSettings,
type TwitchUserInfo,
} from "../src/index.ts";
import { TwitchWorkflowCredentialProvider } from "../src/workflow-credential-provider.ts";
// ---------------------------------------------------------------------------
// Helpers: mock runtime, memory, state
// ---------------------------------------------------------------------------
type RegisterMessageConnectorMock = ReturnType<
typeof vi.fn<(registration: MessageConnectorRegistration) => void>
>;
type MockRuntime = IAgentRuntime & {
registerMessageConnector: RegisterMessageConnectorMock;
registerSendHandler: ReturnType<typeof vi.fn>;
};
type MockRuntimeOverrides = Partial<IAgentRuntime> &
Record<string, unknown> & {
modelResponse?: string;
service?: unknown;
registerMessageConnector?: RegisterMessageConnectorMock;
registerSendHandler?: ReturnType<typeof vi.fn>;
};
type TwitchServiceHarnessFields = {
settings?: TwitchSettings;
joinedChannels?: Set<string>;
accountServices?: Map<string, TwitchService>;
connected?: boolean;
client?: unknown;
};
function makeMockRuntime(overrides: MockRuntimeOverrides = {}): MockRuntime {
return {
agentId: "agent-1",
getSetting: (key: string) =>
(overrides as Record<string, string>)[key] ?? null,
getService: (_name: string) => overrides.service ?? null,
composeState: async (_msg: unknown) => ({ recentMessages: "" }),
useModel: async (_type: string, _opts: unknown) =>
overrides.modelResponse ?? "{}",
emitEvent: async () => {},
registerMessageConnector:
vi.fn<(registration: MessageConnectorRegistration) => void>(),
registerSendHandler: vi.fn(),
...overrides,
} as MockRuntime;
}
function _makeMemory(
source: string = "twitch",
text: string = "hello",
): Partial<Memory> {
return {
content: { text, source },
userId: "user-1",
roomId: "room-1",
};
}
function _makeState(extra: Record<string, unknown> = {}): Partial<State> {
return {
agentName: "TestBot",
recentMessages: "",
data: {},
...extra,
};
}
function makeTwitchSettings(
overrides: Partial<TwitchSettings>,
): TwitchSettings {
return {
username: "testbot",
clientId: "client-id",
accessToken: "access-token",
channel: "mainchannel",
additionalChannels: [],
requireMention: false,
allowedRoles: ["all"],
allowedUserIds: [],
enabled: true,
...overrides,
};
}
function makeTwitchServiceHarness(
fields: TwitchServiceHarnessFields = {},
): TwitchService & TwitchServiceHarnessFields {
return Object.assign(
Object.create(TwitchService.prototype),
fields,
) as TwitchService & TwitchServiceHarnessFields;
}
function _makeUser(overrides: Partial<TwitchUserInfo> = {}): TwitchUserInfo {
return {
userId: "1",
username: "alice",
displayName: "Alice",
isModerator: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
badges: new Map(),
...overrides,
};
}
function _makeMockTwitchService(overrides: Record<string, unknown> = {}) {
return {
isConnected: () => overrides.connected ?? true,
getBotUsername: () => overrides.botUsername ?? "testbot",
getPrimaryChannel: () => overrides.primaryChannel ?? "mainchannel",
getJoinedChannels: () =>
(overrides.joinedChannels as string[]) ?? ["mainchannel"],
sendMessage: async (_text: string, _opts?: TwitchMessageSendOptions) =>
(overrides.sendResult as TwitchSendResult) ?? {
success: true,
messageId: "msg-123",
},
joinChannel: overrides.joinChannel ?? (async () => {}),
leaveChannel: overrides.leaveChannel ?? (async () => {}),
};
}
// ===========================================================================
// 1. Plugin Metadata
// ===========================================================================
describe("Plugin metadata", () => {
test("does not register platform-specific chat actions", () => {
expect(twitchPlugin.actions).toHaveLength(0);
});
test("does not register platform-specific context providers", () => {
expect(twitchPlugin.providers).toHaveLength(0);
});
test("registers Twitch and workflow credential services", () => {
expect(twitchPlugin.services).toHaveLength(2);
expect(twitchPlugin.services).toContain(TwitchService);
expect(twitchPlugin.services).toContain(TwitchWorkflowCredentialProvider);
});
});
// ===========================================================================
// 4. Utility Functions
// ===========================================================================
describe("normalizeChannel", () => {
test.each([
["#mychannel", "mychannel"],
["mychannel", "mychannel"],
["", ""],
["##double", "#double"],
])("normalizeChannel(%s) → %s", (input, expected) => {
expect(normalizeChannel(input)).toBe(expected);
});
});
describe("formatChannelForDisplay", () => {
test.each([
["mychannel", "#mychannel"],
["#mychannel", "#mychannel"],
])("formatChannelForDisplay(%s) → %s", (input, expected) => {
expect(formatChannelForDisplay(input)).toBe(expected);
});
});
describe("getTwitchUserDisplayName", () => {
test("returns displayName when set", () => {
const user = {
userId: "1",
username: "alice",
displayName: "Alice_Cool",
isModerator: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
badges: new Map(),
} as TwitchUserInfo;
expect(getTwitchUserDisplayName(user)).toBe("Alice_Cool");
});
test("falls back to username when displayName is empty", () => {
const user = {
userId: "1",
username: "bob",
displayName: "",
isModerator: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
badges: new Map(),
} as TwitchUserInfo;
expect(getTwitchUserDisplayName(user)).toBe("bob");
});
});
describe("stripMarkdownForTwitch", () => {
test.each([
["**bold text**", "bold text"],
["__bold text__", "bold text"],
["*italic text*", "italic text"],
["_italic text_", "italic text"],
["~~strikethrough~~", "strikethrough"],
["`some code`", "some code"],
["[click here](https://example.com)", "click here"],
["## My Header", "My Header"],
["> quoted text", "quoted text"],
["- item one", "• item one"],
["1. item one", "• item one"],
["a\n\n\n\nb", "a\n\nb"],
["plain text", "plain text"],
[" hello ", "hello"],
])("stripMarkdownForTwitch(%j) → %j", (input, expected) => {
expect(stripMarkdownForTwitch(input)).toBe(expected);
});
test("processes code blocks", () => {
const result = stripMarkdownForTwitch("```js\nconsole.log('hi');\n```");
expect(result.length).toBeGreaterThan(0);
const result2 = stripMarkdownForTwitch("before ```code``` after");
expect(result2).toContain("code");
});
});
describe("splitMessageForTwitch", () => {
test("returns single chunk for short messages", () => {
const result = splitMessageForTwitch("Hello world");
expect(result).toEqual(["Hello world"]);
});
test("splits long messages into multiple chunks", () => {
const longMessage = "A".repeat(600);
const result = splitMessageForTwitch(longMessage);
expect(result.length).toBeGreaterThan(1);
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(MAX_TWITCH_MESSAGE_LENGTH);
}
});
test("prefers splitting at sentence boundaries", () => {
// The ". " must appear past halfway of maxLength to be selected.
// lastIndexOf(". ", maxLength) returns the index of the ".", so the
// split point is at that index — everything before goes to chunk 0.
const prefix = "A".repeat(300);
const text = `${prefix}. ${"B".repeat(250)}`; // total 552
const result = splitMessageForTwitch(text, 500);
expect(result.length).toBe(2);
expect(result[0]).toBe(prefix);
expect(result[1]).toContain("B");
expect(result[1].length).toBeLessThan(text.length);
});
test("falls back to word boundary when no sentence break", () => {
const words = Array(60).fill("word").join(" "); // 60*5-1 = 299 chars
const result = splitMessageForTwitch(words, 50);
expect(result.length).toBeGreaterThan(1);
// Every chunk should be within the max limit
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(50);
}
// Reassembled text should contain all original words
const reassembled = result.join(" ");
expect(reassembled.replace(/\s+/g, " ")).toContain("word");
});
test("respects custom maxLength", () => {
const text = "A".repeat(30);
const result = splitMessageForTwitch(text, 10);
expect(result.length).toBe(3);
});
test("returns empty for single-word exact match", () => {
const text = "A".repeat(500);
const result = splitMessageForTwitch(text, 500);
expect(result).toEqual([text]);
});
});
// ===========================================================================
// 5. Error Classes
// ===========================================================================
describe("Custom Errors", () => {
test("TwitchPluginError is an Error with correct name", () => {
const err = new TwitchPluginError("oops");
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("TwitchPluginError");
expect(err.message).toBe("oops");
});
test("TwitchServiceNotInitializedError has default message", () => {
const err = new TwitchServiceNotInitializedError();
expect(err.message).toBe("Twitch service is not initialized");
expect(err.name).toBe("TwitchServiceNotInitializedError");
expect(err).toBeInstanceOf(TwitchPluginError);
});
test("TwitchNotConnectedError has default message", () => {
const err = new TwitchNotConnectedError();
expect(err.message).toBe("Twitch client is not connected");
expect(err.name).toBe("TwitchNotConnectedError");
expect(err).toBeInstanceOf(TwitchPluginError);
});
test("TwitchConfigurationError stores settingName", () => {
const err = new TwitchConfigurationError("bad config", "MY_SETTING");
expect(err.message).toBe("bad config");
expect(err.settingName).toBe("MY_SETTING");
expect(err.name).toBe("TwitchConfigurationError");
expect(err).toBeInstanceOf(TwitchPluginError);
});
test("TwitchApiError stores statusCode", () => {
const err = new TwitchApiError("api fail", 401);
expect(err.message).toBe("api fail");
expect(err.statusCode).toBe(401);
expect(err.name).toBe("TwitchApiError");
expect(err).toBeInstanceOf(TwitchPluginError);
});
});
describe("Twitch message connector accounts", () => {
test("registers one connector for each started account", () => {
const runtime = makeMockRuntime({
registerMessageConnector:
vi.fn<(registration: MessageConnectorRegistration) => void>(),
registerSendHandler: vi.fn(),
logger: { info: vi.fn() },
});
const primary = makeTwitchServiceHarness({
settings: makeTwitchSettings({
accountId: "primary",
username: "bot_one",
channel: "one",
}),
joinedChannels: new Set(["one"]),
});
const secondary = makeTwitchServiceHarness({
settings: makeTwitchSettings({
accountId: "secondary",
username: "bot_two",
channel: "two",
}),
joinedChannels: new Set(["two"]),
});
const service = makeTwitchServiceHarness({
accountServices: new Map([
["primary", primary],
["secondary", secondary],
]),
});
TwitchService.registerSendHandlers(runtime, service);
expect(runtime.registerMessageConnector).toHaveBeenCalledTimes(2);
expect(
runtime.registerMessageConnector.mock.calls.map(
([registration]) => registration.accountId,
),
).toEqual(["primary", "secondary"]);
});
test("registers accountId and routes sends through that account", async () => {
const runtime = makeMockRuntime({
registerMessageConnector:
vi.fn<(registration: MessageConnectorRegistration) => void>(),
registerSendHandler: vi.fn(),
getRoom: vi.fn(),
logger: { info: vi.fn() },
TWITCH_DEFAULT_ACCOUNT_ID: "streamer",
});
const service = makeTwitchServiceHarness({
settings: makeTwitchSettings({
accountId: "streamer",
username: "testbot",
channel: "mainchannel",
}),
});
const sendMessage = vi
.spyOn(service, "sendMessage")
.mockResolvedValue({ success: true, messageId: "msg-1" });
TwitchService.registerSendHandlers(runtime, service);
expect(runtime.registerMessageConnector).toHaveBeenCalledWith(
expect.objectContaining({
source: "twitch",
accountId: "streamer",
joinHandler: expect.any(Function),
leaveHandler: expect.any(Function),
}),
);
const registration = runtime.registerMessageConnector.mock.calls[0][0];
await registration.sendHandler?.(
runtime,
{
source: "twitch",
accountId: "streamer",
channelId: "mainchannel",
},
{ text: "hello" } satisfies Content,
);
expect(sendMessage).toHaveBeenCalledWith(
"hello",
expect.objectContaining({ channel: "mainchannel" }),
);
});
});
// ===========================================================================
// 7. sendMessage path
// ===========================================================================
// The Twitch MessageConnector (registered by TwitchService.registerSendHandlers)
// is the canonical send path, via MESSAGE operation=send.
// ===========================================================================
// 12. Type Construction
// ===========================================================================
describe("Type construction and shapes", () => {
test("TwitchUserInfo can be constructed with all fields", () => {
const user: TwitchUserInfo = {
userId: "123",
username: "testuser",
displayName: "TestUser",
isModerator: true,
isBroadcaster: false,
isVip: true,
isSubscriber: false,
color: "#FF0000",
badges: new Map([["moderator", "1"]]),
};
expect(user.userId).toBe("123");
expect(user.color).toBe("#FF0000");
expect(user.badges.get("moderator")).toBe("1");
});
test("TwitchMessage can be constructed with reply info", () => {
const msg: TwitchMessage = {
id: "msg-1",
channel: "test",
text: "hello",
user: {
userId: "1",
username: "user1",
displayName: "User1",
isModerator: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
badges: new Map(),
},
timestamp: new Date(),
isAction: false,
isHighlighted: true,
replyTo: {
messageId: "parent-1",
userId: "2",
username: "user2",
text: "original",
},
};
expect(msg.replyTo?.messageId).toBe("parent-1");
expect(msg.isHighlighted).toBe(true);
});
test("TwitchSendResult success shape", () => {
const res: TwitchSendResult = {
success: true,
messageId: "abc-123",
};
expect(res.success).toBe(true);
expect(res.messageId).toBe("abc-123");
expect(res.error).toBeUndefined();
});
test("TwitchSendResult failure shape", () => {
const res: TwitchSendResult = {
success: false,
error: "not connected",
};
expect(res.success).toBe(false);
expect(res.error).toBe("not connected");
expect(res.messageId).toBeUndefined();
});
test("TwitchMessageSendOptions is optional fields", () => {
const opts: TwitchMessageSendOptions = {};
expect(opts.channel).toBeUndefined();
expect(opts.replyTo).toBeUndefined();
const opts2: TwitchMessageSendOptions = {
channel: "test",
replyTo: "msg-1",
};
expect(opts2.channel).toBe("test");
expect(opts2.replyTo).toBe("msg-1");
});
});
// ===========================================================================
// 13. TwitchService Static Properties
// ===========================================================================
describe("TwitchService class", () => {
test("has correct serviceType", () => {
expect(TwitchService.serviceType).toBe("twitch");
});
test("has static start method", () => {
expect(typeof TwitchService.start).toBe("function");
});
test("has static stopRuntime method", () => {
expect(typeof TwitchService.stopRuntime).toBe("function");
});
});