mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(frontend): bound Live Browser WebSocket handshakes (#7301)
* fix(frontend): bound Live Browser WebSocket handshakes * test: register browser-view scoped guidance * fix(frontend): explain Browser Live connection failures --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
1278c5192b
commit
0d3a562c5f
@ -2960,6 +2960,11 @@ The workspace Browser Live client negotiates binary JPEG WebSocket frames,
|
||||
keeps only the newest pending frame per display refresh, and revokes replaced
|
||||
object URLs. Gateway control messages remain JSON, and clients that do not
|
||||
request the binary capability retain the legacy JSON/base64 frame protocol.
|
||||
Opening a Live connection has a 10-second handshake deadline. A stalled
|
||||
handshake enters the existing capped backoff, with up to six reconnects;
|
||||
successful connections reset that retry budget. The panel distinguishes connection
|
||||
timeouts from other connection failures and shows recovery guidance, including
|
||||
when retries are exhausted. Turn Live off and on to start a fresh retry budget.
|
||||
|
||||
### Context Engineering
|
||||
|
||||
|
||||
@ -53,6 +53,7 @@ EXPECTED_GUIDANCE_PATHS = {
|
||||
"backend/packages/harness/deerflow/uploads/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/utils/AGENTS.md",
|
||||
"frontend/src/AGENTS.md",
|
||||
"frontend/src/components/workspace/browser-view/AGENTS.md",
|
||||
"frontend/src/core/memory/AGENTS.md",
|
||||
"frontend/tests/e2e/AGENTS.md",
|
||||
"scripts/AGENTS.md",
|
||||
|
||||
18
frontend/src/components/workspace/browser-view/AGENTS.md
Normal file
18
frontend/src/components/workspace/browser-view/AGENTS.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Browser Live
|
||||
|
||||
`useBrowserStream` owns a 10-second opening-handshake deadline per socket
|
||||
attempt. Timeout retires that attempt and schedules the existing bounded
|
||||
backoff without depending on a later socket event. Open/error/close and effect
|
||||
cleanup clear the deadline; retired callbacks cannot reset the retry budget,
|
||||
update frames, or accept input. Keep the deadline limited to CONNECTING; an
|
||||
established quiet stream has no client idle timeout.
|
||||
|
||||
Expose `closedReason` as `handshake_timeout` or `connection_failed`; WebSocket
|
||||
error/close events cannot reliably identify auth, origin, or network failures.
|
||||
Keep the latest failure reason visible during retries; fence retired callbacks;
|
||||
clear it on open, disable, or thread change. The panel localizes the reason and
|
||||
recovery hint even over a retained frame; exhausted retries must not look pending.
|
||||
|
||||
Lifecycle regressions live in
|
||||
`frontend/tests/unit/components/workspace/browser-view/use-browser-stream.dom.test.tsx`;
|
||||
panel feedback is covered by the adjacent `browser-view-panel.dom.test.tsx`.
|
||||
@ -16,6 +16,7 @@ import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -31,6 +32,7 @@ export function BrowserViewPanel({
|
||||
threadId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const browserView = useMaybeBrowserView();
|
||||
const frame = browserView?.latestFrame ?? null;
|
||||
const imageUrl = frame
|
||||
@ -57,12 +59,8 @@ export function BrowserViewPanel({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const { status, frameUrl, liveUrl, sendInput } = useBrowserStream(
|
||||
threadId,
|
||||
live,
|
||||
streamSeedUrl,
|
||||
handleNavRejected,
|
||||
);
|
||||
const { status, closedReason, frameUrl, liveUrl, sendInput } =
|
||||
useBrowserStream(threadId, live, streamSeedUrl, handleNavRejected);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const surfaceRef = useRef<HTMLImageElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -240,6 +238,12 @@ export function BrowserViewPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const liveFailureMessage =
|
||||
live && closedReason
|
||||
? closedReason === "handshake_timeout"
|
||||
? t.browserStream.handshakeTimeout
|
||||
: t.browserStream.connectionFailed
|
||||
: null;
|
||||
const liveActive = live && status === "open";
|
||||
const liveConnecting = live && status === "connecting";
|
||||
const displayUrl = live
|
||||
@ -407,7 +411,11 @@ export function BrowserViewPanel({
|
||||
title={live ? "Stop live control" : "Take live control"}
|
||||
>
|
||||
<RadioIcon className="size-3.5" />
|
||||
{live ? (status === "open" ? "Live" : "…") : "Live"}
|
||||
{live && status !== "open"
|
||||
? status === "closed"
|
||||
? t.browserStream.disconnected
|
||||
: t.browserStream.connecting
|
||||
: "Live"}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@ -422,6 +430,14 @@ export function BrowserViewPanel({
|
||||
</Button>
|
||||
</header>
|
||||
<main className="relative flex min-h-0 grow flex-col overflow-hidden bg-neutral-950">
|
||||
{liveFailureMessage && (
|
||||
<p
|
||||
role="status"
|
||||
className="bg-background shrink-0 border-b px-3 py-2 text-xs"
|
||||
>
|
||||
{liveFailureMessage}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative min-h-0 grow bg-neutral-900"
|
||||
@ -443,12 +459,18 @@ export function BrowserViewPanel({
|
||||
className="absolute inset-0 m-auto h-fit"
|
||||
icon={<MonitorIcon />}
|
||||
title={
|
||||
live ? "Connecting to live browser…" : "No browser activity yet"
|
||||
liveFailureMessage
|
||||
? t.browserStream.disconnected
|
||||
: live
|
||||
? "Connecting to live browser…"
|
||||
: "No browser activity yet"
|
||||
}
|
||||
description={
|
||||
live
|
||||
? "Waiting for the first live frame."
|
||||
: "Enter a URL above or let the agent browse — the live view will appear here."
|
||||
liveFailureMessage
|
||||
? ""
|
||||
: live
|
||||
? "Waiting for the first live frame."
|
||||
: "Enter a URL above or let the agent browse — the live view will appear here."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -10,6 +10,7 @@ export {
|
||||
type BrowserTab,
|
||||
type BrowserInputEvent,
|
||||
type BrowserStreamStatus,
|
||||
type BrowserStreamClosedReason,
|
||||
} from "./use-browser-stream";
|
||||
export {
|
||||
BrowserViewProvider,
|
||||
|
||||
@ -32,7 +32,11 @@ export type BrowserInputEvent =
|
||||
| { type: "activate_tab"; index: number };
|
||||
|
||||
export type BrowserStreamStatus = "idle" | "connecting" | "open" | "closed";
|
||||
export type BrowserStreamClosedReason =
|
||||
| "handshake_timeout"
|
||||
| "connection_failed";
|
||||
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
const RECONNECT_BASE_DELAY_MS = 800;
|
||||
const RECONNECT_MAX_DELAY_MS = 10_000;
|
||||
const RECONNECT_MAX_ATTEMPTS = 6;
|
||||
@ -63,6 +67,8 @@ export function useBrowserStream(
|
||||
) => void,
|
||||
) {
|
||||
const [status, setStatus] = useState<BrowserStreamStatus>("idle");
|
||||
const [closedReason, setClosedReason] =
|
||||
useState<BrowserStreamClosedReason | null>(null);
|
||||
const [frameBuffer] = useState(() => new LatestBrowserFrameBuffer());
|
||||
const frameUrl = useSyncExternalStore(
|
||||
frameBuffer.subscribe,
|
||||
@ -106,6 +112,7 @@ export function useBrowserStream(
|
||||
|
||||
useEffect(() => {
|
||||
pendingNavigateRef.current = null;
|
||||
setClosedReason(null);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -123,11 +130,14 @@ export function useBrowserStream(
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus("idle");
|
||||
setClosedReason(null);
|
||||
liveUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let closedByEffect = false;
|
||||
let attemptClosed = false;
|
||||
let handshakeTimer: number | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
setStatus("connecting");
|
||||
// browserStreamURL treats empty/undefined seed identically (no seed param),
|
||||
@ -139,6 +149,22 @@ export function useBrowserStream(
|
||||
socket.binaryType = "blob";
|
||||
socketRef.current = socket;
|
||||
|
||||
const clearHandshakeTimer = () => {
|
||||
if (handshakeTimer !== null) {
|
||||
window.clearTimeout(handshakeTimer);
|
||||
handshakeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeSocket = () => {
|
||||
if (
|
||||
socket.readyState === WebSocket.CONNECTING ||
|
||||
socket.readyState === WebSocket.OPEN
|
||||
) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closedByEffect || !enabled) {
|
||||
return;
|
||||
@ -162,7 +188,21 @@ export function useBrowserStream(
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const closeAttempt = (reason: BrowserStreamClosedReason) => {
|
||||
if (closedByEffect || attemptClosed) return;
|
||||
// Fence all callbacks before close() can emit another event. A retired
|
||||
// attempt must not accept input or reset the reconnect budget on late open.
|
||||
attemptClosed = true;
|
||||
clearHandshakeTimer();
|
||||
socketRef.current = null;
|
||||
setClosedReason(reason);
|
||||
setStatus("closed");
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
if (closedByEffect || attemptClosed) return;
|
||||
clearHandshakeTimer();
|
||||
const pendingNavigate = pendingNavigateRef.current;
|
||||
if (pendingNavigate) {
|
||||
socket.send(JSON.stringify(pendingNavigate));
|
||||
@ -174,11 +214,12 @@ export function useBrowserStream(
|
||||
// many healthy connections — scheduleReconnect would bail forever and
|
||||
// Live would go permanently dead until the panel is toggled off/on.
|
||||
reconnectAttemptRef.current = 0;
|
||||
setClosedReason(null);
|
||||
setStatus("open");
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
try {
|
||||
if (closedByEffect) return;
|
||||
if (closedByEffect || attemptClosed) return;
|
||||
if (message.data instanceof Blob) {
|
||||
frameBuffer.push(
|
||||
message.data.type === "image/jpeg"
|
||||
@ -214,26 +255,33 @@ export function useBrowserStream(
|
||||
console.warn("Ignoring malformed browser stream message", error);
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (!closedByEffect) {
|
||||
setStatus("closed");
|
||||
scheduleReconnect();
|
||||
// WebSocket events do not reliably disclose whether an upgrade was refused
|
||||
// by auth, an origin policy, or the network. Keep this reason generic.
|
||||
socket.onclose = () => closeAttempt("connection_failed");
|
||||
socket.onerror = () => closeAttempt("connection_failed");
|
||||
|
||||
handshakeTimer = window.setTimeout(() => {
|
||||
if (
|
||||
closedByEffect ||
|
||||
attemptClosed ||
|
||||
socket.readyState !== WebSocket.CONNECTING
|
||||
) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (!closedByEffect) {
|
||||
setStatus("closed");
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
// A stalled upgrade may never emit error/close, even after close() is
|
||||
// requested. Enter the bounded reconnect path without waiting for either.
|
||||
closeAttempt("handshake_timeout");
|
||||
closeSocket();
|
||||
}, HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
return () => {
|
||||
closedByEffect = true;
|
||||
clearHandshakeTimer();
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
}
|
||||
socketRef.current = null;
|
||||
socket.close();
|
||||
closeSocket();
|
||||
frameBuffer.dispose();
|
||||
};
|
||||
}, [reconnectGeneration, enabled, frameBuffer, threadId]);
|
||||
@ -256,5 +304,5 @@ export function useBrowserStream(
|
||||
sendInput({ type: "navigate", url: target });
|
||||
}, [enabled, seedUrl, sendInput, status]);
|
||||
|
||||
return { status, frameUrl, liveUrl, tabs, sendInput };
|
||||
return { status, closedReason, frameUrl, liveUrl, tabs, sendInput };
|
||||
}
|
||||
|
||||
@ -245,6 +245,15 @@ export const enUS: Translations = {
|
||||
showBrowser: "Open browser panel",
|
||||
},
|
||||
|
||||
browserStream: {
|
||||
connecting: "Connecting…",
|
||||
disconnected: "Disconnected",
|
||||
handshakeTimeout:
|
||||
"Live browser connection timed out. Check your network or proxy, then turn Live off and on to retry.",
|
||||
connectionFailed:
|
||||
"Live browser connection failed or closed. Check your connection and access permissions, then turn Live off and on to retry.",
|
||||
},
|
||||
|
||||
skillUsage: {
|
||||
used: "Skills used",
|
||||
title: "Skills",
|
||||
|
||||
@ -177,6 +177,13 @@ export interface Translations {
|
||||
showBrowser: string;
|
||||
};
|
||||
|
||||
browserStream: {
|
||||
connecting: string;
|
||||
disconnected: string;
|
||||
handshakeTimeout: string;
|
||||
connectionFailed: string;
|
||||
};
|
||||
|
||||
skillUsage: {
|
||||
used: string;
|
||||
title: string;
|
||||
|
||||
@ -233,6 +233,15 @@ export const zhCN: Translations = {
|
||||
showBrowser: "打开浏览器面板",
|
||||
},
|
||||
|
||||
browserStream: {
|
||||
connecting: "正在连接…",
|
||||
disconnected: "连接已断开",
|
||||
handshakeTimeout:
|
||||
"实时浏览器连接超时。请检查网络或代理设置,然后关闭并重新开启 Live 以重试。",
|
||||
connectionFailed:
|
||||
"实时浏览器连接失败或已关闭。请检查网络连接和访问权限,然后关闭并重新开启 Live 以重试。",
|
||||
},
|
||||
|
||||
skillUsage: {
|
||||
used: "使用的技能",
|
||||
title: "技能",
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { afterEach, expect, rs, test } from "@rstest/core";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { BrowserViewPanel } from "@/components/workspace/browser-view/browser-view-panel";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import type { Translations } from "@/core/i18n/locales/types";
|
||||
import { zhCN } from "@/core/i18n/locales/zh-CN";
|
||||
|
||||
const { stream } = rs.hoisted(() => ({
|
||||
stream: {
|
||||
status: "closed" as "idle" | "connecting" | "open" | "closed",
|
||||
closedReason: "handshake_timeout" as
|
||||
| "handshake_timeout"
|
||||
| "connection_failed"
|
||||
| null,
|
||||
frameUrl: null as string | null,
|
||||
liveUrl: null,
|
||||
sendInput: rs.fn(),
|
||||
},
|
||||
}));
|
||||
let translations: Translations = enUS;
|
||||
|
||||
rs.mock("@/components/workspace/browser-view/use-browser-stream", () => ({
|
||||
useBrowserStream: () => stream,
|
||||
}));
|
||||
rs.mock("@/components/workspace/browser-view/context", () => ({
|
||||
useMaybeBrowserView: () => null,
|
||||
}));
|
||||
rs.mock("@/core/i18n/hooks", () => ({
|
||||
useI18n: () => ({ t: translations }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
stream.status = "closed";
|
||||
stream.closedReason = "handshake_timeout";
|
||||
stream.frameUrl = null;
|
||||
translations = enUS;
|
||||
});
|
||||
|
||||
for (const [locale, dictionary] of [
|
||||
["en-US", enUS],
|
||||
["zh-CN", zhCN],
|
||||
] as const) {
|
||||
test.each([false, true])(
|
||||
`shows actionable ${locale} failures with a previous frame: %s`,
|
||||
(hasFrame) => {
|
||||
translations = dictionary;
|
||||
stream.frameUrl = hasFrame ? "data:image/jpeg;base64,AA==" : null;
|
||||
const view = render(<BrowserViewPanel threadId="thread-1" />);
|
||||
|
||||
expect(screen.getByRole("status").textContent).toBe(
|
||||
dictionary.browserStream.handshakeTimeout,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: dictionary.browserStream.disconnected,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("Connecting to live browser…")).toBeNull();
|
||||
if (hasFrame) expect(screen.getByAltText("Browser view")).toBeTruthy();
|
||||
|
||||
stream.closedReason = "connection_failed";
|
||||
view.rerender(<BrowserViewPanel threadId="thread-1" />);
|
||||
expect(screen.getByRole("status").textContent).toBe(
|
||||
dictionary.browserStream.connectionFailed,
|
||||
);
|
||||
|
||||
stream.status = "connecting";
|
||||
stream.frameUrl = null;
|
||||
view.rerender(<BrowserViewPanel threadId="thread-1" />);
|
||||
expect(screen.getByRole("status").textContent).toBe(
|
||||
dictionary.browserStream.connectionFailed,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: dictionary.browserStream.connecting,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
if (hasFrame) expect(screen.getByAltText("Browser view")).toBeTruthy();
|
||||
|
||||
stream.status = "open";
|
||||
stream.closedReason = null;
|
||||
view.rerender(<BrowserViewPanel threadId="thread-1" />);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Live" })).toBeTruthy();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("hides the failure notice when live control is turned off", () => {
|
||||
render(<BrowserViewPanel threadId="thread-1" />);
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTitle("Stop live control"));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(screen.getByTitle("Take live control")).toBeTruthy();
|
||||
});
|
||||
@ -8,12 +8,13 @@ rs.mock("@/components/workspace/browser-view/api", () => ({
|
||||
import { useBrowserStream } from "@/components/workspace/browser-view/use-browser-stream";
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSED = 3;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readyState = 0;
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
binaryType = "";
|
||||
closeCalls = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
@ -55,6 +56,317 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("useBrowserStream", () => {
|
||||
// Regression coverage for bytedance/deer-flow#7296.
|
||||
test("times out a silent handshake and reconnects after the existing backoff", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
const first = FakeWebSocket.instances[0]!;
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(9_999);
|
||||
});
|
||||
expect(first.closeCalls).toBe(0);
|
||||
expect(result.current.status).toBe("connecting");
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(1);
|
||||
});
|
||||
expect(first.closeCalls).toBe(1);
|
||||
expect(result.current.status).toBe("closed");
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
|
||||
// close() deliberately emits no event: timeout recovery must not depend
|
||||
// on an eventual error/close event from the failed handshake.
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(799);
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(1);
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
expect(result.current.status).toBe("connecting");
|
||||
});
|
||||
|
||||
test("clears the handshake deadline after a successful open", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
const first = FakeWebSocket.instances[0]!;
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(9_999);
|
||||
first.open();
|
||||
});
|
||||
expect(rs.getTimerCount()).toBe(0);
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(300_000);
|
||||
});
|
||||
expect(result.current.status).toBe("open");
|
||||
expect(first.closeCalls).toBe(0);
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("failure events share one reconnect and clear the failed handshake deadline", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
const first = FakeWebSocket.instances[0]!;
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(1_000);
|
||||
first.onerror?.();
|
||||
first.disconnect();
|
||||
});
|
||||
expect(rs.getTimerCount()).toBe(1);
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(800);
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
|
||||
const replacement = FakeWebSocket.instances[1]!;
|
||||
act(() => {
|
||||
replacement.open();
|
||||
void rs.advanceTimersByTime(300_000);
|
||||
});
|
||||
expect(rs.getTimerCount()).toBe(0);
|
||||
expect(result.current.status).toBe("open");
|
||||
expect(replacement.closeCalls).toBe(0);
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("clears the handshake deadline when the panel is disabled", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useBrowserStream("thread-1", enabled),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(5_000);
|
||||
});
|
||||
rerender({ enabled: false });
|
||||
expect(rs.getTimerCount()).toBe(0);
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(FakeWebSocket.instances[0]!.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("clears the handshake deadline on unmount", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { unmount } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(5_000);
|
||||
});
|
||||
unmount();
|
||||
expect(rs.getTimerCount()).toBe(0);
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(FakeWebSocket.instances[0]!.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("a thread change gives the new socket its own deadline and ignores stale events", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ threadId }) => useBrowserStream(threadId, true),
|
||||
{ initialProps: { threadId: "thread-1" } },
|
||||
);
|
||||
const first = FakeWebSocket.instances[0]!;
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(5_000);
|
||||
});
|
||||
rerender({ threadId: "thread-2" });
|
||||
const replacement = FakeWebSocket.instances[1]!;
|
||||
expect(first.closeCalls).toBe(1);
|
||||
expect(replacement.url).toBe("ws://example.test/thread-2");
|
||||
|
||||
act(() => {
|
||||
first.open();
|
||||
first.onerror?.();
|
||||
first.disconnect();
|
||||
void rs.advanceTimersByTime(5_000);
|
||||
});
|
||||
expect(result.current.status).toBe("connecting");
|
||||
expect(replacement.closeCalls).toBe(0);
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
replacement.open();
|
||||
void rs.advanceTimersByTime(300_000);
|
||||
});
|
||||
expect(result.current.status).toBe("open");
|
||||
expect(replacement.closeCalls).toBe(0);
|
||||
expect(rs.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("ignores late open, frame, error and close events from a timed-out attempt", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
const first = FakeWebSocket.instances[0]!;
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(first.closeCalls).toBe(1);
|
||||
|
||||
act(() => {
|
||||
first.open();
|
||||
first.onmessage?.({
|
||||
data: JSON.stringify({ type: "url", url: "https://stale.example/" }),
|
||||
} as MessageEvent);
|
||||
first.onerror?.();
|
||||
first.disconnect();
|
||||
});
|
||||
expect(result.current.status).toBe("closed");
|
||||
expect(result.current.liveUrl).toBeNull();
|
||||
expect(rs.getTimerCount()).toBe(1);
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(800);
|
||||
});
|
||||
const replacement = FakeWebSocket.instances[1]!;
|
||||
act(() => {
|
||||
replacement.open();
|
||||
first.open();
|
||||
first.onerror?.();
|
||||
first.disconnect();
|
||||
void rs.advanceTimersByTime(300_000);
|
||||
});
|
||||
expect(result.current.status).toBe("open");
|
||||
expect(replacement.closeCalls).toBe(0);
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
expect(rs.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("stalled handshakes stop after the existing six-reconnect limit", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
for (let attempt = 0; attempt <= 6; attempt += 1) {
|
||||
const socket = FakeWebSocket.instances[attempt]!;
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(socket.closeCalls).toBe(1);
|
||||
expect(result.current.status).toBe("closed");
|
||||
act(() => {
|
||||
// A late open must not replenish a nonzero retry budget.
|
||||
socket.open();
|
||||
socket.disconnect();
|
||||
});
|
||||
expect(result.current.status).toBe("closed");
|
||||
|
||||
if (attempt < 6) {
|
||||
const delay = Math.min(800 * 2 ** attempt, 10_000);
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(delay);
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(attempt + 2);
|
||||
}
|
||||
}
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(300_000);
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(7);
|
||||
expect(result.current.closedReason).toBe("handshake_timeout");
|
||||
expect(result.current.status).toBe("closed");
|
||||
expect(rs.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("preserves a timeout reason through retries and stale events until open", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
const first = FakeWebSocket.instances[0]!;
|
||||
expect(result.current.closedReason).toBeNull();
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(10_000);
|
||||
first.onerror?.();
|
||||
first.disconnect();
|
||||
first.open();
|
||||
});
|
||||
expect(result.current.closedReason).toBe("handshake_timeout");
|
||||
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(800);
|
||||
first.onerror?.();
|
||||
});
|
||||
expect(result.current.status).toBe("connecting");
|
||||
expect(result.current.closedReason).toBe("handshake_timeout");
|
||||
act(() => FakeWebSocket.instances[1]!.open());
|
||||
expect(result.current.status).toBe("open");
|
||||
expect(result.current.closedReason).toBeNull();
|
||||
});
|
||||
|
||||
test.each(["error", "close"] as const)(
|
||||
"reports a socket %s separately from a handshake timeout",
|
||||
(event) => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result } = renderHook(() => useBrowserStream("thread-1", true));
|
||||
const socket = FakeWebSocket.instances[0]!;
|
||||
act(() => {
|
||||
if (event === "error") socket.onerror?.();
|
||||
else socket.disconnect();
|
||||
});
|
||||
expect(result.current.status).toBe("closed");
|
||||
expect(result.current.closedReason).toBe("connection_failed");
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(800);
|
||||
});
|
||||
act(() => FakeWebSocket.instances[1]!.open());
|
||||
expect(result.current.closedReason).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("clears the failure reason when disabled or moved to another thread", () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ threadId, enabled }) => useBrowserStream(threadId, enabled),
|
||||
{ initialProps: { threadId: "thread-1", enabled: true } },
|
||||
);
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(result.current.closedReason).toBe("handshake_timeout");
|
||||
rerender({ threadId: "thread-1", enabled: false });
|
||||
expect(result.current.closedReason).toBeNull();
|
||||
rerender({ threadId: "thread-1", enabled: true });
|
||||
expect(result.current.closedReason).toBeNull();
|
||||
act(() => {
|
||||
void rs.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(result.current.closedReason).toBe("handshake_timeout");
|
||||
const retired = FakeWebSocket.instances.at(-1)!;
|
||||
rerender({ threadId: "thread-2", enabled: true });
|
||||
act(() => retired.onerror?.());
|
||||
expect(result.current.status).toBe("connecting");
|
||||
expect(result.current.closedReason).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps a successfully reconnected socket instead of recreating it", async () => {
|
||||
rs.useFakeTimers();
|
||||
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user