Files
fusion/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts
gsxdsm bdcb048e20 chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts
- Move all co-located *.test.* files into sibling __tests__/ directories so the
  layout is consistent across packages (159 renames + content-rewrite moves).
  Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
  path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
  skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
  accidentally committed in a merge and the stale pi.js was masking a real
  test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
  the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
  createFnAgent / promptWithFallback / describeModel from @fusion/engine
  instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
  the two plugin package.jsons that were missing it; exports describeModel
  from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
  ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
  .test.ts; switch three boundary-result toEqual assertions to toMatchObject
  so the new content/isError fields don't trip exact-match comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

988 lines
33 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useTerminal } from "../useTerminal";
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: MockWebSocket[] = [];
url: string;
readyState = MockWebSocket.CONNECTING;
sent: string[] = [];
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onclose: ((event: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
send = vi.fn((payload: string) => {
this.sent.push(payload);
});
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: 1000 });
});
emitOpen(): void {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event("open"));
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
}
emitClose(code: number): void {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code });
}
}
describe("useTerminal", () => {
const originalWebSocket = globalThis.WebSocket;
beforeEach(() => {
MockWebSocket.instances = [];
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = MockWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = originalWebSocket;
vi.clearAllMocks();
});
it("returns disconnected status when sessionId is null", () => {
const { result } = renderHook(() => useTerminal(null));
expect(result.current.connectionStatus).toBe("disconnected");
});
it("establishes a websocket connection for a valid sessionId", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
expect(result.current.connectionStatus).toBe("connecting");
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0].url).toContain("/api/terminal/ws?sessionId=test-session-123");
});
describe("projectId support", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("includes projectId in WebSocket URL when provided", () => {
const { result } = renderHook(() => useTerminal("test-session-123", "proj-456"));
expect(result.current.connectionStatus).toBe("connecting");
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0].url).toContain("/api/terminal/ws?sessionId=test-session-123");
expect(MockWebSocket.instances[0].url).toContain("projectId=proj-456");
});
it("does not include projectId in URL when not provided", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
expect(result.current.connectionStatus).toBe("connecting");
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0].url).toBe(
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/api/terminal/ws?sessionId=test-session-123`,
);
});
it("updates URL when projectId changes", () => {
const { result, rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
expect(MockWebSocket.instances[0].url).toContain("projectId=proj-A");
// Change projectId
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
// Wait for reconnect
act(() => {
vi.advanceTimersByTime(1000);
});
// New WebSocket should have new projectId
expect(MockWebSocket.instances).toHaveLength(2);
expect(MockWebSocket.instances[1].url).toContain("projectId=proj-B");
});
});
it("reports connected status when the websocket opens", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitOpen();
});
expect(result.current.connectionStatus).toBe("connected");
});
it("sends terminal input when connected", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitOpen();
result.current.sendInput("ls -la");
});
expect(MockWebSocket.instances[0].send).toHaveBeenCalledWith(JSON.stringify({ type: "input", data: "ls -la" }));
});
it("forwards websocket messages to registered callbacks", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onData = vi.fn();
const onConnect = vi.fn();
const onExit = vi.fn();
const onScrollback = vi.fn();
const unsubData = result.current.onData(onData);
const unsubConnect = result.current.onConnect(onConnect);
const unsubExit = result.current.onExit(onExit);
const unsubScrollback = result.current.onScrollback(onScrollback);
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/bash", cwd: "/project" });
MockWebSocket.instances[0].emitMessage({ type: "data", data: "hello world" });
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "previous output" });
MockWebSocket.instances[0].emitMessage({ type: "exit", exitCode: 0 });
});
expect(onConnect).toHaveBeenCalledWith({ shell: "/bin/bash", cwd: "/project" });
expect(onData).toHaveBeenCalledWith("hello world");
expect(onScrollback).toHaveBeenCalledWith("previous output");
expect(onExit).toHaveBeenCalledWith(0);
unsubData();
unsubConnect();
unsubExit();
unsubScrollback();
});
it("responds with pong when server sends ping", () => {
renderHook(() => useTerminal("test-session-123"));
const ws = MockWebSocket.instances[0];
act(() => {
ws.emitOpen();
});
act(() => {
ws.emitMessage({ type: "ping" });
});
const pongSent = ws.sent.find((m) => JSON.parse(m).type === "pong");
expect(pongSent).toBeDefined();
expect(JSON.parse(pongSent!)).toEqual({ type: "pong" });
});
it("does not send pong when websocket is not open", () => {
renderHook(() => useTerminal("test-session-123"));
const ws = MockWebSocket.instances[0];
act(() => {
ws.emitOpen();
});
// Simulate WS in CLOSING state
ws.readyState = MockWebSocket.CLOSING;
act(() => {
ws.emitMessage({ type: "ping" });
});
const pongMessages = ws.sent.filter((m) => JSON.parse(m).type === "pong");
expect(pongMessages).toHaveLength(0);
});
it("stays connected after receiving a ping", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const ws = MockWebSocket.instances[0];
act(() => {
ws.emitOpen();
});
expect(result.current.connectionStatus).toBe("connected");
act(() => {
ws.emitMessage({ type: "ping" });
});
expect(result.current.connectionStatus).toBe("connected");
});
it("does not reconnect for terminal-not-found closes", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitClose(4004);
});
expect(result.current.connectionStatus).toBe("disconnected");
});
describe("onSessionInvalid callback", () => {
it("fires onSessionInvalid callbacks when WebSocket closes with code 4004", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
act(() => {
result.current.onSessionInvalid(onSessionInvalid);
});
act(() => {
MockWebSocket.instances[0].emitClose(4004);
});
expect(onSessionInvalid).toHaveBeenCalledTimes(1);
});
it("does NOT fire onSessionInvalid for close code 4000", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
act(() => {
result.current.onSessionInvalid(onSessionInvalid);
});
act(() => {
MockWebSocket.instances[0].emitClose(4000);
});
expect(onSessionInvalid).not.toHaveBeenCalled();
});
it("does NOT fire onSessionInvalid for normal close codes", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
act(() => {
result.current.onSessionInvalid(onSessionInvalid);
});
act(() => {
MockWebSocket.instances[0].emitClose(1000);
});
expect(onSessionInvalid).not.toHaveBeenCalled();
});
it("unsubscribes correctly", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
const unsub = result.current.onSessionInvalid(onSessionInvalid);
// Unsubscribe
act(() => {
unsub();
});
act(() => {
MockWebSocket.instances[0].emitClose(4004);
});
// Should NOT have been called after unsubscribe
expect(onSessionInvalid).not.toHaveBeenCalled();
});
it("fires for multiple subscribers", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const cb1 = vi.fn();
const cb2 = vi.fn();
act(() => {
result.current.onSessionInvalid(cb1);
result.current.onSessionInvalid(cb2);
});
act(() => {
MockWebSocket.instances[0].emitClose(4004);
});
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
});
});
describe("early message buffering", () => {
it("replays buffered scrollback to late subscribers", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// Send scrollback BEFORE any subscriber is registered
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "previous output" });
});
const onScrollback = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
});
// The late subscriber should receive the buffered scrollback
expect(onScrollback).toHaveBeenCalledWith("previous output");
});
it("replays buffered connected info to late subscribers", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// Send connected info BEFORE any subscriber is registered
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/zsh", cwd: "/home/user" });
});
const onConnect = vi.fn();
act(() => {
result.current.onConnect(onConnect);
});
// The late subscriber should receive the buffered connected info
expect(onConnect).toHaveBeenCalledWith({ shell: "/bin/zsh", cwd: "/home/user" });
});
it("replays buffered data messages to late subscribers", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// Send data messages BEFORE any subscriber is registered
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "data", data: "prompt$ " });
MockWebSocket.instances[0].emitMessage({ type: "data", data: "more output" });
});
const onData = vi.fn();
act(() => {
result.current.onData(onData);
});
// The late subscriber should receive all buffered data messages in order
expect(onData).toHaveBeenCalledTimes(2);
expect(onData).toHaveBeenNthCalledWith(1, "prompt$ ");
expect(onData).toHaveBeenNthCalledWith(2, "more output");
});
it("does not double-deliver messages to early subscribers", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onScrollback = vi.fn();
const onConnect = vi.fn();
const onData = vi.fn();
// Register subscribers BEFORE messages arrive
act(() => {
result.current.onScrollback(onScrollback);
result.current.onConnect(onConnect);
result.current.onData(onData);
});
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "scrollback data" });
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/bash", cwd: "/project" });
MockWebSocket.instances[0].emitMessage({ type: "data", data: "hello" });
});
// Each callback should be called exactly once (no replay double-count)
expect(onScrollback).toHaveBeenCalledTimes(1);
expect(onConnect).toHaveBeenCalledTimes(1);
expect(onData).toHaveBeenCalledTimes(1);
});
it("clears buffered scrollback after first replay to prevent duplicate delivery", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "buf" });
});
const sub1 = vi.fn();
const sub2 = vi.fn();
// First subscriber gets the buffered scrollback
act(() => {
result.current.onScrollback(sub1);
});
expect(sub1).toHaveBeenCalledWith("buf");
// Second subscriber does NOT get the stale buffer — it was already
// delivered to sub1, so replaying it would cause duplicate output.
act(() => {
result.current.onScrollback(sub2);
});
expect(sub2).not.toHaveBeenCalled();
// New live messages should go to both subscribers
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "live-update" });
});
expect(sub1).toHaveBeenCalledTimes(2); // buffer + live
expect(sub2).toHaveBeenCalledTimes(1); // live only
});
it("clears buffer on reconnect so stale data is not replayed", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// First connection receives messages
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "old data" });
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/bash", cwd: "/old" });
});
// Reconnect — this creates a new WebSocket
act(() => {
result.current.reconnect();
});
// Second connection (new MockWebSocket instance at index 1)
// The reconnect closes the old ws and opens a new one
const newWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
const onScrollback = vi.fn();
const onConnect = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
result.current.onConnect(onConnect);
});
// Subscribers registered on the new connection should NOT get old buffer
expect(onScrollback).not.toHaveBeenCalled();
expect(onConnect).not.toHaveBeenCalled();
});
});
describe("first-paint regression — prompt delivery", () => {
it("delivers scrollback, connected, and data messages to subscribers even when they register late", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// Simulate the messages arriving before any subscriber (xterm not ready)
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "shell-prompt$ " });
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/zsh", cwd: "/project" });
MockWebSocket.instances[0].emitMessage({ type: "data", data: "echo hello\n" });
});
const onScrollback = vi.fn();
const onConnect = vi.fn();
const onData = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
result.current.onConnect(onConnect);
result.current.onData(onData);
});
// All three message types should be replayed to late subscribers
expect(onScrollback).toHaveBeenCalledWith("shell-prompt$ ");
expect(onConnect).toHaveBeenCalledWith({ shell: "/bin/zsh", cwd: "/project" });
expect(onData).toHaveBeenCalledWith("echo hello\n");
});
it("does not lose prompt when data arrives after scrollback before subscriber", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// Simulate the exact startup race:
// 1. Scrollback arrives (may include partial prompt)
// 2. Data arrives with the rest of the prompt
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "user@host" });
MockWebSocket.instances[0].emitMessage({ type: "data", data: " ~/project $ " });
});
const onScrollback = vi.fn();
const onData = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
result.current.onData(onData);
});
// Both messages should be delivered (no loss)
expect(onScrollback).toHaveBeenCalledWith("user@host");
expect(onData).toHaveBeenCalledWith(" ~/project $ ");
});
});
describe("first-paint regression — no duplicate output", () => {
it("does not replay scrollback to second subscriber to prevent duplicate terminal output", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "prompt$ " });
});
const sub1 = vi.fn();
act(() => {
result.current.onScrollback(sub1);
});
expect(sub1).toHaveBeenCalledWith("prompt$ ");
// Second subscriber does NOT get the replay (prevents duplicate output)
const sub2 = vi.fn();
act(() => {
result.current.onScrollback(sub2);
});
expect(sub2).not.toHaveBeenCalled();
// But live messages go to both
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "new!" });
});
expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub2).toHaveBeenCalledWith("new!");
});
it("does not replay data to second subscriber to prevent duplicate terminal output", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "data", data: "output" });
});
const sub1 = vi.fn();
act(() => {
result.current.onData(sub1);
});
expect(sub1).toHaveBeenCalledWith("output");
// Second subscriber does NOT get the stale buffer
const sub2 = vi.fn();
act(() => {
result.current.onData(sub2);
});
expect(sub2).not.toHaveBeenCalled();
// But live messages go to both
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "data", data: "live" });
});
expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub2).toHaveBeenCalledWith("live");
});
it("does not replay connected info to second subscriber to prevent duplicate tab title update", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/bash", cwd: "/home" });
});
const sub1 = vi.fn();
act(() => {
result.current.onConnect(sub1);
});
expect(sub1).toHaveBeenCalledWith({ shell: "/bin/bash", cwd: "/home" });
// Second subscriber does NOT get stale connected info
const sub2 = vi.fn();
act(() => {
result.current.onConnect(sub2);
});
expect(sub2).not.toHaveBeenCalled();
});
});
describe("reconnect — scrollback replay on new connection", () => {
it("delivers fresh scrollback on reconnect without stale data", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// First connection — send some data
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "old prompt$ " });
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/zsh", cwd: "/old" });
});
// Register subscriber on old connection
const onScrollback = vi.fn();
const onConnect = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
result.current.onConnect(onConnect);
});
// Old buffer was delivered
expect(onScrollback).toHaveBeenCalledWith("old prompt$ ");
// Reconnect — creates new WebSocket, clears buffers
act(() => {
result.current.reconnect();
});
const newWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
// New connection sends fresh scrollback
act(() => {
newWs.emitMessage({ type: "scrollback", data: "new prompt$ " });
newWs.emitMessage({ type: "connected", shell: "/bin/zsh", cwd: "/new" });
});
// Existing subscriber gets new live data
expect(onScrollback).toHaveBeenCalledTimes(2); // old buffer + new live
expect(onScrollback).toHaveBeenLastCalledWith("new prompt$ ");
expect(onConnect).toHaveBeenCalledTimes(2); // old buffer + new live
expect(onConnect).toHaveBeenLastCalledWith({ shell: "/bin/zsh", cwd: "/new" });
});
});
describe("heartbeat interval", () => {
it("sends client ping at 45-second interval (not 30s)", () => {
vi.useFakeTimers();
renderHook(() => useTerminal("heartbeat-test"));
const ws = MockWebSocket.instances[0];
act(() => {
ws.emitOpen();
});
// Clear any messages sent during open
ws.sent.length = 0;
// At 30 seconds, no ping should be sent yet (old interval was 30s)
act(() => {
vi.advanceTimersByTime(30000);
});
const pingsAt30s = ws.sent.filter((m) => {
try { return JSON.parse(m).type === "ping"; } catch { return false; }
});
expect(pingsAt30s).toHaveLength(0);
// At 45 seconds, the first ping should be sent
act(() => {
vi.advanceTimersByTime(15000);
});
const pingsAt45s = ws.sent.filter((m) => {
try { return JSON.parse(m).type === "ping"; } catch { return false; }
});
expect(pingsAt45s).toHaveLength(1);
// At 90 seconds, second ping should be sent
act(() => {
vi.advanceTimersByTime(45000);
});
const pingsAt90s = ws.sent.filter((m) => {
try { return JSON.parse(m).type === "ping"; } catch { return false; }
});
expect(pingsAt90s).toHaveLength(2);
vi.useRealTimers();
});
});
describe("stale-context isolation", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("closes old WebSocket when projectId changes", () => {
const { rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
const oldWs = MockWebSocket.instances[0];
const oldWsCloseSpy = vi.spyOn(oldWs, "close");
// Change projectId
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
// Old WebSocket should be closed
expect(oldWsCloseSpy).toHaveBeenCalled();
});
it("stale WebSocket onopen does not update status when context changed", () => {
const { rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
const oldWs = MockWebSocket.instances[0];
// Change projectId - this closes old ws
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
// Simulate the OLD WebSocket opening (stale event)
act(() => {
oldWs.emitOpen();
});
// The stale ws onopen should not affect the new context's status
// We need to verify that the new WebSocket (proj-B) is the one that matters
expect(MockWebSocket.instances).toHaveLength(2);
const newWs = MockWebSocket.instances[1];
// The new ws should be connecting, not connected (since we haven't opened it yet)
// Wait for the new ws to be created
act(() => {
vi.advanceTimersByTime(0);
});
});
it("stale WebSocket onmessage does not call callbacks when context changed", () => {
const { rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
// Register callback BEFORE context change
const onData = vi.fn();
const { result } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) => {
const hook = useTerminal(sessionId, projectId);
return hook;
},
{
initialProps: { sessionId: "test-session-123", projectId: "proj-A" },
wrapper: ({ children }) => children,
},
);
// Use a simpler approach: test within a single hook instance
const { result: singleResult } = renderHook(() => {
const hook = useTerminal("test-session-123", "proj-A");
return hook;
});
const dataCallback = vi.fn();
singleResult.current.onData(dataCallback);
// Change projectId - this closes old ws and increments context version
singleResult.current; // access to ensure re-render
const { rerender: rerenderSingle } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
// Change projectId
rerenderSingle({ sessionId: "test-session-123", projectId: "proj-B" });
const oldWs = MockWebSocket.instances[0];
// Simulate the OLD WebSocket receiving a message (stale event)
act(() => {
oldWs.emitMessage({ type: "data", data: "stale data" });
});
// The stale message should NOT call the callback
expect(dataCallback).not.toHaveBeenCalled();
});
it("stale WebSocket reconnect timeout is ignored when context changed", () => {
const { rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
// Open and then close the WebSocket to trigger reconnect timeout
act(() => {
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitClose(1006); // Unexpected close triggers reconnect
});
// Wait for reconnect to be scheduled
act(() => {
vi.advanceTimersByTime(1000);
});
// Now change projectId before reconnect fires
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
// Advance past when reconnect would have fired
act(() => {
vi.advanceTimersByTime(2000);
});
// At this point:
// - ws1 was created (original)
// - ws1 close triggered reconnect
// - reconnect timeout fired and created ws2 (for stale context A)
// - context changed to B
// - closeWebSocketForContextChange closed ws1 and ws2
// - connect() created ws3 (for new context B)
// The stale ws2 reconnect should NOT have created another instance
expect(MockWebSocket.instances).toHaveLength(3);
// The final ws should be for project B
expect(MockWebSocket.instances[2].url).toContain("projectId=proj-B");
});
it("resets connection status on context change", () => {
const { result, rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
// Connect
act(() => {
MockWebSocket.instances[0].emitOpen();
});
expect(result.current.connectionStatus).toBe("connected");
// Change projectId
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
// Status should be reset to disconnected/connecting
// (disconnected initially, then connecting for the new context)
act(() => {
vi.advanceTimersByTime(0);
});
expect(result.current.connectionStatus).toBe("connecting");
});
it("only reconnects to active context (new projectId)", () => {
const { result, rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
// Open the first WebSocket
act(() => {
MockWebSocket.instances[0].emitOpen();
});
// Close to trigger reconnect
act(() => {
MockWebSocket.instances[0].emitClose(1006);
});
// Wait for reconnect timeout to fire
act(() => {
vi.advanceTimersByTime(1000);
});
// At this point:
// - ws1 was closed
// - onclose scheduled reconnect
// - reconnect timeout fired and called connect()
// - connect() closed ws1 and created ws2 (proj-A)
// Change projectId - this closes ws2 and creates ws3 (proj-B)
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
// Wait for effects
act(() => {
vi.advanceTimersByTime(0);
});
// Final ws should be for project B
const finalWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
expect(finalWs.url).toContain("projectId=proj-B");
// Open the final ws
act(() => {
finalWs.emitOpen();
});
// Status should be connected
expect(result.current.connectionStatus).toBe("connected");
});
it("clears buffer on context change", () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId?: string }) => useTerminal("test-session-123", projectId),
{ initialProps: { projectId: "proj-A" as string | undefined } },
);
// Send some data before context change
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "buffered data" });
});
// Register a callback that would receive the buffer
const scrollbackCallback = vi.fn();
result.current.onScrollback(scrollbackCallback);
// Change projectId - this should clear the buffer
rerender({ projectId: "proj-B" });
// Wait for new context
act(() => {
vi.advanceTimersByTime(0);
});
// Register a new callback on the NEW context
const newScrollbackCallback = vi.fn();
result.current.onScrollback(newScrollbackCallback);
// No buffered data should be delivered because the buffer was cleared
expect(newScrollbackCallback).not.toHaveBeenCalled();
});
it("handles rapid context switches correctly", () => {
const { result, rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
// Rapid switches: A -> B -> C
// Each switch closes old ws and creates new ws
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
rerender({ sessionId: "test-session-123", projectId: "proj-C" });
// Wait for effects
act(() => {
vi.advanceTimersByTime(0);
});
// Each rerender creates a new ws (close old + connect new)
// ws1 (proj-A), ws2 (proj-B), ws3 (proj-C)
expect(MockWebSocket.instances).toHaveLength(3);
expect(MockWebSocket.instances[2].url).toContain("projectId=proj-C");
});
it("sessionId change also triggers context cleanup", () => {
const { result, rerender } = renderHook(
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
useTerminal(sessionId, projectId),
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
);
const oldWs = MockWebSocket.instances[0];
const oldWsCloseSpy = vi.spyOn(oldWs, "close");
// Change sessionId only
rerender({ sessionId: "test-session-456", projectId: "proj-A" });
// Old WebSocket should be closed
expect(oldWsCloseSpy).toHaveBeenCalled();
// New WebSocket should be created with new sessionId
expect(MockWebSocket.instances).toHaveLength(2);
expect(MockWebSocket.instances[1].url).toContain("sessionId=test-session-456");
});
});
});