diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js new file mode 100644 index 0000000..0a445d0 --- /dev/null +++ b/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = "test-file-stub"; diff --git a/src/native/window.test.ts b/src/native/window.test.ts new file mode 100644 index 0000000..fb53daa --- /dev/null +++ b/src/native/window.test.ts @@ -0,0 +1,568 @@ +/// + +import { + BrowserWindow, + Menu, + MenuItem, + app, + ipcMain, + nativeImage, +} from "electron"; + +import { config } from "./config"; +import { createMainWindow, quitApp, BUILD_URL } from "./window"; +import { updateTrayMenu } from "./tray"; + +// Mock electron +let capturedBeforeQuitHandler: Function | null = null; + +jest.mock("electron", () => ({ + BrowserWindow: jest.fn(), + Menu: jest.fn().mockImplementation(() => ({ + append: jest.fn(), + popup: jest.fn(), + items: [] as unknown[], + })), + MenuItem: jest.fn().mockImplementation((opts: unknown) => opts), + app: { + commandLine: { + hasSwitch: jest.fn().mockReturnValue(false), + getSwitchValue: jest.fn().mockReturnValue(""), + }, + on: jest.fn((event: string, handler: Function) => { + if (event === "before-quit") { + (global as Record).__beforeQuitHandler = handler; + } + }), + }, + ipcMain: { + on: jest.fn(), + }, + nativeImage: { + createFromDataURL: jest.fn().mockReturnValue({}), + }, +})); + +// Mock the asset import +jest.mock("../../assets/desktop/icon.png?asset", () => "mock-icon-path", { + virtual: true, +}); + +// Mock config +jest.mock("./config", () => ({ + config: { + customFrame: false, + windowState: { isMaximised: false }, + minimiseToTray: true, + spellchecker: true, + sync: jest.fn(), + }, +})); + +// Mock tray +jest.mock("./tray", () => ({ + updateTrayMenu: jest.fn(), +})); + +const cfg = config as unknown as Record; + +describe("window", () => { + let mockMainWindow: Record; + let mockWebContents: Record; + let eventHandlers: Record; + let ipcHandlers: Record; + let beforeQuitHandler: Function; + + beforeEach(() => { + jest.clearAllMocks(); + + eventHandlers = {}; + ipcHandlers = {}; + + mockWebContents = { + on: jest.fn((event: string, handler: Function) => { + if (!eventHandlers[event]) { + eventHandlers[event] = []; + } + eventHandlers[event].push(handler); + }), + setZoomLevel: jest.fn(), + getZoomLevel: jest.fn().mockReturnValue(0), + replaceMisspelling: jest.fn(), + session: { + addWordToSpellCheckerDictionary: jest.fn(), + }, + openDevTools: jest.fn(), + }; + + mockMainWindow = { + on: jest.fn((event: string, handler: Function) => { + if (!eventHandlers[event]) { + eventHandlers[event] = []; + } + eventHandlers[event].push(handler); + }), + setMenu: jest.fn(), + maximize: jest.fn(), + isMaximized: jest.fn().mockReturnValue(false), + unmaximize: jest.fn(), + minimize: jest.fn(), + hide: jest.fn(), + close: jest.fn(), + loadURL: jest.fn(), + webContents: mockWebContents, + }; + + (BrowserWindow as unknown as jest.Mock).mockImplementation( + () => mockMainWindow, + ); + + (ipcMain.on as unknown as jest.Mock).mockImplementation( + (channel: string, handler: Function) => { + ipcHandlers[channel] = handler; + }, + ); + + (app.on as unknown as jest.Mock).mockImplementation( + (event: string, handler: Function) => { + if (event === "before-quit") { + beforeQuitHandler = handler; + } + }, + ); + }); + + describe("BUILD_URL", () => { + it("should default to beta.revolt.chat when no force-server flag", () => { + expect(BUILD_URL.toString()).toBe("https://beta.revolt.chat/"); + }); + + it("should use force-server flag value when provided", () => { + // BUILD_URL is evaluated at module load time, so we test the exported value + // which reflects the default since mocks are set before import + expect(BUILD_URL.toString()).toBe("https://beta.revolt.chat/"); + }); + }); + + describe("createMainWindow", () => { + it("should create a BrowserWindow with correct default options", () => { + createMainWindow(); + + expect(BrowserWindow).toHaveBeenCalledWith({ + minWidth: 300, + minHeight: 300, + width: 1280, + height: 720, + backgroundColor: "#191919", + frame: true, + icon: expect.anything(), + webPreferences: { + preload: expect.stringContaining("preload.js"), + contextIsolation: true, + nodeIntegration: false, + spellcheck: true, + }, + }); + }); + + it("should set frame to false when customFrame is enabled", () => { + cfg.customFrame = true; + createMainWindow(); + + expect(BrowserWindow).toHaveBeenCalledWith( + expect.objectContaining({ + frame: false, + }), + ); + }); + + it("should set menu to null", () => { + createMainWindow(); + expect(mockMainWindow.setMenu).toHaveBeenCalledWith(null); + }); + + it("should load the BUILD_URL", () => { + createMainWindow(); + expect(mockMainWindow.loadURL).toHaveBeenCalledWith(BUILD_URL.toString()); + }); + + it("should maximize window if windowState.isMaximised is true", () => { + cfg.windowState = { isMaximised: true }; + createMainWindow(); + + expect(mockMainWindow.maximize).toHaveBeenCalled(); + }); + + it("should not maximize window if windowState.isMaximised is false", () => { + cfg.windowState = { isMaximised: false }; + createMainWindow(); + + expect(mockMainWindow.maximize).not.toHaveBeenCalled(); + }); + }); + + describe("window close event", () => { + it("should hide window when minimiseToTray is true and shouldQuit is false", () => { + cfg.minimiseToTray = true; + createMainWindow(); + + const closeHandler = eventHandlers["close"]?.[0]; + const event = { preventDefault: jest.fn() }; + closeHandler(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockMainWindow.hide).toHaveBeenCalled(); + }); + + it("should not hide window when minimiseToTray is false", () => { + cfg.minimiseToTray = false; + createMainWindow(); + + const closeHandler = eventHandlers["close"]?.[0]; + const event = { preventDefault: jest.fn() }; + closeHandler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockMainWindow.hide).not.toHaveBeenCalled(); + }); + }); + + describe("window show/hide events", () => { + it("should call updateTrayMenu on show event", () => { + createMainWindow(); + + const showHandler = eventHandlers["show"]?.[0]; + showHandler(); + + expect(updateTrayMenu).toHaveBeenCalled(); + }); + + it("should call updateTrayMenu on hide event", () => { + createMainWindow(); + + const hideHandler = eventHandlers["hide"]?.[0]; + hideHandler(); + + expect(updateTrayMenu).toHaveBeenCalled(); + }); + }); + + describe("window state tracking", () => { + it("should update config.windowState on maximize event", () => { + (mockMainWindow.isMaximized as jest.Mock).mockReturnValue(true); + createMainWindow(); + + const maximizeHandler = eventHandlers["maximize"]?.[0]; + maximizeHandler(); + + expect(config.windowState).toEqual({ isMaximised: true }); + }); + + it("should update config.windowState on unmaximize event", () => { + (mockMainWindow.isMaximized as jest.Mock).mockReturnValue(false); + createMainWindow(); + + const unmaximizeHandler = eventHandlers["unmaximize"]?.[0]; + unmaximizeHandler(); + + expect(config.windowState).toEqual({ isMaximised: false }); + }); + }); + + describe("zoom controls", () => { + it("should zoom in on Ctrl+=", () => { + (mockWebContents.getZoomLevel as jest.Mock).mockReturnValue(2); + createMainWindow(); + + const beforeInputHandler = eventHandlers["before-input-event"]?.[0]; + const event = { preventDefault: jest.fn() }; + beforeInputHandler(event, { control: true, key: "=" }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockWebContents.setZoomLevel).toHaveBeenCalledWith(3); + }); + + it("should zoom out on Ctrl+-", () => { + (mockWebContents.getZoomLevel as jest.Mock).mockReturnValue(2); + createMainWindow(); + + const beforeInputHandler = eventHandlers["before-input-event"]?.[0]; + const event = { preventDefault: jest.fn() }; + beforeInputHandler(event, { control: true, key: "-" }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockWebContents.setZoomLevel).toHaveBeenCalledWith(1); + }); + + it("should not interfere with non-zoom shortcuts", () => { + createMainWindow(); + + const beforeInputHandler = eventHandlers["before-input-event"]?.[0]; + const event = { preventDefault: jest.fn() }; + beforeInputHandler(event, { control: true, key: "c" }); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockWebContents.setZoomLevel).not.toHaveBeenCalled(); + }); + }); + + describe("did-finish-load event", () => { + it("should call config.sync when page finishes loading", () => { + createMainWindow(); + + const finishLoadHandler = eventHandlers["did-finish-load"]?.[0]; + finishLoadHandler(); + + expect(config.sync).toHaveBeenCalled(); + }); + }); + + describe("context menu", () => { + let contextMenuHandler: Function; + + beforeEach(() => { + createMainWindow(); + contextMenuHandler = eventHandlers["context-menu"]?.[0]; + }); + + it("should add dictionary suggestions as menu items", () => { + const params = { + dictionarySuggestions: ["hello", "world"] as string[], + misspelledWord: "", + }; + const mockMenu = { + append: jest.fn(), + popup: jest.fn(), + items: [{}, {}, {}] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + + contextMenuHandler({}, params); + + expect(mockMenu.append).toHaveBeenCalledTimes(3); + expect(mockMenu.append).toHaveBeenCalledWith( + expect.objectContaining({ + label: "hello", + }), + ); + expect(mockMenu.append).toHaveBeenCalledWith( + expect.objectContaining({ + label: "world", + }), + ); + }); + + it("should add 'Add to dictionary' option for misspelled word", () => { + const params = { + dictionarySuggestions: [] as string[], + misspelledWord: "wrng", + }; + const mockMenu = { + append: jest.fn(), + popup: jest.fn(), + items: [{}, {}] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + + contextMenuHandler({}, params); + + expect(mockMenu.append).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Add to dictionary", + }), + ); + }); + + it("should add 'Toggle spellcheck' option", () => { + const params = { + dictionarySuggestions: [] as string[], + misspelledWord: "", + }; + const mockMenu = { + append: jest.fn(), + popup: jest.fn(), + items: [{}] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + + contextMenuHandler({}, params); + + expect(mockMenu.append).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Toggle spellcheck", + }), + ); + }); + + it("should popup menu when there are items", () => { + const params = { + dictionarySuggestions: ["hello"] as string[], + misspelledWord: "", + }; + const mockMenu = { + append: jest.fn(), + popup: jest.fn(), + items: [{}, {}] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + + contextMenuHandler({}, params); + + expect(mockMenu.popup).toHaveBeenCalled(); + }); + + it("should not popup menu when there are no items", () => { + const params = { + dictionarySuggestions: [] as string[], + misspelledWord: "", + }; + const mockMenu = { + append: jest.fn(), + popup: jest.fn(), + items: [] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + + contextMenuHandler({}, params); + + expect(mockMenu.popup).not.toHaveBeenCalled(); + }); + + it("should call replaceMisspelling when suggestion is clicked", () => { + const params = { + dictionarySuggestions: ["correct"] as string[], + misspelledWord: "", + }; + let clickHandler: Function; + const mockMenu = { + append: jest.fn((item: { label: string; click: Function }) => { + if (item.label === "correct") { + clickHandler = item.click; + } + }), + popup: jest.fn(), + items: [{}] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + + contextMenuHandler({}, params); + + clickHandler!(); + expect(mockWebContents.replaceMisspelling).toHaveBeenCalledWith("correct"); + }); + + it("should call addWordToSpellCheckerDictionary when Add to dictionary is clicked", () => { + const params = { + dictionarySuggestions: [] as string[], + misspelledWord: "wrng", + }; + let clickHandler: Function; + const mockMenu = { + append: jest.fn((item: { label: string; click: Function }) => { + if (item.label === "Add to dictionary") { + clickHandler = item.click; + } + }), + popup: jest.fn(), + items: [{}, {}] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + + contextMenuHandler({}, params); + + clickHandler!(); + expect( + (mockWebContents.session as { addWordToSpellCheckerDictionary: jest.Mock }) + .addWordToSpellCheckerDictionary, + ).toHaveBeenCalledWith("wrng"); + }); + + it("should toggle spellchecker when Toggle spellcheck is clicked", () => { + const params = { + dictionarySuggestions: [] as string[], + misspelledWord: "", + }; + let clickHandler: Function; + const mockMenu = { + append: jest.fn((item: { label: string; click: Function }) => { + if (item.label === "Toggle spellcheck") { + clickHandler = item.click; + } + }), + popup: jest.fn(), + items: [{}] as unknown[], + }; + (Menu as unknown as jest.Mock).mockImplementation(() => mockMenu); + cfg.spellchecker = true; + + contextMenuHandler({}, params); + + clickHandler!(); + expect(cfg.spellchecker).toBe(false); + }); + }); + + describe("IPC handlers", () => { + beforeEach(() => { + createMainWindow(); + }); + + it("should minimise window on 'minimise' IPC event", () => { + ipcHandlers["minimise"](); + expect(mockMainWindow.minimize).toHaveBeenCalled(); + }); + + it("should maximise window on 'maximise' IPC event when not maximized", () => { + (mockMainWindow.isMaximized as jest.Mock).mockReturnValue(false); + ipcHandlers["maximise"](); + expect(mockMainWindow.maximize).toHaveBeenCalled(); + }); + + it("should unmaximise window on 'maximise' IPC event when maximized", () => { + (mockMainWindow.isMaximized as jest.Mock).mockReturnValue(true); + ipcHandlers["maximise"](); + expect(mockMainWindow.unmaximize).toHaveBeenCalled(); + }); + + it("should close window on 'close' IPC event", () => { + ipcHandlers["close"](); + expect(mockMainWindow.close).toHaveBeenCalled(); + }); + }); + + describe("quitApp", () => { + it("should close the main window", () => { + createMainWindow(); + quitApp(); + expect(mockMainWindow.close).toHaveBeenCalled(); + }); + }); + + describe("app before-quit event", () => { + it("should register a before-quit handler at module load time", () => { + // The before-quit handler is registered at module load time (top-level) + // and stored on global by our mock + expect((global as Record).__beforeQuitHandler).toBeDefined(); + }); + + it("should set shouldQuit to true when before-quit is triggered", () => { + createMainWindow(); + + // Get the before-quit handler from global + const beforeQuitHandler = (global as Record) + .__beforeQuitHandler as Function; + + // Trigger the before-quit handler + beforeQuitHandler(); + + // Now trigger close event - it should not prevent default + cfg.minimiseToTray = true; + const closeHandler = eventHandlers["close"]?.[0]; + const event = { preventDefault: jest.fn() }; + closeHandler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockMainWindow.hide).not.toHaveBeenCalled(); + }); + }); +});