fix(plugins): stabilize Twitch and Signal setup

This commit is contained in:
Peter Steinberger
2026-05-24 01:18:12 +01:00
parent 25ccadd22a
commit c617009cbf
3 changed files with 58 additions and 16 deletions

View File

@@ -159,6 +159,16 @@ describe("detectSignalApiMode", () => {
expect(result).toBe("native");
});
it("prefers native even when the container probe resolves first", async () => {
mockNativeCheck.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ ok: true, status: 200 }), 1)),
);
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
const result = await detectSignalApiMode("http://localhost:8080");
expect(result).toBe("native");
});
it("throws error when neither endpoint responds", async () => {
mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
@@ -530,6 +540,26 @@ describe("streamSignalEvents", () => {
"+14259798283",
);
});
it("does not reuse a cached container mode for no-account receive streams", async () => {
setApiMode("auto");
mockNativeCheck.mockResolvedValue({ ok: false, status: 404 });
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
await expect(signalCheck("http://auto-cache-no-account.local:8080")).resolves.toEqual({
ok: true,
status: 200,
});
await expect(
streamSignalEvents({
baseUrl: "http://auto-cache-no-account.local:8080",
onEvent: vi.fn(),
}),
).rejects.toThrow("Signal API not reachable at http://auto-cache-no-account.local:8080");
expect(mockStreamContainerEvents).not.toHaveBeenCalled();
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
});
});
describe("fetchAttachment", () => {

View File

@@ -65,7 +65,7 @@ async function resolveAutoApiMode(
if (
cached.mode !== "container" ||
!options.requireContainerReceive ||
cached.receiveAccount === options.account
(Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim())
) {
return cached.mode;
}
@@ -103,32 +103,29 @@ async function resolveApiModeForOperation(params: {
/**
* Detect which Signal API mode is available by probing endpoints.
* First endpoint to respond OK wins.
* Native wins when both APIs are healthy because it preserves the richer JSON-RPC contract.
*/
export async function detectSignalApiMode(
baseUrl: string,
timeoutMs = DEFAULT_TIMEOUT_MS,
options: { account?: string; requireContainerReceive?: boolean } = {},
): Promise<"native" | "container"> {
const nativePromise = nativeCheck(baseUrl, timeoutMs).then((r) =>
r.ok ? ("native" as const) : Promise.reject(new Error("native not ok")),
);
const containerAccount = options.requireContainerReceive ? options.account?.trim() : undefined;
const nativePromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false }));
const containerPromise = containerAccount
? containerCheck(baseUrl, timeoutMs, containerAccount).then((r) =>
r.ok ? ("container" as const) : Promise.reject(new Error("container not ok")),
)
? containerCheck(baseUrl, timeoutMs, containerAccount).catch(() => ({ ok: false }))
: options.requireContainerReceive
? Promise.reject(new Error("container receive account required"))
: containerCheck(baseUrl, timeoutMs).then((r) =>
r.ok ? ("container" as const) : Promise.reject(new Error("container not ok")),
);
? Promise.resolve({ ok: false })
: containerCheck(baseUrl, timeoutMs).catch(() => ({ ok: false }));
try {
return await Promise.any([nativePromise, containerPromise]);
} catch {
throw new Error(`Signal API not reachable at ${baseUrl}`);
const [nativeResult, containerResult] = await Promise.all([nativePromise, containerPromise]);
if (nativeResult.ok) {
return "native";
}
if (containerResult.ok) {
return "container";
}
throw new Error(`Signal API not reachable at ${baseUrl}`);
}
/**

View File

@@ -186,6 +186,21 @@ describe("TwitchClientManager", () => {
expect(mockConnect).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent client creation for the same account", async () => {
mockConnect.mockImplementationOnce(() => {});
const first = manager.getClient(testAccount);
const second = manager.getClient(testAccount);
await Promise.resolve();
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(authSuccessHandlers).toHaveLength(1);
authSuccessHandlers[0]?.();
const [client1, client2] = await Promise.all([first, second]);
expect(client1).toBe(client2);
});
it("should create separate clients for different accounts", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);