Switch to using messaging and startable stoppable from NPM package (#7368)

* Add custom jest resolver to fix requiring "uuid" module

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Update dependencies

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce test utils for rendering and running with thrown mobx reactions

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Extract startable-stoppable to NPM package

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Extract messaging to NPM package

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using startable-stoppable from NPM package

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using messaging from the Feature

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove old implementation of messaging

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make setupping app paths happen earlier in renderer

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix typo

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add kludge to make testing-library work properly from test-utils package

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix code style

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add lint:fix -root script

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix unrelated failing unit tests

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Turn of no-floating-promises from typescript linting for being broken

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make linting not happen for dist -directories

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make linting failures appear as failure

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Stop running prettier twice

It already gets ran as eslint-plugin.

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make CI run unit tests for all packages by consolidating name of NPM script

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add missing unit tests for coverage

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Skip coverage for test utils

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove check for coverage in packages which are not ready for it

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Stop collecting coverage from index.ts files them being indirections to the implementation

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Implement sending message to channel in main

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add missing feature dependencies

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add dummy implementations for requesting in main from renderer

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Re-enable communicating from main to cluster frames

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Ignore trivial files from coverage

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Update package-lock

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Extract message-bridge to separate NPM package to prevent dev dependencies being in the production bundle

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Extract computed channel to own NPM package for clear dependencies

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Consolidate electron related stuff to a directory

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add missing publish configurations

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Ignore test implementation from coverage being not interesting

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

---------

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne SavolainenandMikko Aspiala authored and GitHub committed 2023-03-21 11:38:43 +02:00
1 parent df49ff9b96
commit 0f1f030a06
261 files changed
+4447 -1795

No files matched your search

@@ -5,5 +5,5 @@ export * from "./src/start-application/time-slots";
export type { StartApplication } from "./src/start-application/start-application.injectable";
export { startApplicationInjectionToken } from "./src/start-application/start-application.injectable";
export { applicationInformationToken } from "./src/application-information-token";
export type { ApplicationInformation } from "./src/application-information-token";
export { applicationInformationToken } from "./src/application-information-token.no-coverage";
export type { ApplicationInformation } from "./src/application-information-token.no-coverage";
@@ -26,7 +26,7 @@
"scripts": {
"build": "webpack",
"dev": "webpack --mode=development --watch",
"test": "jest --coverage --runInBand",
"test:unit": "jest --coverage --runInBand",
"lint": "lens-lint",
"lint:fix": "lens-lint --fix"
},
@@ -4,17 +4,18 @@ import * as timeSlots from "./time-slots";
export type StartApplication = () => Promise<void>;
export const startApplicationInjectionToken =
getInjectionToken<StartApplication>({
id: "start-application-injection-token",
});
export const startApplicationInjectionToken = getInjectionToken<StartApplication>({
id: "start-application-injection-token",
});
const startApplicationInjectable = getInjectable({
id: "start-application",
instantiate: (di): StartApplication => {
const runManyAsync = runManyFor(di)
const beforeApplicationIsLoading = runManyAsync(timeSlots.beforeApplicationIsLoadingInjectionToken);
const runManyAsync = runManyFor(di);
const beforeApplicationIsLoading = runManyAsync(
timeSlots.beforeApplicationIsLoadingInjectionToken,
);
const onLoadOfApplication = runManyAsync(timeSlots.onLoadOfApplicationInjectionToken);
const afterApplicationIsLoaded = runManyAsync(timeSlots.afterApplicationIsLoadedInjectionToken);
@@ -4,6 +4,7 @@ import { applicationFeature } from "../feature";
import { startApplicationInjectionToken } from "./start-application.injectable";
import * as timeSlots from "./time-slots";
import asyncFn, { AsyncFnMock } from "@async-fn/jest";
import { getPromiseStatus } from "@k8slens/test-utils";
describe("starting-of-application", () => {
let di: DiContainer;
@@ -47,10 +48,12 @@ describe("starting-of-application", () => {
});
describe("when application is started", () => {
let actualPromise: Promise<void>;
beforeEach(() => {
const startApplication = di.inject(startApplicationInjectionToken);
void startApplication();
actualPromise = startApplication();
});
it("calls runnables registered in before application is loading", () => {
@@ -66,10 +69,28 @@ describe("starting-of-application", () => {
expect(onLoadOfApplicationMock).toHaveBeenCalled();
});
it("when runnables in before application is loading resolve, calls runnables registered in after load of application", async () => {
await onLoadOfApplicationMock.resolve();
describe("when runnables in before application is loading resolve", () => {
beforeEach(async () => {
await onLoadOfApplicationMock.resolve();
});
expect(afterApplicationIsLoadedMock).toHaveBeenCalled();
it("calls runnables registered in after load of application", async () => {
expect(afterApplicationIsLoadedMock).toHaveBeenCalled();
});
it("does not resolve yet", async () => {
const promiseStatus = await getPromiseStatus(actualPromise);
expect(promiseStatus.fulfilled).toBe(false);
});
it("when runnables in after application is loaded resolve, resolves", async () => {
await afterApplicationIsLoadedMock.resolve();
const promiseStatus = await getPromiseStatus(actualPromise);
expect(promiseStatus.fulfilled).toBe(true);
});
});
});
});
@@ -1,4 +1,4 @@
import { overrideSideEffectsWithFakes } from "./src/override-side-effects-with-fakes";
import { overrideSideEffectsWithFakes } from "./src/test-utils/override-side-effects-with-fakes";
export * from "./src/start-application/time-slots";
@@ -26,7 +26,7 @@
"scripts": {
"build": "webpack",
"dev": "webpack --mode=development --watch",
"test": "jest --coverage --runInBand",
"test:unit": "jest --coverage --runInBand",
"lint": "lens-lint",
"lint:fix": "lens-lint --fix"
},
@@ -1,5 +1,5 @@
import type { DiContainer } from "@ogre-tools/injectable";
import whenAppIsReadyInjectable from "./start-application/when-app-is-ready.injectable";
import whenAppIsReadyInjectable from "../start-application/when-app-is-ready.injectable";
export const overrideSideEffectsWithFakes = (di: DiContainer) => {
di.override(whenAppIsReadyInjectable, () => () => Promise.resolve());
@@ -1 +1,2 @@
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;
module.exports =
require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;
@@ -26,7 +26,6 @@
"scripts": {
"build": "webpack",
"dev": "webpack --mode=development --watch",
"test": "jest --coverage --runInBand",
"lint": "lens-lint",
"lint:fix": "lens-lint --fix"
},
@@ -26,7 +26,7 @@
"scripts": {
"build": "webpack",
"dev": "webpack --mode=development --watch",
"test": "jest --coverage --runInBand",
"test:unit": "jest --coverage --runInBand",
"lint": "lens-lint",
"lint:fix": "lens-lint --fix"
},
@@ -0,0 +1,6 @@
{
"extends": "@k8slens/eslint-config/eslint",
"parserOptions": {
"project": "./tsconfig.json"
}
}
@@ -0,0 +1 @@
"@k8slens/eslint-config/prettier"
@@ -0,0 +1,2 @@
export * from "./src/features/actual";
export * as testUtils from "./src/features/unit-testing";
@@ -0,0 +1 @@
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact;
@@ -0,0 +1,49 @@
{
"name": "@k8slens/messaging",
"private": false,
"version": "1.0.0-alpha.1",
"description": "An abstraction for messaging between Lens environments",
"type": "commonjs",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"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/feature-core": "^6.5.0-alpha.0",
"@k8slens/startable-stoppable": "^1.0.0-alpha.1",
"@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",
"lodash": "^4.17.21",
"mobx": "^6.7.0"
},
"devDependencies": {
"@k8slens/eslint-config": "^6.5.0-alpha.1"
}
}
@@ -0,0 +1,5 @@
export interface Channel<MessageTemplate = void, ReturnTemplate = void> {
id: string;
_messageTemplate?: MessageTemplate;
_returnTemplate?: ReturnTemplate;
}
@@ -0,0 +1,18 @@
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)$/)],
});
},
});
@@ -0,0 +1,53 @@
/* c8 ignore next */
export { messagingFeature } from "./feature";
export { getRequestChannel } from "./request/get-request-channel";
export { getMessageChannel } from "./message/get-message-channel";
export { requestFromChannelInjectionToken } from "./request/request-from-channel-injection-token";
export type { Channel } from "./channel.no-coverage";
export { sendMessageToChannelInjectionToken } from "./message/message-to-channel-injection-token";
export type { SendMessageToChannel } from "./message/message-to-channel-injection-token";
export type {
GetMessageChannelListenerInfo,
MessageChannel,
MessageChannelHandler,
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";
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";
@@ -0,0 +1,106 @@
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<ListeningOfChannels>({
id: "listening-of-channels-injection-token",
});
const listening = <T extends { id: string; channel: Channel<unknown> }>(
channelListeners: IComputedValue<T[]>,
enlistChannelListener: (listener: T) => () => void,
getId: (listener: T) => string,
) => {
const listenerDisposers = new Map<string, () => 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());
};
};
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;
@@ -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;
@@ -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<MessageChannel<unknown>>,
) => () => void;
export const enlistMessageChannelListenerInjectionToken =
getInjectionToken<EnlistMessageChannelListener>({
id: "listening-to-a-message-channel",
});
@@ -0,0 +1,5 @@
import type { MessageChannel } from "./message-channel-listener-injection-token";
export const getMessageChannel = <Request>(id: string): MessageChannel<Request> => ({
id,
});
@@ -0,0 +1,51 @@
import type { DiContainerForInjection } from "@ogre-tools/injectable";
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
export interface MessageChannel<Message> {
id: string;
_messageSignature?: Message;
}
export type ExtraData = { processId: number; frameId: number };
export type MessageChannelHandler<Channel> = Channel extends MessageChannel<infer Message>
? (message: Message, data: ExtraData) => void
: never;
export interface MessageChannelListener<Channel> {
id: string;
channel: Channel;
handler: MessageChannelHandler<Channel>;
}
export const messageChannelListenerInjectionToken = getInjectionToken<
MessageChannelListener<MessageChannel<unknown>>
>({
id: "message-channel-listener",
});
export interface GetMessageChannelListenerInfo<Channel extends MessageChannel<Message>, Message> {
id: string;
channel: Channel;
getHandler: (di: DiContainerForInjection) => MessageChannelHandler<Channel>;
causesSideEffects?: boolean;
}
export const getMessageChannelListenerInjectable = <
Channel extends MessageChannel<Message>,
Message,
>(
info: GetMessageChannelListenerInfo<Channel, Message>,
) =>
getInjectable({
id: `${info.channel.id}-message-listener-${info.id}`,
instantiate: (di): MessageChannelListener<Channel> => ({
id: `${info.channel.id}-message-listener-${info.id}`,
channel: info.channel,
handler: info.getHandler(di),
}),
injectionToken: messageChannelListenerInjectionToken,
causesSideEffects: info.causesSideEffects,
});
@@ -0,0 +1,11 @@
import { getInjectionToken } from "@ogre-tools/injectable";
import type { MessageChannel } from "./message-channel-listener-injection-token";
export interface SendMessageToChannel {
(channel: MessageChannel<void>): void;
<Message>(channel: MessageChannel<Message>, message: Message): void;
}
export const sendMessageToChannelInjectionToken = getInjectionToken<SendMessageToChannel>({
id: "send-message-to-message-channel",
});
@@ -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<RequestChannel<unknown, unknown>>,
) => () => void;
export const enlistRequestChannelListenerInjectionToken =
getInjectionToken<EnlistRequestChannelListener>({
id: "listening-to-a-request-channel",
});
@@ -0,0 +1,7 @@
import type { RequestChannel } from "./request-channel-listener-injection-token";
export const getRequestChannel = <Request, Response>(
id: string,
): RequestChannel<Request, Response> => ({
id,
});
@@ -0,0 +1,56 @@
import type { DiContainerForInjection } from "@ogre-tools/injectable";
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
export interface RequestChannel<Request, Response> {
id: string;
_requestSignature?: Request;
_responseSignature?: Response;
}
export type RequestChannelHandler<Channel> = Channel extends RequestChannel<
infer Request,
infer Response
>
? (req: Request) => Promise<Response> | Response
: never;
export interface RequestChannelListener<Channel> {
id: string;
channel: Channel;
handler: RequestChannelHandler<Channel>;
}
export const requestChannelListenerInjectionToken = getInjectionToken<
RequestChannelListener<RequestChannel<unknown, unknown>>
>({
id: "request-channel-listener",
});
export interface GetRequestChannelListenerInjectableInfo<
Channel extends RequestChannel<Request, Response>,
Request,
Response,
> {
id: string;
channel: Channel;
getHandler: (di: DiContainerForInjection) => RequestChannelHandler<Channel>;
}
export const getRequestChannelListenerInjectable = <
Channel extends RequestChannel<Request, Response>,
Request,
Response,
>(
info: GetRequestChannelListenerInjectableInfo<Channel, Request, Response>,
) =>
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,
});
@@ -0,0 +1,21 @@
import { getInjectionToken } from "@ogre-tools/injectable";
import type { RequestChannel } from "./request-channel-listener-injection-token";
export interface RequestFromChannel {
<Request, Response>(
channel: RequestChannel<Request, Response>,
request: Request,
): Promise<Response>;
<Response>(channel: RequestChannel<void, Response>): Promise<Response>;
}
export type ChannelRequester<Channel> = Channel extends RequestChannel<
infer Request,
infer Response
>
? (req: Request) => Promise<Awaited<Response>>
: never;
export const requestFromChannelInjectionToken = getInjectionToken<RequestFromChannel>({
id: "request-from-request-channel",
});
@@ -0,0 +1,18 @@
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)$/)],
});
},
});
@@ -0,0 +1 @@
export { messagingFeatureForUnitTesting } from "./feature";
@@ -0,0 +1,31 @@
import { sendMessageToChannelInjectionToken } from "../actual/message/message-to-channel-injection-token";
import { enlistMessageChannelListenerInjectionToken } from "../actual/message/enlist-message-channel-listener-injection-token";
import { requestFromChannelInjectionToken } from "../actual/request/request-from-channel-injection-token";
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",
/* c8 ignore next */
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",
/* c8 ignore next */
instantiate: () => () => Promise.resolve(),
injectionToken: requestFromChannelInjectionToken,
});
export const enlistRequestChannelListenerStubInjectable = getInjectable({
id: "enlist-request-channel-listener-stub",
instantiate: () => () => () => {},
injectionToken: enlistRequestChannelListenerInjectionToken,
});
@@ -0,0 +1,169 @@
import { createContainer, DiContainer, 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 { messagingFeatureForUnitTesting } from "./features/unit-testing";
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 { getMessageChannel } from "./features/actual/message/get-message-channel";
import { applicationFeature, startApplicationInjectionToken } from "@k8slens/application";
describe("listening-of-messages", () => {
let di: DiContainer;
let enlistMessageChannelListenerMock: jest.MockedFunction<EnlistMessageChannelListener>;
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-channel-id-message-listener-some-listener"
? disposeSomeListenerMock
: disposeSomeUnrelatedListenerMock,
);
runInAction(() => {
registerFeature(di, applicationFeature, messagingFeatureForUnitTesting);
});
di.override(enlistMessageChannelListenerInjectionToken, () => enlistMessageChannelListenerMock);
});
describe("given listening of channels has not started yet", () => {
describe("when a new listener gets registered", () => {
let someChannel: MessageChannel<string>;
let someMessageHandler: () => void;
let someListenerInjectable: Injectable<
MessageChannelListener<MessageChannel<string>>,
MessageChannelListener<MessageChannel<unknown>>
>;
beforeEach(() => {
someChannel = getMessageChannel("some-channel-id");
someMessageHandler = () => {};
someListenerInjectable = getMessageChannelListenerInjectable({
id: "some-listener",
channel: someChannel,
getHandler: () => someMessageHandler,
});
runInAction(() => {
di.register(someListenerInjectable);
});
});
describe("when application is started", () => {
beforeEach(async () => {
const startApplication = di.inject(startApplicationInjectionToken);
await startApplication();
});
it("enlists a listener for the channel", () => {
expect(enlistMessageChannelListenerMock).toHaveBeenCalledWith({
id: "some-channel-id-message-listener-some-listener",
channel: someChannel,
handler: someMessageHandler,
});
});
describe("when another listener gets registered", () => {
let someOtherListenerInjectable: Injectable<
MessageChannelListener<MessageChannel<string>>,
MessageChannelListener<MessageChannel<unknown>>,
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-channel-id-message-listener-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();
});
});
});
});
});
});
});
});
@@ -0,0 +1,204 @@
import { createContainer, DiContainer, 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 { messagingFeatureForUnitTesting } from "./features/unit-testing";
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 { noop } from "lodash/fp";
import { getRequestChannel } from "./features/actual/request/get-request-channel";
import { applicationFeature, startApplicationInjectionToken } from "@k8slens/application";
describe("listening-of-requests", () => {
let di: DiContainer;
let enlistRequestChannelListenerMock: jest.MockedFunction<EnlistRequestChannelListener>;
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-channel-id-request-listener-some-listener"
? disposeSomeListenerMock
: disposeSomeUnrelatedListenerMock,
);
runInAction(() => {
registerFeature(di, applicationFeature, messagingFeatureForUnitTesting);
});
di.override(enlistRequestChannelListenerInjectionToken, () => enlistRequestChannelListenerMock);
});
describe("given listening of channels has not started yet", () => {
describe("when a new listener gets registered", () => {
let someChannel: RequestChannel<string, string>;
let someOtherChannel: RequestChannel<string, string>;
let someRequestHandler: () => string;
let someListenerInjectable: Injectable<
RequestChannelListener<RequestChannel<string, string>>,
RequestChannelListener<RequestChannel<unknown, unknown>>
>;
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);
});
});
describe("when application is started", () => {
beforeEach(async () => {
const startApplication = di.inject(startApplicationInjectionToken);
await startApplication();
});
it("enlists a listener for the channel", () => {
expect(enlistRequestChannelListenerMock).toHaveBeenCalledWith({
id: "some-channel-id-request-listener-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<RequestChannel<string, string>>,
RequestChannelListener<RequestChannel<unknown, unknown>>
>;
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-channel-id-request-listener-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();
});
});
});
});
});
});
});
});
@@ -0,0 +1,4 @@
{
"extends": "@k8slens/typescript/config/base.json",
"include": ["**/*.ts", "**/*.tsx"]
}
@@ -0,0 +1 @@
module.exports = require("@k8slens/webpack").configForNode;
@@ -0,0 +1,6 @@
{
"extends": "@k8slens/eslint-config/eslint",
"parserOptions": {
"project": "./tsconfig.json"
}
}
@@ -0,0 +1 @@
"@k8slens/eslint-config/prettier"
@@ -0,0 +1,12 @@
export {
computedChannelInjectionToken,
computedChannelObserverInjectionToken,
} from "./src/computed-channel/computed-channel.injectable";
export type {
ChannelObserver,
ComputedChannelFactory,
JsonifiableObject,
JsonifiableArray,
Jsonifiable,
} from "./src/computed-channel/computed-channel.injectable";
@@ -0,0 +1 @@
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact;
@@ -0,0 +1,51 @@
{
"name": "@k8slens/computed-channel",
"private": false,
"version": "1.0.0-alpha.1",
"description": "MobX-like computed between channels",
"type": "commonjs",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"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/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",
"lodash": "^4.17.21",
"mobx": "^6.8.0"
},
"devDependencies": {
"@k8slens/eslint-config": "^6.5.0-alpha.1",
"@k8slens/messaging-fake-bridge": "^1.0.0-alpha.1",
"type-fest": "^2.14.0"
}
}
@@ -0,0 +1,64 @@
import { reaction } from "mobx";
import { getMessageChannelListenerInjectable } from "@k8slens/messaging";
import { sendMessageToChannelInjectionToken } from "@k8slens/messaging";
import type { JsonPrimitive } from "type-fest";
import { computedChannelObserverInjectionToken } from "./computed-channel.injectable";
import { getMessageChannel } from "@k8slens/messaging";
export type JsonifiableObject = { [Key in string]?: Jsonifiable } | { toJSON: () => Jsonifiable };
export type JsonifiableArray = readonly Jsonifiable[];
export type Jsonifiable = JsonPrimitive | JsonifiableObject | JsonifiableArray;
export type ComputedChannelAdminMessage = {
channelId: string;
status: "became-observed" | "became-unobserved";
};
export const computedChannelAdministrationChannel = getMessageChannel<ComputedChannelAdminMessage>(
"computed-channel-administration-channel",
);
export const computedChannelAdministrationListenerInjectable = getMessageChannelListenerInjectable({
id: "computed-channel-administration",
channel: computedChannelAdministrationChannel,
getHandler: (di) => {
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
const disposersByChannelId = new Map<string, () => 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?.();
}
};
},
});
@@ -0,0 +1,110 @@
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import {
_getGlobalState,
computed,
IComputedValue,
observable,
onBecomeObserved,
onBecomeUnobserved,
runInAction,
} from "mobx";
import type { MessageChannel } from "@k8slens/messaging";
import { getMessageChannelListenerInjectable } from "@k8slens/messaging";
import { sendMessageToChannelInjectionToken } from "@k8slens/messaging";
import type { JsonPrimitive } from "type-fest";
import { computedChannelAdministrationChannel } from "./computed-channel-administration-channel.injectable";
export type JsonifiableObject = { [Key in string]?: Jsonifiable } | { toJSON: () => Jsonifiable };
export type JsonifiableArray = readonly Jsonifiable[];
export type Jsonifiable = JsonPrimitive | JsonifiableObject | JsonifiableArray;
export type ComputedChannelFactory = <T>(
channel: MessageChannel<T>,
pendingValue: T,
) => IComputedValue<T>;
export const computedChannelInjectionToken = getInjectionToken<ComputedChannelFactory>({
id: "computed-channel-injection-token",
});
export type ChannelObserver<T extends Jsonifiable> = {
channel: MessageChannel<T>;
observer: IComputedValue<T>;
};
export const computedChannelObserverInjectionToken = getInjectionToken<
ChannelObserver<Jsonifiable>
>({
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 default computedChannelInjectable;
@@ -0,0 +1,571 @@
import React from "react";
import { act } from "@testing-library/react";
import { createContainer, DiContainer, getInjectable } from "@ogre-tools/injectable";
import { getMessageBridgeFake, MessageBridgeFake } from "@k8slens/messaging-fake-bridge";
import { startApplicationInjectionToken } from "@k8slens/application";
import {
computed,
IComputedValue,
IObservableValue,
observable,
reaction,
runInAction,
} from "mobx";
import type { MessageChannel } from "@k8slens/messaging";
import { getMessageChannelListenerInjectable } from "@k8slens/messaging";
import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx";
import { registerFeature } from "@k8slens/feature-core";
import { testUtils } from "@k8slens/messaging";
import {
computedChannelInjectionToken,
computedChannelObserverInjectionToken,
} from "./computed-channel.injectable";
import { runWithThrownMobxReactions, renderFor } from "@k8slens/test-utils";
import { observer } from "mobx-react";
import {
computedChannelAdministrationChannel,
ComputedChannelAdminMessage,
} from "./computed-channel-administration-channel.injectable";
import { computedChannelFeature } from "../feature";
const testChannel: MessageChannel<string> = { id: "some-channel-id" };
const testChannel2: MessageChannel<string> = { id: "some-other-channel-id" };
const TestComponent = observer(({ someComputed }: { someComputed: IComputedValue<string> }) => (
<div>{someComputed.get()}</div>
));
[{ 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(() => {
const messagingFeature = testUtils.messagingFeatureForUnitTesting;
registerFeature(di1, messagingFeature, computedChannelFeature);
registerFeature(di2, messagingFeature, computedChannelFeature);
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<string>;
let computedTestChannel: IComputedValue<string>;
beforeEach(() => {
someObservable = observable.box<string>("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(<TestComponent someComputed={computedTestChannel} />);
});
const scenarioName = scenarioIsAsync ? "when all messages are propagated" : "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
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");
});
const scenarioName = scenarioIsAsync
? "when admin messages are propagated"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
beforeEach((done) => {
if (scenarioIsAsync) {
void messageBridgeFake.messagePropagation().then(done);
} else {
done();
}
});
it("administration-message to start observing gets listened", () => {
expect(latestAdminMessage).toEqual({
channelId: "some-channel-id",
status: "became-observed",
});
});
const scenarioName = scenarioIsAsync
? "when returning value-messages propagate"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
beforeEach((done) => {
if (scenarioIsAsync) {
void 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");
});
});
const scenarioName = scenarioIsAsync
? "when value-messages propagate"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
beforeEach((done) => {
if (scenarioIsAsync) {
void 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();
});
const scenarioName = scenarioIsAsync
? "when admin-messages propagate"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
beforeEach((done) => {
if (scenarioIsAsync) {
void 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");
});
const scenarioName = scenarioIsAsync
? "when admin messages propagate"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
beforeEach((done) => {
if (scenarioIsAsync) {
latestAdminMessage = undefined;
void 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");
});
const scenarioTitle = scenarioIsAsync
? "when value-messages propagate back"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioTitle, () => {
beforeEach((done) => {
if (scenarioIsAsync) {
latestValueMessage = undefined;
void 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<string>("");
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");
});
const scenarioName = scenarioIsAsync
? "when messages would be propagated"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
beforeEach((done) => {
if (scenarioIsAsync) {
void 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<string>;
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");
});
});
});
}),
);
@@ -0,0 +1,42 @@
import { onLoadOfApplicationInjectionToken } from "@k8slens/application";
import { pipeline } from "@ogre-tools/fp";
import { getInjectable } from "@ogre-tools/injectable";
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
import { filter, groupBy, nth, map, toPairs } from "lodash/fp";
import { reaction } from "mobx";
import { computedChannelObserverInjectionToken } from "./computed-channel.injectable";
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,
});
@@ -0,0 +1,18 @@
import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration";
import { getFeature } from "@k8slens/feature-core";
import { messagingFeature } from "@k8slens/messaging";
export const computedChannelFeature = getFeature({
id: "computed-channel",
dependencies: [messagingFeature],
register: (di) => {
autoRegister({
di,
targetModule: module,
getRequireContexts: () => [require.context("./", true, /\.injectable\.(ts|tsx)$/)],
});
},
});
@@ -0,0 +1,4 @@
{
"extends": "@k8slens/typescript/config/base.json",
"include": ["**/*.ts", "**/*.tsx"]
}
@@ -0,0 +1 @@
module.exports = require("@k8slens/webpack").configForNode;
@@ -0,0 +1,6 @@
{
"extends": "@k8slens/eslint-config/eslint",
"parserOptions": {
"project": "./tsconfig.json"
}
}
@@ -0,0 +1 @@
"@k8slens/eslint-config/prettier"
@@ -0,0 +1 @@
export { messagingFeatureForMain } from "./src/feature";
@@ -0,0 +1 @@
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;
@@ -0,0 +1,47 @@
{
"name": "@k8slens/messaging-for-main",
"private": false,
"version": "1.0.0-alpha.1",
"description": "Implementations for 'messaging' in Electron main",
"type": "commonjs",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"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"
},
"devDependencies": {
"@k8slens/eslint-config": "^6.5.0-alpha.1"
}
}
@@ -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 = (nativeEvent: IpcMainEvent, message: unknown) => {
handler(message, { frameId: nativeEvent.frameId, processId: nativeEvent.processId });
};
ipcMain.on(channel.id, nativeOnCallback);
return () => {
ipcMain.off(channel.id, nativeOnCallback);
};
};
},
injectionToken: enlistMessageChannelListenerInjectionToken,
});
export default enlistMessageChannelListenerInjectable;
@@ -0,0 +1,95 @@
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 { messagingFeatureForMain } 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, messagingFeatureForMain);
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]({ frameId: 42, processId: 84 } as IpcMainEvent, "some-message");
});
it("calls the handler with the message", () => {
expect(handlerMock).toHaveBeenCalledWith("some-message", { frameId: 42, processId: 84 });
});
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]({ frameId: 42, processId: 84 } as IpcMainEvent, 42);
expect(handlerMock).toHaveBeenCalledWith(42, { frameId: 42, processId: 84 });
});
it("given boolean as message, when message arrives, calls the handler with the message", () => {
onMock.mock.calls[0][1]({ frameId: 42, processId: 84 } as IpcMainEvent, true);
expect(handlerMock).toHaveBeenCalledWith(true, { frameId: 42, processId: 84 });
});
it("given object as message, when message arrives, calls the handler with the message", () => {
onMock.mock.calls[0][1]({ frameId: 42, processId: 84 } as IpcMainEvent, { some: "object" });
expect(handlerMock).toHaveBeenCalledWith({ some: "object" }, { frameId: 42, processId: 84 });
});
});
});
@@ -0,0 +1,31 @@
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<unknown, unknown>>(
listener: RequestChannelListener<TChannel>,
) => () => 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;
@@ -0,0 +1,147 @@
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 { messagingFeatureForMain } from "../feature";
type TestRequestChannel = RequestChannel<unknown, unknown>;
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, messagingFeatureForMain);
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<RequestChannelHandler<TestRequestChannel>>;
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<any>;
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" });
});
});
});
@@ -0,0 +1,18 @@
import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration";
import { getFeature } from "@k8slens/feature-core";
import { messagingFeature } from "@k8slens/messaging";
export const messagingFeatureForMain = getFeature({
id: "messaging-for-main",
register: (di) => {
autoRegister({
di,
targetModule: module,
getRequireContexts: () => [require.context("./", true, /\.injectable\.(ts|tsx)$/)],
});
},
dependencies: [messagingFeature],
});
@@ -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;
@@ -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 { messagingFeatureForMain } from "../feature";
describe("ipc-main", () => {
let di: DiContainer;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, messagingFeatureForMain);
});
it("is the actual IPC-main of Electron", () => {
const actual = di.inject(ipcMainInjectable);
expect(actual).toBe(ipcMain);
});
});
@@ -0,0 +1,23 @@
/* c8 ignore start */
import { getInjectable } from "@ogre-tools/injectable";
import {
RequestChannel,
RequestFromChannel,
requestFromChannelInjectionToken,
} from "@k8slens/messaging";
const requestFromChannelInjectable = getInjectable({
id: "request-from-channel",
instantiate: () =>
((channel: RequestChannel<any, any>) => {
throw new Error(
`Tried to request from channel "${channel.id}" but requesting in "main" it's not supported yet`,
);
}) as unknown as RequestFromChannel,
injectionToken: requestFromChannelInjectionToken,
});
export default requestFromChannelInjectable;
/* c8 ignore stop */
@@ -0,0 +1,21 @@
import { getMessageChannel, getMessageChannelListenerInjectable } from "@k8slens/messaging";
import frameIdsInjectable from "./frameIds.injectable";
const frameCommunicationAdminChannel = getMessageChannel<undefined>(
"frame-communication-admin-channel",
);
const allowCommunicationListenerInjectable = getMessageChannelListenerInjectable({
id: "allow-communication",
channel: frameCommunicationAdminChannel,
getHandler: (di) => {
const frameIds = di.inject(frameIdsInjectable);
return (_, { frameId, processId }) => {
frameIds.add({ frameId, processId });
};
},
});
export default allowCommunicationListenerInjectable;
@@ -0,0 +1,8 @@
import { getInjectable } from "@ogre-tools/injectable";
const frameIdsInjectable = getInjectable({
id: "frame-ids",
instantiate: () => new Set<{ frameId: number; processId: number }>(),
});
export default frameIdsInjectable;
@@ -0,0 +1,12 @@
/* c8 ignore start */
import { getInjectable } from "@ogre-tools/injectable";
import { webContents } from "electron";
const getWebContentsInjectable = getInjectable({
id: "web-contents",
instantiate: () => () => webContents.getAllWebContents(),
causesSideEffects: true,
});
export default getWebContentsInjectable;
/* c8 ignore stop */
@@ -0,0 +1,121 @@
import { registerFeature } from "@k8slens/feature-core";
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { messagingFeatureForMain } from "../feature";
import { getMessageChannel, sendMessageToChannelInjectionToken } from "@k8slens/messaging";
import getWebContentsInjectable from "./get-web-contents.injectable";
import type { WebContents } from "electron";
import allowCommunicationListenerInjectable from "./allow-communication-listener.injectable";
const someChannel = getMessageChannel<string>("some-channel");
describe("send-message-to-channel", () => {
let di: DiContainer;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, messagingFeatureForMain);
});
it("given no web contents, when sending a message, does not do anything", () => {
di.override(getWebContentsInjectable, () => () => []);
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
expect(() => sendMessageToChannel(someChannel, "some-message")).not.toThrow();
});
describe("given web content that is alive", () => {
let sendToFrameMock: jest.Mock;
let sendMessageMock: jest.Mock;
beforeEach(() => {
sendToFrameMock = jest.fn();
sendMessageMock = jest.fn();
di.override(getWebContentsInjectable, () => () => [
{
send: (...args: any[]) => sendMessageMock("first", ...args),
sendToFrame: (...args: any[]) => sendToFrameMock("first", ...args),
isDestroyed: () => false,
isCrashed: () => false,
} as unknown as WebContents,
{
send: (...args: any[]) => sendMessageMock("second", ...args),
sendToFrame: (...args: any[]) => sendToFrameMock("second", ...args),
isDestroyed: () => false,
isCrashed: () => false,
} as unknown as WebContents,
]);
});
it("when sending message, sends the message to webcontents", () => {
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
sendMessageToChannel(someChannel, "some-message");
expect(sendMessageMock.mock.calls).toEqual([
["first", "some-channel", "some-message"],
["second", "some-channel", "some-message"],
]);
});
describe("when multiple renderers inform that they are ready to listen messages", () => {
beforeEach(() => {
const allowCommunicationListener = di.inject(allowCommunicationListenerInjectable);
allowCommunicationListener.handler(undefined, { frameId: 42, processId: 126 });
allowCommunicationListener.handler(undefined, { frameId: 84, processId: 168 });
});
describe("when sending a message", () => {
beforeEach(() => {
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
sendMessageToChannel(someChannel, "some-message");
});
it("sends the message to webcontents", () => {
expect(sendMessageMock.mock.calls).toEqual([
["first", "some-channel", "some-message"],
["second", "some-channel", "some-message"],
]);
});
it("sends the message to individual frames in webcontents", () => {
expect(sendToFrameMock.mock.calls).toEqual([
["first", [42, 126], "some-channel", "some-message"],
["first", [84, 168], "some-channel", "some-message"],
["second", [42, 126], "some-channel", "some-message"],
["second", [84, 168], "some-channel", "some-message"],
]);
});
});
});
});
it("given non alive web contents, when sending a message, does not send messages", () => {
const sendToWebContentsMock = jest.fn();
di.override(getWebContentsInjectable, () => () => [
{
send: sendToWebContentsMock,
isDestroyed: () => true,
isCrashed: () => false,
} as unknown as WebContents,
{
send: sendToWebContentsMock,
isDestroyed: () => false,
isCrashed: () => true,
} as unknown as WebContents,
]);
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
sendMessageToChannel(someChannel, "irrelevant");
expect(sendToWebContentsMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,48 @@
import { getInjectable } from "@ogre-tools/injectable";
import { pipeline } from "@ogre-tools/fp";
import { SendMessageToChannel, sendMessageToChannelInjectionToken } from "@k8slens/messaging";
import getWebContentsInjectable from "./get-web-contents.injectable";
import { flatMap, reject } from "lodash/fp";
import type { WebContents } from "electron";
import frameIdsInjectable from "./frameIds.injectable";
const isDestroyed = (webContent: WebContents) => webContent.isDestroyed();
const isCrashed = (webContent: WebContents) => webContent.isCrashed();
const forEach =
<T>(predicate: (item: T) => void) =>
(items: T[]) =>
items.forEach(predicate);
const sendMessageToChannelInjectable = getInjectable({
id: "send-message-to-channel",
instantiate: (di) => {
const getWebContents = di.inject(getWebContentsInjectable);
const frameIds = di.inject(frameIdsInjectable);
return ((channel, message) => {
pipeline(
getWebContents(),
reject(isDestroyed),
reject(isCrashed),
flatMap((webContent) => [
(channelId: string, ...args: any[]) => webContent.send(channelId, ...args),
...[...frameIds].map(({ frameId, processId }) => (channelId: string, ...args: any[]) => {
webContent.sendToFrame([frameId, processId], channelId, ...args);
}),
]),
forEach((send) => {
send(channel.id, message);
}),
);
}) as SendMessageToChannel;
},
injectionToken: sendMessageToChannelInjectionToken,
});
export default sendMessageToChannelInjectable;
@@ -0,0 +1,4 @@
{
"extends": "@k8slens/typescript/config/base.json",
"include": ["**/*.ts"]
}
@@ -0,0 +1 @@
module.exports = require("@k8slens/webpack").configForNode;
@@ -0,0 +1,6 @@
{
"extends": "@k8slens/eslint-config/eslint",
"parserOptions": {
"project": "./tsconfig.json"
}
}
@@ -0,0 +1 @@
"@k8slens/eslint-config/prettier"
@@ -0,0 +1 @@
export { messagingFeatureForRenderer } from "./src/feature";
@@ -0,0 +1 @@
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;
@@ -0,0 +1,48 @@
{
"name": "@k8slens/messaging-for-renderer",
"private": false,
"version": "1.0.0-alpha.1",
"description": "Implementations for 'messaging' in Electron renderer",
"type": "commonjs",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"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"
},
"devDependencies": {
"@k8slens/eslint-config": "^6.5.0-alpha.1"
}
}
@@ -0,0 +1,25 @@
import { getInjectable } from "@ogre-tools/injectable";
import { onLoadOfApplicationInjectionToken } from "@k8slens/application";
import { getMessageChannel, sendMessageToChannelInjectionToken } from "@k8slens/messaging";
export const frameCommunicationAdminChannel = getMessageChannel<undefined>(
"frame-communication-admin-channel",
);
const allowCommunicationToIframeInjectable = getInjectable({
id: "allow-communication-to-iframe-injectable",
instantiate: (di) => {
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
return {
run: () => {
sendMessageToChannel(frameCommunicationAdminChannel);
},
};
},
injectionToken: onLoadOfApplicationInjectionToken,
});
export default allowCommunicationToIframeInjectable;
@@ -0,0 +1,35 @@
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx";
import { startApplicationInjectionToken } from "@k8slens/application";
import { registerFeature } from "@k8slens/feature-core";
import { messagingFeatureForRenderer } from "./feature";
import { runInAction } from "mobx";
import ipcRendererInjectable from "./ipc/ipc-renderer.injectable";
import { sendMessageToChannelInjectionToken } from "@k8slens/messaging";
import { frameCommunicationAdminChannel } from "./allow-communication-to-iframe.injectable";
describe("allow communication to iframe", () => {
let di: DiContainer;
let sendMessageToChannelMock: jest.Mock;
beforeEach(() => {
di = createContainer("irrelevant");
registerMobX(di);
runInAction(() => {
registerFeature(di, messagingFeatureForRenderer);
});
di.override(ipcRendererInjectable, () => ({ on: () => {} } as unknown));
sendMessageToChannelMock = jest.fn();
di.override(sendMessageToChannelInjectionToken, () => sendMessageToChannelMock);
});
it("when application starts, sends message to communication channel to register the frame ID and process ID for further usage", async () => {
await di.inject(startApplicationInjectionToken)();
expect(sendMessageToChannelMock).toHaveBeenCalledWith(frameCommunicationAdminChannel);
});
});
@@ -0,0 +1,18 @@
import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration";
import { getFeature } from "@k8slens/feature-core";
import { messagingFeature } from "@k8slens/messaging";
export const messagingFeatureForRenderer = getFeature({
id: "messaging-for-renderer",
register: (di) => {
autoRegister({
di,
targetModule: module,
getRequireContexts: () => [require.context("./", true, /\.injectable\.(ts|tsx)$/)],
});
},
dependencies: [messagingFeature],
});
@@ -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;
@@ -0,0 +1,27 @@
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { registerFeature } from "@k8slens/feature-core";
import ipcRendererInjectable from "./ipc-renderer.injectable";
import { messagingFeatureForRenderer } from "../feature";
import { ipcRenderer } from "electron";
describe("ipc-renderer", () => {
let di: DiContainer;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, messagingFeatureForRenderer);
});
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);
});
});
@@ -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;
@@ -0,0 +1,95 @@
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 { messagingFeatureForRenderer } 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, messagingFeatureForRenderer);
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" });
});
});
});
@@ -0,0 +1,18 @@
/* c8 ignore start */
import { getInjectable } from "@ogre-tools/injectable";
import { enlistRequestChannelListenerInjectionToken } from "@k8slens/messaging";
const enlistRequestChannelListenerInjectable = getInjectable({
id: "enlist-request-channel-listener-for-renderer",
instantiate: () => (listener) => {
throw new Error(
`Tried to enlist request channel "${listener.channel.id}" in "renderer", but requesting it's not supported yet.`,
);
},
injectionToken: enlistRequestChannelListenerInjectionToken,
});
export default enlistRequestChannelListenerInjectable;
/* c8 ignore end */
@@ -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;
@@ -0,0 +1,21 @@
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { registerFeature } from "@k8slens/feature-core";
import { messagingFeatureForRenderer } from "../feature";
import { ipcRenderer } from "electron";
import invokeIpcInjectable from "./invoke-ipc.injectable";
describe("ipc-renderer", () => {
let di: DiContainer;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, messagingFeatureForRenderer);
});
it("is IPC-renderer invoke of Electron", () => {
const actual = di.inject(invokeIpcInjectable);
expect(actual).toBe(ipcRenderer.invoke);
});
});
@@ -0,0 +1,18 @@
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;
@@ -0,0 +1,46 @@
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { registerFeature } from "@k8slens/feature-core";
import { requestFromChannelInjectionToken } from "@k8slens/messaging";
import { messagingFeatureForRenderer } 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<number>>;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, messagingFeatureForRenderer);
invokeIpcMock = asyncFn();
di.override(invokeIpcInjectable, () => invokeIpcMock);
});
describe("when called", () => {
let actualPromise: Promise<number>;
beforeEach(() => {
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
const someChannel: RequestChannel<string, number> = {
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);
});
});
});
@@ -0,0 +1,19 @@
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;
@@ -0,0 +1,38 @@
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { registerFeature } from "@k8slens/feature-core";
import { sendMessageToChannelInjectionToken } from "@k8slens/messaging";
import { messagingFeatureForRenderer } 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<number>>;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, messagingFeatureForRenderer);
sendToIpcMock = asyncFn();
di.override(sendToIpcInjectable, () => sendToIpcMock);
});
describe("when called", () => {
beforeEach(() => {
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
const someChannel: MessageChannel<number> = {
id: "some-channel-id",
};
sendMessageToChannel(someChannel, 42);
});
it("sends to ipcRenderer of Electron", () => {
expect(sendToIpcMock).toHaveBeenCalledWith("some-channel-id", 42);
});
});
});
@@ -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;
@@ -0,0 +1,21 @@
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { registerFeature } from "@k8slens/feature-core";
import { messagingFeatureForRenderer } 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, messagingFeatureForRenderer);
});
it("is IPC-renderer send of Electron", () => {
const actual = di.inject(sendToIpcInjectable);
expect(actual).toBe(ipcRenderer.send);
});
});
@@ -0,0 +1,4 @@
{
"extends": "@k8slens/typescript/config/base.json",
"include": ["**/*.ts"]
}
@@ -0,0 +1 @@
module.exports = require("@k8slens/webpack").configForNode;
@@ -0,0 +1,6 @@
{
"extends": "@k8slens/eslint-config/eslint",
"parserOptions": {
"project": "./tsconfig.json"
}
}
@@ -0,0 +1 @@
"@k8slens/eslint-config/prettier"
@@ -0,0 +1,3 @@
export type { MessageBridgeFake } from "./src/get-message-bridge-fake/get-message-bridge-fake";
export { getMessageBridgeFake } from "./src/get-message-bridge-fake/get-message-bridge-fake";
@@ -0,0 +1 @@
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact;
@@ -0,0 +1,49 @@
{
"name": "@k8slens/messaging-fake-bridge",
"private": false,
"version": "1.0.0-alpha.1",
"description": "Fake implementation to bridge multiple dependency injection containers.",
"type": "commonjs",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"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/messaging": "^1.0.0-alpha.1",
"@ogre-tools/fp": "^15.1.2",
"@ogre-tools/injectable": "^15.1.2",
"lodash": "^4.17.21"
},
"devDependencies": {
"@async-fn/jest": "^1.6.4",
"@k8slens/feature-core": "6.5.0-alpha.1",
"@k8slens/eslint-config": "^6.5.0-alpha.1",
"@ogre-tools/injectable-extension-for-mobx": "^15.1.2",
"mobx": "^6.7.0"
}
}
@@ -0,0 +1,397 @@
import { createContainer, DiContainer, Injectable } from "@ogre-tools/injectable";
import asyncFn, { AsyncFnMock } from "@async-fn/jest";
import { registerFeature } from "@k8slens/feature-core";
import {
getMessageChannel,
getMessageChannelListenerInjectable,
getRequestChannel,
getRequestChannelListenerInjectable,
MessageChannel,
testUtils,
RequestChannel,
requestFromChannelInjectionToken,
sendMessageToChannelInjectionToken,
} from "@k8slens/messaging";
import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx";
import { runInAction } from "mobx";
import { getPromiseStatus } from "@k8slens/test-utils";
import { getMessageBridgeFake } from "./get-message-bridge-fake";
import { startApplicationInjectionToken } from "@k8slens/application";
type SomeMessageChannel = MessageChannel<string>;
type SomeRequestChannel = RequestChannel<string, number>;
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",
};
[{ 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(() => {
const feature = testUtils.messagingFeatureForUnitTesting;
registerFeature(someDi1, feature);
registerFeature(someDi2, feature);
registerFeature(someDiWithoutListeners, feature);
});
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<unknown, unknown>;
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");
});
const scenarioTitle = scenarioIsAsync
? "when all message steps are propagated using a wrapper"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioTitle, () => {
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",
{
frameId: 42,
processId: 42,
},
);
});
scenarioIsAsync &&
it("the wrapper gets called with the both propagations", () => {
expect(someWrapper).toHaveBeenCalledTimes(2);
});
});
const scenarioName: string = scenarioIsAsync
? "when all message steps are propagated not using a wrapper"
: "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
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",
{
frameId: 42,
processId: 42,
},
);
});
});
});
});
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();
});
const scenarioName = scenarioIsAsync ? "when messages are propagated" : "immediately";
// eslint-disable-next-line jest/valid-title
describe(scenarioName, () => {
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", {
frameId: 42,
processId: 42,
});
expect(someHandler2MockInDi2).toHaveBeenCalledWith("some-message", {
frameId: 42,
processId: 42,
});
});
});
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", {
frameId: 42,
processId: 42,
});
expect(someHandler2MockInDi2).toHaveBeenCalledWith("some-message", {
frameId: 42,
processId: 42,
});
});
});
});
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<number>>;
let someHandler1MockInDi2: AsyncFnMock<(message: string) => Promise<number>>;
let someListener1InDi2: Injectable<unknown, unknown>;
let actualPromise: Promise<number>;
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',
);
});
});
});
}),
);
@@ -0,0 +1,196 @@
import type { DiContainer } from "@ogre-tools/injectable";
import type { Channel, MessageChannelHandler, RequestChannelHandler } from "@k8slens/messaging";
import {
enlistMessageChannelListenerInjectionToken,
enlistRequestChannelListenerInjectionToken,
RequestFromChannel,
requestFromChannelInjectionToken,
sendMessageToChannelInjectionToken,
} from "@k8slens/messaging";
import { pipeline } from "@ogre-tools/fp";
import { filter, map } from "lodash/fp";
import asyncFn, { AsyncFnMock } from "@async-fn/jest";
export type MessageBridgeFake = {
involve: (...dis: DiContainer[]) => void;
messagePropagation: () => Promise<void>;
messagePropagationRecursive: (callback: any) => any;
setAsync: (value: boolean) => void;
};
const overrideMessaging = ({
di,
messageListenersByDi,
messagePropagationBuffer,
getAsyncModeStatus,
}: {
di: DiContainer;
messageListenersByDi: Map<DiContainer, Map<string, Set<MessageChannelHandler<Channel>>>>;
messagePropagationBuffer: Set<{ resolve: () => Promise<void> }>;
getAsyncModeStatus: () => boolean;
}) => {
const messageHandlersByChannel = new Map<string, Set<MessageChannelHandler<Channel>>>();
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, { frameId: 42, processId: 42 }));
});
messagePropagationBuffer.add(resolvableHandlePromise);
} else {
handlersForChannel.forEach((handler) => handler(message, { frameId: 42, processId: 42 }));
}
});
});
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<string, Set<RequestChannelHandler<Channel>>>>;
}) => {
const requestHandlersByChannel = new Map<string, Set<RequestChannelHandler<Channel>>>();
requestListenersByDi.set(di, requestHandlersByChannel);
di.override(
requestFromChannelInjectionToken,
() =>
(async (channel, request) =>
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];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [handler] = listeners!;
return handler;
},
async (handler) => 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);
};
});
};
export const getMessageBridgeFake = (): MessageBridgeFake => {
const messageListenersByDi = new Map<
DiContainer,
Map<string, Set<MessageChannelHandler<Channel>>>
>();
const requestListenersByDi = new Map<
DiContainer,
Map<string, Set<RequestChannelHandler<Channel>>>
>();
const messagePropagationBuffer = new Set<AsyncFnMock<() => 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;
},
};
};
@@ -0,0 +1,4 @@
{
"extends": "@k8slens/typescript/config/base.json",
"include": ["**/*.ts", "**/*.tsx"]
}
@@ -0,0 +1 @@
module.exports = require("@k8slens/webpack").configForNode;