


























@@ -0,0 +1,120 @@
1+/**
2+ * Live Twitch IRC verification for the runStoppablePassiveMonitor lifecycle
3+ * pattern used by the Twitch gateway.
4+ *
5+ * This test connects to irc.chat.twitch.tv using the same twurple stack the
6+ * Twitch plugin uses, then drives that connection through the helper this PR
7+ * wires into twitchPlugin.gateway.startAccount. It asserts the post-fix
8+ * invariant — startAccount-shaped task stays pending after a successful
9+ * connection and only resolves when the abort signal fires — using real
10+ * network rather than mocks.
11+ *
12+ * Skipped by default. Enable with:
13+ * TWITCH_LIVE_TEST=1
14+ * TWITCH_USERNAME=<bot username>
15+ * TWITCH_ACCESS_TOKEN=<oauth:token without the "oauth:" prefix>
16+ * TWITCH_CLIENT_ID=<client id>
17+ * TWITCH_CHANNEL=<channel name to join>
18+ */
19+20+import { StaticAuthProvider } from "@twurple/auth";
21+import { ChatClient } from "@twurple/chat";
22+import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
23+import { describe, expect, it } from "vitest";
24+25+const LIVE = process.env.TWITCH_LIVE_TEST === "1";
26+const HAS_CREDS = Boolean(
27+process.env.TWITCH_USERNAME &&
28+process.env.TWITCH_ACCESS_TOKEN &&
29+process.env.TWITCH_CLIENT_ID &&
30+process.env.TWITCH_CHANNEL,
31+);
32+33+const maybeDescribe = LIVE && HAS_CREDS ? describe : describe.skip;
34+35+maybeDescribe("twitch live IRC lifecycle (skipped unless TWITCH_LIVE_TEST=1)", () => {
36+it("real twurple connection + runStoppablePassiveMonitor stays pending until abort, then stops cleanly", async () => {
37+const accessTokenRaw = process.env.TWITCH_ACCESS_TOKEN!.replace(/^oauth:/, "");
38+const clientId = process.env.TWITCH_CLIENT_ID!;
39+const channel = process.env.TWITCH_CHANNEL!;
40+const username = process.env.TWITCH_USERNAME!;
41+42+const start = Date.now();
43+const log = (msg: string) => {
44+console.log(`[T+${Date.now() - start}ms] ${msg}`);
45+};
46+47+log(`username=${username} channel=#${channel}`);
48+49+const authProvider = new StaticAuthProvider(clientId, accessTokenRaw, [
50+"chat:read",
51+"chat:edit",
52+]);
53+54+const abort = new AbortController();
55+let connectedAt: number | null = null;
56+let settled = false;
57+let stopCalled = false;
58+59+const task = runStoppablePassiveMonitor({
60+abortSignal: abort.signal,
61+start: async () => {
62+const chat = new ChatClient({
63+ authProvider,
64+channels: [channel],
65+authIntents: ["chat"],
66+});
67+68+chat.onConnect(() => {
69+connectedAt = Date.now() - start;
70+log(`Connected to Twitch as ${username}`);
71+});
72+chat.onJoin((joinedChannel: string, joinedUser: string) => {
73+log(`Joined #${joinedChannel} as ${joinedUser}`);
74+});
75+chat.onDisconnect((manually: boolean, reason?: Error) => {
76+log(`Disconnected (manual=${manually}, reason=${reason?.message ?? "n/a"})`);
77+});
78+79+chat.connect();
80+81+return {
82+stop: () => {
83+stopCalled = true;
84+log(`stop() invoked`);
85+chat.quit();
86+},
87+};
88+},
89+})
90+.then(() => {
91+settled = true;
92+log(`task RESOLVED`);
93+})
94+.catch((err: unknown) => {
95+settled = true;
96+log(`task REJECTED: ${err instanceof Error ? err.message : String(err)}`);
97+throw err;
98+});
99+100+// Wait long enough that the original bug would have manifested.
101+// The reported time-to-restart in #60071 is ~2ms after connect.
102+const WATCH_MS = 15_000;
103+await new Promise((resolve) => setTimeout(resolve, WATCH_MS));
104+105+expect(connectedAt, "expected onConnect within the watch window").not.toBeNull();
106+expect(settled, "task must not have settled before abort").toBe(false);
107+log(
108+`--- t+${WATCH_MS}ms checkpoint: connected=${connectedAt}ms, settled=${settled}, stopCalled=${stopCalled}`,
109+);
110+111+abort.abort();
112+log(`abort() called`);
113+114+await task;
115+116+expect(settled).toBe(true);
117+expect(stopCalled, "stop hook must run on abort").toBe(true);
118+log(`PASS — promise pending for ${WATCH_MS}ms after connect, then stopped on abort`);
119+}, 60_000);
120+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。