474 websocket add a snack bar to notify the user on websocket status changes (#483)
* [#474] added snackbar for websocket status * [#474 & #447] added ping to complete browser online/offline status * [#447] ping timeout as an env var
This commit is contained in:
@@ -3,13 +3,40 @@ import { createWebSocketConnection } from "@/websocket/connection/createConnecti
|
|||||||
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
|
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
|
||||||
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
|
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
|
||||||
import { WebSocketGate } from "@/websocket/WebSocketGate";
|
import { WebSocketGate } from "@/websocket/WebSocketGate";
|
||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore, Store } from "@reduxjs/toolkit";
|
||||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
|
import { I18nContext } from "twake-i18n";
|
||||||
|
|
||||||
jest.mock("@/websocket/connection/createConnection");
|
jest.mock("@/websocket/connection/createConnection");
|
||||||
jest.mock("@/websocket/operations/registerToCalendars");
|
jest.mock("@/websocket/operations/registerToCalendars");
|
||||||
jest.mock("@/websocket/operations/unregisterToCalendars");
|
jest.mock("@/websocket/operations/unregisterToCalendars");
|
||||||
|
jest.mock("@/websocket/connection/lifecycle/pingWebSocket");
|
||||||
|
|
||||||
|
function TestWrapper({ store }: { store: Store }) {
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider
|
||||||
|
value={{
|
||||||
|
t: (key: string, vars?: Record<string, string>) => {
|
||||||
|
if (key === "locale") return "en";
|
||||||
|
if (vars) {
|
||||||
|
const params = Object.entries(vars)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(",");
|
||||||
|
return `${key}(${params})`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
f: (date: Date) => date.toString(),
|
||||||
|
lang: "en",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Provider store={store}>
|
||||||
|
<WebSocketGate />
|
||||||
|
</Provider>
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("WebSocketGate", () => {
|
describe("WebSocketGate", () => {
|
||||||
let store: any;
|
let store: any;
|
||||||
@@ -30,9 +57,11 @@ describe("WebSocketGate", () => {
|
|||||||
const createMockSocket = (readyState = WebSocket.OPEN) => ({
|
const createMockSocket = (readyState = WebSocket.OPEN) => ({
|
||||||
readyState,
|
readyState,
|
||||||
close: jest.fn(),
|
close: jest.fn(),
|
||||||
|
send: jest.fn(),
|
||||||
addEventListener: jest.fn(),
|
addEventListener: jest.fn(),
|
||||||
removeEventListener: jest.fn(),
|
removeEventListener: jest.fn(),
|
||||||
cleanup: jest.fn(),
|
cleanup: jest.fn(),
|
||||||
|
onmessage: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,6 +73,7 @@ describe("WebSocketGate", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest.clearAllTimers();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,11 +91,7 @@ describe("WebSocketGate", () => {
|
|||||||
it("should not create connection when user is not authenticated", () => {
|
it("should not create connection when user is not authenticated", () => {
|
||||||
const unauthStore = createMockStore(null, null);
|
const unauthStore = createMockStore(null, null);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={unauthStore} />);
|
||||||
<Provider store={unauthStore}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(createWebSocketConnection).not.toHaveBeenCalled();
|
expect(createWebSocketConnection).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -73,11 +99,7 @@ describe("WebSocketGate", () => {
|
|||||||
it("should create connection when user is authenticated", async () => {
|
it("should create connection when user is authenticated", async () => {
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -87,22 +109,14 @@ describe("WebSocketGate", () => {
|
|||||||
it("should close existing socket when user logs out", async () => {
|
it("should close existing socket when user logs out", async () => {
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const unauthStore = createMockStore(null, null);
|
const unauthStore = createMockStore(null, null);
|
||||||
rerender(
|
rerender(<TestWrapper store={unauthStore} />);
|
||||||
<Provider store={unauthStore}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockSocket.close).toHaveBeenCalled();
|
expect(mockSocket.close).toHaveBeenCalled();
|
||||||
@@ -114,11 +128,7 @@ describe("WebSocketGate", () => {
|
|||||||
it("should create connection with callbacks", async () => {
|
it("should create connection with callbacks", async () => {
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledWith(
|
expect(createWebSocketConnection).toHaveBeenCalledWith(
|
||||||
@@ -141,11 +151,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
@@ -172,11 +178,7 @@ describe("WebSocketGate", () => {
|
|||||||
new Error("Connection failed")
|
new Error("Connection failed")
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(consoleError).toHaveBeenCalledWith(
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
@@ -191,11 +193,7 @@ describe("WebSocketGate", () => {
|
|||||||
it("should close socket on component unmount", async () => {
|
it("should close socket on component unmount", async () => {
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
const { unmount } = render(
|
const { unmount } = render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
@@ -230,11 +228,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -275,11 +269,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -311,11 +301,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -346,11 +332,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -362,19 +344,11 @@ describe("WebSocketGate", () => {
|
|||||||
jest.advanceTimersByTime(1000);
|
jest.advanceTimersByTime(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second failure
|
expect(consoleLog).toHaveBeenCalledWith(
|
||||||
await act(async () => {
|
expect.stringContaining("(attempt 1/10)")
|
||||||
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
|
);
|
||||||
jest.advanceTimersByTime(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Success - should log and reset counter
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(consoleLog).toHaveBeenCalledWith(
|
|
||||||
"WebSocket connected successfully"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Success - should reset counter
|
||||||
// Next failure should start from attempt 1 again
|
// Next failure should start from attempt 1 again
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
|
onCloseCallback?.(new CloseEvent("close", { code: 1006 }));
|
||||||
@@ -400,11 +374,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -417,11 +387,7 @@ describe("WebSocketGate", () => {
|
|||||||
|
|
||||||
// Lose authentication before timeout fires
|
// Lose authentication before timeout fires
|
||||||
const unauthStore = createMockStore(null, null);
|
const unauthStore = createMockStore(null, null);
|
||||||
rerender(
|
rerender(<TestWrapper store={unauthStore} />);
|
||||||
<Provider store={unauthStore}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Advance timer
|
// Advance timer
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -444,11 +410,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { unmount } = render(
|
const { unmount } = render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -487,11 +449,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
||||||
@@ -540,11 +498,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -577,11 +531,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -609,11 +559,7 @@ describe("WebSocketGate", () => {
|
|||||||
it("should not reconnect when online event fires if already connected", async () => {
|
it("should not reconnect when online event fires if already connected", async () => {
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -640,11 +586,7 @@ describe("WebSocketGate", () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
@@ -693,11 +635,7 @@ describe("WebSocketGate", () => {
|
|||||||
connectingSocket
|
connectingSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
@@ -713,11 +651,7 @@ describe("WebSocketGate", () => {
|
|||||||
);
|
);
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
||||||
@@ -731,11 +665,7 @@ describe("WebSocketGate", () => {
|
|||||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [
|
||||||
@@ -764,11 +694,7 @@ describe("WebSocketGate", () => {
|
|||||||
);
|
);
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalled();
|
expect(registerToCalendars).toHaveBeenCalled();
|
||||||
@@ -795,11 +721,7 @@ describe("WebSocketGate", () => {
|
|||||||
);
|
);
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalled();
|
expect(registerToCalendars).toHaveBeenCalled();
|
||||||
@@ -829,11 +751,7 @@ describe("WebSocketGate", () => {
|
|||||||
throw new Error("Registration failed");
|
throw new Error("Registration failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(consoleError).toHaveBeenCalledWith(
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
@@ -868,11 +786,7 @@ describe("WebSocketGate", () => {
|
|||||||
localStorage.setItem("selectedCalendars", JSON.stringify([]));
|
localStorage.setItem("selectedCalendars", JSON.stringify([]));
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
@@ -888,11 +802,7 @@ describe("WebSocketGate", () => {
|
|||||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalledTimes(1);
|
expect(registerToCalendars).toHaveBeenCalledTimes(1);
|
||||||
@@ -913,11 +823,7 @@ describe("WebSocketGate", () => {
|
|||||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalled();
|
expect(registerToCalendars).toHaveBeenCalled();
|
||||||
@@ -945,11 +851,7 @@ describe("WebSocketGate", () => {
|
|||||||
);
|
);
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(registerToCalendars).toHaveBeenCalled();
|
expect(registerToCalendars).toHaveBeenCalled();
|
||||||
@@ -979,11 +881,7 @@ describe("WebSocketGate", () => {
|
|||||||
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
localStorage.setItem("selectedCalendars", JSON.stringify(["cal1"]));
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket);
|
||||||
|
|
||||||
render(
|
render(<TestWrapper store={store} />);
|
||||||
<Provider store={store}>
|
|
||||||
<WebSocketGate />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createWebSocketConnection).toHaveBeenCalled();
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
@@ -1004,4 +902,107 @@ describe("WebSocketGate", () => {
|
|||||||
expect(registerToCalendars).not.toHaveBeenCalled();
|
expect(registerToCalendars).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ping/Pong Integration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger reconnection when ping detects dead connection (via socket close)", async () => {
|
||||||
|
const consoleWarn = jest.spyOn(console, "warn").mockImplementation();
|
||||||
|
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
|
||||||
|
|
||||||
|
(createWebSocketConnection as jest.Mock).mockImplementation(
|
||||||
|
(callbacks) => {
|
||||||
|
onCloseCallback = callbacks.onClose;
|
||||||
|
return Promise.resolve(mockSocket);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TestWrapper store={store} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
(createWebSocketConnection as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
// In the real implementation, when ping detects dead connection,
|
||||||
|
// it calls socket.close() which triggers the onClose callback
|
||||||
|
// This simulates that flow
|
||||||
|
await act(async () => {
|
||||||
|
if (onCloseCallback) {
|
||||||
|
onCloseCallback(new CloseEvent("close", { code: 1006 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should schedule reconnection
|
||||||
|
expect(consoleWarn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("WebSocket closed unexpectedly")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance to trigger reconnection
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should reconnect
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleWarn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop ping monitoring when socket closes normally", async () => {
|
||||||
|
let onCloseCallback: ((event: CloseEvent) => void) | undefined;
|
||||||
|
|
||||||
|
(createWebSocketConnection as jest.Mock).mockImplementation(
|
||||||
|
(callbacks) => {
|
||||||
|
onCloseCallback = callbacks.onClose;
|
||||||
|
return Promise.resolve(mockSocket);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TestWrapper store={store} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normal close (code 1000) - like logout or page navigation
|
||||||
|
await act(async () => {
|
||||||
|
if (onCloseCallback) {
|
||||||
|
onCloseCallback(new CloseEvent("close", { code: 1000 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not attempt reconnection
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createWebSocketConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cleanup ping monitoring on component unmount", async () => {
|
||||||
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
|
const { unmount } = render(<TestWrapper store={store} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unmount should cleanup
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockSocket.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ var DEBUG = false;
|
|||||||
var LANG = "en";
|
var LANG = "en";
|
||||||
var WEBSOCKET_URL = "wss://calendar.example.com";
|
var WEBSOCKET_URL = "wss://calendar.example.com";
|
||||||
var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce
|
var WS_DEBOUNCE_PERIOD_MS = 100; // milliseconds, remove or set to 0 to disable debounce
|
||||||
|
var WS_PING_PERIOD_MS = 30000;
|
||||||
|
var WS_PING_TIMEOUT_PERIOD_MS = 35000;
|
||||||
|
|||||||
@@ -299,6 +299,13 @@
|
|||||||
"endDate": "End Date",
|
"endDate": "End Date",
|
||||||
"endTime": "End Time"
|
"endTime": "End Time"
|
||||||
},
|
},
|
||||||
|
"websocket": {
|
||||||
|
"browserOnline": "You’re back online. Reconnecting live updates…",
|
||||||
|
"browserOffline": "You’re offline. Live updates are paused until the connection is restored.",
|
||||||
|
"reconnected": "Live updates are back.",
|
||||||
|
"error": "There was a problem with live updates: %{error}",
|
||||||
|
"closedUnexpectedly": "Live updates were interrupted. Trying to reconnect…"
|
||||||
|
},
|
||||||
"months": {
|
"months": {
|
||||||
"standalone": {
|
"standalone": {
|
||||||
"0": "January",
|
"0": "January",
|
||||||
|
|||||||
@@ -300,6 +300,13 @@
|
|||||||
"endDate": "Date de la fin",
|
"endDate": "Date de la fin",
|
||||||
"endTime": "Heure de la fin"
|
"endTime": "Heure de la fin"
|
||||||
},
|
},
|
||||||
|
"websocket": {
|
||||||
|
"browserOnline": "Vous êtes de nouveau en ligne. Reconnexion des mises à jour en temps réel…",
|
||||||
|
"browserOffline": "Vous êtes hors ligne. Les mises à jour en temps réel sont mises en pause jusqu’au rétablissement de la connexion.",
|
||||||
|
"reconnected": "Les mises à jour en temps réel sont de nouveau actives.",
|
||||||
|
"error": "Un problème est survenu avec les mises à jour en temps réel : %{error}",
|
||||||
|
"closedUnexpectedly": "Les mises à jour en temps réel ont été interrompues. Tentative de reconnexion…"
|
||||||
|
},
|
||||||
"months": {
|
"months": {
|
||||||
"standalone": {
|
"standalone": {
|
||||||
"0": "Janvier",
|
"0": "Janvier",
|
||||||
|
|||||||
@@ -300,6 +300,13 @@
|
|||||||
"endDate": "Дата окончания",
|
"endDate": "Дата окончания",
|
||||||
"endTime": "Время окончания"
|
"endTime": "Время окончания"
|
||||||
},
|
},
|
||||||
|
"websocket": {
|
||||||
|
"browserOnline": "Вы снова в сети. Подключаем обновления в реальном времени…",
|
||||||
|
"browserOffline": "Вы не в сети. Обновления в реальном времени приостановлены до восстановления соединения.",
|
||||||
|
"reconnected": "Обновления в реальном времени снова работают.",
|
||||||
|
"error": "Произошла ошибка в обновлениях в реальном времени: %{error}",
|
||||||
|
"closedUnexpectedly": "Обновления в реальном времени были прерваны. Пробуем переподключиться…"
|
||||||
|
},
|
||||||
"months": {
|
"months": {
|
||||||
"standalone": {
|
"standalone": {
|
||||||
"0": "Январь",
|
"0": "Январь",
|
||||||
|
|||||||
@@ -298,6 +298,13 @@
|
|||||||
"endDate": "Ngày kết thúc",
|
"endDate": "Ngày kết thúc",
|
||||||
"endTime": "Giờ kết thúc"
|
"endTime": "Giờ kết thúc"
|
||||||
},
|
},
|
||||||
|
"websocket": {
|
||||||
|
"browserOnline": "Bạn đã trực tuyến trở lại. Đang kết nối lại cập nhật thời gian thực…",
|
||||||
|
"browserOffline": "Bạn đang ngoại tuyến. Cập nhật thời gian thực sẽ tạm dừng cho đến khi kết nối được khôi phục.",
|
||||||
|
"reconnected": "Cập nhật thời gian thực đã hoạt động trở lại.",
|
||||||
|
"error": "Đã xảy ra sự cố với cập nhật thời gian thực: %{error}",
|
||||||
|
"closedUnexpectedly": "Cập nhật thời gian thực đã bị gián đoạn. Đang thử kết nối lại…"
|
||||||
|
},
|
||||||
"months": {
|
"months": {
|
||||||
"standalone": {
|
"standalone": {
|
||||||
"0": "Tháng 1",
|
"0": "Tháng 1",
|
||||||
|
|||||||
+4
-1
@@ -58,7 +58,10 @@ class IntersectionObserverMock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(global as any).IntersectionObserver = IntersectionObserverMock;
|
(global as any).IntersectionObserver = IntersectionObserverMock;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
(window as any).WS_PING_PERIOD_MS = 5000;
|
||||||
|
(window as any).WS_PING_TIMEOUT_PERIOD_MS = 5000;
|
||||||
|
}
|
||||||
// Suppress jsdom CSS selector parsing errors for Emotion/MUI
|
// Suppress jsdom CSS selector parsing errors for Emotion/MUI
|
||||||
if (typeof window !== "undefined" && window.getComputedStyle) {
|
if (typeof window !== "undefined" && window.getComputedStyle) {
|
||||||
const originalGetComputedStyle = window.getComputedStyle;
|
const originalGetComputedStyle = window.getComputedStyle;
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
|||||||
import { AppDispatch } from "@/app/store";
|
import { AppDispatch } from "@/app/store";
|
||||||
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
|
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useI18n } from "twake-i18n";
|
||||||
import type { WebSocketWithCleanup } from "./connection";
|
import type { WebSocketWithCleanup } from "./connection";
|
||||||
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
|
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
|
||||||
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
|
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
|
||||||
import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect";
|
import { useWebSocketReconnect } from "./connection/lifecycle/useWebSocketReconnect";
|
||||||
import { updateCalendars } from "./messaging/updateCalendars";
|
import { updateCalendars } from "./messaging/updateCalendars";
|
||||||
import { syncCalendarRegistrations } from "./operations";
|
import { syncCalendarRegistrations } from "./operations";
|
||||||
|
import { WebSocketStatusSnackbar } from "./WebSocketStatusSnackbar";
|
||||||
|
import {
|
||||||
|
setupWebSocketPing,
|
||||||
|
type PingCleanup,
|
||||||
|
} from "./connection/lifecycle/pingWebSocket";
|
||||||
|
|
||||||
export function WebSocketGate() {
|
export function WebSocketGate() {
|
||||||
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
||||||
@@ -16,12 +22,19 @@ export function WebSocketGate() {
|
|||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
|
const pingCleanupRef = useRef<PingCleanup | null>(null);
|
||||||
|
|
||||||
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const connectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const CONNECT_TIMEOUT_MS = 10_000;
|
const CONNECT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
const hadSocketBeforeRef = useRef(false);
|
const hadSocketBeforeRef = useRef(false);
|
||||||
const justReconnectedRef = useRef(false);
|
const justReconnectedRef = useRef(false);
|
||||||
|
const [websocketStatus, setWebSocketStatus] = useState("");
|
||||||
|
const [websocketStatusSerity, setWebSocketStatusSerity] = useState<
|
||||||
|
"success" | "info" | "warning" | "error" | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const isAuthenticated = useAppSelector((state) =>
|
const isAuthenticated = useAppSelector((state) =>
|
||||||
@@ -81,6 +94,8 @@ export function WebSocketGate() {
|
|||||||
`WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || "none"}). ` +
|
`WebSocket closed unexpectedly (code: ${event.code}, reason: ${event.reason || "none"}). ` +
|
||||||
`Attempting to reconnect...`
|
`Attempting to reconnect...`
|
||||||
);
|
);
|
||||||
|
setWebSocketStatus(t("websocket.closedUnexpectedly"));
|
||||||
|
setWebSocketStatusSerity("warning");
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
} else {
|
} else {
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
@@ -92,6 +107,10 @@ export function WebSocketGate() {
|
|||||||
|
|
||||||
const onError = useCallback((error: Event) => {
|
const onError = useCallback((error: Event) => {
|
||||||
console.error("WebSocket error:", error);
|
console.error("WebSocket error:", error);
|
||||||
|
const errorMessage =
|
||||||
|
(error as ErrorEvent)?.message ?? error.type ?? "unknown";
|
||||||
|
setWebSocketStatus(t("websocket.error", { error: errorMessage }));
|
||||||
|
setWebSocketStatusSerity("error");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const callBacks = useMemo(
|
const callBacks = useMemo(
|
||||||
@@ -110,8 +129,6 @@ export function WebSocketGate() {
|
|||||||
// Reset reconnection state on successful connection and mark for calendar re-sync
|
// Reset reconnection state on successful connection and mark for calendar re-sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSocketOpen) {
|
if (isSocketOpen) {
|
||||||
console.log("WebSocket connected successfully");
|
|
||||||
|
|
||||||
if (connectTimeoutRef.current) {
|
if (connectTimeoutRef.current) {
|
||||||
clearTimeout(connectTimeoutRef.current);
|
clearTimeout(connectTimeoutRef.current);
|
||||||
connectTimeoutRef.current = null;
|
connectTimeoutRef.current = null;
|
||||||
@@ -119,6 +136,8 @@ export function WebSocketGate() {
|
|||||||
|
|
||||||
if (hadSocketBeforeRef.current) {
|
if (hadSocketBeforeRef.current) {
|
||||||
justReconnectedRef.current = true;
|
justReconnectedRef.current = true;
|
||||||
|
setWebSocketStatus(t("websocket.reconnected"));
|
||||||
|
setWebSocketStatusSerity("success");
|
||||||
}
|
}
|
||||||
|
|
||||||
hadSocketBeforeRef.current = true;
|
hadSocketBeforeRef.current = true;
|
||||||
@@ -227,7 +246,8 @@ export function WebSocketGate() {
|
|||||||
// Handle browser online/offline events
|
// Handle browser online/offline events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
console.log("Browser is online, attempting WebSocket reconnection");
|
setWebSocketStatus(t("websocket.browserOnline"));
|
||||||
|
setWebSocketStatusSerity("success");
|
||||||
if (!isSocketOpen && isAuthenticatedRef.current) {
|
if (!isSocketOpen && isAuthenticatedRef.current) {
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
clearReconnectTimeout();
|
clearReconnectTimeout();
|
||||||
@@ -236,9 +256,8 @@ export function WebSocketGate() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOffline = () => {
|
const handleOffline = () => {
|
||||||
console.log(
|
setWebSocketStatus(t("websocket.browserOffline"));
|
||||||
"Browser is offline, pausing WebSocket reconnection attempts"
|
setWebSocketStatusSerity("warning");
|
||||||
);
|
|
||||||
cleanupConnection();
|
cleanupConnection();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,5 +279,52 @@ export function WebSocketGate() {
|
|||||||
};
|
};
|
||||||
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout]);
|
}, [isSocketOpen, isAuthenticated, clearReconnectTimeout]);
|
||||||
|
|
||||||
return null;
|
useEffect(() => {
|
||||||
|
// Only set up ping if socket is open
|
||||||
|
if (!isSocketOpen || !socketRef.current) {
|
||||||
|
// Clean up existing ping if socket closed
|
||||||
|
if (pingCleanupRef.current) {
|
||||||
|
pingCleanupRef.current.stop();
|
||||||
|
pingCleanupRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up ping monitoring
|
||||||
|
const pingCleanup = setupWebSocketPing(socketRef.current, {
|
||||||
|
onConnectionDead: () => {
|
||||||
|
console.warn("WebSocket connection appears dead (no pong received)");
|
||||||
|
setWebSocketStatus(t("websocket.browserOffline"));
|
||||||
|
setWebSocketStatusSerity("warning");
|
||||||
|
|
||||||
|
// Trigger reconnection
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPingFail: () => {
|
||||||
|
console.warn("Failed to send ping");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pingCleanupRef.current = pingCleanup;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pingCleanupRef.current) {
|
||||||
|
pingCleanupRef.current.stop();
|
||||||
|
pingCleanupRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isSocketOpen]);
|
||||||
|
|
||||||
|
return websocketStatus ? (
|
||||||
|
<WebSocketStatusSnackbar
|
||||||
|
message={websocketStatus}
|
||||||
|
severity={websocketStatusSerity}
|
||||||
|
onClose={() => {
|
||||||
|
setWebSocketStatus("");
|
||||||
|
setWebSocketStatusSerity(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Alert, Button, Snackbar } from "@linagora/twake-mui";
|
||||||
|
import { useI18n } from "twake-i18n";
|
||||||
|
|
||||||
|
export function WebSocketStatusSnackbar({
|
||||||
|
message,
|
||||||
|
severity,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
severity: "success" | "info" | "warning" | "error" | undefined;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
open={!!message}
|
||||||
|
onClose={onClose}
|
||||||
|
autoHideDuration={
|
||||||
|
severity === "warning" || severity === "error" ? null : 6000
|
||||||
|
}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity={severity}
|
||||||
|
onClose={onClose}
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
action={
|
||||||
|
<Button color="inherit" size="small" onClick={onClose}>
|
||||||
|
{t("common.ok")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { WebSocketWithCleanup } from "../types";
|
||||||
|
|
||||||
|
export interface PingConfig {
|
||||||
|
/** Interval between ping attempts in milliseconds */
|
||||||
|
pingInterval?: number;
|
||||||
|
/** Timeout for pong response in milliseconds */
|
||||||
|
pongTimeout?: number;
|
||||||
|
/** Callback when connection is deemed dead */
|
||||||
|
onConnectionDead?: () => void;
|
||||||
|
/** Callback when ping fails */
|
||||||
|
onPingFail?: () => void;
|
||||||
|
/** Callback when pong is received successfully */
|
||||||
|
onPongReceived?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingCleanup {
|
||||||
|
stop: () => void;
|
||||||
|
/** Force send a ping immediately */
|
||||||
|
sendPing: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PING_INTERVAL = (window as any).WS_PING_PERIOD_MS ?? 30000;
|
||||||
|
const DEFAULT_PONG_TIMEOUT = (window as any).WS_PING_TIMEOUT_PERIOD_MS ?? 35000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a ping/pong mechanism to monitor WebSocket connection health
|
||||||
|
*
|
||||||
|
* @param socket - The WebSocket connection to monitor
|
||||||
|
* @param config - Configuration options for ping behavior
|
||||||
|
* @returns Cleanup object with stop() and sendPing() methods
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const pingCleanup = setupWebSocketPing(socket, {
|
||||||
|
* pingInterval: 30000,
|
||||||
|
* pongTimeout: 5000,
|
||||||
|
* onConnectionDead: () => {
|
||||||
|
* console.log('Connection is dead, reconnecting...');
|
||||||
|
* reconnect();
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Later, when closing the connection:
|
||||||
|
* pingCleanup.stop();
|
||||||
|
*/
|
||||||
|
export function setupWebSocketPing(
|
||||||
|
socket: WebSocketWithCleanup | null,
|
||||||
|
config: PingConfig = {}
|
||||||
|
): PingCleanup {
|
||||||
|
const {
|
||||||
|
pingInterval = DEFAULT_PING_INTERVAL,
|
||||||
|
pongTimeout = DEFAULT_PONG_TIMEOUT,
|
||||||
|
onConnectionDead,
|
||||||
|
onPingFail,
|
||||||
|
onPongReceived,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
let pingIntervalId: NodeJS.Timeout | null = null;
|
||||||
|
let pongTimeoutId: NodeJS.Timeout | null = null;
|
||||||
|
let isWaitingForPong = false;
|
||||||
|
let isStopped = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (pingIntervalId) {
|
||||||
|
clearInterval(pingIntervalId);
|
||||||
|
pingIntervalId = null;
|
||||||
|
}
|
||||||
|
if (pongTimeoutId) {
|
||||||
|
clearTimeout(pongTimeoutId);
|
||||||
|
pongTimeoutId = null;
|
||||||
|
}
|
||||||
|
isWaitingForPong = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendPing = () => {
|
||||||
|
if (isStopped || !socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're still waiting for a previous pong, connection might be dead
|
||||||
|
if (isWaitingForPong) {
|
||||||
|
console.warn(
|
||||||
|
"Pong not received for previous ping. Connection may be dead."
|
||||||
|
);
|
||||||
|
onPingFail?.();
|
||||||
|
onConnectionDead?.();
|
||||||
|
isStopped = true;
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
|
||||||
|
|
||||||
|
isWaitingForPong = true;
|
||||||
|
|
||||||
|
// Set timeout for pong response
|
||||||
|
pongTimeoutId = setTimeout(() => {
|
||||||
|
if (isWaitingForPong) {
|
||||||
|
console.warn("Pong timeout exceeded. Connection may be dead.");
|
||||||
|
onPingFail?.();
|
||||||
|
onConnectionDead?.();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}, pongTimeout);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send ping:", error);
|
||||||
|
onPingFail?.();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePong = () => {
|
||||||
|
if (pongTimeoutId) {
|
||||||
|
clearTimeout(pongTimeoutId);
|
||||||
|
pongTimeoutId = null;
|
||||||
|
}
|
||||||
|
isWaitingForPong = false;
|
||||||
|
onPongReceived?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start ping interval
|
||||||
|
const startPinging = () => {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
console.warn("Cannot start pinging: socket is not open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send first ping immediately
|
||||||
|
sendPing();
|
||||||
|
|
||||||
|
// Set up recurring pings
|
||||||
|
pingIntervalId = setInterval(() => {
|
||||||
|
sendPing();
|
||||||
|
}, pingInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up message listener for pong responses
|
||||||
|
const originalOnMessage = socket?.onmessage;
|
||||||
|
if (socket) {
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
let isPong = false;
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
// Check if it's an empty object {}
|
||||||
|
isPong =
|
||||||
|
typeof payload === "object" &&
|
||||||
|
payload !== null &&
|
||||||
|
Object.keys(payload).length === 0;
|
||||||
|
} catch {
|
||||||
|
// Non-JSON payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isPong) {
|
||||||
|
handlePong();
|
||||||
|
}
|
||||||
|
// Call original handler
|
||||||
|
originalOnMessage?.call(socket, event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the ping mechanism
|
||||||
|
startPinging();
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
isStopped = true;
|
||||||
|
cleanup();
|
||||||
|
// Restore original onmessage handler
|
||||||
|
if (socket) {
|
||||||
|
socket.onmessage = originalOnMessage ?? null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendPing: () => {
|
||||||
|
if (!isStopped) {
|
||||||
|
sendPing();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { WS_INBOUND_EVENTS } from "../protocols";
|
import { WS_INBOUND_EVENTS } from "../protocols";
|
||||||
|
|
||||||
export function parseMessage(message: unknown) {
|
export function parseMessage(message: unknown) {
|
||||||
console.log("WebSocket message received:", message);
|
|
||||||
const calendarsToRefresh = new Set<string>();
|
const calendarsToRefresh = new Set<string>();
|
||||||
const calendarsToHide = new Set<string>();
|
const calendarsToHide = new Set<string>();
|
||||||
if (typeof message !== "object" || message === null) {
|
if (typeof message !== "object" || message === null) {
|
||||||
@@ -20,6 +19,8 @@ export function parseMessage(message: unknown) {
|
|||||||
value.forEach((cal: string) => calendarsToHide.add(cal));
|
value.forEach((cal: string) => calendarsToHide.add(cal));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case WS_INBOUND_EVENTS.CALENDAR_CLIENT_REGISTERED:
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
calendarsToRefresh.add(key);
|
calendarsToRefresh.add(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export const WS_INBOUND_EVENTS = {
|
|||||||
CONNECTION_CLOSED: "close",
|
CONNECTION_CLOSED: "close",
|
||||||
CLIENT_REGISTERED: "registered",
|
CLIENT_REGISTERED: "registered",
|
||||||
CLIENT_UNREGISTERED: "unregistered",
|
CLIENT_UNREGISTERED: "unregistered",
|
||||||
|
CALENDAR_CLIENT_REGISTERED: "calendarListRegistered",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user