Extract agnostic and electron-main ways of starting application to separate packages (#7259)

* Introduce an agnostic way to start application is the Feature

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

* Introduce directory for application-packages

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

* Remove non agnostic timeslots from agnostic application

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

* Introduce empty package for electron main specific stuff for Application Feature

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

* Introduce electron-main specific timeslots

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

* Update injectable version

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

* Update README

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

* Tweak export names to make them easier to import

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

* Make starting of application synchronous for Electron main

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

* Tweak versions in package jsons

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

* Consolidate name of feature file

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

---------

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen authored and GitHub committed 2023-03-06 10:17:24 +02:00
1 parent a917494e9c
commit a9bd5ae7e2
35 files changed
+1841 -1036

No files matched your search

+5 -5
View File
@@ -130,11 +130,11 @@
"@k8slens/node-fetch": "^6.5.0-alpha.0",
"@kubernetes/client-node": "^0.18.1",
"@material-ui/styles": "^4.11.5",
"@ogre-tools/fp": "^15.1.1",
"@ogre-tools/injectable": "^15.1.1",
"@ogre-tools/injectable-extension-for-auto-registration": "^15.1.1",
"@ogre-tools/injectable-extension-for-mobx": "^15.1.1",
"@ogre-tools/injectable-react": "^15.1.1",
"@ogre-tools/fp": "^15.1.2",
"@ogre-tools/injectable": "^15.1.2",
"@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2",
"@ogre-tools/injectable-extension-for-mobx": "^15.1.2",
"@ogre-tools/injectable-react": "^15.1.2",
"@sentry/electron": "^3.0.8",
"@sentry/integrations": "^6.19.3",
"@side/jest-runtime": "^1.1.0",
+7 -5
View File
@@ -196,14 +196,16 @@
},
"dependencies": {
"@k8slens/application": "^6.5.0-alpha.0",
"@k8slens/feature-core": "^6.5.0-alpha.0",
"@k8slens/core": "^6.5.0-alpha.0",
"@k8slens/ensure-binaries": "^6.5.0-alpha.0",
"@k8slens/generate-tray-icons": "^6.5.0-alpha.0",
"@ogre-tools/fp": "^15.1.1",
"@ogre-tools/injectable": "^15.1.1",
"@ogre-tools/injectable-extension-for-auto-registration": "^15.1.1",
"@ogre-tools/injectable-extension-for-mobx": "^15.1.1",
"@ogre-tools/injectable-react": "^15.1.1",
"@ogre-tools/fp": "^15.1.2",
"@ogre-tools/injectable": "^15.1.2",
"@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2",
"@ogre-tools/injectable-extension-for-mobx": "^15.1.2",
"@ogre-tools/injectable-react": "^15.1.2",
"@ogre-tools/injectable-utils": "^15.1.2",
"mobx": "^6.8.0",
"rimraf": "^4.1.2"
},
@@ -1,18 +0,0 @@
# @k8slens/application
This package contains stuff related to creating Lens-applications.
In the beginning it will contain just the injection tokens used to configure the application.
## Install
```bash
$ npm install @k8slens/application
```
## Usage
As of now, this package doesn't do anything alone. It just provides you way to register implementation for contract.
Future ambition is that all stuff related to how applications are built will be relocated here.
@@ -0,0 +1,39 @@
# @k8slens/application
This package contains stuff related to creating Lens-applications.
# Usage
```bash
$ npm install @k8slens/application-for-electron-main
```
```typescript
import { applicationFeature, startApplicationInjectionToken } from "@k8slens/application";
import { registerFeature } from "@k8slens/feature-core";
import { createContainer } from "@ogre-tools/injectable";
const di = createContainer("some-container");
registerFeature(di, applicationFeature);
const startApplication = di.inject(startApplicationInjectionToken);
startApplication();
```
## Features
#### Start application
`startApplicationInjectionToken`
Starts the application and calls timeslots in specified order. Check for timeslots for more info.
## Extendability
#### Timeslots
1. `beforeApplicationIsLoadingInjectionToken`
2. `onLoadOfApplicationInjectionToken`
3. `afterApplicationIsLoadedInjectionToken`
@@ -0,0 +1,11 @@
export { applicationFeature } from "./src/feature";
export { onLoadOfApplicationInjectionToken } from "./src/start-application/timeslots/on-load-of-application-injection-token";
export { beforeApplicationIsLoadingInjectionToken } from "./src/start-application/timeslots/before-application-is-loading-injection-token";
export { afterApplicationIsLoadedInjectionToken } from "./src/start-application/timeslots/after-application-is-loaded-injection-token";
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";
@@ -1,2 +1,2 @@
module.exports =
require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact;
require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;
@@ -13,7 +13,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/lensapp/monorepo.git"
"url": "git+https://github.com/lensapp/lens.git"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -29,8 +29,15 @@
"test": "jest --coverage --runInBand"
},
"peerDependencies": {
"@ogre-tools/fp": "^15.1.1",
"@ogre-tools/injectable": "^15.1.1",
"@k8slens/feature-core": "^6.5.0-alpha.0",
"@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-utils": "^15.1.2",
"lodash": "^4.17.15"
},
"devDependencies": {
"@async-fn/jest": "^1.6.4"
}
}
@@ -0,0 +1,17 @@
import { getFeature } from "@k8slens/feature-core";
import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration";
export const applicationFeature = getFeature({
id: "application",
register: (di) => {
autoRegister({
di,
targetModule: module,
getRequireContexts: () => [
require.context("./", true, /\.injectable\.(ts|tsx)$/),
],
});
},
});
@@ -0,0 +1,42 @@
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import { runManyFor } from "@ogre-tools/injectable-utils";
import { beforeApplicationIsLoadingInjectionToken } from "./timeslots/before-application-is-loading-injection-token";
import { onLoadOfApplicationInjectionToken } from "./timeslots/on-load-of-application-injection-token";
import { afterApplicationIsLoadedInjectionToken } from "./timeslots/after-application-is-loaded-injection-token";
export type StartApplication = () => Promise<void>;
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(
beforeApplicationIsLoadingInjectionToken
);
const onLoadOfApplication = runManyAsync(onLoadOfApplicationInjectionToken);
const afterApplicationIsLoaded = runManyAsync(
afterApplicationIsLoadedInjectionToken
);
return async () => {
await beforeApplicationIsLoading();
await onLoadOfApplication();
await afterApplicationIsLoaded();
};
},
injectionToken: startApplicationInjectionToken,
});
export default startApplicationInjectable;
@@ -0,0 +1,82 @@
import {
createContainer,
DiContainer,
getInjectable,
} from "@ogre-tools/injectable";
import { registerFeature } from "@k8slens/feature-core";
import { applicationFeature } from "../feature";
import { startApplicationInjectionToken } from "./start-application.injectable";
import { beforeApplicationIsLoadingInjectionToken } from "./timeslots/before-application-is-loading-injection-token";
import asyncFn, { AsyncFnMock } from "@async-fn/jest";
import { onLoadOfApplicationInjectionToken } from "./timeslots/on-load-of-application-injection-token";
import { afterApplicationIsLoadedInjectionToken } from "./timeslots/after-application-is-loaded-injection-token";
describe("starting-of-application", () => {
let di: DiContainer;
let beforeApplicationIsLoadingMock: AsyncFnMock<() => Promise<void>>;
let onLoadOfApplicationMock: AsyncFnMock<() => Promise<void>>;
let afterApplicationIsLoadedMock: AsyncFnMock<() => Promise<void>>;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, applicationFeature);
beforeApplicationIsLoadingMock = asyncFn();
onLoadOfApplicationMock = asyncFn();
afterApplicationIsLoadedMock = asyncFn();
const beforeApplicationIsLoadingInjectable = getInjectable({
id: "before-application-is-loading",
instantiate: () => ({ run: beforeApplicationIsLoadingMock }),
injectionToken: beforeApplicationIsLoadingInjectionToken,
});
const onLoadOfApplicationInjectable = getInjectable({
id: "on-load-of-application",
instantiate: () => ({ run: onLoadOfApplicationMock }),
injectionToken: onLoadOfApplicationInjectionToken,
});
const afterApplicationIsLoadedInjectable = getInjectable({
id: "after-application-is-loaded",
instantiate: () => ({ run: afterApplicationIsLoadedMock }),
injectionToken: afterApplicationIsLoadedInjectionToken,
});
di.register(
beforeApplicationIsLoadingInjectable,
onLoadOfApplicationInjectable,
afterApplicationIsLoadedInjectable
);
});
describe("when application is started", () => {
beforeEach(() => {
const startApplication = di.inject(startApplicationInjectionToken);
startApplication();
});
it("calls runnables registered in before application is loading", () => {
expect(beforeApplicationIsLoadingMock).toHaveBeenCalled();
});
describe("when runnables in before application is loading resolve", () => {
beforeEach(async () => {
await beforeApplicationIsLoadingMock.resolve();
});
it("calls runnables registered in on load 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();
expect(afterApplicationIsLoadedMock).toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,7 @@
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Runnable } from "@ogre-tools/injectable-utils";
export const afterApplicationIsLoadedInjectionToken =
getInjectionToken<Runnable>({
id: "after-application-is-loaded-injection-token",
});
@@ -0,0 +1,7 @@
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Runnable } from "@ogre-tools/injectable-utils";
export const beforeApplicationIsLoadingInjectionToken =
getInjectionToken<Runnable>({
id: "before-application-is-loading-injection-token",
});
@@ -0,0 +1,6 @@
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Runnable } from "@ogre-tools/injectable-utils";
export const onLoadOfApplicationInjectionToken = getInjectionToken<Runnable>({
id: "on-load-of-application",
});
@@ -0,0 +1,35 @@
# @k8slens/application-for-electron-main
This Feature extends `@k8slens/application` with Electron specifics.
# Usage
```bash
$ npm install @k8slens/application-for-electron-main
```
```typescript
import { applicationFeature, startApplicationInjectionToken } from "@k8slens/application";
import { applicationFeatureForElectronMain } from "@k8slens/application-for-electron-main";
import { registerFeature } from "@k8slens/feature-core";
import { createContainer } from "@ogre-tools/injectable";
const di = createContainer("some-container");
registerFeature(di, applicationFeature, applicationFeatureForElectronMain);
const startApplication = di.inject(startApplicationInjectionToken);
startApplication();
```
# Extendability
### Timeslots
#### `beforeAnythingInjectionToken`
Runnables registered here will be called before anything else. **Special requirement** here is that everything here needs to be synchronous.
#### `beforeElectronIsReadyInjectionToken`
Runnables registered here will be called right after runnables in `beforeAnythingInjectionToken` but still before we are sure that Electron application is ready (`electron.app.whenReady()`). **Special requirement** here is that everything here needs to be synchronous.
@@ -0,0 +1,8 @@
import { overrideSideEffectsWithFakes } from "./src/override-side-effects-with-fakes";
export { beforeElectronIsReadyInjectionToken } from "./src/start-application/timeslots/before-electron-is-ready-injection-token";
export { beforeAnythingInjectionToken } from "./src/start-application/timeslots/before-anything-injection-token";
export { applicationFeatureForElectronMain } from "./src/feature";
export const testUtils = { overrideSideEffectsWithFakes }
@@ -0,0 +1,2 @@
module.exports =
require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;
@@ -0,0 +1,44 @@
{
"name": "@k8slens/application-for-electron-main",
"private": false,
"version": "6.5.0-alpha.0",
"description": "Electron's main specifics for creating Lens applications",
"type": "commonjs",
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"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": "jest --coverage --runInBand"
},
"peerDependencies": {
"@k8slens/feature-core": "^6.5.0-alpha.0",
"@k8slens/application": "^6.5.0-alpha.0",
"@ogre-tools/injectable": "^15.1.2",
"@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2",
"@ogre-tools/injectable-utils": "^15.1.2",
"electron": "^19.1.9"
},
"devDependencies": {
"@async-fn/jest": "^1.6.4",
"@ogre-tools/test-utils": "^15.1.2"
}
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { app } from "electron";
const electronAppInjectable = getInjectable({
id: "electron-app",
instantiate: () => app,
causesSideEffects: true,
});
export default electronAppInjectable;
@@ -0,0 +1,17 @@
import electronAppInjectable from "./electron-app.injectable";
import { app } from 'electron';
import { createContainer } from "@ogre-tools/injectable";
import { applicationFeatureForElectronMain } from "../feature";
import { registerFeature } from "@k8slens/feature-core";
describe('electron-app', () => {
it('is electron app', () => {
const di = createContainer('irrelevant');
registerFeature(di, applicationFeatureForElectronMain)
const actual = di.inject(electronAppInjectable);
expect(actual).toBe(app);
});
});
@@ -0,0 +1,22 @@
import { getFeature } from "@k8slens/feature-core";
import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration";
import { applicationFeature } from '@k8slens/application';
export const applicationFeatureForElectronMain = getFeature({
id: "application-for-electron-main",
register: (di) => {
autoRegister({
di,
targetModule: module,
getRequireContexts: () => [
require.context("./", true, /\.injectable\.(ts|tsx)$/),
],
});
},
dependencies: [
applicationFeature
]
});
@@ -0,0 +1,6 @@
import type { DiContainer } from "@ogre-tools/injectable";
import whenAppIsReadyInjectable from "./start-application/when-app-is-ready.injectable";
export const overrideSideEffectsWithFakes = (di: DiContainer) => {
di.override(whenAppIsReadyInjectable, () => () => Promise.resolve())
};
@@ -0,0 +1,58 @@
import {
DiContainer,
getInjectable,
instantiationDecoratorToken,
lifecycleEnum,
} from "@ogre-tools/injectable";
import { startApplicationInjectionToken } from "@k8slens/application";
import whenAppIsReadyInjectable from "./when-app-is-ready.injectable";
import { beforeAnythingInjectionToken } from "./timeslots/before-anything-injection-token";
import { beforeElectronIsReadyInjectionToken } from "./timeslots/before-electron-is-ready-injection-token";
import { runManySyncFor } from "@ogre-tools/injectable-utils";
const startElectronApplicationInjectable = getInjectable({
id: "start-electron-application",
instantiate: () => ({
decorate:
(toBeDecorated: unknown) =>
(di: DiContainer, ...args: unknown[]) => {
const whenAppIsReady = di.inject(whenAppIsReadyInjectable);
const runManySync = runManySyncFor(di);
const beforeAnything = runManySync(beforeAnythingInjectionToken);
const beforeElectronIsReady = runManySync(
beforeElectronIsReadyInjectionToken
);
const typedToBeDecorated = toBeDecorated as (
di: DiContainer,
...args: unknown[]
) => unknown;
const startApplication = typedToBeDecorated(di, ...args) as (
...args: unknown[]
) => unknown;
return (...startApplicationArgs: unknown[]) => {
beforeAnything();
beforeElectronIsReady();
return (async () => {
await whenAppIsReady();
return startApplication(...startApplicationArgs);
})()
};
},
target: startApplicationInjectionToken,
}),
decorable: false,
injectionToken: instantiationDecoratorToken,
lifecycle: lifecycleEnum.singleton,
});
export default startElectronApplicationInjectable;
@@ -0,0 +1,6 @@
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Runnable } from "@ogre-tools/injectable-utils";
export const beforeAnythingInjectionToken = getInjectionToken<Runnable>({
id: "before-anything",
});
@@ -0,0 +1,6 @@
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Runnable } from "@ogre-tools/injectable-utils";
export const beforeElectronIsReadyInjectionToken = getInjectionToken<Runnable>({
id: "before-electron-is-ready-injection-token",
});
@@ -0,0 +1,16 @@
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron/electron-app.injectable";
const whenAppIsReadyInjectable = getInjectable({
id: "when-app-is-ready",
instantiate: (di) => {
const electronApp = di.inject(electronAppInjectable);
return () => electronApp.whenReady();
},
decorable: false,
});
export default whenAppIsReadyInjectable;
@@ -0,0 +1,50 @@
import { createContainer, DiContainer } from "@ogre-tools/injectable";
import { applicationFeatureForElectronMain } from "../../feature";
import { registerFeature } from "@k8slens/feature-core";
import whenAppIsReadyInjectable from "../when-app-is-ready.injectable";
import { getPromiseStatus } from "@ogre-tools/test-utils";
import electronAppInjectable from "../../electron/electron-app.injectable";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
describe("when-app-is-ready", () => {
let di: DiContainer;
let whenReadyMock: AsyncFnMock<() => Promise<void>>;
beforeEach(() => {
di = createContainer("irrelevant");
registerFeature(di, applicationFeatureForElectronMain);
whenReadyMock = asyncFn();
di.override(
electronAppInjectable,
() => ({ whenReady: whenReadyMock } as unknown)
);
});
describe("when called", () => {
let actualPromise: Promise<void>;
beforeEach(() => {
const whenAppIsReady = di.inject(whenAppIsReadyInjectable);
actualPromise = whenAppIsReady();
});
it("does not resolve yet", async () => {
const promiseStatus = await getPromiseStatus(actualPromise);
expect(promiseStatus.fulfilled).toBe(false);
});
it("when app is ready, resolves", async () => {
await whenReadyMock.resolve();
const promiseStatus = await getPromiseStatus(actualPromise);
expect(promiseStatus.fulfilled).toBe(true);
});
});
});
@@ -0,0 +1,94 @@
import {
createContainer,
DiContainer,
getInjectable,
} from "@ogre-tools/injectable";
import { registerFeature } from "@k8slens/feature-core";
import { applicationFeatureForElectronMain } from "./feature";
import {
beforeApplicationIsLoadingInjectionToken,
startApplicationInjectionToken,
} from "@k8slens/application";
import asyncFn, { AsyncFnMock } from "@async-fn/jest";
import whenAppIsReadyInjectable from "./start-application/when-app-is-ready.injectable";
import { beforeAnythingInjectionToken } from "./start-application/timeslots/before-anything-injection-token";
import { beforeElectronIsReadyInjectionToken } from "./start-application/timeslots/before-electron-is-ready-injection-token";
describe("starting-of-electron-main-application", () => {
let di: DiContainer;
let beforeAnythingMock: jest.Mock;
let beforeElectronIsReadyMock: jest.Mock;
let beforeApplicationIsLoadingMock: AsyncFnMock<() => Promise<void>>;
let whenAppIsReadyMock: AsyncFnMock<() => Promise<void>>;
beforeEach(() => {
di = createContainer("irrelevant");
beforeAnythingMock = jest.fn();
beforeElectronIsReadyMock = jest.fn();
beforeApplicationIsLoadingMock = asyncFn();
whenAppIsReadyMock = asyncFn();
registerFeature(di, applicationFeatureForElectronMain);
const beforeAnythingIsLoadingInjectable = getInjectable({
id: "before-anything",
instantiate: () => ({ run: beforeAnythingMock }),
injectionToken: beforeAnythingInjectionToken,
});
const beforeElectronIsReadyIsLoadingInjectable = getInjectable({
id: "before-electron-is-ready",
instantiate: () => ({ run: beforeElectronIsReadyMock }),
injectionToken: beforeElectronIsReadyInjectionToken,
});
const beforeApplicationIsLoadingInjectable = getInjectable({
id: "before-application-is-loading",
instantiate: () => ({ run: beforeApplicationIsLoadingMock }),
injectionToken: beforeApplicationIsLoadingInjectionToken,
});
di.register(
beforeAnythingIsLoadingInjectable,
beforeElectronIsReadyIsLoadingInjectable,
beforeApplicationIsLoadingInjectable
);
di.override(whenAppIsReadyInjectable, () => whenAppIsReadyMock);
});
describe("when application is started", () => {
beforeEach(() => {
const startApplication = di.inject(startApplicationInjectionToken);
startApplication();
});
it("calls for synchronous runnables for before anything", () => {
expect(beforeAnythingMock).toHaveBeenCalled();
});
it("calls for synchronous runnables for before electron is ready", () => {
expect(beforeElectronIsReadyMock).toHaveBeenCalled();
});
it("calls to wait when electron is ready", () => {
expect(whenAppIsReadyMock).toHaveBeenCalled();
});
it("does not call runnables for before application is loading yet", () => {
expect(beforeApplicationIsLoadingMock).not.toHaveBeenCalled();
});
describe("when electron is ready", () => {
beforeEach(async () => {
await whenAppIsReadyMock.resolve();
});
it("calls runnables for before application is loading", () => {
expect(beforeApplicationIsLoadingMock).toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,3 @@
{
"extends": "@k8slens/typescript/config/base.json"
}
@@ -0,0 +1 @@
module.exports = require("@k8slens/webpack").configForNode;
@@ -1,3 +0,0 @@
export { applicationInformationToken } from "./src/application-information-token";
export type { ApplicationInformation } from "./src/application-information-token";
@@ -31,6 +31,6 @@
"test": "jest --coverage --runInBand"
},
"peerDependencies": {
"@ogre-tools/injectable": "^15.1.1"
"@ogre-tools/injectable": "^15.1.2"
}
}