Skip to content
File

Blob: test/shared-session.test.ts

typescript284 lines
1// @vitest-environment jsdom
2 
3/**
4 * Unit tests for shared-session detection via BroadcastChannel.
5 *
6 * Covers:
7 * - detection when another tab announces the same session
8 * - clearing when peer leaves
9 * - query/response protocol
10 * - stale peer eviction
11 * - session change (leave old, announce new)
12 * - self-filtering
13 * - graceful degradation when BroadcastChannel is unavailable
14 */
15import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
16 
17// ---------------------------------------------------------------------------
18// Mock BroadcastChannel
19// ---------------------------------------------------------------------------
20 
21type BCListener = (event: { data: unknown }) => void;
22 
23class MockBroadcastChannel {
24 onmessage: BCListener | null = null;
25 posted: unknown[] = [];
26 closed = false;
27 
28 constructor(public name: string) {
29 MockBroadcastChannel.instances.push(this);
30 }
31 
32 postMessage(data: unknown) {
33 if (this.closed) return;
34 this.posted.push(data);
35 }
36 
37 close() {
38 this.closed = true;
39 }
40 
41 // Test helper: simulate receiving a message from another tab
42 receive(data: unknown) {
43 this.onmessage?.({ data });
44 }
45 
46 static instances: MockBroadcastChannel[] = [];
47 static reset() {
48 MockBroadcastChannel.instances = [];
49 }
50}
51 
52// ---------------------------------------------------------------------------
53// Mock zustand store
54// ---------------------------------------------------------------------------
55 
56let sharedSessionValue = false;
57const mockSetSharedSession = vi.fn((val: boolean) => {
58 sharedSessionValue = val;
59});
60 
61vi.mock("../src/client/workspace/store", () => ({
62 useWorkspaceStore: (selector: (s: Record<string, unknown>) => unknown) => {
63 const fakeState = {
64 setSharedSession: mockSetSharedSession,
65 };
66 return selector(fakeState);
67 },
68}));
69 
70// ---------------------------------------------------------------------------
71// Mock React hooks (minimal)
72// ---------------------------------------------------------------------------
73 
74let effectCleanup: (() => void) | undefined;
75const stableTabId = "test-tab-id-123";
76 
77vi.mock("react", () => ({
78 useEffect: (fn: () => (() => void) | void, _deps: unknown[]) => {
79 effectCleanup?.();
80 const cleanup = fn();
81 effectCleanup = cleanup ?? undefined;
82 },
83 useRef: () => {
84 // Always return our stable test tab ID so we can match against it
85 return { current: stableTabId };
86 },
87}));
88 
89// ---------------------------------------------------------------------------
90// Import after mocks
91// ---------------------------------------------------------------------------
92 
93import { useSharedSessionDetection } from "../src/client/terminal/use-shared-session-detection";
94 
95// ---------------------------------------------------------------------------
96// Setup
97// ---------------------------------------------------------------------------
98 
99const originalBroadcastChannel = globalThis.BroadcastChannel;
100 
101beforeEach(() => {
102 MockBroadcastChannel.reset();
103 (globalThis as Record<string, unknown>).BroadcastChannel = MockBroadcastChannel as unknown;
104 sharedSessionValue = false;
105 mockSetSharedSession.mockClear();
106 effectCleanup = undefined;
107 vi.useFakeTimers();
108});
109 
110afterEach(() => {
111 effectCleanup?.();
112 effectCleanup = undefined;
113 (globalThis as Record<string, unknown>).BroadcastChannel = originalBroadcastChannel;
114 vi.useRealTimers();
115});
116 
117// ---------------------------------------------------------------------------
118// Helpers
119// ---------------------------------------------------------------------------
120 
121function getChannel(): MockBroadcastChannel {
122 return MockBroadcastChannel.instances[MockBroadcastChannel.instances.length - 1];
123}
124 
125// ---------------------------------------------------------------------------
126// Tests
127// ---------------------------------------------------------------------------
128 
129describe("useSharedSessionDetection", () => {
130 it("returns false when alone (no peers)", () => {
131 useSharedSessionDetection("session-1");
132 // setSharedSession called with false on init
133 expect(sharedSessionValue).toBe(false);
134 });
135 
136 it("broadcasts announce and query on mount", () => {
137 useSharedSessionDetection("session-1");
138 const ch = getChannel();
139 
140 const announces = ch.posted.filter((m: any) => m.type === "announce" && m.sessionId === "session-1");
141 const queries = ch.posted.filter((m: any) => m.type === "query" && m.sessionId === "session-1");
142 expect(announces.length).toBeGreaterThanOrEqual(1);
143 expect(queries.length).toBeGreaterThanOrEqual(1);
144 });
145 
146 it("sets shared=true when peer announces same session", () => {
147 useSharedSessionDetection("session-1");
148 const ch = getChannel();
149 
150 ch.receive({
151 type: "announce",
152 sessionId: "session-1",
153 tabId: "other-tab-1",
154 });
155 
156 expect(mockSetSharedSession).toHaveBeenCalledWith(true);
157 });
158 
159 it("sets shared=false after peer sends leave", () => {
160 useSharedSessionDetection("session-1");
161 const ch = getChannel();
162 
163 // Peer joins
164 ch.receive({
165 type: "announce",
166 sessionId: "session-1",
167 tabId: "other-tab-1",
168 });
169 expect(mockSetSharedSession).toHaveBeenCalledWith(true);
170 
171 mockSetSharedSession.mockClear();
172 
173 // Peer leaves
174 ch.receive({
175 type: "leave",
176 sessionId: "session-1",
177 tabId: "other-tab-1",
178 });
179 expect(mockSetSharedSession).toHaveBeenCalledWith(false);
180 });
181 
182 it("responds to query with announce", () => {
183 useSharedSessionDetection("session-1");
184 const ch = getChannel();
185 
186 // Clear initial posts
187 ch.posted.length = 0;
188 
189 ch.receive({
190 type: "query",
191 sessionId: "session-1",
192 tabId: "other-tab-2",
193 });
194 
195 // Should have posted an announce in response
196 const responseAnnounces = ch.posted.filter(
197 (m: any) => m.type === "announce" && m.sessionId === "session-1" && m.tabId === stableTabId,
198 );
199 expect(responseAnnounces.length).toBe(1);
200 });
201 
202 it("does not count self", () => {
203 useSharedSessionDetection("session-1");
204 const ch = getChannel();
205 
206 // Simulate receiving own announce (same tabId)
207 ch.receive({
208 type: "announce",
209 sessionId: "session-1",
210 tabId: stableTabId,
211 });
212 
213 // Should still be false — self is filtered
214 const sharedCalls = mockSetSharedSession.mock.calls.filter((c) => c[0] === true);
215 expect(sharedCalls).toHaveLength(0);
216 });
217 
218 it("evicts stale peers after heartbeat timeout", () => {
219 useSharedSessionDetection("session-1");
220 const ch = getChannel();
221 
222 // Peer joins
223 ch.receive({
224 type: "announce",
225 sessionId: "session-1",
226 tabId: "stale-peer",
227 });
228 expect(mockSetSharedSession).toHaveBeenCalledWith(true);
229 
230 mockSetSharedSession.mockClear();
231 
232 // Advance past stale threshold (15s) + heartbeat (10s)
233 vi.advanceTimersByTime(20_000);
234 
235 // After heartbeat ran and evicted the stale peer
236 expect(mockSetSharedSession).toHaveBeenCalledWith(false);
237 });
238 
239 it("removes peer that switches to a different session", () => {
240 useSharedSessionDetection("session-1");
241 const ch = getChannel();
242 
243 // Peer announces same session
244 ch.receive({
245 type: "announce",
246 sessionId: "session-1",
247 tabId: "peer-x",
248 });
249 expect(mockSetSharedSession).toHaveBeenCalledWith(true);
250 
251 mockSetSharedSession.mockClear();
252 
253 // Peer now announces a different session
254 ch.receive({
255 type: "announce",
256 sessionId: "session-2",
257 tabId: "peer-x",
258 });
259 // peer-x's sessionId is now session-2, which doesn't match ours
260 expect(mockSetSharedSession).toHaveBeenCalledWith(false);
261 });
262 
263 it("sends leave on cleanup", () => {
264 useSharedSessionDetection("session-1");
265 const ch = getChannel();
266 
267 ch.posted.length = 0;
268 effectCleanup?.();
269 effectCleanup = undefined;
270 
271 const leaves = ch.posted.filter((m: any) => m.type === "leave");
272 expect(leaves.length).toBeGreaterThanOrEqual(1);
273 });
274 
275 it("is graceful when BroadcastChannel is unavailable", () => {
276 delete (globalThis as Record<string, unknown>).BroadcastChannel;
277 
278 // Should not throw
279 expect(() => useSharedSessionDetection("session-1")).not.toThrow();
280 // Should set false (default)
281 expect(mockSetSharedSession).toHaveBeenCalledWith(false);
282 });
283});