fix(plugins): avoid Signal and Twitch setup regressions

This commit is contained in:
Peter Steinberger
2026-05-24 01:28:53 +01:00
parent c617009cbf
commit 5b2703e24d
3 changed files with 79 additions and 9 deletions

View File

@@ -169,6 +169,21 @@ describe("detectSignalApiMode", () => {
expect(result).toBe("native");
});
it("returns container after the native preference grace when native does not respond", async () => {
vi.useFakeTimers();
try {
mockNativeCheck.mockImplementation(() => new Promise(() => {}));
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
const result = detectSignalApiMode("http://localhost:8080");
await Promise.resolve();
await vi.advanceTimersByTimeAsync(50);
await expect(result).resolves.toBe("container");
} finally {
vi.useRealTimers();
}
});
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" });

View File

@@ -21,6 +21,7 @@ import {
const DEFAULT_TIMEOUT_MS = 10_000;
const MODE_CACHE_TTL_MS = 30_000;
const NATIVE_PREFERENCE_GRACE_MS = 50;
export type SignalSseEvent = {
event?: string;
@@ -55,6 +56,19 @@ function resolveAutoProbeTimeoutMs(timeoutMs: number | undefined): number {
: DEFAULT_TIMEOUT_MS;
}
function waitForNativePreferenceGrace(
nativeResultPromise: Promise<{ ok: boolean }>,
): Promise<{ ok: boolean }> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve({ ok: false }), NATIVE_PREFERENCE_GRACE_MS);
timer.unref?.();
nativeResultPromise.then((result) => {
clearTimeout(timer);
resolve(result);
});
});
}
async function resolveAutoApiMode(
baseUrl: string,
timeoutMs = DEFAULT_TIMEOUT_MS,
@@ -111,21 +125,36 @@ export async function detectSignalApiMode(
options: { account?: string; requireContainerReceive?: boolean } = {},
): Promise<"native" | "container"> {
const containerAccount = options.requireContainerReceive ? options.account?.trim() : undefined;
const nativePromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false }));
const containerPromise = containerAccount
const nativeResultPromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false }));
const containerResultPromise = containerAccount
? containerCheck(baseUrl, timeoutMs, containerAccount).catch(() => ({ ok: false }))
: options.requireContainerReceive
? Promise.resolve({ ok: false })
: containerCheck(baseUrl, timeoutMs).catch(() => ({ ok: false }));
const [nativeResult, containerResult] = await Promise.all([nativePromise, containerPromise]);
if (nativeResult.ok) {
return "native";
const nativeHealthyPromise = nativeResultPromise.then((result) => {
if (result.ok) {
return "native" as const;
}
throw new Error("native not ok");
});
const containerHealthyPromise = containerResultPromise.then((result) => {
if (result.ok) {
return "container" as const;
}
throw new Error("container not ok");
});
try {
const firstHealthy = await Promise.any([nativeHealthyPromise, containerHealthyPromise]);
if (firstHealthy === "native") {
return "native";
}
const nativeResult = await waitForNativePreferenceGrace(nativeResultPromise);
return nativeResult.ok ? "native" : "container";
} catch {
throw new Error(`Signal API not reachable at ${baseUrl}`);
}
if (containerResult.ok) {
return "container";
}
throw new Error(`Signal API not reachable at ${baseUrl}`);
}
/**

View File

@@ -201,6 +201,32 @@ describe("TwitchClientManager", () => {
expect(client1).toBe(client2);
});
it("waits for disconnect after authentication failure retry events", async () => {
mockConnect.mockImplementationOnce(() => {});
const connection = manager.getClient(testAccount);
await Promise.resolve();
authFailureHandlers[0]?.("bad token", 1);
let settled = false;
void connection.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await Promise.resolve();
expect(settled).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
"Twitch authentication failed for testbot; waiting for retry, disconnect, or timeout: bad token",
);
disconnectHandlers[0]?.(false, new Error("disconnected"));
await expect(connection).rejects.toThrow("disconnected");
});
it("should create separate clients for different accounts", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);