From f4e18a9fba9410ba0293b4c80ec93a2eb7bfa601 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Mon, 13 Mar 2023 08:18:12 +0200 Subject: [PATCH] Extract messaging to NPM package Co-authored-by: Mikko Aspiala Signed-off-by: Janne Savolainen --- package-lock.json | 180 +++-- .../utils/channel/get-request-channel.ts | 9 - .../messaging/agnostic/index.ts | 2 + .../messaging/agnostic/jest.config.js | 2 + .../messaging/agnostic/package.json | 45 ++ .../features/actual/channel.no-coverage.ts | 5 + .../computed-channel.injectable.ts | 211 ++++++ .../computed-channel.test.tsx | 614 ++++++++++++++++++ .../agnostic/src/features/actual/feature.ts | 20 + .../agnostic/src/features/actual/index.ts | 64 ++ .../listening-of-channels.injectable.ts | 118 ++++ .../start-listening-of-channels.injectable.ts | 21 + ...essage-channel-listener-injection-token.ts | 15 + .../actual/message/get-message-channel.ts | 7 + ...essage-channel-listener-injection-token.ts | 54 ++ ...-to-channel-injection-token.no-coverage.ts | 12 + ...equest-channel-listener-injection-token.ts | 15 + .../actual/request/get-request-channel.ts | 7 + ...equest-channel-listener-injection-token.ts | 56 ++ ...rom-channel-injection-token.no-coverage.ts | 22 + .../src/features/unit-testing/feature.ts | 20 + .../get-message-bridge-fake.test.ts | 430 ++++++++++++ .../get-message-bridge-fake.ts | 210 ++++++ .../src/features/unit-testing/index.ts | 2 + .../unit-testing/test-doubles.injectable.ts | 29 + .../src/listening-of-messages.test.ts | 199 ++++++ .../src/listening-of-requests.test.ts | 236 +++++++ .../messaging/agnostic/tsconfig.json | 3 + .../messaging/agnostic/webpack.config.js | 1 + .../messaging/main/index.ts | 1 + .../messaging/main/jest.config.js | 2 + .../messaging/main/package.json | 38 ++ ...ist-message-channel-listener.injectable.ts | 28 + .../enlist-message-channel-listener.test.ts | 103 +++ ...ist-request-channel-listener.injectable.ts | 37 ++ .../enlist-request-channel-listener.test.ts | 158 +++++ .../messaging/main/src/feature.ts | 17 + .../main/src/ipc-main/ipc-main.injectable.ts | 10 + .../main/src/ipc-main/ipc-main.test.ts | 21 + .../main/src/listening-of-channels.test.ts | 38 ++ .../messaging/main/tsconfig.json | 3 + .../messaging/main/webpack.config.js | 1 + .../messaging/renderer/index.ts | 1 + .../messaging/renderer/jest.config.js | 2 + .../messaging/renderer/package.json | 39 ++ .../messaging/renderer/src/feature.ts | 17 + .../src/ipc/ipc-renderer.injectable.ts | 10 + .../renderer/src/ipc/ipc-renderer.test.ts | 27 + ...ist-message-channel-listener.injectable.ts | 28 + .../enlist-message-channel-listener.test.ts | 103 +++ .../listening-of-channels.test.ts | 38 ++ .../invoke-ipc.injectable.ts | 10 + .../requesting-of-requests/invoke-ipc.test.ts | 21 + .../request-from-channel.injectable.ts | 19 + .../request-from-channel.test.ts | 49 ++ .../message-to-channel.injectable.ts | 22 + .../message-to-channel.test.ts | 40 ++ .../send-to-ipc.injectable.ts | 10 + .../sending-of-messages/send-to-ipc.test.ts | 21 + .../messaging/renderer/tsconfig.json | 3 + .../messaging/renderer/webpack.config.js | 1 + .../utility-features/utilities/package.json | 6 +- 62 files changed, 3458 insertions(+), 75 deletions(-) delete mode 100644 packages/core/src/common/utils/channel/get-request-channel.ts create mode 100644 packages/technical-features/messaging/agnostic/index.ts create mode 100644 packages/technical-features/messaging/agnostic/jest.config.js create mode 100644 packages/technical-features/messaging/agnostic/package.json create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/channel.no-coverage.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.injectable.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.test.tsx create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/feature.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/index.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/listening-of-channels.injectable.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/start-listening-of-channels.injectable.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/message/enlist-message-channel-listener-injection-token.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/message/get-message-channel.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/message/message-channel-listener-injection-token.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/message/message-to-channel-injection-token.no-coverage.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/request/enlist-request-channel-listener-injection-token.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/request/get-request-channel.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/request/request-channel-listener-injection-token.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/actual/request/request-from-channel-injection-token.no-coverage.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/unit-testing/feature.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.test.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/unit-testing/index.ts create mode 100644 packages/technical-features/messaging/agnostic/src/features/unit-testing/test-doubles.injectable.ts create mode 100644 packages/technical-features/messaging/agnostic/src/listening-of-messages.test.ts create mode 100644 packages/technical-features/messaging/agnostic/src/listening-of-requests.test.ts create mode 100644 packages/technical-features/messaging/agnostic/tsconfig.json create mode 100644 packages/technical-features/messaging/agnostic/webpack.config.js create mode 100644 packages/technical-features/messaging/main/index.ts create mode 100644 packages/technical-features/messaging/main/jest.config.js create mode 100644 packages/technical-features/messaging/main/package.json create mode 100644 packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.injectable.ts create mode 100644 packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.test.ts create mode 100644 packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.injectable.ts create mode 100644 packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.test.ts create mode 100644 packages/technical-features/messaging/main/src/feature.ts create mode 100644 packages/technical-features/messaging/main/src/ipc-main/ipc-main.injectable.ts create mode 100644 packages/technical-features/messaging/main/src/ipc-main/ipc-main.test.ts create mode 100644 packages/technical-features/messaging/main/src/listening-of-channels.test.ts create mode 100644 packages/technical-features/messaging/main/tsconfig.json create mode 100644 packages/technical-features/messaging/main/webpack.config.js create mode 100644 packages/technical-features/messaging/renderer/index.ts create mode 100644 packages/technical-features/messaging/renderer/jest.config.js create mode 100644 packages/technical-features/messaging/renderer/package.json create mode 100644 packages/technical-features/messaging/renderer/src/feature.ts create mode 100644 packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.injectable.ts create mode 100644 packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.test.ts create mode 100644 packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.injectable.ts create mode 100644 packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.test.ts create mode 100644 packages/technical-features/messaging/renderer/src/listening-of-messages/listening-of-channels.test.ts create mode 100644 packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.injectable.ts create mode 100644 packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.test.ts create mode 100644 packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.injectable.ts create mode 100644 packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.test.ts create mode 100644 packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.injectable.ts create mode 100644 packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.test.ts create mode 100644 packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.injectable.ts create mode 100644 packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.test.ts create mode 100644 packages/technical-features/messaging/renderer/tsconfig.json create mode 100644 packages/technical-features/messaging/renderer/webpack.config.js diff --git a/package-lock.json b/package-lock.json index cb6d040ff3..2beb8e16da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,20 +73,20 @@ } }, "node_modules/@babel/core": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.3.tgz", - "integrity": "sha512-qIJONzoa/qiHghnm0l1n4i/6IIziDpzqc36FBs4pzMhDUraHqponwJLiAKm1hGLP3OSB/TVNz6rMwVGpwxxySw==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.0.tgz", + "integrity": "sha512-PuxUbxcW6ZYe656yL3EAhpy7qXKq0DmYsrJLpbB8XrsCP9Nm+XCg9XFMb5vIDliPD7+U/+M+QJlH17XOcB7eXA==", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.21.3", + "@babel/generator": "^7.21.0", "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-module-transforms": "^7.21.2", + "@babel/helper-module-transforms": "^7.21.0", "@babel/helpers": "^7.21.0", - "@babel/parser": "^7.21.3", + "@babel/parser": "^7.21.0", "@babel/template": "^7.20.7", - "@babel/traverse": "^7.21.3", - "@babel/types": "^7.21.3", + "@babel/traverse": "^7.21.0", + "@babel/types": "^7.21.0", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -115,9 +115,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.21.3.tgz", - "integrity": "sha512-kfhmPimwo6k4P8zxNs8+T7yR44q1LdpsZdE1NkCsVlfiuTPRfnGgjaF8Qgug9q9Pou17u6wneYF0lDCZJATMFg==", + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz", + "integrity": "sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==", "peer": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", @@ -151,11 +151,11 @@ } }, "node_modules/@babel/generator": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.3.tgz", - "integrity": "sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA==", + "version": "7.21.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.1.tgz", + "integrity": "sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA==", "dependencies": { - "@babel/types": "^7.21.3", + "@babel/types": "^7.21.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -595,9 +595,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.3.tgz", - "integrity": "sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ==", + "version": "7.21.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.2.tgz", + "integrity": "sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ==", "bin": { "parser": "bin/babel-parser.js" }, @@ -1269,9 +1269,9 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.21.3.tgz", - "integrity": "sha512-bp6hwMFzuiE4HqYEyoGJ/V2LeIWn+hLVKc4pnj++E5XQptwhtcGmSayM029d/j2X1bPKGTlsyPwAubuU22KhMA==", + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.20.7.tgz", + "integrity": "sha512-Xwg403sRrZb81IVB79ZPqNQME23yhugYVqgTxAhT99h485F4f+GMELFhhOsscDUB7HCswepKeCKLn/GZvUKoBA==", "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" @@ -1523,9 +1523,9 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.21.3.tgz", - "integrity": "sha512-Wxc+TvppQG9xWFYatvCGPvZ6+SIUxQ2ZdiBP+PHYMIjnPXD+uThCshaz4NZOnODAtBjjcVQQ/3OKs9LW28purQ==", + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.20.7.tgz", + "integrity": "sha512-WiWBIkeHKVOSYPO0pWkxGPfKeWrCJyD3NJ53+Lrp/QMSZbsVPovrVl2aWZ19D/LTVnaDv5Ap7GJ/B2CTOZdrfA==", "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" @@ -1754,12 +1754,11 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.21.3.tgz", - "integrity": "sha512-RQxPz6Iqt8T0uw/WsJNReuBpWpBqs/n7mNo18sKLoTbMp+UrEekhH+pKSVC7gWz+DNjo9gryfV8YzCiT45RgMw==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.21.0.tgz", + "integrity": "sha512-xo///XTPp3mDzTtrqXoBlK9eiAYW3wv9JXglcn/u1bi60RW11dEUxIgA8cbnDhutS1zacjMRmAwxE0gMklLnZg==", "peer": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-create-class-features-plugin": "^7.21.0", "@babel/helper-plugin-utils": "^7.20.2", "@babel/plugin-syntax-typescript": "^7.20.0" @@ -1997,18 +1996,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.3.tgz", - "integrity": "sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ==", + "version": "7.21.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.2.tgz", + "integrity": "sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw==", "dependencies": { "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.21.3", + "@babel/generator": "^7.21.1", "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-function-name": "^7.21.0", "@babel/helper-hoist-variables": "^7.18.6", "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.21.3", - "@babel/types": "^7.21.3", + "@babel/parser": "^7.21.2", + "@babel/types": "^7.21.2", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -2017,9 +2016,9 @@ } }, "node_modules/@babel/types": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.3.tgz", - "integrity": "sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==", + "version": "7.21.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.2.tgz", + "integrity": "sha512-3wRZSs7jiFaB8AjxiiD+VqN5DTG2iRvJGQ+qYFrs/654lg6kGTQWIOFjlBo5RaXuAZjBmP3+OQH4dmhqiiyYxw==", "dependencies": { "@babel/helper-string-parser": "^7.19.4", "@babel/helper-validator-identifier": "^7.19.1", @@ -3114,9 +3113,9 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.4.tgz", - "integrity": "sha512-SQOeVbMwb1di+mVWWJLpsUTToKfqVNioXys011beCAhyOIFtS+GQoW4EQSneuxzmQKddExDwQ+X0hLl4lJJaSQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.3.tgz", + "integrity": "sha512-upVRtrNZuYNsw+EoxkiBFRPROnU8UTy/u/dZ9U0W14BlemPYODwhhxYXSR2Y9xOnvr1XtptJRWx7gL8Te1qaog==", "dev": true }, "node_modules/@floating-ui/dom": { @@ -4722,6 +4721,18 @@ "resolved": "packages/technical-features/application/legacy-extensions", "link": true }, + "node_modules/@k8slens/messaging": { + "resolved": "packages/technical-features/messaging/agnostic", + "link": true + }, + "node_modules/@k8slens/messaging-for-main": { + "resolved": "packages/technical-features/messaging/main", + "link": true + }, + "node_modules/@k8slens/messaging-for-renderer": { + "resolved": "packages/technical-features/messaging/renderer", + "link": true + }, "node_modules/@k8slens/node-fetch": { "resolved": "packages/node-fetch", "link": true @@ -6172,7 +6183,6 @@ "version": "15.1.2", "resolved": "https://registry.npmjs.org/@ogre-tools/test-utils/-/test-utils-15.1.2.tgz", "integrity": "sha512-WGuJoHgFJCt0u5ok9BnQKSkF0J1MYPrRlr0naNUUywZgNSrPy64TqlY8AEEIe2cquUZMwe2wsv9esg+KDRUnrA==", - "dev": true, "peerDependencies": { "lodash": "^4.17.21" } @@ -12941,9 +12951,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.329", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.329.tgz", - "integrity": "sha512-dcwPzNUG4+reo5z+wHnrl2eZMu4kz+nLQEeepxLEDTLDC7Mi7AVTM4NXWct1TZyu3G4oQgygaAfbByaBtPqw2Q==" + "version": "1.4.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.328.tgz", + "integrity": "sha512-DE9tTy2PNmy1v55AZAO542ui+MLC2cvINMK4P2LXGsJdput/ThVG9t+QGecPuAZZSgC8XoI+Jh9M1OG9IoNSCw==" }, "node_modules/electron-updater": { "version": "4.6.5", @@ -21292,16 +21302,6 @@ "language-subtag-registry": "~0.3.2" } }, - "node_modules/launch-editor": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.0.tgz", - "integrity": "sha512-JpDCcQnyAAzZZaZ7vEiSqL690w7dAEyLao+KC96zBplnYbJS7TYNjvM3M7y3dGz+v7aIsJk3hllWuc0kWAjyRQ==", - "dev": true, - "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.7.3" - } - }, "node_modules/lazy-cache": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", @@ -30142,9 +30142,9 @@ } }, "node_modules/rimraf/node_modules/glob": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.0.tgz", - "integrity": "sha512-EAZejC7JvnQINayvB/7BJbpZpNOJ8Lrw2OZNEvQxe0vaLn1SuwMcfV7/MNaX8L/T0wmptBFI4YMtDvSBxYDc7w==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.2.1.tgz", + "integrity": "sha512-Pxxgq3W0HyA3XUvSXcFhRSs+43Jsx0ddxcFrbjxNGkL2Ak5BAUBxLqI5G6ADDeCHLfzzXFhe0b1yYcctGmytMA==", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -32811,6 +32811,7 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, "engines": { "node": ">=12.20" }, @@ -33630,9 +33631,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.12.0.tgz", - "integrity": "sha512-XRN9YRnvOj3TQQ5w/0pR1y1xDcVnbWtNkTri46kuEbaWUPTHsWUvOyAAI7PZHLY+hsFki2kRltJjKMw7e+IiqA==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.11.1.tgz", + "integrity": "sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==", "dev": true, "dependencies": { "@types/bonjour": "^3.5.9", @@ -33654,7 +33655,6 @@ "html-entities": "^2.3.2", "http-proxy-middleware": "^2.0.3", "ipaddr.js": "^2.0.1", - "launch-editor": "^2.6.0", "open": "^8.0.9", "p-retry": "^4.5.0", "rimraf": "^3.0.2", @@ -33664,7 +33664,7 @@ "sockjs": "^0.3.24", "spdy": "^4.0.2", "webpack-dev-middleware": "^5.3.1", - "ws": "^8.13.0" + "ws": "^8.4.2" }, "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" @@ -37225,6 +37225,55 @@ "@ogre-tools/injectable": "^15.1.2" } }, + "packages/technical-features/messaging/agnostic": { + "name": "@k8slens/messaging", + "version": "1.0.0-alpha.1", + "license": "MIT", + "devDependencies": { + "@async-fn/jest": "^1.6.4", + "type-fest": "^2.14.0" + }, + "peerDependencies": { + "@k8slens/application": "^6.5.0-alpha.0", + "@k8slens/startable-stoppable": "^1.0.0-alpha.1", + "@ogre-tools/fp": "^15.1.2", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "@ogre-tools/injectable-extension-for-mobx": "^15.1.2", + "@ogre-tools/test-utils": "^15.1.2", + "lodash": "^4.17.21", + "mobx": "^6.7.0" + } + }, + "packages/technical-features/messaging/main": { + "name": "@k8slens/messaging-for-main", + "version": "1.0.0-alpha.1", + "license": "MIT", + "peerDependencies": { + "@k8slens/application": "^6.5.0-alpha.0", + "@k8slens/feature-core": "^6.5.0-alpha.0", + "@k8slens/messaging": "^1.0.0-alpha.1", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "electron": "^19.1.8", + "lodash": "^4.17.21" + } + }, + "packages/technical-features/messaging/renderer": { + "name": "@k8slens/messaging-for-renderer", + "version": "1.0.0-alpha.1", + "license": "MIT", + "peerDependencies": { + "@k8slens/application": "^6.5.0-alpha.0", + "@k8slens/messaging": "^1.0.0-alpha.1", + "@k8slens/run-many": "^1.0.0-alpha.1", + "@k8slens/startable-stoppable": "^1.0.0-alpha.1", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "electron": "^19.1.8", + "lodash": "^4.17.21" + } + }, "packages/utility-features/run-many": { "name": "@k8slens/run-many", "version": "1.0.0-alpha.1", @@ -37237,6 +37286,7 @@ } }, "packages/utility-features/startable-stoppable": { + "name": "@k8slens/startable-stoppable", "version": "1.0.0-alpha.1", "license": "MIT" }, @@ -37256,9 +37306,11 @@ "name": "@k8slens/utilities", "version": "1.0.0-alpha.1", "license": "MIT", + "devDependencies": { + "type-fest": "^2.14.0" + }, "peerDependencies": { - "mobx": "^6.8.0", - "type-fest": "^2.19.0" + "mobx": "^6.8.0" } } } diff --git a/packages/core/src/common/utils/channel/get-request-channel.ts b/packages/core/src/common/utils/channel/get-request-channel.ts deleted file mode 100644 index 4dc5b4914e..0000000000 --- a/packages/core/src/common/utils/channel/get-request-channel.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { RequestChannel } from "./request-channel-listener-injection-token"; - -export const getRequestChannel = (id: string): RequestChannel => ({ - id, -}); diff --git a/packages/technical-features/messaging/agnostic/index.ts b/packages/technical-features/messaging/agnostic/index.ts new file mode 100644 index 0000000000..75b20812f1 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/index.ts @@ -0,0 +1,2 @@ +export * from "./src/features/actual"; +export * as testUtils from "./src/features/unit-testing"; diff --git a/packages/technical-features/messaging/agnostic/jest.config.js b/packages/technical-features/messaging/agnostic/jest.config.js new file mode 100644 index 0000000000..23be80353b --- /dev/null +++ b/packages/technical-features/messaging/agnostic/jest.config.js @@ -0,0 +1,2 @@ +module.exports = + require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact; diff --git a/packages/technical-features/messaging/agnostic/package.json b/packages/technical-features/messaging/agnostic/package.json new file mode 100644 index 0000000000..a0d4e388fa --- /dev/null +++ b/packages/technical-features/messaging/agnostic/package.json @@ -0,0 +1,45 @@ +{ + "name": "@k8slens/messaging", + "private": false, + "version": "1.0.0-alpha.1", + "description": "An abstraction for messaging between Lens environments", + "type": "commonjs", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "author": { + "name": "OpenLens Authors", + "email": "info@k8slens.dev" + }, + "license": "MIT", + "homepage": "https://github.com/lensapp/lens", + "scripts": { + "build": "webpack", + "dev": "webpack --mode=development --watch", + "test:unit": "jest --coverage --runInBand", + "lint:fix": "lens-lint --fix", + "lint": "lens-lint" + }, + "peerDependencies": { + "@k8slens/application": "^6.5.0-alpha.0", + "@k8slens/startable-stoppable": "^1.0.0-alpha.1", + "@ogre-tools/fp": "^15.1.2", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "@ogre-tools/injectable-extension-for-mobx": "^15.1.2", + "@ogre-tools/test-utils": "^15.1.2", + "lodash": "^4.17.21", + "mobx": "^6.7.0" + }, + + "devDependencies": { + "@async-fn/jest": "^1.6.4", + "type-fest": "^2.14.0" + } +} diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/channel.no-coverage.ts b/packages/technical-features/messaging/agnostic/src/features/actual/channel.no-coverage.ts new file mode 100644 index 0000000000..62a2ea1490 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/channel.no-coverage.ts @@ -0,0 +1,5 @@ +export interface Channel { + id: string; + _messageTemplate?: MessageTemplate; + _returnTemplate?: ReturnTemplate; +} diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.injectable.ts b/packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.injectable.ts new file mode 100644 index 0000000000..d6d707e921 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.injectable.ts @@ -0,0 +1,211 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; + +import { + _getGlobalState, + computed, + IComputedValue, + observable, + onBecomeObserved, + onBecomeUnobserved, + reaction, + runInAction, +} from "mobx"; + +import type { MessageChannel } from "../message/message-channel-listener-injection-token"; +import { getMessageChannelListenerInjectable } from "../message/message-channel-listener-injection-token"; +import { sendMessageToChannelInjectionToken } from "../message/message-to-channel-injection-token.no-coverage"; +import { onLoadOfApplicationInjectionToken } from "@k8slens/application"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, groupBy, map, nth, toPairs } from "lodash/fp"; +import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx"; +import type { JsonPrimitive } from "type-fest"; + +export type JsonifiableObject = + | { [Key in string]?: Jsonifiable } + | { toJSON: () => Jsonifiable }; +export type JsonifiableArray = readonly Jsonifiable[]; +export type Jsonifiable = JsonPrimitive | JsonifiableObject | JsonifiableArray; + +export type ComputedChannelFactory = ( + channel: MessageChannel, + pendingValue: T +) => IComputedValue; + +export const computedChannelInjectionToken = + getInjectionToken({ + id: "computed-channel-injection-token", + }); + +export type ChannelObserver = { + channel: MessageChannel; + observer: IComputedValue; +}; +export type ComputedChannelAdminMessage = { + channelId: string; + status: "became-observed" | "became-unobserved"; +}; + +export const computedChannelObserverInjectionToken = getInjectionToken< + ChannelObserver +>({ + id: "computed-channel-observer", +}); + +const computedChannelInjectable = getInjectable({ + id: "computed-channel", + + instantiate: (di) => { + const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken); + + return ((channel, pendingValue) => { + const observableValue = observable.box(pendingValue); + + const computedValue = computed(() => { + const { trackingDerivation } = _getGlobalState(); + + const contextIsReactive = !!trackingDerivation; + + if (!contextIsReactive) { + throw new Error( + `Tried to access value of computed channel "${channel.id}" outside of reactive context. This is not possible, as the value is acquired asynchronously sometime *after* being observed. Not respecting that, the value could be stale.` + ); + } + + return observableValue.get(); + }); + + const valueReceiverInjectable = getMessageChannelListenerInjectable({ + id: `computed-channel-value-receiver-for-${channel.id}`, + channel, + + getHandler: () => (message) => { + runInAction(() => { + observableValue.set(message); + }); + }, + }); + + runInAction(() => { + di.register(valueReceiverInjectable); + }); + + onBecomeObserved(computedValue, () => { + runInAction(() => { + observableValue.set(pendingValue); + }); + + sendMessageToChannel(computedChannelAdministrationChannel, { + channelId: channel.id, + status: "became-observed", + }); + }); + + onBecomeUnobserved(computedValue, () => { + runInAction(() => { + observableValue.set(pendingValue); + }); + + sendMessageToChannel(computedChannelAdministrationChannel, { + channelId: channel.id, + status: "became-unobserved", + }); + }); + + return computedValue; + }) as ComputedChannelFactory; + }, + + injectionToken: computedChannelInjectionToken, +}); + +export const duplicateChannelObserverGuardInjectable = getInjectable({ + id: "duplicate-channel-observer-guard", + + instantiate: (di) => { + const computedInjectMany = di.inject(computedInjectManyInjectable); + + return { + run: () => { + reaction( + () => computedInjectMany(computedChannelObserverInjectionToken).get(), + (observers) => { + const duplicateObserverChannelIds = pipeline( + observers, + groupBy((observer) => observer.channel.id), + toPairs, + filter(([, channelObservers]) => channelObservers.length > 1), + map(nth(0)) + ); + + if (duplicateObserverChannelIds.length) { + throw new Error( + `Tried to register duplicate channel observer for channels "${duplicateObserverChannelIds.join( + '", "' + )}"` + ); + } + } + ); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export const computedChannelAdministrationChannel: MessageChannel = + { + id: "computed-channel-administration-channel", + }; + +export const computedChannelAdministrationListenerInjectable = + getMessageChannelListenerInjectable({ + id: "computed-channel-administration", + getHandler: (di) => { + const sendMessageToChannel = di.inject( + sendMessageToChannelInjectionToken + ); + + const disposersByChannelId = new Map void>(); + + return (message) => { + if (message.status === "became-observed") { + const result = di + .injectMany(computedChannelObserverInjectionToken) + .find( + (channelObserver) => + channelObserver.channel.id === message.channelId + ); + + if (result === undefined) { + return; + } + + const disposer = reaction( + () => result.observer.get(), + (observed) => + sendMessageToChannel( + { + id: message.channelId, + }, + + observed + ), + { + fireImmediately: true, + } + ); + + disposersByChannelId.set(message.channelId, disposer); + } else { + const disposer = disposersByChannelId.get(message.channelId); + + disposer!(); + } + }; + }, + + channel: computedChannelAdministrationChannel, + }); + +export default computedChannelInjectable; diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.test.tsx b/packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.test.tsx new file mode 100644 index 0000000000..1d1dbb7f0f --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/computed-channel/computed-channel.test.tsx @@ -0,0 +1,614 @@ +import React from "react"; +import { act } from "@testing-library/react"; +import { + createContainer, + DiContainer, + getInjectable, +} from "@ogre-tools/injectable"; +import { + getMessageBridgeFake, + MessageBridgeFake, +} from "../../unit-testing/get-message-bridge-fake/get-message-bridge-fake"; +import { startApplicationInjectionToken } from "@k8slens/application"; +import { + computed, + IComputedValue, + IObservableValue, + observable, + reaction, + runInAction, +} from "mobx"; +import type { MessageChannel } from "../message/message-channel-listener-injection-token"; +import { getMessageChannelListenerInjectable } from "../message/message-channel-listener-injection-token"; +import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx"; +import { registerFeature } from "@k8slens/feature-core"; +import { messagingFeature } from "../feature"; +import { + computedChannelAdministrationChannel, + ComputedChannelAdminMessage, + computedChannelInjectionToken, + computedChannelObserverInjectionToken, +} from "./computed-channel.injectable"; +import { runWithThrownMobxReactions, renderFor } from "@k8slens/test-utils"; +import { observer } from "mobx-react"; + +const testChannel: MessageChannel = { id: "some-channel-id" }; +const testChannel2: MessageChannel = { id: "some-other-channel-id" }; + +[{ scenarioIsAsync: true }, { scenarioIsAsync: false }].forEach( + ({ scenarioIsAsync }) => + describe(`computed-channel, given running message bridge fake as ${ + scenarioIsAsync ? "async" : "sync" + }`, () => { + describe("given multiple dis and a message channel and a channel observer and application has started", () => { + let di1: DiContainer; + let di2: DiContainer; + let latestAdminMessage: ComputedChannelAdminMessage | undefined; + let latestValueMessage: string | undefined; + let messageBridgeFake: MessageBridgeFake; + + beforeEach(async () => { + latestAdminMessage = undefined; + latestValueMessage = undefined; + + di1 = createContainer("some-container-1"); + di2 = createContainer("some-container-2"); + registerMobX(di1); + registerMobX(di2); + + const administrationChannelTestListenerInjectable = + getMessageChannelListenerInjectable({ + id: "administration-channel-test-listener", + channel: computedChannelAdministrationChannel, + + getHandler: () => (adminMessage) => { + latestAdminMessage = adminMessage; + }, + }); + + const channelValueTestListenerInjectable = + getMessageChannelListenerInjectable({ + id: "test-channel-value-listener", + channel: testChannel, + + getHandler: () => (message) => { + latestValueMessage = message; + }, + }); + + runInAction(() => { + registerFeature(di1, messagingFeature); + registerFeature(di2, messagingFeature); + + di1.register(channelValueTestListenerInjectable); + di2.register(administrationChannelTestListenerInjectable); + }); + + messageBridgeFake = getMessageBridgeFake(); + messageBridgeFake.setAsync(scenarioIsAsync); + messageBridgeFake.involve(di1, di2); + + await Promise.all([ + di1.inject(startApplicationInjectionToken)(), + di2.inject(startApplicationInjectionToken)(), + ]); + }); + + describe("given a channel observer and matching computed channel for the channel in di-2", () => { + let someObservable: IObservableValue; + let computedTestChannel: IComputedValue; + + beforeEach(() => { + someObservable = observable.box("some-initial-value"); + + const channelObserverInjectable = getInjectable({ + id: "some-channel-observer", + + instantiate: () => ({ + channel: testChannel, + observer: computed(() => someObservable.get()), + }), + + injectionToken: computedChannelObserverInjectionToken, + }); + + runInAction(() => { + di2.register(channelObserverInjectable); + }); + + const computedChannel = di1.inject(computedChannelInjectionToken); + + computedTestChannel = computedChannel( + testChannel, + "some-pending-value" + ); + }); + + it("there is no admin message yet", () => { + expect(latestAdminMessage).toBeUndefined(); + }); + + describe("when observing the computed value in a component in di-1", () => { + let rendered: any; + + beforeEach(() => { + const render = renderFor(di2); + + rendered = render( + + ); + }); + + describe( + scenarioIsAsync + ? "when all messages are propagated" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake + .messagePropagationRecursive(act) + .then(done); + } else { + done(); + } + }); + + it("renders", () => { + expect(rendered.container).toHaveTextContent( + "some-initial-value" + ); + }); + } + ); + }); + + describe("when observing the computed channel in di-1", () => { + let observedValue: string | undefined; + let stopObserving: () => void; + + beforeEach(() => { + observedValue = undefined; + + stopObserving = reaction( + () => computedTestChannel.get(), + (value) => { + observedValue = value; + }, + + { + fireImmediately: true, + } + ); + }); + + scenarioIsAsync && + it("computed test channel value is observed as the pending value", () => { + expect(observedValue).toBe("some-pending-value"); + }); + + describe( + scenarioIsAsync + ? "when admin messages are propagated" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake.messagePropagation().then(done); + } else { + done(); + } + }); + + it("administration-message to start observing gets listened", () => { + expect(latestAdminMessage).toEqual({ + channelId: "some-channel-id", + status: "became-observed", + }); + }); + + describe( + scenarioIsAsync + ? "when returning value-messages propagate" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake.messagePropagation().then(done); + } else { + done(); + } + }); + + it("the computed channel value in di-1 matches the value in di-2", () => { + expect(observedValue).toBe("some-initial-value"); + }); + + it("the value gets listened in di-1", () => { + expect(latestValueMessage).toBe("some-initial-value"); + }); + + describe("when the observed value changes", () => { + beforeEach(async () => { + latestValueMessage = undefined; + + runInAction(() => { + someObservable.set("some-new-value"); + }); + }); + + describe( + scenarioIsAsync + ? "when value-messages propagate" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake.messagePropagation().then(done); + } else { + done(); + } + }); + + it("the computed channel value in di-1 changes", () => { + expect(observedValue).toBe("some-new-value"); + }); + + it("the new value gets listened in di-1", () => { + expect(latestValueMessage).toBe("some-new-value"); + }); + } + ); + }); + + describe("when stopping observation for the channel in di-1", () => { + beforeEach(async () => { + latestValueMessage = undefined; + + stopObserving(); + }); + + describe( + scenarioIsAsync + ? "when admin-messages propagate" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake.messagePropagation().then(done); + } else { + done(); + } + }); + + it("messages administration channel to stop observing", () => { + expect(latestAdminMessage).toEqual({ + channelId: "some-channel-id", + status: "became-unobserved", + }); + }); + + it("no value gets listened in di-1 anymore", () => { + expect(latestValueMessage).toBeUndefined(); + }); + + describe("when the observed value changes", () => { + beforeEach(async () => { + latestValueMessage = undefined; + + runInAction(() => { + someObservable.set("some-new-value-2"); + }); + }); + + it("when accessing the computed value outside of reactive context, throws", () => { + expect(() => { + computedTestChannel.get(); + }).toThrow( + 'Tried to access value of computed channel "some-channel-id" outside of reactive context. This is not possible, as the value is acquired asynchronously sometime *after* being observed. Not respecting that, the value could be stale.' + ); + }); + + it("no value gets listened in di-1 anymore", () => { + expect(latestValueMessage).toBeUndefined(); + }); + + describe("when observing the computed channel again", () => { + beforeEach(() => { + observedValue = undefined; + + reaction( + () => computedTestChannel.get(), + (value) => { + observedValue = value; + }, + + { + fireImmediately: true, + } + ); + }); + + scenarioIsAsync && + it("computed test channel value is observed as the pending value again", () => { + expect(observedValue).toBe( + "some-pending-value" + ); + }); + + describe( + scenarioIsAsync + ? "when admin messages propagate" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + latestAdminMessage = undefined; + + messageBridgeFake + .messagePropagation() + .then(done); + } else { + done(); + } + }); + + it("administration-message to start observing gets listened again", () => { + expect(latestAdminMessage).toEqual({ + channelId: "some-channel-id", + status: "became-observed", + }); + }); + + scenarioIsAsync && + it("computed test channel value is still observed as the pending value", () => { + expect(observedValue).toBe( + "some-pending-value" + ); + }); + + describe( + scenarioIsAsync + ? "when value-messages propagate back" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + latestValueMessage = undefined; + + messageBridgeFake + .messagePropagation() + .then(done); + } else { + done(); + } + }); + + it("the computed channel value changes", () => { + expect(observedValue).toBe( + "some-new-value-2" + ); + }); + + it("the current value gets listened", () => { + expect(latestValueMessage).toBe( + "some-new-value-2" + ); + }); + } + ); + } + ); + }); + }); + } + ); + + it("when accessing the computed value outside of reactive context, throws", () => { + expect(() => { + computedTestChannel.get(); + }).toThrow( + 'Tried to access value of computed channel "some-channel-id" outside of reactive context. This is not possible, as the value is acquired asynchronously sometime *after* being observed. Not respecting that, the value could be stale.' + ); + }); + }); + + it("given observation of unrelated computed channel is stopped, observation of other computed channel still works", async () => { + const someOtherObservable = observable.box(""); + + const channelObserver2Injectable = getInjectable({ + id: "some-channel-observer-2", + + instantiate: () => ({ + channel: testChannel2, + observer: computed(() => someOtherObservable.get()), + }), + + injectionToken: computedChannelObserverInjectionToken, + }); + + runInAction(() => { + di2.register(channelObserver2Injectable); + }); + + const computedChannel = di1.inject( + computedChannelInjectionToken + ); + + computedTestChannel = computedChannel( + testChannel2, + "some-pending-value" + ); + + reaction( + () => computedTestChannel.get(), + (value) => { + observedValue = value; + }, + + { + fireImmediately: true, + } + ); + + scenarioIsAsync && + (await messageBridgeFake.messagePropagation()); + + stopObserving(); + + scenarioIsAsync && + (await messageBridgeFake.messagePropagation()); + + runInAction(() => { + someOtherObservable.set("some-value"); + }); + + scenarioIsAsync && + (await messageBridgeFake.messagePropagation()); + + expect(observedValue).toBe("some-value"); + }); + + describe("when observing the computed channel again", () => { + beforeEach(() => { + latestAdminMessage = undefined; + + reaction( + () => computedTestChannel.get(), + (value) => { + observedValue = value; + }, + + { + fireImmediately: true, + } + ); + }); + + it("doesn't send second administration message", () => { + expect(latestAdminMessage).toBeUndefined(); + }); + + it("when one of the observations stops, doesn't send administration message to stop observing", async () => { + latestAdminMessage = undefined; + + stopObserving(); + + expect(latestAdminMessage).toBeUndefined(); + }); + }); + + describe("when accessing the computed value outside of reactive context", () => { + let nonReactiveValue: string; + + beforeEach(() => { + latestValueMessage = undefined; + latestAdminMessage = undefined; + + nonReactiveValue = computedTestChannel.get(); + }); + + it("the non reactive value is what ever happens to be the current value from di-2", () => { + expect(nonReactiveValue).toBe("some-initial-value"); + }); + + describe( + scenarioIsAsync + ? "when messages would be propagated" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake.messagePropagation().then(done); + } else { + done(); + } + }); + + it("does not send new value message", () => { + expect(latestValueMessage).toBeUndefined(); + }); + + it("does not send new admin message", () => { + expect(latestAdminMessage).toBeUndefined(); + }); + } + ); + }); + } + ); + } + ); + }); + + it("when accessing the computed value outside of reactive context, throws", () => { + expect(() => { + computedTestChannel.get(); + }).toThrow( + 'Tried to access value of computed channel "some-channel-id" outside of reactive context. This is not possible, as the value is acquired asynchronously sometime *after* being observed. Not respecting that, the value could be stale.' + ); + }); + + it("given duplicate channel observer for the channel is registered, when the computed channel is observer, throws", () => { + const duplicateChannelObserverInjectable = getInjectable({ + id: "some-duplicate-channel-observer", + + instantiate: () => ({ + channel: testChannel, + observer: computed(() => "irrelevant"), + }), + + injectionToken: computedChannelObserverInjectionToken, + }); + + expect(() => { + runWithThrownMobxReactions(() => { + runInAction(() => { + di2.register(duplicateChannelObserverInjectable); + }); + }); + }).toThrow( + 'Tried to register duplicate channel observer for channels "some-channel-id"' + ); + }); + }); + + describe("given no channel observer but still a computed channel", () => { + let computedTestChannel: IComputedValue; + + beforeEach(() => { + const computedChannel = di1.inject(computedChannelInjectionToken); + + computedTestChannel = computedChannel( + testChannel, + "some-pending-value" + ); + }); + + it("when the computed channel is observed, observes as undefined", () => { + let observedValue = "some-value-to-never-be-seen-in-unit-test"; + + reaction( + () => computedTestChannel.get(), + + (value) => { + observedValue = value; + }, + + { + fireImmediately: true, + } + ); + + expect(observedValue).toBe("some-pending-value"); + }); + }); + }); + }) +); + +const TestComponent = observer( + ({ someComputed }: { someComputed: IComputedValue }) => ( +
{someComputed.get()}
+ ) +); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/feature.ts b/packages/technical-features/messaging/agnostic/src/features/actual/feature.ts new file mode 100644 index 0000000000..e520892217 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/feature.ts @@ -0,0 +1,20 @@ +import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration"; +import { applicationFeature } from "@k8slens/application"; +import { getFeature } from "@k8slens/feature-core"; + +export const messagingFeature = getFeature({ + id: "messaging", + + dependencies: [applicationFeature], + + register: (di) => { + autoRegister({ + di, + targetModule: module, + + getRequireContexts: () => [ + require.context("./", true, /\.injectable\.(ts|tsx)$/), + ], + }); + }, +}); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/index.ts b/packages/technical-features/messaging/agnostic/src/features/actual/index.ts new file mode 100644 index 0000000000..fe3ce02165 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/index.ts @@ -0,0 +1,64 @@ +export { messagingFeature } from "./feature"; + +export { getRequestChannel } from "./request/get-request-channel"; +export { getMessageChannel } from "./message/get-message-channel"; + +export { + computedChannelInjectionToken, + computedChannelObserverInjectionToken, +} from "./computed-channel/computed-channel.injectable"; + +export type { + ChannelObserver, + ComputedChannelFactory, + JsonifiableObject, + JsonifiableArray, + Jsonifiable, +} from "./computed-channel/computed-channel.injectable"; + +export { requestFromChannelInjectionToken } from "./request/request-from-channel-injection-token.no-coverage"; + +export type { Channel } from "./channel.no-coverage"; + +export { sendMessageToChannelInjectionToken } from "./message/message-to-channel-injection-token.no-coverage"; +export type { SendMessageToChannel } from "./message/message-to-channel-injection-token.no-coverage"; + +export type { + GetMessageChannelListenerInfo, + MessageChannel, + MessageChannelListener, +} from "./message/message-channel-listener-injection-token"; + +export { + messageChannelListenerInjectionToken, + getMessageChannelListenerInjectable, +} from "./message/message-channel-listener-injection-token"; + +export type { + RequestChannel, + RequestChannelHandler, +} from "./request/request-channel-listener-injection-token"; + +export type { + RequestFromChannel, + ChannelRequester, +} from "./request/request-from-channel-injection-token.no-coverage"; + +export type { EnlistMessageChannelListener } from "./message/enlist-message-channel-listener-injection-token"; +export { enlistMessageChannelListenerInjectionToken } from "./message/enlist-message-channel-listener-injection-token"; + +export type { EnlistRequestChannelListener } from "./request/enlist-request-channel-listener-injection-token"; +export { enlistRequestChannelListenerInjectionToken } from "./request/enlist-request-channel-listener-injection-token"; + +export type { ListeningOfChannels } from "./listening-of-channels/listening-of-channels.injectable"; +export { listeningOfChannelsInjectionToken } from "./listening-of-channels/listening-of-channels.injectable"; + +export type { + GetRequestChannelListenerInjectableInfo, + RequestChannelListener, +} from "./request/request-channel-listener-injection-token"; + +export { + getRequestChannelListenerInjectable, + requestChannelListenerInjectionToken, +} from "./request/request-channel-listener-injection-token"; diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/listening-of-channels.injectable.ts b/packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/listening-of-channels.injectable.ts new file mode 100644 index 0000000000..b5da24466a --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/listening-of-channels.injectable.ts @@ -0,0 +1,118 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../message/enlist-message-channel-listener-injection-token"; + +import { + getStartableStoppable, + StartableStoppable, +} from "@k8slens/startable-stoppable"; + +import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx"; +import { IComputedValue, reaction } from "mobx"; + +import { messageChannelListenerInjectionToken } from "../message/message-channel-listener-injection-token"; +import { requestChannelListenerInjectionToken } from "../request/request-channel-listener-injection-token"; +import { enlistRequestChannelListenerInjectionToken } from "../request/enlist-request-channel-listener-injection-token"; +import type { Channel } from "../channel.no-coverage"; + +export type ListeningOfChannels = StartableStoppable; +export const listeningOfChannelsInjectionToken = + getInjectionToken({ + id: "listening-of-channels-injection-token", + }); + +const listeningOfChannelsInjectable = getInjectable({ + id: "listening-of-channels", + + instantiate: (di) => { + const enlistMessageChannelListener = di.inject( + enlistMessageChannelListenerInjectionToken + ); + + const enlistRequestChannelListener = di.inject( + enlistRequestChannelListenerInjectionToken + ); + + const computedInjectMany = di.inject(computedInjectManyInjectable); + + const messageChannelListeners = computedInjectMany( + messageChannelListenerInjectionToken + ); + + const requestChannelListeners = computedInjectMany( + requestChannelListenerInjectionToken + ); + + return getStartableStoppable("listening-of-channels", () => { + const stopListeningOfMessageChannels = listening( + messageChannelListeners, + enlistMessageChannelListener, + (x) => x.id + ); + + const stopListeningOfRequestChannels = listening( + requestChannelListeners, + enlistRequestChannelListener, + (x) => x.channel.id + ); + + return () => { + stopListeningOfMessageChannels(); + stopListeningOfRequestChannels(); + }; + }); + }, + + injectionToken: listeningOfChannelsInjectionToken, +}); + +export default listeningOfChannelsInjectable; + +const listening = }>( + channelListeners: IComputedValue, + enlistChannelListener: (listener: T) => () => void, + getId: (listener: T) => string +) => { + const listenerDisposers = new Map void>(); + + const reactionDisposer = reaction( + () => channelListeners.get(), + (newValues, oldValues = []) => { + const addedListeners = newValues.filter( + (newValue) => !oldValues.some((oldValue) => oldValue.id === newValue.id) + ); + + const removedListeners = oldValues.filter( + (oldValue) => !newValues.some((newValue) => newValue.id === oldValue.id) + ); + + addedListeners.forEach((listener) => { + const id = getId(listener); + + if (listenerDisposers.has(id)) { + throw new Error( + `Tried to add listener for channel "${listener.channel.id}" but listener already exists.` + ); + } + + const disposer = enlistChannelListener(listener); + + listenerDisposers.set(id, disposer); + }); + + removedListeners.forEach((listener) => { + const dispose = listenerDisposers.get(getId(listener)); + + dispose?.(); + + listenerDisposers.delete(getId(listener)); + }); + }, + + { fireImmediately: true } + ); + + return () => { + reactionDisposer(); + listenerDisposers.forEach((dispose) => dispose()); + }; +}; diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/start-listening-of-channels.injectable.ts b/packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/start-listening-of-channels.injectable.ts new file mode 100644 index 0000000000..eb265b1ca2 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/listening-of-channels/start-listening-of-channels.injectable.ts @@ -0,0 +1,21 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { onLoadOfApplicationInjectionToken } from "@k8slens/application"; +import { listeningOfChannelsInjectionToken } from "./listening-of-channels.injectable"; + +const startListeningOfChannelsInjectable = getInjectable({ + id: "start-listening-of-channels", + + instantiate: (di) => { + const listeningOfChannels = di.inject(listeningOfChannelsInjectionToken); + + return { + run: async () => { + await listeningOfChannels.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startListeningOfChannelsInjectable; diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/message/enlist-message-channel-listener-injection-token.ts b/packages/technical-features/messaging/agnostic/src/features/actual/message/enlist-message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..9169dab74d --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/message/enlist-message-channel-listener-injection-token.ts @@ -0,0 +1,15 @@ +import { getInjectionToken } from "@ogre-tools/injectable"; + +import type { + MessageChannel, + MessageChannelListener, +} from "./message-channel-listener-injection-token"; + +export type EnlistMessageChannelListener = ( + listener: MessageChannelListener> +) => () => void; + +export const enlistMessageChannelListenerInjectionToken = + getInjectionToken({ + id: "listening-to-a-message-channel", + }); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/message/get-message-channel.ts b/packages/technical-features/messaging/agnostic/src/features/actual/message/get-message-channel.ts new file mode 100644 index 0000000000..aee2413d07 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/message/get-message-channel.ts @@ -0,0 +1,7 @@ +import type { MessageChannel } from "./message-channel-listener-injection-token"; + +export const getMessageChannel = ( + id: string +): MessageChannel => ({ + id, +}); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/message/message-channel-listener-injection-token.ts b/packages/technical-features/messaging/agnostic/src/features/actual/message/message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..9df97a75a6 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/message/message-channel-listener-injection-token.ts @@ -0,0 +1,54 @@ +import type { DiContainerForInjection } from "@ogre-tools/injectable"; +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; + +export interface MessageChannel { + id: string; + _messageSignature?: Message; +} + +export type MessageChannelHandler = Channel extends MessageChannel< + infer Message +> + ? (message: Message) => void + : never; + +export interface MessageChannelListener { + id: string; + channel: Channel; + handler: MessageChannelHandler; +} + +export const messageChannelListenerInjectionToken = getInjectionToken< + MessageChannelListener> +>({ + id: "message-channel-listener", +}); + +export interface GetMessageChannelListenerInfo< + Channel extends MessageChannel, + Message +> { + id: string; + channel: Channel; + getHandler: (di: DiContainerForInjection) => MessageChannelHandler; + causesSideEffects?: boolean; +} + +export const getMessageChannelListenerInjectable = < + Channel extends MessageChannel, + Message +>( + info: GetMessageChannelListenerInfo +) => + getInjectable({ + id: `${info.channel.id}-message-listener-${info.id}`, + + instantiate: (di): MessageChannelListener => ({ + id: `${info.channel.id}-message-listener-${info.id}`, + channel: info.channel, + handler: info.getHandler(di), + }), + + injectionToken: messageChannelListenerInjectionToken, + causesSideEffects: info.causesSideEffects, + }); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/message/message-to-channel-injection-token.no-coverage.ts b/packages/technical-features/messaging/agnostic/src/features/actual/message/message-to-channel-injection-token.no-coverage.ts new file mode 100644 index 0000000000..84a0478e69 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/message/message-to-channel-injection-token.no-coverage.ts @@ -0,0 +1,12 @@ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MessageChannel } from "./message-channel-listener-injection-token"; + +export interface SendMessageToChannel { + (channel: MessageChannel): void; + (channel: MessageChannel, message: Message): void; +} + +export const sendMessageToChannelInjectionToken = + getInjectionToken({ + id: "send-message-to-message-channel", + }); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/request/enlist-request-channel-listener-injection-token.ts b/packages/technical-features/messaging/agnostic/src/features/actual/request/enlist-request-channel-listener-injection-token.ts new file mode 100644 index 0000000000..420305341b --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/request/enlist-request-channel-listener-injection-token.ts @@ -0,0 +1,15 @@ +import { getInjectionToken } from "@ogre-tools/injectable"; + +import type { + RequestChannel, + RequestChannelListener, +} from "./request-channel-listener-injection-token"; + +export type EnlistRequestChannelListener = ( + listener: RequestChannelListener> +) => () => void; + +export const enlistRequestChannelListenerInjectionToken = + getInjectionToken({ + id: "listening-to-a-request-channel", + }); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/request/get-request-channel.ts b/packages/technical-features/messaging/agnostic/src/features/actual/request/get-request-channel.ts new file mode 100644 index 0000000000..90bf947a99 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/request/get-request-channel.ts @@ -0,0 +1,7 @@ +import type { RequestChannel } from "./request-channel-listener-injection-token"; + +export const getRequestChannel = ( + id: string +): RequestChannel => ({ + id, +}); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/request/request-channel-listener-injection-token.ts b/packages/technical-features/messaging/agnostic/src/features/actual/request/request-channel-listener-injection-token.ts new file mode 100644 index 0000000000..095c1bb713 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/request/request-channel-listener-injection-token.ts @@ -0,0 +1,56 @@ +import type { DiContainerForInjection } from "@ogre-tools/injectable"; +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; + +export interface RequestChannel { + id: string; + _requestSignature?: Request; + _responseSignature?: Response; +} + +export type RequestChannelHandler = Channel extends RequestChannel< + infer Request, + infer Response +> + ? (req: Request) => Promise | Response + : never; + +export interface RequestChannelListener { + id: string; + channel: Channel; + handler: RequestChannelHandler; +} + +export const requestChannelListenerInjectionToken = getInjectionToken< + RequestChannelListener> +>({ + id: "request-channel-listener", +}); + +export interface GetRequestChannelListenerInjectableInfo< + Channel extends RequestChannel, + Request, + Response +> { + id: string; + channel: Channel; + getHandler: (di: DiContainerForInjection) => RequestChannelHandler; +} + +export const getRequestChannelListenerInjectable = < + Channel extends RequestChannel, + Request, + Response +>( + info: GetRequestChannelListenerInjectableInfo +) => + getInjectable({ + id: `${info.channel.id}-request-listener-${info.id}`, + + instantiate: (di) => ({ + id: `${info.channel.id}-request-listener-${info.id}`, + channel: info.channel, + handler: info.getHandler(di), + }), + + injectionToken: requestChannelListenerInjectionToken, + }); diff --git a/packages/technical-features/messaging/agnostic/src/features/actual/request/request-from-channel-injection-token.no-coverage.ts b/packages/technical-features/messaging/agnostic/src/features/actual/request/request-from-channel-injection-token.no-coverage.ts new file mode 100644 index 0000000000..665af0369e --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/actual/request/request-from-channel-injection-token.no-coverage.ts @@ -0,0 +1,22 @@ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { RequestChannel } from "./request-channel-listener-injection-token"; + +export interface RequestFromChannel { + ( + channel: RequestChannel, + request: Request + ): Promise; + (channel: RequestChannel): Promise; +} + +export type ChannelRequester = Channel extends RequestChannel< + infer Request, + infer Response +> + ? (req: Request) => Promise> + : never; + +export const requestFromChannelInjectionToken = + getInjectionToken({ + id: "request-from-request-channel", + }); diff --git a/packages/technical-features/messaging/agnostic/src/features/unit-testing/feature.ts b/packages/technical-features/messaging/agnostic/src/features/unit-testing/feature.ts new file mode 100644 index 0000000000..f8a0774fed --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/unit-testing/feature.ts @@ -0,0 +1,20 @@ +import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration"; +import { getFeature } from "@k8slens/feature-core"; +import { messagingFeature } from "../actual/feature"; + +export const messagingFeatureForUnitTesting = getFeature({ + id: "messaging-for-unit-testing", + + dependencies: [messagingFeature], + + register: (di) => { + autoRegister({ + di, + targetModule: module, + + getRequireContexts: () => [ + require.context("./", true, /\.injectable\.(ts|tsx)$/), + ], + }); + }, +}); diff --git a/packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.test.ts b/packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.test.ts new file mode 100644 index 0000000000..afd9557e1c --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.test.ts @@ -0,0 +1,430 @@ +import { + createContainer, + DiContainer, + Injectable, +} from "@ogre-tools/injectable"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import { registerFeature } from "@k8slens/feature-core/src/register-feature"; +import { + getMessageChannelListenerInjectable, + MessageChannel, +} from "../../actual/message/message-channel-listener-injection-token"; +import { sendMessageToChannelInjectionToken } from "../../actual/message/message-to-channel-injection-token.no-coverage"; +import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx"; +import { runInAction } from "mobx"; +import { + getRequestChannelListenerInjectable, + RequestChannel, +} from "../../actual/request/request-channel-listener-injection-token"; +import { requestFromChannelInjectionToken } from "../../actual/request/request-from-channel-injection-token.no-coverage"; +import { getPromiseStatus } from "@k8slens/test-utils"; +import { getMessageBridgeFake } from "./get-message-bridge-fake"; +import { getMessageChannel } from "../../actual/message/get-message-channel"; +import { getRequestChannel } from "../../actual/request/get-request-channel"; +import { startApplicationInjectionToken } from "@k8slens/application"; +import { messagingFeatureForUnitTesting } from "../feature"; + +[{ scenarioIsAsync: true }, { scenarioIsAsync: false }].forEach( + ({ scenarioIsAsync }) => + describe(`get-message-bridge-fake, given running as ${ + scenarioIsAsync ? "async" : "sync" + }`, () => { + let messageBridgeFake: any; + + beforeEach(() => { + messageBridgeFake = getMessageBridgeFake(); + }); + + describe("given multiple DIs are involved", () => { + let someDi1: DiContainer; + let someDi2: DiContainer; + let someDiWithoutListeners: DiContainer; + + beforeEach(async () => { + someDi1 = createContainer("some-di-1"); + someDi2 = createContainer("some-di-2"); + + someDiWithoutListeners = createContainer("some-di-3"); + + registerMobX(someDi1); + registerMobX(someDi2); + registerMobX(someDiWithoutListeners); + + runInAction(() => { + registerFeature(someDi1, messagingFeatureForUnitTesting); + registerFeature(someDi2, messagingFeatureForUnitTesting); + registerFeature( + someDiWithoutListeners, + messagingFeatureForUnitTesting + ); + }); + + messageBridgeFake.involve(someDi1, someDi2, someDiWithoutListeners); + + if (scenarioIsAsync) { + messageBridgeFake.setAsync(scenarioIsAsync); + } + + await Promise.all([ + someDi1.inject(startApplicationInjectionToken)(), + someDi2.inject(startApplicationInjectionToken)(), + someDiWithoutListeners.inject(startApplicationInjectionToken)(), + ]); + }); + + describe("given there are message listeners", () => { + let someHandler1MockInDi1: jest.Mock; + let someHandler1MockInDi2: jest.Mock; + let someHandler2MockInDi2: jest.Mock; + let someListener1InDi2: Injectable; + + beforeEach(() => { + someHandler1MockInDi1 = jest.fn(); + someHandler1MockInDi2 = jest.fn(); + someHandler2MockInDi2 = jest.fn(); + + const someListener1InDi1 = getMessageChannelListenerInjectable({ + id: "some-listener-in-di-1", + channel: someMessageChannel, + getHandler: () => someHandler1MockInDi1, + }); + + someListener1InDi2 = getMessageChannelListenerInjectable({ + id: "some-listener-in-di-2", + channel: someMessageChannel, + getHandler: () => someHandler1MockInDi2, + }); + + const someListener2InDi2 = getMessageChannelListenerInjectable({ + id: "some-listener-2-in-di-2", + channel: someMessageChannel, + getHandler: () => someHandler2MockInDi2, + }); + + runInAction(() => { + someDi1.register(someListener1InDi1); + someDi2.register(someListener1InDi2); + someDi2.register(someListener2InDi2); + }); + }); + + describe("given there is a listener in di-2 that responds to a message with a message", () => { + beforeEach(() => { + const someResponder = getMessageChannelListenerInjectable({ + id: "some-responder-di-2", + channel: someMessageChannel, + + getHandler: (di) => { + const sendMessage = di.inject( + sendMessageToChannelInjectionToken + ); + + return (message) => { + sendMessage( + someMessageChannel, + `some-response-to: ${message}` + ); + }; + }, + }); + + runInAction(() => { + someDi2.register(someResponder); + }); + }); + + describe("given a message is sent in di-1", () => { + beforeEach(() => { + const sendMessageToChannelFromDi1 = someDi1.inject( + sendMessageToChannelInjectionToken + ); + + sendMessageToChannelFromDi1(someMessageChannel, "some-message"); + }); + + describe( + scenarioIsAsync + ? "when all message steps are propagated using a wrapper" + : "immediately", + () => { + let someWrapper: jest.Mock; + + beforeEach((done) => { + someWrapper = jest.fn((propagation) => propagation()); + + if (scenarioIsAsync) { + messageBridgeFake + .messagePropagationRecursive(someWrapper) + .then(done); + } else { + done(); + } + }); + + it("the response gets handled in di-1", () => { + expect(someHandler1MockInDi1).toHaveBeenCalledWith( + "some-response-to: some-message" + ); + }); + + scenarioIsAsync && + it("the wrapper gets called with the both propagations", () => { + expect(someWrapper).toHaveBeenCalledTimes(2); + }); + } + ); + + describe( + scenarioIsAsync + ? "when all message steps are propagated not using a wrapper" + : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake + .messagePropagationRecursive() + .then(done); + } else { + done(); + } + }); + + it("the response gets handled in di-1", () => { + expect(someHandler1MockInDi1).toHaveBeenCalledWith( + "some-response-to: some-message" + ); + }); + } + ); + }); + }); + + describe("when sending message in a DI", () => { + beforeEach(() => { + const sendMessageToChannelFromDi1 = someDi1.inject( + sendMessageToChannelInjectionToken + ); + + sendMessageToChannelFromDi1(someMessageChannel, "some-message"); + }); + + it("listener in sending DI does not handle the message", () => { + expect(someHandler1MockInDi1).not.toHaveBeenCalled(); + }); + + scenarioIsAsync && + it("listeners in other than sending DIs do not handle the message yet", () => { + expect(someHandler1MockInDi2).not.toHaveBeenCalled(); + expect(someHandler2MockInDi2).not.toHaveBeenCalled(); + }); + + describe( + scenarioIsAsync ? "when messages are propagated" : "immediately", + () => { + beforeEach((done) => { + if (scenarioIsAsync) { + messageBridgeFake.messagePropagation().then(done); + } else { + done(); + } + }); + + it("listeners in other than sending DIs handle the message", () => { + expect(someHandler1MockInDi2).toHaveBeenCalledWith( + "some-message" + ); + + expect(someHandler2MockInDi2).toHaveBeenCalledWith( + "some-message" + ); + }); + } + ); + + scenarioIsAsync && + describe("when messages are propagated using a wrapper, such as act() in react testing lib", () => { + let someWrapper: jest.Mock; + + beforeEach(async () => { + someWrapper = jest.fn((observation) => observation()); + + await messageBridgeFake.messagePropagation(someWrapper); + }); + + it("the wrapper gets called with the related propagation", async () => { + expect(someWrapper).toHaveBeenCalledTimes(1); + }); + + it("listeners still handle the message", () => { + expect(someHandler1MockInDi2).toHaveBeenCalledWith( + "some-message" + ); + + expect(someHandler2MockInDi2).toHaveBeenCalledWith( + "some-message" + ); + }); + }); + }); + + it("given a listener is deregistered, when sending message, deregistered listener does not handle the message", () => { + runInAction(() => { + someDi2.deregister(someListener1InDi2); + }); + + const sendMessageToChannelFromDi1 = someDi1.inject( + sendMessageToChannelInjectionToken + ); + + someHandler1MockInDi2.mockClear(); + + sendMessageToChannelFromDi1(someMessageChannel, "irrelevant"); + + expect(someHandler1MockInDi2).not.toHaveBeenCalled(); + }); + }); + + describe("given there are request listeners", () => { + let someHandler1MockInDi1: AsyncFnMock< + (message: string) => Promise + >; + + let someHandler1MockInDi2: AsyncFnMock< + (message: string) => Promise + >; + + let someListener1InDi2: Injectable; + let actualPromise: Promise; + + beforeEach(() => { + someHandler1MockInDi1 = asyncFn(); + someHandler1MockInDi2 = asyncFn(); + + const someListener1InDi1 = getRequestChannelListenerInjectable({ + id: "some-request-listener-in-di-1", + channel: someOtherRequestChannel, + getHandler: () => someHandler1MockInDi1, + }); + + someListener1InDi2 = getRequestChannelListenerInjectable({ + id: "some-request-listener-in-di-2", + channel: someRequestChannel, + getHandler: () => someHandler1MockInDi2, + }); + + runInAction(() => { + someDi1.register(someListener1InDi1); + someDi2.register(someListener1InDi2); + }); + }); + + describe("when requesting from a channel in a DI", () => { + beforeEach(() => { + const requestFromChannelFromDi1 = someDi1.inject( + requestFromChannelInjectionToken + ); + + actualPromise = requestFromChannelFromDi1( + someRequestChannel, + "some-request" + ); + }); + + it("listener in requesting DI does not handle the request", () => { + expect(someHandler1MockInDi1).not.toHaveBeenCalled(); + }); + + it("the listener in other than requesting DIs handle the request", () => { + expect(someHandler1MockInDi2).toHaveBeenCalledWith( + "some-request" + ); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when handle resolves, resolves with response", async () => { + await someHandler1MockInDi2.resolve(42); + + const actual = await actualPromise; + + expect(actual).toBe(42); + }); + }); + + it("given a listener is deregistered, when requesting, deregistered listener does not handle the request", () => { + runInAction(() => { + someDi2.deregister(someListener1InDi2); + }); + + const sendMessageToChannelFromDi1 = someDi1.inject( + sendMessageToChannelInjectionToken + ); + + someHandler1MockInDi2.mockClear(); + + sendMessageToChannelFromDi1(someMessageChannel, "irrelevant"); + + expect(someHandler1MockInDi2).not.toHaveBeenCalled(); + }); + + it("given there are multiple listeners between different DIs for same channel, when requesting, throws", () => { + const someConflictingListenerInjectable = + getRequestChannelListenerInjectable({ + id: "conflicting-listener", + channel: someRequestChannel, + getHandler: () => () => 84, + }); + + runInAction(() => { + someDi1.register(someConflictingListenerInjectable); + }); + + const requestFromChannelFromDi2 = someDi2.inject( + requestFromChannelInjectionToken + ); + + return expect(() => + requestFromChannelFromDi2(someRequestChannel, "irrelevant") + ).rejects.toThrow( + 'Tried to make a request but multiple listeners were discovered for channel "some-request-channel" in multiple DIs.' + ); + }); + + it("when requesting from channel without listener, throws", () => { + const requestFromChannel = someDi1.inject( + requestFromChannelInjectionToken + ); + + return expect(() => + requestFromChannel( + someRequestChannelWithoutListeners, + "irrelevant" + ) + ).rejects.toThrow( + 'Tried to make a request but no listeners for channel "some-request-channel-without-listeners" was discovered in any DIs' + ); + }); + }); + }); + }) +); + +type SomeMessageChannel = MessageChannel; +type SomeRequestChannel = RequestChannel; + +const someMessageChannel: SomeMessageChannel = getMessageChannel( + "some-message-channel" +); +const someRequestChannel: SomeRequestChannel = getRequestChannel( + "some-request-channel" +); +const someOtherRequestChannel: SomeRequestChannel = { + id: "some-other-request-channel", +}; +const someRequestChannelWithoutListeners: SomeRequestChannel = { + id: "some-request-channel-without-listeners", +}; diff --git a/packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.ts b/packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.ts new file mode 100644 index 0000000000..e674966214 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/unit-testing/get-message-bridge-fake/get-message-bridge-fake.ts @@ -0,0 +1,210 @@ +import type { DiContainer } from "@ogre-tools/injectable"; +import type { Channel } from "../../actual/channel.no-coverage"; +import type { MessageChannelHandler } from "../../actual/message/message-channel-listener-injection-token"; +import type { RequestChannelHandler } from "../../actual/request/request-channel-listener-injection-token"; +import { sendMessageToChannelInjectionToken } from "../../actual/message/message-to-channel-injection-token.no-coverage"; +import { enlistMessageChannelListenerInjectionToken } from "../../actual/message/enlist-message-channel-listener-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, map } from "lodash/fp"; +import { + RequestFromChannel, + requestFromChannelInjectionToken, +} from "../../actual/request/request-from-channel-injection-token.no-coverage"; +import { enlistRequestChannelListenerInjectionToken } from "../../actual/request/enlist-request-channel-listener-injection-token"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; + +export type MessageBridgeFake = { + involve: (...dis: DiContainer[]) => void; + messagePropagation: () => Promise; + messagePropagationRecursive: (callback: any) => any; + setAsync: (value: boolean) => void; +}; + +export const getMessageBridgeFake = (): MessageBridgeFake => { + const messageListenersByDi = new Map< + DiContainer, + Map>> + >(); + + const requestListenersByDi = new Map< + DiContainer, + Map>> + >(); + + const messagePropagationBuffer = new Set void>>(); + + const messagePropagation = async ( + wrapper: (callback: any) => any = (callback) => callback() + ) => { + const oldMessages = [...messagePropagationBuffer.values()]; + messagePropagationBuffer.clear(); + await Promise.all(oldMessages.map((x) => wrapper(x.resolve))); + }; + + const messagePropagationRecursive = async ( + wrapper: (callback: any) => any = (callback) => callback() + ) => { + while (messagePropagationBuffer.size) { + await messagePropagation(wrapper); + } + }; + + let asyncModeStatus = false; + const getAsyncModeStatus = () => asyncModeStatus; + + return { + involve: (...dis: DiContainer[]) => { + dis.forEach((di) => { + overrideRequesting({ di, requestListenersByDi }); + + overrideMessaging({ + di, + messageListenersByDi, + messagePropagationBuffer, + getAsyncModeStatus, + }); + }); + }, + + messagePropagation, + + messagePropagationRecursive, + + setAsync: (value) => { + asyncModeStatus = value; + }, + }; +}; + +const overrideMessaging = ({ + di, + messageListenersByDi, + messagePropagationBuffer, + getAsyncModeStatus, +}: { + di: DiContainer; + + messageListenersByDi: Map< + DiContainer, + Map>> + >; + + messagePropagationBuffer: Set<{ resolve: () => Promise }>; + + getAsyncModeStatus: () => boolean; +}) => { + const messageHandlersByChannel = new Map< + string, + Set> + >(); + + messageListenersByDi.set(di, messageHandlersByChannel); + + di.override(sendMessageToChannelInjectionToken, () => (channel, message) => { + const allOtherDis = [...messageListenersByDi.keys()].filter( + (x) => x !== di + ); + + allOtherDis.forEach((otherDi) => { + const listeners = messageListenersByDi.get(otherDi); + + const handlersForChannel = listeners!.get(channel.id); + + if (!handlersForChannel) { + return; + } + + if (getAsyncModeStatus()) { + const resolvableHandlePromise = asyncFn(); + + resolvableHandlePromise().then(() => { + handlersForChannel.forEach((handler) => handler(message)); + }); + + messagePropagationBuffer.add(resolvableHandlePromise); + } else { + handlersForChannel.forEach((handler) => handler(message)); + } + }); + }); + + di.override(enlistMessageChannelListenerInjectionToken, () => (listener) => { + if (!messageHandlersByChannel.has(listener.channel.id)) { + messageHandlersByChannel.set(listener.channel.id, new Set()); + } + + const handlerSet = messageHandlersByChannel.get(listener.channel.id); + + handlerSet!.add(listener.handler); + + return () => { + handlerSet!.delete(listener.handler); + }; + }); +}; + +const overrideRequesting = ({ + di, + requestListenersByDi, +}: { + di: DiContainer; + + requestListenersByDi: Map< + DiContainer, + Map>> + >; +}) => { + const requestHandlersByChannel = new Map< + string, + Set> + >(); + + requestListenersByDi.set(di, requestHandlersByChannel); + + di.override( + requestFromChannelInjectionToken, + () => + (async (channel, request) => + await pipeline( + [...requestListenersByDi.values()], + map((listenersByChannel) => listenersByChannel!.get(channel.id)), + filter((x) => !!x), + + (channelSpecificListeners) => { + if (channelSpecificListeners.length === 0) { + throw new Error( + `Tried to make a request but no listeners for channel "${channel.id}" was discovered in any DIs` + ); + } + + if (channelSpecificListeners.length > 1) { + throw new Error( + `Tried to make a request but multiple listeners were discovered for channel "${channel.id}" in multiple DIs.` + ); + } + + const listeners = channelSpecificListeners[0]; + + const [handler] = listeners!; + + return handler; + }, + + async (handler) => await handler(request) + )) as RequestFromChannel + ); + + di.override(enlistRequestChannelListenerInjectionToken, () => (listener) => { + if (!requestHandlersByChannel.has(listener.channel.id)) { + requestHandlersByChannel.set(listener.channel.id, new Set()); + } + + const handlerSet = requestHandlersByChannel.get(listener.channel.id); + + handlerSet!.add(listener.handler); + + return () => { + handlerSet!.delete(listener.handler); + }; + }); +}; diff --git a/packages/technical-features/messaging/agnostic/src/features/unit-testing/index.ts b/packages/technical-features/messaging/agnostic/src/features/unit-testing/index.ts new file mode 100644 index 0000000000..73eb5e4f43 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/unit-testing/index.ts @@ -0,0 +1,2 @@ +export { messagingFeatureForUnitTesting } from "./feature"; +export { getMessageBridgeFake } from "./get-message-bridge-fake/get-message-bridge-fake"; diff --git a/packages/technical-features/messaging/agnostic/src/features/unit-testing/test-doubles.injectable.ts b/packages/technical-features/messaging/agnostic/src/features/unit-testing/test-doubles.injectable.ts new file mode 100644 index 0000000000..6adf145a3d --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/features/unit-testing/test-doubles.injectable.ts @@ -0,0 +1,29 @@ +import { sendMessageToChannelInjectionToken } from "../actual/message/message-to-channel-injection-token.no-coverage"; +import { enlistMessageChannelListenerInjectionToken } from "../actual/message/enlist-message-channel-listener-injection-token"; +import { requestFromChannelInjectionToken } from "../actual/request/request-from-channel-injection-token.no-coverage"; +import { enlistRequestChannelListenerInjectionToken } from "../actual/request/enlist-request-channel-listener-injection-token"; +import { getInjectable } from "@ogre-tools/injectable"; + +export const sendMessageToChannelStubInjectable = getInjectable({ + id: "send-message-to-channel-stub", + instantiate: () => () => {}, + injectionToken: sendMessageToChannelInjectionToken, +}); + +export const enlistMessageChannelListenerStubInjectable = getInjectable({ + id: "enlist-message-channel-listener-stub", + instantiate: () => () => () => {}, + injectionToken: enlistMessageChannelListenerInjectionToken, +}); + +export const requestFromChannelStubInjectable = getInjectable({ + id: "request-from-channel-stub", + instantiate: () => () => Promise.resolve(), + injectionToken: requestFromChannelInjectionToken, +}); + +export const enlistRequestChannelListenerStubInjectable = getInjectable({ + id: "enlist-request-channel-listener-stub", + instantiate: () => () => () => {}, + injectionToken: enlistRequestChannelListenerInjectionToken, +}); diff --git a/packages/technical-features/messaging/agnostic/src/listening-of-messages.test.ts b/packages/technical-features/messaging/agnostic/src/listening-of-messages.test.ts new file mode 100644 index 0000000000..6f5c9916eb --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/listening-of-messages.test.ts @@ -0,0 +1,199 @@ +import { + createContainer, + DiContainer, + getInjectable, + Injectable, +} from "@ogre-tools/injectable"; + +import { registerFeature } from "@k8slens/feature-core"; +import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx"; +import { runInAction } from "mobx"; + +import { + EnlistMessageChannelListener, + enlistMessageChannelListenerInjectionToken, +} from "./features/actual/message/enlist-message-channel-listener-injection-token"; + +import { messagingFeature } from "./features/actual/feature"; + +import { + getMessageChannelListenerInjectable, + MessageChannel, + MessageChannelListener, +} from "./features/actual/message/message-channel-listener-injection-token"; + +import { listeningOfChannelsInjectionToken } from "./features/actual/listening-of-channels/listening-of-channels.injectable"; +import { enlistRequestChannelListenerInjectionToken } from "./features/actual/request/enlist-request-channel-listener-injection-token"; +import { sendMessageToChannelInjectionToken } from "./features/actual/message/message-to-channel-injection-token.no-coverage"; +import { getMessageChannel } from "./features/actual/message/get-message-channel"; + +describe("listening-of-messages", () => { + let di: DiContainer; + let enlistMessageChannelListenerMock: jest.MockedFunction; + let disposeSomeListenerMock: jest.Mock; + let disposeSomeUnrelatedListenerMock: jest.Mock; + + beforeEach(() => { + di = createContainer("irrelevant"); + + registerMobX(di); + + disposeSomeListenerMock = jest.fn(); + disposeSomeUnrelatedListenerMock = jest.fn(); + + enlistMessageChannelListenerMock = jest.fn((listener) => + listener.id === "some-listener" + ? disposeSomeListenerMock + : disposeSomeUnrelatedListenerMock + ); + + const someEnlistMessageChannelListenerInjectable = getInjectable({ + id: "some-enlist-message-channel-listener", + instantiate: () => enlistMessageChannelListenerMock, + injectionToken: enlistMessageChannelListenerInjectionToken, + }); + + const someEnlistRequestChannelListenerInjectable = getInjectable({ + id: "some-enlist-request-channel-listener", + instantiate: () => () => () => {}, + injectionToken: enlistRequestChannelListenerInjectionToken, + }); + + const sendMessageToChannelDummyInjectable = getInjectable({ + id: "send-message-to-channel-dummy", + instantiate: () => () => {}, + injectionToken: sendMessageToChannelInjectionToken, + }); + + runInAction(() => { + di.register( + someEnlistMessageChannelListenerInjectable, + someEnlistRequestChannelListenerInjectable, + sendMessageToChannelDummyInjectable + ); + + registerFeature(di, messagingFeature); + }); + }); + + describe("given listening of channels has not started yet", () => { + describe("when a new listener gets registered", () => { + let someChannel: MessageChannel; + let someMessageHandler: () => void; + + let someListenerInjectable: Injectable< + MessageChannelListener>, + MessageChannelListener> + >; + + beforeEach(() => { + someChannel = getMessageChannel("some-channel-id"); + + someMessageHandler = () => {}; + + someListenerInjectable = getMessageChannelListenerInjectable({ + id: "some-listener", + channel: someChannel, + getHandler: () => someMessageHandler, + }); + + runInAction(() => { + di.register(someListenerInjectable); + }); + }); + + // Todo: make starting automatic by using a runnable with a timeslot. + describe("when listening of channels is started", () => { + beforeEach(() => { + const listeningOnMessageChannels = di.inject( + listeningOfChannelsInjectionToken + ); + + listeningOnMessageChannels.start(); + }); + + it("it enlists a listener for the channel", () => { + expect(enlistMessageChannelListenerMock).toHaveBeenCalledWith({ + id: "some-listener", + channel: someChannel, + handler: someMessageHandler, + }); + }); + + describe("when another listener gets registered", () => { + let someOtherListenerInjectable: Injectable< + MessageChannelListener>, + MessageChannelListener>, + void + >; + + beforeEach(() => { + const handler = () => someMessageHandler; + + someOtherListenerInjectable = getMessageChannelListenerInjectable({ + id: "some-other-listener", + channel: someChannel, + getHandler: handler, + }); + + enlistMessageChannelListenerMock.mockClear(); + + runInAction(() => { + di.register(someOtherListenerInjectable); + }); + }); + + it("only enlists it as well", () => { + expect(enlistMessageChannelListenerMock.mock.calls).toEqual([ + [ + { + id: "some-other-listener", + channel: someChannel, + handler: someMessageHandler, + }, + ], + ]); + }); + + describe("when one of the listeners gets deregistered", () => { + beforeEach(() => { + runInAction(() => { + di.deregister(someListenerInjectable); + }); + }); + + it("the listener gets disposed", () => { + expect(disposeSomeListenerMock).toHaveBeenCalled(); + }); + + it("the unrelated listener does not get disposed", () => { + expect(disposeSomeUnrelatedListenerMock).not.toHaveBeenCalled(); + }); + + describe("when listening of channels stops", () => { + beforeEach(() => { + const listening = di.inject(listeningOfChannelsInjectionToken); + + listening.stop(); + }); + + it("remaining listeners get disposed", () => { + expect(disposeSomeUnrelatedListenerMock).toHaveBeenCalled(); + }); + + it("when yet another listener gets registered, does not enlist it", () => { + enlistMessageChannelListenerMock.mockClear(); + + runInAction(() => { + di.register(someListenerInjectable); + }); + + expect(enlistMessageChannelListenerMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/technical-features/messaging/agnostic/src/listening-of-requests.test.ts b/packages/technical-features/messaging/agnostic/src/listening-of-requests.test.ts new file mode 100644 index 0000000000..bd6409ed08 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/src/listening-of-requests.test.ts @@ -0,0 +1,236 @@ +import { + createContainer, + DiContainer, + getInjectable, + Injectable, +} from "@ogre-tools/injectable"; + +import { registerFeature } from "@k8slens/feature-core"; +import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx"; +import { _resetGlobalState, configure, runInAction } from "mobx"; + +import { + EnlistRequestChannelListener, + enlistRequestChannelListenerInjectionToken, +} from "./features/actual/request/enlist-request-channel-listener-injection-token"; + +import { messagingFeature } from "./features/actual/feature"; + +import { + getRequestChannelListenerInjectable, + RequestChannel, + RequestChannelListener, +} from "./features/actual/request/request-channel-listener-injection-token"; + +import { listeningOfChannelsInjectionToken } from "./features/actual/listening-of-channels/listening-of-channels.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "./features/actual/message/enlist-message-channel-listener-injection-token"; +import { noop } from "lodash/fp"; +import { sendMessageToChannelInjectionToken } from "./features/actual/message/message-to-channel-injection-token.no-coverage"; +import { getRequestChannel } from "./features/actual/request/get-request-channel"; + +describe("listening-of-requests", () => { + let di: DiContainer; + let enlistRequestChannelListenerMock: jest.MockedFunction; + let disposeSomeListenerMock: jest.Mock; + let disposeSomeUnrelatedListenerMock: jest.Mock; + + beforeEach(() => { + configure({ + disableErrorBoundaries: false, + }); + + _resetGlobalState(); + + di = createContainer("irrelevant"); + + registerMobX(di); + + disposeSomeListenerMock = jest.fn(); + disposeSomeUnrelatedListenerMock = jest.fn(); + + enlistRequestChannelListenerMock = jest.fn((listener) => + listener.id === "some-listener" + ? disposeSomeListenerMock + : disposeSomeUnrelatedListenerMock + ); + + const someEnlistMessageChannelListenerInjectable = getInjectable({ + id: "some-enlist-message-channel-listener", + instantiate: () => () => () => {}, + injectionToken: enlistMessageChannelListenerInjectionToken, + }); + + const someEnlistRequestChannelListenerInjectable = getInjectable({ + id: "some-enlist-request-channel-listener", + instantiate: () => enlistRequestChannelListenerMock, + injectionToken: enlistRequestChannelListenerInjectionToken, + }); + + const sendMessageToChannelDummyInjectable = getInjectable({ + id: "send-message-to-channel-dummy", + instantiate: () => () => {}, + injectionToken: sendMessageToChannelInjectionToken, + }); + + runInAction(() => { + di.register( + someEnlistMessageChannelListenerInjectable, + someEnlistRequestChannelListenerInjectable, + sendMessageToChannelDummyInjectable + ); + + registerFeature(di, messagingFeature); + }); + }); + + describe("given listening of channels has not started yet", () => { + describe("when a new listener gets registered", () => { + let someChannel: RequestChannel; + let someOtherChannel: RequestChannel; + let someRequestHandler: () => string; + + let someListenerInjectable: Injectable< + RequestChannelListener>, + RequestChannelListener> + >; + + beforeEach(() => { + someChannel = getRequestChannel("some-channel-id"); + someOtherChannel = getRequestChannel("some-other-channel-id"); + + someRequestHandler = () => "some-response"; + + someListenerInjectable = getRequestChannelListenerInjectable({ + id: "some-listener", + channel: someChannel, + getHandler: () => someRequestHandler, + }); + + runInAction(() => { + di.register(someListenerInjectable); + }); + }); + + // Todo: make starting automatic by using a runnable with a timeslot. + describe("when listening of channels is started", () => { + beforeEach(() => { + const listeningOnRequestChannels = di.inject( + listeningOfChannelsInjectionToken + ); + + listeningOnRequestChannels.start(); + }); + + it("it enlists a listener for the channel", () => { + expect(enlistRequestChannelListenerMock).toHaveBeenCalledWith({ + id: "some-listener", + channel: someChannel, + handler: someRequestHandler, + }); + }); + + it("when another listener for same channel gets registered, throws", () => { + const originalConsoleWarn = console.warn; + + console.warn = noop; + + configure({ + disableErrorBoundaries: true, + }); + + console.warn = originalConsoleWarn; + + const handler = () => someRequestHandler; + + const someConflictingListenerInjectable = + getRequestChannelListenerInjectable({ + id: "some-other-listener", + channel: someChannel, + getHandler: handler, + }); + + expect(() => { + runInAction(() => { + di.register(someConflictingListenerInjectable); + }); + }).toThrow( + 'Tried to add listener for channel "some-channel-id" but listener already exists.' + ); + }); + + describe("when another listener gets registered", () => { + let someOtherListenerInjectable: Injectable< + RequestChannelListener>, + RequestChannelListener> + >; + + beforeEach(() => { + const handler = () => someRequestHandler; + + someOtherListenerInjectable = getRequestChannelListenerInjectable({ + id: "some-other-listener", + channel: someOtherChannel, + getHandler: handler, + }); + + enlistRequestChannelListenerMock.mockClear(); + + runInAction(() => { + di.register(someOtherListenerInjectable); + }); + }); + + it("only enlists it as well", () => { + expect(enlistRequestChannelListenerMock.mock.calls).toEqual([ + [ + { + id: "some-other-listener", + channel: someOtherChannel, + handler: someRequestHandler, + }, + ], + ]); + }); + + describe("when one of the listeners gets deregistered", () => { + beforeEach(() => { + runInAction(() => { + di.deregister(someListenerInjectable); + }); + }); + + it("the listener gets disposed", () => { + expect(disposeSomeListenerMock).toHaveBeenCalled(); + }); + + it("the unrelated listener does not get disposed", () => { + expect(disposeSomeUnrelatedListenerMock).not.toHaveBeenCalled(); + }); + + describe("when listening of channels stops", () => { + beforeEach(() => { + const listening = di.inject(listeningOfChannelsInjectionToken); + + listening.stop(); + }); + + it("remaining listeners get disposed", () => { + expect(disposeSomeUnrelatedListenerMock).toHaveBeenCalled(); + }); + + it("when yet another listener gets registered, does not enlist it", () => { + enlistRequestChannelListenerMock.mockClear(); + + runInAction(() => { + di.register(someListenerInjectable); + }); + + expect(enlistRequestChannelListenerMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/technical-features/messaging/agnostic/tsconfig.json b/packages/technical-features/messaging/agnostic/tsconfig.json new file mode 100644 index 0000000000..a4f6fa613e --- /dev/null +++ b/packages/technical-features/messaging/agnostic/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@k8slens/typescript/config/base.json" +} diff --git a/packages/technical-features/messaging/agnostic/webpack.config.js b/packages/technical-features/messaging/agnostic/webpack.config.js new file mode 100644 index 0000000000..3183f30179 --- /dev/null +++ b/packages/technical-features/messaging/agnostic/webpack.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/webpack").configForNode; diff --git a/packages/technical-features/messaging/main/index.ts b/packages/technical-features/messaging/main/index.ts new file mode 100644 index 0000000000..1ff9afff0d --- /dev/null +++ b/packages/technical-features/messaging/main/index.ts @@ -0,0 +1 @@ +export { messagingFeatureForMain } from "./src/feature"; diff --git a/packages/technical-features/messaging/main/jest.config.js b/packages/technical-features/messaging/main/jest.config.js new file mode 100644 index 0000000000..6d3d6ff231 --- /dev/null +++ b/packages/technical-features/messaging/main/jest.config.js @@ -0,0 +1,2 @@ +module.exports = + require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode; diff --git a/packages/technical-features/messaging/main/package.json b/packages/technical-features/messaging/main/package.json new file mode 100644 index 0000000000..02f0f57623 --- /dev/null +++ b/packages/technical-features/messaging/main/package.json @@ -0,0 +1,38 @@ +{ + "name": "@k8slens/messaging-for-main", + "private": false, + "version": "1.0.0-alpha.1", + "description": "Implementations for 'messaging' in Electron main", + "type": "commonjs", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "author": { + "name": "OpenLens Authors", + "email": "info@k8slens.dev" + }, + "license": "MIT", + "homepage": "https://github.com/lensapp/lens", + "scripts": { + "build": "webpack", + "dev": "webpack --mode=development --watch", + "test:unit": "jest --coverage --runInBand", + "lint": "lens-lint", + "lint:fix": "lens-lint --fix" + }, + "peerDependencies": { + "@k8slens/application": "^6.5.0-alpha.0", + "@k8slens/feature-core": "^6.5.0-alpha.0", + "@k8slens/messaging": "^1.0.0-alpha.1", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "electron": "^19.1.8", + "lodash": "^4.17.21" + } +} diff --git a/packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.injectable.ts b/packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.injectable.ts new file mode 100644 index 0000000000..94b155a006 --- /dev/null +++ b/packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.injectable.ts @@ -0,0 +1,28 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IpcMainEvent } from "electron"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "@k8slens/messaging"; + +const enlistMessageChannelListenerInjectable = getInjectable({ + id: "enlist-message-channel-listener", + + instantiate: (di) => { + const ipcMain = di.inject(ipcMainInjectable); + + return ({ channel, handler }) => { + const nativeOnCallback = (_: IpcMainEvent, message: unknown) => { + handler(message); + }; + + ipcMain.on(channel.id, nativeOnCallback); + + return () => { + ipcMain.off(channel.id, nativeOnCallback); + }; + }; + }, + + injectionToken: enlistMessageChannelListenerInjectionToken, +}); + +export default enlistMessageChannelListenerInjectable; diff --git a/packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.test.ts b/packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.test.ts new file mode 100644 index 0000000000..6d35f2f743 --- /dev/null +++ b/packages/technical-features/messaging/main/src/channel-listeners/enlist-message-channel-listener.test.ts @@ -0,0 +1,103 @@ +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { IpcMain, IpcMainEvent } from "electron"; +import { + EnlistMessageChannelListener, + enlistMessageChannelListenerInjectionToken, +} from "@k8slens/messaging"; +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { feature } from "../feature"; + +describe("enlist message channel listener in main", () => { + let enlistMessageChannelListener: EnlistMessageChannelListener; + let ipcMainStub: IpcMain; + let onMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, feature); + + 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({ + id: "some-listener", + 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 object as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, { some: "object" }); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.injectable.ts b/packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.injectable.ts new file mode 100644 index 0000000000..a4a16cadc4 --- /dev/null +++ b/packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.injectable.ts @@ -0,0 +1,37 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IpcMainInvokeEvent } from "electron"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { + RequestChannel, + RequestChannelListener, +} from "@k8slens/messaging"; +import { enlistRequestChannelListenerInjectionToken } from "@k8slens/messaging"; + +export type EnlistRequestChannelListener = < + TChannel extends RequestChannel +>( + listener: RequestChannelListener +) => () => void; + +const enlistRequestChannelListenerInjectable = getInjectable({ + id: "enlist-request-channel-listener-for-main", + + instantiate: (di): EnlistRequestChannelListener => { + const ipcMain = di.inject(ipcMainInjectable); + + return ({ channel, handler }) => { + const nativeHandleCallback = (_: IpcMainInvokeEvent, request: unknown) => + handler(request); + + ipcMain.handle(channel.id, nativeHandleCallback); + + return () => { + ipcMain.off(channel.id, nativeHandleCallback); + }; + }; + }, + + injectionToken: enlistRequestChannelListenerInjectionToken, +}); + +export default enlistRequestChannelListenerInjectable; diff --git a/packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.test.ts b/packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.test.ts new file mode 100644 index 0000000000..d642601a53 --- /dev/null +++ b/packages/technical-features/messaging/main/src/channel-listeners/enlist-request-channel-listener.test.ts @@ -0,0 +1,158 @@ +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { IpcMain, IpcMainInvokeEvent } from "electron"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { EnlistRequestChannelListener } from "./enlist-request-channel-listener.injectable"; +import enlistRequestChannelListenerInjectable from "./enlist-request-channel-listener.injectable"; +import type { RequestChannel, RequestChannelHandler } from "@k8slens/messaging"; +import { getPromiseStatus } from "@k8slens/test-utils"; +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { feature } from "../feature"; + +type TestRequestChannel = RequestChannel; + +const testRequestChannel: TestRequestChannel = { + id: "some-channel-id", +}; + +describe("enlist request channel listener in main", () => { + let enlistRequestChannelListener: EnlistRequestChannelListener; + let ipcMainStub: IpcMain; + let handleMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, feature); + + handleMock = jest.fn(); + offMock = jest.fn(); + + ipcMainStub = { + handle: handleMock, + off: offMock, + } as unknown as IpcMain; + + di.override(ipcMainInjectable, () => ipcMainStub); + + enlistRequestChannelListener = di.inject( + enlistRequestChannelListenerInjectable + ); + }); + + describe("when called", () => { + let handlerMock: AsyncFnMock>; + let disposer: () => void; + + beforeEach(() => { + handlerMock = asyncFn(); + + disposer = enlistRequestChannelListener({ + id: "some-listener", + channel: testRequestChannel, + 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 response", async () => { + await handlerMock.resolve({ some: "object" }); + + const actual = await actualPromise; + + expect(actual).toEqual({ 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 object as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, { some: "object" }); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/packages/technical-features/messaging/main/src/feature.ts b/packages/technical-features/messaging/main/src/feature.ts new file mode 100644 index 0000000000..925f9630ba --- /dev/null +++ b/packages/technical-features/messaging/main/src/feature.ts @@ -0,0 +1,17 @@ +import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration"; +import { getFeature } from "@k8slens/feature-core"; + +export const messagingFeatureForMain = getFeature({ + id: "messaging-for-main", + + register: (di) => { + autoRegister({ + di, + targetModule: module, + + getRequireContexts: () => [ + require.context("./", true, /\.injectable\.(ts|tsx)$/), + ], + }); + }, +}); diff --git a/packages/technical-features/messaging/main/src/ipc-main/ipc-main.injectable.ts b/packages/technical-features/messaging/main/src/ipc-main/ipc-main.injectable.ts new file mode 100644 index 0000000000..fc55a6b414 --- /dev/null +++ b/packages/technical-features/messaging/main/src/ipc-main/ipc-main.injectable.ts @@ -0,0 +1,10 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { ipcMain } from "electron"; + +const ipcMainInjectable = getInjectable({ + id: "ipc-main", + instantiate: () => ipcMain, + causesSideEffects: true, +}); + +export default ipcMainInjectable; diff --git a/packages/technical-features/messaging/main/src/ipc-main/ipc-main.test.ts b/packages/technical-features/messaging/main/src/ipc-main/ipc-main.test.ts new file mode 100644 index 0000000000..6b4a1dea2d --- /dev/null +++ b/packages/technical-features/messaging/main/src/ipc-main/ipc-main.test.ts @@ -0,0 +1,21 @@ +import { createContainer, DiContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import ipcMainInjectable from "./ipc-main.injectable"; +import { ipcMain } from "electron"; +import { feature } from "../feature"; + +describe("ipc-main", () => { + let di: DiContainer; + + beforeEach(() => { + di = createContainer("irrelevant"); + + registerFeature(di, feature); + }); + + it("is the actual IPC-main of Electron", () => { + const actual = di.inject(ipcMainInjectable); + + expect(actual).toBe(ipcMain); + }); +}); diff --git a/packages/technical-features/messaging/main/src/listening-of-channels.test.ts b/packages/technical-features/messaging/main/src/listening-of-channels.test.ts new file mode 100644 index 0000000000..b9bb90871c --- /dev/null +++ b/packages/technical-features/messaging/main/src/listening-of-channels.test.ts @@ -0,0 +1,38 @@ +import { createContainer, getInjectable } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { feature } from "./feature"; +import { listeningOfChannelsInjectionToken } from "@k8slens/messaging"; +import { getStartableStoppable } from "@k8slens/startable-stoppable"; +import { runManyFor } from "@k8slens/run-many"; +import { onLoadOfApplicationInjectionToken } from "@k8slens/application"; + +describe("listening-of-channels", () => { + it("when on application load, starts listening of channels", async () => { + const di = createContainer("irrelevant"); + + registerFeature(di, feature); + + const listeningOfChannelsMock = jest.fn(() => () => {}); + + const listeningOfChannelsInjectableStub = getInjectable({ + id: "some-runnable", + + instantiate: () => + getStartableStoppable("some-listening-of-channels-implementation", () => + listeningOfChannelsMock() + ), + + injectionToken: listeningOfChannelsInjectionToken, + }); + + di.register(listeningOfChannelsInjectableStub); + + const onLoadOfApplication = runManyFor(di)( + onLoadOfApplicationInjectionToken + ); + + await onLoadOfApplication(); + + expect(listeningOfChannelsMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/technical-features/messaging/main/tsconfig.json b/packages/technical-features/messaging/main/tsconfig.json new file mode 100644 index 0000000000..a4f6fa613e --- /dev/null +++ b/packages/technical-features/messaging/main/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@k8slens/typescript/config/base.json" +} diff --git a/packages/technical-features/messaging/main/webpack.config.js b/packages/technical-features/messaging/main/webpack.config.js new file mode 100644 index 0000000000..3183f30179 --- /dev/null +++ b/packages/technical-features/messaging/main/webpack.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/webpack").configForNode; diff --git a/packages/technical-features/messaging/renderer/index.ts b/packages/technical-features/messaging/renderer/index.ts new file mode 100644 index 0000000000..337d473d1f --- /dev/null +++ b/packages/technical-features/messaging/renderer/index.ts @@ -0,0 +1 @@ +export { messagingFeatureForRenderer } from "./src/feature"; diff --git a/packages/technical-features/messaging/renderer/jest.config.js b/packages/technical-features/messaging/renderer/jest.config.js new file mode 100644 index 0000000000..6d3d6ff231 --- /dev/null +++ b/packages/technical-features/messaging/renderer/jest.config.js @@ -0,0 +1,2 @@ +module.exports = + require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode; diff --git a/packages/technical-features/messaging/renderer/package.json b/packages/technical-features/messaging/renderer/package.json new file mode 100644 index 0000000000..7e6bb1ae4f --- /dev/null +++ b/packages/technical-features/messaging/renderer/package.json @@ -0,0 +1,39 @@ +{ + "name": "@k8slens/messaging-for-renderer", + "private": false, + "version": "1.0.0-alpha.1", + "description": "Implementations for 'messaging' in Electron renderer", + "type": "commonjs", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "author": { + "name": "OpenLens Authors", + "email": "info@k8slens.dev" + }, + "license": "MIT", + "homepage": "https://github.com/lensapp/lens", + "scripts": { + "build": "webpack", + "dev": "webpack --mode=development --watch", + "test:unit": "jest --coverage --runInBand", + "lint": "lens-lint", + "lint:fix": "lens-lint --fix" + }, + "peerDependencies": { + "@k8slens/application": "^6.5.0-alpha.0", + "@k8slens/messaging": "^1.0.0-alpha.1", + "@k8slens/run-many": "^1.0.0-alpha.1", + "@k8slens/startable-stoppable": "^1.0.0-alpha.1", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "electron": "^19.1.8", + "lodash": "^4.17.21" + } +} diff --git a/packages/technical-features/messaging/renderer/src/feature.ts b/packages/technical-features/messaging/renderer/src/feature.ts new file mode 100644 index 0000000000..9cec963ebc --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/feature.ts @@ -0,0 +1,17 @@ +import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration"; +import { getFeature } from "@k8slens/feature-core"; + +export const messagingFeatureForRenderer = getFeature({ + id: "messaging-for-renderer", + + register: (di) => { + autoRegister({ + di, + targetModule: module, + + getRequireContexts: () => [ + require.context("./", true, /\.injectable\.(ts|tsx)$/), + ], + }); + }, +}); diff --git a/packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.injectable.ts b/packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.injectable.ts new file mode 100644 index 0000000000..a2ecffac15 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.injectable.ts @@ -0,0 +1,10 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { ipcRenderer } from "electron"; + +const ipcRendererInjectable = getInjectable({ + id: "ipc-renderer", + instantiate: () => ipcRenderer, + causesSideEffects: true, +}); + +export default ipcRendererInjectable; diff --git a/packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.test.ts b/packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.test.ts new file mode 100644 index 0000000000..7bb2917bf3 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/ipc/ipc-renderer.test.ts @@ -0,0 +1,27 @@ +import { createContainer, DiContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import ipcRendererInjectable from "./ipc-renderer.injectable"; +import { feature } from "../feature"; +import { ipcRenderer } from "electron"; + +describe("ipc-renderer", () => { + let di: DiContainer; + + beforeEach(() => { + di = createContainer("irrelevant"); + + registerFeature(di, feature); + }); + + it("is not undefined", () => { + const actual = di.inject(ipcRendererInjectable); + + expect(actual).not.toBeUndefined(); + }); + + it("is IPC-renderer of Electron", () => { + const actual = di.inject(ipcRendererInjectable); + + expect(actual).toBe(ipcRenderer); + }); +}); diff --git a/packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.injectable.ts b/packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.injectable.ts new file mode 100644 index 0000000000..64e9b1f873 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.injectable.ts @@ -0,0 +1,28 @@ +import ipcRendererInjectable from "../ipc/ipc-renderer.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { IpcRendererEvent } from "electron"; +import { enlistMessageChannelListenerInjectionToken } from "@k8slens/messaging"; + +const enlistMessageChannelListenerInjectable = getInjectable({ + id: "enlist-message-channel-listener-for-renderer", + + instantiate: (di) => { + const ipcRenderer = di.inject(ipcRendererInjectable); + + return ({ channel, handler }) => { + const nativeCallback = (_: IpcRendererEvent, message: unknown) => { + handler(message); + }; + + ipcRenderer.on(channel.id, nativeCallback); + + return () => { + ipcRenderer.off(channel.id, nativeCallback); + }; + }; + }, + + injectionToken: enlistMessageChannelListenerInjectionToken, +}); + +export default enlistMessageChannelListenerInjectable; diff --git a/packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.test.ts b/packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.test.ts new file mode 100644 index 0000000000..4725d533f9 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/listening-of-messages/enlist-message-channel-listener.test.ts @@ -0,0 +1,103 @@ +import type { IpcRendererEvent, IpcRenderer } from "electron"; +import ipcRendererInjectable from "../ipc/ipc-renderer.injectable"; +import { + EnlistMessageChannelListener, + enlistMessageChannelListenerInjectionToken, +} from "@k8slens/messaging"; +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { feature } from "../feature"; + +describe("enlist message channel listener in renderer", () => { + let enlistMessageChannelListener: EnlistMessageChannelListener; + let ipcRendererStub: IpcRenderer; + let onMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, feature); + + 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({ + id: "some-listener", + 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 object as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, { some: "object" }); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/packages/technical-features/messaging/renderer/src/listening-of-messages/listening-of-channels.test.ts b/packages/technical-features/messaging/renderer/src/listening-of-messages/listening-of-channels.test.ts new file mode 100644 index 0000000000..1c71cbf5e2 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/listening-of-messages/listening-of-channels.test.ts @@ -0,0 +1,38 @@ +import { createContainer, getInjectable } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { feature } from "../feature"; +import { listeningOfChannelsInjectionToken } from "@k8slens/messaging"; +import { getStartableStoppable } from "@k8slens/startable-stoppable"; +import { runManyFor } from "@k8slens/run-many"; +import { onLoadOfApplicationInjectionToken } from "@k8slens/application"; + +describe("listening-of-channels", () => { + it("when before frame starts, starts listening of channels", async () => { + const di = createContainer("irrelevant"); + + registerFeature(di, feature); + + const listeningOfChannelsMock = jest.fn(() => () => {}); + + const listeningOfChannelsInjectableStub = getInjectable({ + id: "some-runnable", + + instantiate: () => + getStartableStoppable("some-listening-of-channels-implementation", () => + listeningOfChannelsMock() + ), + + injectionToken: listeningOfChannelsInjectionToken, + }); + + di.register(listeningOfChannelsInjectableStub); + + const onLoadOfApplication = runManyFor(di)( + onLoadOfApplicationInjectionToken + ); + + await onLoadOfApplication(); + + expect(listeningOfChannelsMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.injectable.ts b/packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.injectable.ts new file mode 100644 index 0000000000..03329d0c92 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.injectable.ts @@ -0,0 +1,10 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import ipcRendererInjectable from "../ipc/ipc-renderer.injectable"; + +const invokeIpcInjectable = getInjectable({ + id: "invoke-ipc", + + instantiate: (di) => di.inject(ipcRendererInjectable).invoke, +}); + +export default invokeIpcInjectable; diff --git a/packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.test.ts b/packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.test.ts new file mode 100644 index 0000000000..416b3b1129 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/requesting-of-requests/invoke-ipc.test.ts @@ -0,0 +1,21 @@ +import { createContainer, DiContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { feature } from "../feature"; +import { ipcRenderer } from "electron"; +import invokeIpcInjectable from "./invoke-ipc.injectable"; + +describe("ipc-renderer", () => { + let di: DiContainer; + + beforeEach(() => { + di = createContainer("irrelevant"); + + registerFeature(di, feature); + }); + + it("is IPC-renderer invoke of Electron", () => { + const actual = di.inject(invokeIpcInjectable); + + expect(actual).toBe(ipcRenderer.invoke); + }); +}); diff --git a/packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.injectable.ts b/packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.injectable.ts new file mode 100644 index 0000000000..7d46c0681c --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.injectable.ts @@ -0,0 +1,19 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import type { RequestFromChannel } from "@k8slens/messaging"; +import { requestFromChannelInjectionToken } from "@k8slens/messaging"; +import invokeIpcInjectable from "./invoke-ipc.injectable"; + +const requestFromChannelInjectable = getInjectable({ + id: "request-from-channel", + + instantiate: (di) => { + const invokeIpc = di.inject(invokeIpcInjectable); + + return ((channel, request) => + invokeIpc(channel.id, request)) as RequestFromChannel; + }, + + injectionToken: requestFromChannelInjectionToken, +}); + +export default requestFromChannelInjectable; diff --git a/packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.test.ts b/packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.test.ts new file mode 100644 index 0000000000..dc0e5888dd --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/requesting-of-requests/request-from-channel.test.ts @@ -0,0 +1,49 @@ +import { createContainer, DiContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { requestFromChannelInjectionToken } from "@k8slens/messaging"; +import { feature } from "../feature"; +import type { RequestChannel } from "@k8slens/messaging"; +import invokeIpcInjectable from "./invoke-ipc.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; + +describe("request-from-channel", () => { + let di: DiContainer; + let invokeIpcMock: AsyncFnMock<() => Promise>; + + beforeEach(() => { + di = createContainer("irrelevant"); + + registerFeature(di, feature); + + invokeIpcMock = asyncFn(); + di.override(invokeIpcInjectable, () => invokeIpcMock); + }); + + describe("when called", () => { + let actualPromise: Promise; + + beforeEach(() => { + const requestFromChannel = di.inject(requestFromChannelInjectionToken); + + const someChannel: RequestChannel = { + id: "some-channel-id", + }; + + actualPromise = requestFromChannel(someChannel, "some-request-payload"); + }); + + it("invokes ipcRenderer of Electron", () => { + expect(invokeIpcMock).toHaveBeenCalledWith( + "some-channel-id", + "some-request-payload" + ); + }); + + it("when invoke resolves with response, resolves with said response", async () => { + await invokeIpcMock.resolve(42); + + expect(await actualPromise).toBe(42); + }); + }); +}); diff --git a/packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.injectable.ts b/packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.injectable.ts new file mode 100644 index 0000000000..7ba5d51631 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.injectable.ts @@ -0,0 +1,22 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import sendToIpcInjectable from "./send-to-ipc.injectable"; +import { + SendMessageToChannel, + sendMessageToChannelInjectionToken, +} from "@k8slens/messaging"; + +const messageToChannelInjectable = getInjectable({ + id: "message-to-channel", + + instantiate: (di) => { + const sendToIpc = di.inject(sendToIpcInjectable); + + return ((channel, message) => { + sendToIpc(channel.id, message); + }) as SendMessageToChannel; + }, + + injectionToken: sendMessageToChannelInjectionToken, +}); + +export default messageToChannelInjectable; diff --git a/packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.test.ts b/packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.test.ts new file mode 100644 index 0000000000..7790df50c2 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/sending-of-messages/message-to-channel.test.ts @@ -0,0 +1,40 @@ +import { createContainer, DiContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { sendMessageToChannelInjectionToken } from "@k8slens/messaging"; +import { feature } from "../feature"; +import type { MessageChannel } from "@k8slens/messaging"; +import sendToIpcInjectable from "./send-to-ipc.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; + +describe("message-from-channel", () => { + let di: DiContainer; + let sendToIpcMock: AsyncFnMock<() => Promise>; + + beforeEach(() => { + di = createContainer("irrelevant"); + + registerFeature(di, feature); + + sendToIpcMock = asyncFn(); + di.override(sendToIpcInjectable, () => sendToIpcMock); + }); + + describe("when called", () => { + beforeEach(() => { + const sendMessageToChannel = di.inject( + sendMessageToChannelInjectionToken + ); + + const someChannel: MessageChannel = { + id: "some-channel-id", + }; + + sendMessageToChannel(someChannel, 42); + }); + + it("sends to ipcRenderer of Electron", () => { + expect(sendToIpcMock).toHaveBeenCalledWith("some-channel-id", 42); + }); + }); +}); diff --git a/packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.injectable.ts b/packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.injectable.ts new file mode 100644 index 0000000000..059dcbe8c2 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.injectable.ts @@ -0,0 +1,10 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import ipcRendererInjectable from "../ipc/ipc-renderer.injectable"; + +const sendToIpcInjectable = getInjectable({ + id: "send-to-ipc", + + instantiate: (di) => di.inject(ipcRendererInjectable).send, +}); + +export default sendToIpcInjectable; diff --git a/packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.test.ts b/packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.test.ts new file mode 100644 index 0000000000..b98aff2ff7 --- /dev/null +++ b/packages/technical-features/messaging/renderer/src/sending-of-messages/send-to-ipc.test.ts @@ -0,0 +1,21 @@ +import { createContainer, DiContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@k8slens/feature-core"; +import { feature } from "../feature"; +import { ipcRenderer } from "electron"; +import sendToIpcInjectable from "./send-to-ipc.injectable"; + +describe("ipc-renderer", () => { + let di: DiContainer; + + beforeEach(() => { + di = createContainer("irrelevant"); + + registerFeature(di, feature); + }); + + it("is IPC-renderer send of Electron", () => { + const actual = di.inject(sendToIpcInjectable); + + expect(actual).toBe(ipcRenderer.send); + }); +}); diff --git a/packages/technical-features/messaging/renderer/tsconfig.json b/packages/technical-features/messaging/renderer/tsconfig.json new file mode 100644 index 0000000000..a4f6fa613e --- /dev/null +++ b/packages/technical-features/messaging/renderer/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@k8slens/typescript/config/base.json" +} diff --git a/packages/technical-features/messaging/renderer/webpack.config.js b/packages/technical-features/messaging/renderer/webpack.config.js new file mode 100644 index 0000000000..3183f30179 --- /dev/null +++ b/packages/technical-features/messaging/renderer/webpack.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/webpack").configForNode; diff --git a/packages/utility-features/utilities/package.json b/packages/utility-features/utilities/package.json index ba22c430aa..e6977fcab7 100644 --- a/packages/utility-features/utilities/package.json +++ b/packages/utility-features/utilities/package.json @@ -25,7 +25,9 @@ "test": "jest --coverage --runInBand" }, "peerDependencies": { - "mobx": "^6.8.0", - "type-fest": "^2.19.0" + "mobx": "^6.8.0" + }, + "devDependencies": { + "type-fest": "^2.14.0" } }