From 077b2089b3ace29556b401320c85443346350586 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 31 May 2022 10:39:33 +0300 Subject: [PATCH] Serialize messages in channels to make IPC not blow up Co-authored-by: Mikko Aspiala Signed-off-by: Janne Savolainen --- src/common/utils/tentative-parse-json.ts | 15 ++ src/common/utils/tentative-stringify-json.ts | 15 ++ ...ist-message-channel-listener.injectable.ts | 11 +- .../enlist-message-channel-listener.test.ts | 97 ++++++++++ ...ist-request-channel-listener.injectable.ts | 6 +- .../enlist-request-channel-listener.test.ts | 147 +++++++++++++++ .../channel/message-to-channel.injectable.ts | 6 +- src/main/channel/message-to-channel.test.ts | 167 ++++++++++++++++++ ...ist-message-channel-listener.injectable.ts | 11 +- .../enlist-message-channel-listener.test.ts | 97 ++++++++++ .../channel/message-to-channel.test.ts | 57 ++++++ .../request-from-channel.injectable.ts | 11 +- .../channel/request-from-channel.test.ts | 121 +++++++++++++ .../channel/send-to-main.injectable.ts | 5 +- .../override-messaging-from-main-to-window.ts | 3 +- 15 files changed, 760 insertions(+), 9 deletions(-) create mode 100644 src/common/utils/tentative-parse-json.ts create mode 100644 src/common/utils/tentative-stringify-json.ts create mode 100644 src/main/channel/channel-listeners/enlist-message-channel-listener.test.ts create mode 100644 src/main/channel/channel-listeners/enlist-request-channel-listener.test.ts create mode 100644 src/main/channel/message-to-channel.test.ts create mode 100644 src/renderer/channel/channel-listeners/enlist-message-channel-listener.test.ts create mode 100644 src/renderer/channel/message-to-channel.test.ts create mode 100644 src/renderer/channel/request-from-channel.test.ts diff --git a/src/common/utils/tentative-parse-json.ts b/src/common/utils/tentative-parse-json.ts new file mode 100644 index 0000000000..a0cb089a74 --- /dev/null +++ b/src/common/utils/tentative-parse-json.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { defaultTo } from "lodash/fp"; +import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; + +export const tentativeParseJson = (toBeParsed: any) => pipeline( + toBeParsed, + withErrorSuppression(JSON.parse), + defaultTo(toBeParsed), +); + + diff --git a/src/common/utils/tentative-stringify-json.ts b/src/common/utils/tentative-stringify-json.ts new file mode 100644 index 0000000000..dc7206be7c --- /dev/null +++ b/src/common/utils/tentative-stringify-json.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { defaultTo } from "lodash/fp"; +import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; + +export const tentativeStringifyJson = (toBeParsed: any) => pipeline( + toBeParsed, + withErrorSuppression(JSON.stringify), + defaultTo(toBeParsed), +); + + diff --git a/src/main/channel/channel-listeners/enlist-message-channel-listener.injectable.ts b/src/main/channel/channel-listeners/enlist-message-channel-listener.injectable.ts index 37db20de3f..3315760857 100644 --- a/src/main/channel/channel-listeners/enlist-message-channel-listener.injectable.ts +++ b/src/main/channel/channel-listeners/enlist-message-channel-listener.injectable.ts @@ -6,6 +6,8 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { IpcMainEvent } from "electron"; import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; import { enlistMessageChannelListenerInjectionToken } from "../../../common/channel/enlist-message-channel-listener-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeParseJson } from "../../../common/utils/tentative-parse-json"; const enlistMessageChannelListenerInjectable = getInjectable({ id: "enlist-message-channel-listener-for-main", @@ -14,8 +16,13 @@ const enlistMessageChannelListenerInjectable = getInjectable({ const ipcMain = di.inject(ipcMainInjectable); return ({ channel, handler }) => { - const nativeOnCallback = (_: IpcMainEvent, message: unknown) => - handler(message); + const nativeOnCallback = (_: IpcMainEvent, message: unknown) => { + pipeline( + message, + tentativeParseJson, + handler, + ); + }; ipcMain.on(channel.id, nativeOnCallback); diff --git a/src/main/channel/channel-listeners/enlist-message-channel-listener.test.ts b/src/main/channel/channel-listeners/enlist-message-channel-listener.test.ts new file mode 100644 index 0000000000..8299d91d11 --- /dev/null +++ b/src/main/channel/channel-listeners/enlist-message-channel-listener.test.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { EnlistMessageChannelListener } from "../../../common/channel/enlist-message-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "../../../common/channel/enlist-message-channel-listener-injection-token"; +import type { IpcMain, IpcMainEvent } from "electron"; + +describe("enlist message channel listener in main", () => { + let enlistMessageChannelListener: EnlistMessageChannelListener; + let ipcMainStub: IpcMain; + let onMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + onMock = jest.fn(); + offMock = jest.fn(); + + ipcMainStub = { + on: onMock, + off: offMock, + } as unknown as IpcMain; + + di.override(ipcMainInjectable, () => ipcMainStub); + + enlistMessageChannelListener = di.inject( + enlistMessageChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: jest.Mock; + let disposer: () => void; + + beforeEach(() => { + handlerMock = jest.fn(); + + disposer = enlistMessageChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(onMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when message arrives", () => { + beforeEach(() => { + onMock.mock.calls[0][1]({} as IpcMainEvent, "some-message"); + }); + + it("calls the handler with the message", () => { + expect(handlerMock).toHaveBeenCalledWith("some-message"); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/main/channel/channel-listeners/enlist-request-channel-listener.injectable.ts b/src/main/channel/channel-listeners/enlist-request-channel-listener.injectable.ts index bd4d49757f..2ca6d9aa71 100644 --- a/src/main/channel/channel-listeners/enlist-request-channel-listener.injectable.ts +++ b/src/main/channel/channel-listeners/enlist-request-channel-listener.injectable.ts @@ -6,6 +6,9 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { IpcMainInvokeEvent } from "electron"; import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; import { enlistRequestChannelListenerInjectionToken } from "../../../common/channel/enlist-request-channel-listener-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeParseJson } from "../../../common/utils/tentative-parse-json"; +import { tentativeStringifyJson } from "../../../common/utils/tentative-stringify-json"; const enlistRequestChannelListenerInjectable = getInjectable({ id: "enlist-request-channel-listener-for-main", @@ -14,7 +17,8 @@ const enlistRequestChannelListenerInjectable = getInjectable({ const ipcMain = di.inject(ipcMainInjectable); return ({ channel, handler }) => { - const nativeHandleCallback = (_: IpcMainInvokeEvent, message: unknown) => handler(message); + const nativeHandleCallback = (_: IpcMainInvokeEvent, request: unknown) => + pipeline(request, tentativeParseJson, handler, tentativeStringifyJson); ipcMain.handle(channel.id, nativeHandleCallback); diff --git a/src/main/channel/channel-listeners/enlist-request-channel-listener.test.ts b/src/main/channel/channel-listeners/enlist-request-channel-listener.test.ts new file mode 100644 index 0000000000..719c288a17 --- /dev/null +++ b/src/main/channel/channel-listeners/enlist-request-channel-listener.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { IpcMain, IpcMainInvokeEvent } from "electron"; +import type { EnlistRequestChannelListener } from "../../../common/channel/enlist-request-channel-listener-injection-token"; +import { enlistRequestChannelListenerInjectionToken } from "../../../common/channel/enlist-request-channel-listener-injection-token"; +import { getPromiseStatus } from "../../../common/test-utils/get-promise-status"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; + +describe("enlist request channel listener in main", () => { + let enlistRequestChannelListener: EnlistRequestChannelListener; + let ipcMainStub: IpcMain; + let handleMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + handleMock = jest.fn(); + offMock = jest.fn(); + + ipcMainStub = { + handle: handleMock, + off: offMock, + } as unknown as IpcMain; + + di.override(ipcMainInjectable, () => ipcMainStub); + + enlistRequestChannelListener = di.inject( + enlistRequestChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: AsyncFnMock<(message: any) => any>; + let disposer: () => void; + + beforeEach(() => { + handlerMock = asyncFn(); + + disposer = enlistRequestChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(handleMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when request arrives", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = handleMock.mock.calls[0][1]( + {} as IpcMainInvokeEvent, + "some-request", + ); + }); + + it("calls the handler with the request", () => { + expect(handlerMock).toHaveBeenCalledWith("some-request"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when handler resolves with response, listener resolves with the response", () => { + beforeEach(async () => { + await handlerMock.resolve("some-response"); + }); + + it("resolves with the response", async () => { + const actual = await actualPromise; + + expect(actual).toBe('"some-response"'); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve(42); + + const actual = await actualPromise; + + expect(actual).toBe("42"); + }); + + it("given boolean as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve(true); + + const actual = await actualPromise; + + expect(actual).toBe("true"); + }); + + it("given object as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve({ some: "object" }); + + const actual = await actualPromise; + + expect(actual).toBe(JSON.stringify({ some: "object" })); + }); + }); + + it("given number as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/main/channel/message-to-channel.injectable.ts b/src/main/channel/message-to-channel.injectable.ts index 072726a52b..8d423da307 100644 --- a/src/main/channel/message-to-channel.injectable.ts +++ b/src/main/channel/message-to-channel.injectable.ts @@ -8,6 +8,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { filter } from "lodash/fp"; import { messageToChannelInjectionToken } from "../../common/channel/message-to-channel-injection-token"; import type { MessageChannel } from "../../common/channel/message-channel-injection-token"; +import { tentativeStringifyJson } from "../../common/utils/tentative-stringify-json"; const messageToChannelInjectable = getInjectable({ id: "message-to-channel", @@ -18,13 +19,16 @@ const messageToChannelInjectable = getInjectable({ // TODO: Figure out way to improve typing in internals // Notice that this should be injected using "messageToChannelInjectionToken" which is typed correctly. return (channel: MessageChannel, message?: unknown) => { + const stringifiedMessage = tentativeStringifyJson(message); + + const visibleWindows = pipeline( getAllLensWindows(), filter((lensWindow) => !!lensWindow.visible), ); visibleWindows.forEach((lensWindow) => - lensWindow.send({ channel: channel.id, data: message ? [message] : [] }), + lensWindow.send({ channel: channel.id, data: stringifiedMessage ? [stringifiedMessage] : [] }), ); }; }, diff --git a/src/main/channel/message-to-channel.test.ts b/src/main/channel/message-to-channel.test.ts new file mode 100644 index 0000000000..c5829ea596 --- /dev/null +++ b/src/main/channel/message-to-channel.test.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MessageToChannel } from "../../common/channel/message-to-channel-injection-token"; +import { messageToChannelInjectionToken } from "../../common/channel/message-to-channel-injection-token"; +import closeAllWindowsInjectable from "../start-main-application/lens-window/hide-all-windows/close-all-windows.injectable"; +import type { MessageChannel } from "../../common/channel/message-channel-injection-token"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import createLensWindowInjectable from "../start-main-application/lens-window/application-window/create-lens-window.injectable"; +import type { LensWindow } from "../start-main-application/lens-window/application-window/lens-window-injection-token"; +import { lensWindowInjectionToken } from "../start-main-application/lens-window/application-window/lens-window-injection-token"; +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import sendToChannelInElectronBrowserWindowInjectable from "../start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable"; + +describe("message to channel from main", () => { + let messageToChannel: MessageToChannel; + let someTestWindow: LensWindow; + let someOtherTestWindow: LensWindow; + let sendToChannelInBrowserMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + sendToChannelInBrowserMock = jest.fn(); + di.override(sendToChannelInElectronBrowserWindowInjectable, () => sendToChannelInBrowserMock); + + someTestWindow = createTestWindow(di, "some-test-window-id"); + someOtherTestWindow = createTestWindow(di, "some-other-test-window-id"); + + messageToChannel = di.inject(messageToChannelInjectionToken); + + const closeAllWindows = di.inject(closeAllWindowsInjectable); + + closeAllWindows(); + }); + + it("given no visible windows, when messaging to channel, does not message to any window", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock).not.toHaveBeenCalled(); + }); + + describe("given visible window", () => { + beforeEach(async () => { + await someTestWindow.show(); + }); + + it("when messaging to channel, messages to window", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + ]); + }); + + it("given boolean as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, true); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ["true"], + }, + ], + ]); + }); + + it("given number as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, 42); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ["42"], + }, + ], + ]); + }); + + it("given object as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, { some: "object" }); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: [JSON.stringify({ some: "object" })], + }, + ], + ]); + }); + }); + + it("given multiple visible windows, when messaging to channel, messages to window", async () => { + await someTestWindow.show(); + await someOtherTestWindow.show(); + + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + ]); + }); +}); + +const someChannel: MessageChannel = { id: "some-channel" }; + +const createTestWindow = (di: DiContainer, id: string) => { + const testWindowInjectable = getInjectable({ + id, + + instantiate: (di) => { + const createLensWindow = di.inject(createLensWindowInjectable); + + return createLensWindow({ + id, + title: "Some test window", + defaultHeight: 42, + defaultWidth: 42, + getContentUrl: () => "some-content-url", + resizable: true, + windowFrameUtilitiesAreShown: false, + centered: false, + }); + }, + + injectionToken: lensWindowInjectionToken, + }); + + di.register(testWindowInjectable); + + return di.inject(testWindowInjectable); +}; diff --git a/src/renderer/channel/channel-listeners/enlist-message-channel-listener.injectable.ts b/src/renderer/channel/channel-listeners/enlist-message-channel-listener.injectable.ts index ed9736c454..23dbf90602 100644 --- a/src/renderer/channel/channel-listeners/enlist-message-channel-listener.injectable.ts +++ b/src/renderer/channel/channel-listeners/enlist-message-channel-listener.injectable.ts @@ -6,6 +6,8 @@ import ipcRendererInjectable from "../ipc-renderer.injectable"; import { getInjectable } from "@ogre-tools/injectable"; import type { IpcRendererEvent } from "electron"; import { enlistMessageChannelListenerInjectionToken } from "../../../common/channel/enlist-message-channel-listener-injection-token"; +import { tentativeParseJson } from "../../../common/utils/tentative-parse-json"; +import { pipeline } from "@ogre-tools/fp"; const enlistMessageChannelListenerInjectable = getInjectable({ id: "enlist-message-channel-listener-for-renderer", @@ -14,8 +16,13 @@ const enlistMessageChannelListenerInjectable = getInjectable({ const ipcRenderer = di.inject(ipcRendererInjectable); return ({ channel, handler }) => { - const nativeCallback = (_: IpcRendererEvent, message: unknown) => - handler(message); + const nativeCallback = (_: IpcRendererEvent, message: unknown) => { + pipeline( + message, + tentativeParseJson, + handler, + ); + }; ipcRenderer.on(channel.id, nativeCallback); diff --git a/src/renderer/channel/channel-listeners/enlist-message-channel-listener.test.ts b/src/renderer/channel/channel-listeners/enlist-message-channel-listener.test.ts new file mode 100644 index 0000000000..316252808e --- /dev/null +++ b/src/renderer/channel/channel-listeners/enlist-message-channel-listener.test.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { EnlistMessageChannelListener } from "../../../common/channel/enlist-message-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "../../../common/channel/enlist-message-channel-listener-injection-token"; +import type { IpcRendererEvent, IpcRenderer } from "electron"; +import ipcRendererInjectable from "../ipc-renderer.injectable"; + +describe("enlist message channel listener in renderer", () => { + let enlistMessageChannelListener: EnlistMessageChannelListener; + let ipcRendererStub: IpcRenderer; + let onMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + onMock = jest.fn(); + offMock = jest.fn(); + + ipcRendererStub = { + on: onMock, + off: offMock, + } as unknown as IpcRenderer; + + di.override(ipcRendererInjectable, () => ipcRendererStub); + + enlistMessageChannelListener = di.inject( + enlistMessageChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: jest.Mock; + let disposer: () => void; + + beforeEach(() => { + handlerMock = jest.fn(); + + disposer = enlistMessageChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(onMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when message arrives", () => { + beforeEach(() => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, "some-message"); + }); + + it("calls the handler with the message", () => { + expect(handlerMock).toHaveBeenCalledWith("some-message"); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/renderer/channel/message-to-channel.test.ts b/src/renderer/channel/message-to-channel.test.ts new file mode 100644 index 0000000000..dfca81ccf5 --- /dev/null +++ b/src/renderer/channel/message-to-channel.test.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MessageToChannel } from "../../common/channel/message-to-channel-injection-token"; +import type { MessageChannel } from "../../common/channel/message-channel-injection-token"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import { messageToChannelInjectionToken } from "../../common/channel/message-to-channel-injection-token"; +import ipcRendererInjectable from "./ipc-renderer.injectable"; +import type { IpcRenderer } from "electron"; + +describe("message to channel from renderer", () => { + let messageToChannel: MessageToChannel; + let sendMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + sendMock = jest.fn(); + + di.override(ipcRendererInjectable, () => ({ + send: sendMock, + }) as unknown as IpcRenderer); + + messageToChannel = di.inject(messageToChannelInjectionToken); + }); + + it("given string as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendMock).toHaveBeenCalledWith("some-channel-id", '"some-message"'); + }); + + it("given boolean as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, true); + + expect(sendMock).toHaveBeenCalledWith("some-channel-id", "true"); + }); + + it("given number as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, 42); + + expect(sendMock).toHaveBeenCalledWith("some-channel-id", "42"); + }); + + it("given object as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, { some: "object" }); + + expect(sendMock).toHaveBeenCalledWith( + "some-channel-id", + JSON.stringify({ some: "object" }), + ); + }); +}); + +const someChannel: MessageChannel = { id: "some-channel-id" }; diff --git a/src/renderer/channel/request-from-channel.injectable.ts b/src/renderer/channel/request-from-channel.injectable.ts index ec1be037b7..ca5757b9a2 100644 --- a/src/renderer/channel/request-from-channel.injectable.ts +++ b/src/renderer/channel/request-from-channel.injectable.ts @@ -5,6 +5,9 @@ import { getInjectable } from "@ogre-tools/injectable"; import ipcRendererInjectable from "./ipc-renderer.injectable"; import { requestFromChannelInjectionToken } from "../../common/channel/request-from-channel-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeStringifyJson } from "../../common/utils/tentative-stringify-json"; +import { tentativeParseJson } from "../../common/utils/tentative-parse-json"; const requestFromChannelInjectable = getInjectable({ id: "request-from-channel", @@ -12,7 +15,13 @@ const requestFromChannelInjectable = getInjectable({ instantiate: (di) => { const ipcRenderer = di.inject(ipcRendererInjectable); - return (channel, ...[request]) => ipcRenderer.invoke(channel.id, request); + return async (channel, ...[request]) => + await pipeline( + request, + tentativeStringifyJson, + (req) => ipcRenderer.invoke(channel.id, req), + tentativeParseJson, + ); }, injectionToken: requestFromChannelInjectionToken, diff --git a/src/renderer/channel/request-from-channel.test.ts b/src/renderer/channel/request-from-channel.test.ts new file mode 100644 index 0000000000..a6c7581042 --- /dev/null +++ b/src/renderer/channel/request-from-channel.test.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MessageChannel } from "../../common/channel/message-channel-injection-token"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import ipcRendererInjectable from "./ipc-renderer.injectable"; +import type { IpcRenderer } from "electron"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { RequestFromChannel } from "../../common/channel/request-from-channel-injection-token"; +import { requestFromChannelInjectionToken } from "../../common/channel/request-from-channel-injection-token"; +import requestFromChannelInjectable from "./request-from-channel.injectable"; +import { getPromiseStatus } from "../../common/test-utils/get-promise-status"; + +describe("request from channel in renderer", () => { + let requestFromChannel: RequestFromChannel; + let invokeMock: AsyncFnMock<(channelId: string, request: any) => any>; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.unoverride(requestFromChannelInjectable); + + invokeMock = asyncFn(); + + di.override(ipcRendererInjectable, () => ({ + invoke: invokeMock, + }) as unknown as IpcRenderer); + + requestFromChannel = di.inject(requestFromChannelInjectionToken); + }); + + describe("when messaging to channel", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = requestFromChannel(someChannel, "some-message"); + }); + + it("sends stringified message", () => { + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", '"some-message"'); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when invoking resolves, resolves", async () => { + await invokeMock.resolve("some-response"); + + const actual = await actualPromise; + + expect(actual).toBe("some-response"); + }); + + it("when invoking resolves with stringified string, resolves with string", async () => { + await invokeMock.resolve('"some-response"'); + + const actual = await actualPromise; + + expect(actual).toBe("some-response"); + }); + + it("when invoking resolves with stringified boolean, resolves with boolean", async () => { + await invokeMock.resolve("true"); + + const actual = await actualPromise; + + expect(actual).toBe(true); + }); + + it("when invoking resolves with stringified number, resolves with number", async () => { + await invokeMock.resolve("42"); + + const actual = await actualPromise; + + expect(actual).toBe(42); + }); + + it("when invoking resolves with stringified object, resolves with object", async () => { + await invokeMock.resolve(JSON.stringify({ some: "object" })); + + const actual = await actualPromise; + + expect(actual).toEqual({ some: "object" }); + }); + }); + + it("given string as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, "some-message"); + + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", '"some-message"'); + }); + + it("given boolean as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, true); + + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", "true"); + }); + + it("given number as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, 42); + + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", "42"); + }); + + it("given object as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, { some: "object" }); + + expect(invokeMock).toHaveBeenCalledWith( + "some-channel-id", + JSON.stringify({ some: "object" }), + ); + }); +}); + +const someChannel: MessageChannel = { id: "some-channel-id" }; diff --git a/src/renderer/channel/send-to-main.injectable.ts b/src/renderer/channel/send-to-main.injectable.ts index e9f68a9324..bb6d05c1dd 100644 --- a/src/renderer/channel/send-to-main.injectable.ts +++ b/src/renderer/channel/send-to-main.injectable.ts @@ -5,6 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { JsonValue } from "type-fest"; import ipcRendererInjectable from "./ipc-renderer.injectable"; +import { tentativeStringifyJson } from "../../common/utils/tentative-stringify-json"; const sendToMainInjectable = getInjectable({ id: "send-to-main", @@ -14,7 +15,9 @@ const sendToMainInjectable = getInjectable({ // TODO: Figure out way to improve typing in internals return (channelId: string, message: JsonValue extends T ? T : never ) => { - ipcRenderer.send(channelId, ...(message ? [message] : [])); + const stringifiedMessage = tentativeStringifyJson(message); + + ipcRenderer.send(channelId, ...(stringifiedMessage ? [stringifiedMessage] : [])); }; }, }); diff --git a/src/test-utils/channel-fakes/override-messaging-from-main-to-window.ts b/src/test-utils/channel-fakes/override-messaging-from-main-to-window.ts index 01af393020..bd318343ca 100644 --- a/src/test-utils/channel-fakes/override-messaging-from-main-to-window.ts +++ b/src/test-utils/channel-fakes/override-messaging-from-main-to-window.ts @@ -9,6 +9,7 @@ import type { SendToViewArgs } from "../../main/start-main-application/lens-wind import enlistMessageChannelListenerInjectableInRenderer from "../../renderer/channel/channel-listeners/enlist-message-channel-listener.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; import assert from "assert"; +import { tentativeParseJson } from "../../common/utils/tentative-parse-json"; export const overrideMessagingFromMainToWindow = (mainDi: DiContainer) => { const messageChannelListenerFakesForRenderer = new Map< @@ -47,7 +48,7 @@ export const overrideMessagingFromMainToWindow = (mainDi: DiContainer) => { ); } - const message = data[0]; + const message = tentativeParseJson(data[0]); listeners.forEach((listener) => listener.handler(message)); },