diff --git a/.deps/dev.md b/.deps/dev.md index f9f4a8343..70d6123d0 100644 --- a/.deps/dev.md +++ b/.deps/dev.md @@ -106,15 +106,19 @@ | [`@types/lodash@4.14.200`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #4131 | | [`@types/minimist@1.2.4`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #10839 | | [`@types/normalize-package-data@2.4.3`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #10792 | +| [`@types/prop-types@15.7.9`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #16176 | | [`@types/qs@6.9.9`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #13991 | | [`@types/react-copy-to-clipboard@5.0.7`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/react-dom@18.3.0`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/react-router-dom@5.3.3`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/react-router@5.1.20`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/react-test-renderer@18.3.0`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | -| [`@types/redux-mock-store@1.0.5`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | +| [`@types/react@18.2.36`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #8234 | +| [`@types/redux-logger@3.0.13`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | +| [`@types/redux-mock-store@1.0.6`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/request@2.48.12`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/sanitize-html@2.9.3`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | +| [`@types/scheduler@0.16.5`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #7582 | | [`@types/semver@7.5.4`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #10842 | | [`@types/stack-utils@2.0.2`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/webpack@5.28.5`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | @@ -242,6 +246,7 @@ | `cssom@0.3.8` | MIT | clearlydefined | | `cssom@0.5.0` | MIT | clearlydefined | | [`cssstyle@2.3.0`](https://github.com/jsdom/cssstyle) | MIT | clearlydefined | +| `csstype@3.1.2` | MIT | #11847 | | `data-urls@3.0.2` | MIT | clearlydefined | | `debounce@1.2.1` | MIT | clearlydefined | | [`debug@4.3.4`](git://github.com/debug-js/debug.git) | MIT | clearlydefined | @@ -250,6 +255,7 @@ | `decamelize@5.0.1` | MIT | clearlydefined | | [`decimal.js@10.4.3`](https://github.com/MikeMcl/decimal.js.git) | MIT | clearlydefined | | [`dedent@1.5.1`](git://github.com/dmnd/dedent.git) | MIT | #14381 | +| [`deep-diff@0.3.8`](git://github.com/flitbit/diff.git) | MIT | clearlydefined | | [`deep-is@0.1.4`](http://github.com/thlorenz/deep-is.git) | MIT | #2130 | | `default-browser-id@3.0.0` | MIT | clearlydefined | | `default-browser@4.0.0` | MIT | clearlydefined | @@ -610,6 +616,7 @@ | [`pure-rand@6.0.4`](git+https://github.com/dubzzz/pure-rand.git) | MIT | #8423 | | [`queue-microtask@1.2.3`](git://github.com/feross/queue-microtask.git) | MIT | clearlydefined | | `quick-lru@5.1.1` | MIT | clearlydefined | +| [`react-is@17.0.2`](https://github.com/facebook/react.git) | MIT | clearlydefined | | [`react-is@18.2.0`](https://github.com/facebook/react.git) | MIT | clearlydefined | | [`react-is@18.3.1`](https://github.com/facebook/react.git) | MIT | clearlydefined | | [`react-shallow-renderer@16.15.0`](https://reactjs.org/) | MIT | clearlydefined | @@ -620,7 +627,9 @@ | `rechoir@0.8.0` | MIT | clearlydefined | | `redent@3.0.0` | MIT | clearlydefined | | `redent@4.0.0` | MIT | clearlydefined | +| [`redux-logger@3.0.6`](git+https://github.com/theaqua/redux-logger.git) | MIT | clearlydefined | | [`redux-mock-store@1.5.4`](git+https://github.com/arnaudbenard/redux-mock-store.git) | MIT | clearlydefined | +| [`redux@4.2.1`](http://redux.js.org) | MIT | #7046 | | [`reflect.getprototypeof@1.0.4`](git+https://github.com/es-shims/Reflect.getPrototypeOf.git) | MIT | #13910 | | [`regexp.prototype.flags@1.5.1`](git://github.com/es-shims/RegExp.prototype.flags.git) | MIT | #8199 | | [`relateurl@0.2.7`](git://github.com/stevenvachon/relateurl.git) | MIT | clearlydefined | diff --git a/.deps/prod.md b/.deps/prod.md index 606edd239..ab36a251c 100644 --- a/.deps/prod.md +++ b/.deps/prod.md @@ -3,7 +3,6 @@ | Packages | License | Resolved CQs | | --- | --- | --- | | [`@babel/runtime@7.23.2`](https://github.com/babel/babel.git) | MIT | #10718 | -| [`@babel/runtime@7.24.5`](https://github.com/babel/babel.git) | MIT | #13900 | | [`@codemirror/autocomplete@6.18.1`](https://github.com/codemirror/autocomplete.git) | MIT | clearlydefined | | [`@codemirror/lang-yaml@6.1.1`](git+https://github.com/codemirror/lang-yaml.git) | MIT | clearlydefined | | [`@codemirror/language@6.10.3`](https://github.com/codemirror/language.git) | MIT | clearlydefined | @@ -52,6 +51,7 @@ | [`@patternfly/react-table@4.113.6`](https://github.com/patternfly/patternfly-react.git) | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@patternfly/react-table/4.113.6) | | [`@patternfly/react-tokens@4.94.7`](https://github.com/patternfly/patternfly-react.git) | MIT | clearlydefined | | [`@pkgjs/parseargs@0.11.0`](git@github.com:pkgjs/parseargs.git) | MIT | #8236 | +| [`@reduxjs/toolkit@2.2.7`](git+https://github.com/reduxjs/redux-toolkit.git) | MIT | #14170 | | [`@remix-run/router@1.19.1`](https://github.com/remix-run/react-router) | MIT | clearlydefined | | `@sideway/address@4.1.5` | BSD-3-Clause | #3098 | | `@sideway/formula@3.0.1` | BSD-3-Clause | clearlydefined | @@ -62,21 +62,17 @@ | [`@types/estree@1.0.4`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #8266 | | [`@types/estree@1.0.6`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #8266 | | [`@types/hast@3.0.4`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | -| [`@types/hoist-non-react-statics@3.3.5`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/js-yaml@4.0.8`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/mdast@4.0.4`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/ms@0.7.34`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #10811 | | [`@types/node-fetch@2.6.9`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #11004 | | [`@types/node@20.8.10`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #10804 | | [`@types/node@22.8.2`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | -| [`@types/prop-types@15.7.9`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #16176 | -| [`@types/react-redux@7.1.33`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #10970 | -| [`@types/react@18.2.36`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #8234 | | [`@types/request@2.48.11`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | -| [`@types/scheduler@0.16.5`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #7582 | | [`@types/tough-cookie@4.0.4`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #10798 | | [`@types/unist@2.0.11`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/unist@3.0.3`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | +| [`@types/use-sync-external-store@0.0.3`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | clearlydefined | | [`@types/ws@8.5.8`](https://github.com/DefinitelyTyped/DefinitelyTyped.git) | MIT | #6016 | | [`@ungap/structured-clone@1.2.0`](git+https://github.com/ungap/structured-clone.git) | ISC | clearlydefined | | [`abbrev@2.0.0`](https://github.com/npm/abbrev-js.git) | ISC | clearlydefined | @@ -155,7 +151,6 @@ | [`create-hmac@1.1.7`](https://github.com/crypto-browserify/createHmac.git) | MIT | clearlydefined | | [`cross-spawn@7.0.3`](git@github.com:moxystudio/node-cross-spawn.git) | MIT | clearlydefined | | [`crypto-browserify@3.12.0`](git://github.com/crypto-browserify/crypto-browserify.git) | MIT | #1033 | -| `csstype@3.1.2` | MIT | #11847 | | [`dashdash@1.14.1`](git://github.com/trentm/node-dashdash.git) | MIT | #14596 | | `date-fns@3.6.0` | MIT | #14000 | | [`dateformat@4.6.3`](https://github.com/felixge/node-dateformat.git) | MIT | clearlydefined | @@ -250,7 +245,6 @@ | [`help-me@4.2.0`](https://github.com/mcollina/help-me.git) | MIT | clearlydefined | | `history@4.10.1` | MIT | clearlydefined | | [`hmac-drbg@1.0.1`](git+ssh://git@github.com/indutny/hmac-drbg.git) | MIT | clearlydefined | -| [`hoist-non-react-statics@3.3.2`](git://github.com/mridgway/hoist-non-react-statics.git) | BSD-3-Clause | clearlydefined | | `html-url-attributes@3.0.1` | MIT | clearlydefined | | [`htmlparser2@8.0.2`](git://github.com/fb55/htmlparser2.git) | MIT | clearlydefined | | `http-cache-semantics@4.1.1` | BSD-2-Clause | clearlydefined | @@ -261,6 +255,7 @@ | `https@1.0.0` | ISC | clearlydefined | | [`iconv-lite@0.6.3`](git://github.com/ashtuchkin/iconv-lite.git) | MIT | clearlydefined | | [`ieee754@1.2.1`](git://github.com/feross/ieee754.git) | BSD-3-Clause | clearlydefined | +| [`immer@10.1.1`](https://github.com/immerjs/immer.git) | MIT | clearlydefined | | [`imurmurhash@0.1.4`](https://github.com/jensyt/imurmurhash-js) | MIT | clearlydefined | | `indent-string@4.0.0` | MIT | clearlydefined | | [`inflight@1.0.6`](https://github.com/npm/inflight.git) | ISC | clearlydefined | @@ -434,10 +429,9 @@ | [`react-fast-compare@3.2.2`](https://github.com/FormidableLabs/react-fast-compare) | MIT | clearlydefined | | [`react-helmet@6.1.0`](https://github.com/nfl/react-helmet) | MIT | clearlydefined | | [`react-is@16.13.1`](https://github.com/facebook/react.git) | MIT | clearlydefined | -| [`react-is@17.0.2`](https://github.com/facebook/react.git) | MIT | clearlydefined | | `react-markdown@9.0.1` | MIT | clearlydefined | | [`react-pluralize@1.6.3`](https://github.com/tsmith123/react-pluralize) | MIT | clearlydefined | -| [`react-redux@7.2.9`](https://github.com/reduxjs/react-redux) | MIT | #2978 | +| [`react-redux@9.1.2`](https://github.com/reduxjs/react-redux) | MIT | #13913 | | [`react-router-dom@6.26.1`](https://github.com/remix-run/react-router) | MIT | #15860 | | [`react-router@6.26.1`](https://github.com/remix-run/react-router) | MIT | clearlydefined | | [`react-side-effect@2.1.2`](https://github.com/gaearon/react-side-effect.git) | MIT | clearlydefined | @@ -447,8 +441,8 @@ | [`readable-stream@4.4.2`](git://github.com/nodejs/readable-stream) | MIT | #8426 | | [`real-require@0.2.0`](git+https://github.com/pinojs/real-require.git) | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/real-require/0.2.0) | | [`reconnecting-websocket@4.4.0`](git+https://github.com/pladaria/reconnecting-websocket.git) | MIT | #940 | -| [`redux-thunk@2.4.2`](https://github.com/reduxjs/redux-thunk) | MIT | clearlydefined | -| [`redux@4.2.1`](http://redux.js.org) | MIT | #7046 | +| [`redux-thunk@3.1.0`](https://github.com/reduxjs/redux-thunk) | MIT | clearlydefined | +| [`redux@5.0.1`](http://redux.js.org) | MIT | clearlydefined | | [`reflect-metadata@0.1.13`](https://github.com/rbuckton/reflect-metadata.git) | Apache-2.0 | clearlydefined | | [`reflect-metadata@0.1.14`](https://github.com/rbuckton/reflect-metadata.git) | Apache-2.0 | clearlydefined | | [`reflect-metadata@0.2.2`](https://github.com/rbuckton/reflect-metadata.git) | Apache-2.0 | clearlydefined | @@ -458,7 +452,7 @@ | [`request@2.88.2`](https://github.com/request/request.git) | Apache-2.0 | #997 | | `require-from-string@2.0.2` | MIT | clearlydefined | | [`requires-port@1.0.0`](https://github.com/unshiftio/requires-port) | MIT | clearlydefined | -| [`reselect@4.1.8`](https://github.com/reduxjs/reselect.git) | MIT | clearlydefined | +| [`reselect@5.1.1`](https://github.com/reduxjs/reselect.git) | MIT | clearlydefined | | `resolve-pathname@3.0.0` | MIT | clearlydefined | | [`ret@0.5.0`](git://github.com/fent/ret.js.git) | MIT | clearlydefined | | [`retry@0.12.0`](git://github.com/tim-kos/node-retry.git) | MIT | clearlydefined | @@ -543,6 +537,7 @@ | [`universalify@2.0.1`](git+https://github.com/RyanZim/universalify.git) | MIT | clearlydefined | | [`uri-js@4.4.1`](http://github.com/garycourt/uri-js) | BSD-2-Clause | #1086 | | [`url-parse@1.5.10`](https://github.com/unshiftio/url-parse.git) | MIT | clearlydefined | +| [`use-sync-external-store@1.2.2`](https://github.com/facebook/react.git) | MIT | clearlydefined | | [`util-deprecate@1.0.2`](git://github.com/TooTallNate/util-deprecate.git) | MIT | #5885 | | [`uuid@3.4.0`](https://github.com/uuidjs/uuid.git) | MIT | #2733 | | [`uuid@7.0.3`](https://github.com/uuidjs/uuid.git) | MIT | #57 | diff --git a/packages/dashboard-frontend/package.json b/packages/dashboard-frontend/package.json index d5cf3d018..93e94fcc4 100644 --- a/packages/dashboard-frontend/package.json +++ b/packages/dashboard-frontend/package.json @@ -40,6 +40,7 @@ "@patternfly/react-core": "^4.276.11", "@patternfly/react-icons": "^4.93.7", "@patternfly/react-table": "^4.113.3", + "@reduxjs/toolkit": "^2.2.7", "axios": "^1.7.0", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", @@ -61,14 +62,12 @@ "react-helmet": "^6.1.0", "react-markdown": "^9.0.1", "react-pluralize": "^1.6.3", - "react-redux": "^7.2.9", + "react-redux": "^9.1.2", "react-router-dom": "^6.26.1", "react-tooltip": "^4.5.1", "reconnecting-websocket": "^4.4.0", - "redux": "^4.2.1", - "redux-thunk": "^2.4.2", + "redux": "^5.0.1", "reflect-metadata": "^0.1.13", - "reselect": "^4.1.8", "sanitize-html": "^2.11.0" }, "devDependencies": { @@ -89,7 +88,8 @@ "@types/react-dom": "^18.3.0", "@types/react-router-dom": "^5.3.3", "@types/react-test-renderer": "^18.3.0", - "@types/redux-mock-store": "^1.0.2", + "@types/redux-logger": "^3.0.13", + "@types/redux-mock-store": "^1.0.6", "@types/sanitize-html": "^2.9.0", "@types/webpack": "^5.28.5", "@typescript-eslint/eslint-plugin": "^6.4.0", @@ -118,6 +118,7 @@ "mini-css-extract-plugin": "^2.7.6", "prettier": "^3.2.5", "react-test-renderer": "^18.3.1", + "redux-logger": "^3.0.6", "redux-mock-store": "^1.5.4", "source-map-loader": "^4.0.1", "speed-measure-webpack-plugin": "^1.5.0", diff --git a/packages/dashboard-frontend/src/Layout/Header/Tools/AboutMenu/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/Header/Tools/AboutMenu/__tests__/index.spec.tsx index a10dbb5ac..47ddd2ca4 100644 --- a/packages/dashboard-frontend/src/Layout/Header/Tools/AboutMenu/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Header/Tools/AboutMenu/__tests__/index.spec.tsx @@ -15,16 +15,14 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; import renderer from 'react-test-renderer'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; +import { AboutMenu } from '@/Layout/Header/Tools/AboutMenu'; import { BRANDING_DEFAULT, BrandingData } from '@/services/bootstrap/branding.constant'; -import { che } from '@/services/models'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { selectBranding } from '@/store/Branding/selectors'; -import * as InfrastructureNamespacesStore from '@/store/InfrastructureNamespaces'; - -import { AboutMenu } from '..'; +import { infrastructureNamespacesActionCreators } from '@/store/InfrastructureNamespaces'; jest.mock('gravatar-url', () => { return function () { @@ -35,12 +33,10 @@ jest.mock('gravatar-url', () => { jest.mock('@/store/InfrastructureNamespaces', () => { return { actionCreators: { - requestNamespaces: - (): AppThunk> => - async (): Promise => { - return Promise.resolve([]); - }, - } as InfrastructureNamespacesStore.ActionCreators, + requestNamespaces: (): AppThunk => async (): Promise => { + return Promise.resolve(); + }, + } as typeof infrastructureNamespacesActionCreators, }; }); @@ -131,7 +127,7 @@ describe('About Menu', () => { }); function createStore(cheCliTool: string, name: string, email: string): Store { - return new FakeStoreBuilder() + return new MockStoreBuilder() .withUserProfile({ username: name, email, diff --git a/packages/dashboard-frontend/src/Layout/Header/Tools/ApplicationsMenu/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/Header/Tools/ApplicationsMenu/__tests__/index.spec.tsx index 4ed1d1f14..8c3a8d7e6 100644 --- a/packages/dashboard-frontend/src/Layout/Header/Tools/ApplicationsMenu/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Header/Tools/ApplicationsMenu/__tests__/index.spec.tsx @@ -16,7 +16,7 @@ import { Provider } from 'react-redux'; import renderer from 'react-test-renderer'; import { Store } from 'redux'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { selectApplications } from '@/store/ClusterInfo/selectors'; import { ApplicationsMenu } from '..'; @@ -89,7 +89,7 @@ describe('About Menu', () => { }); function createStore(): Store { - return new FakeStoreBuilder() + return new MockStoreBuilder() .withClusterInfo({ applications: [ { diff --git a/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/__tests__/index.spec.tsx index 37e038f24..7898885cf 100644 --- a/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/__tests__/index.spec.tsx @@ -16,26 +16,22 @@ import { createHashHistory } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; import renderer from 'react-test-renderer'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; +import UserMenu from '@/Layout/Header/Tools/UserMenu'; import { BRANDING_DEFAULT, BrandingData } from '@/services/bootstrap/branding.constant'; -import { che } from '@/services/models'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { selectBranding } from '@/store/Branding/selectors'; -import * as InfrastructureNamespacesStore from '@/store/InfrastructureNamespaces'; - -import UserMenu from '..'; +import { infrastructureNamespacesActionCreators } from '@/store/InfrastructureNamespaces'; jest.mock('@/store/InfrastructureNamespaces', () => { return { actionCreators: { - requestNamespaces: - (): AppThunk> => - async (): Promise => { - return Promise.resolve([]); - }, - } as InfrastructureNamespacesStore.ActionCreators, + requestNamespaces: (): AppThunk => async () => { + return Promise.resolve(); + }, + } as typeof infrastructureNamespacesActionCreators, }; }); @@ -87,7 +83,7 @@ describe('User Menu', () => { }); function createStore(name: string, email: string): Store { - return new FakeStoreBuilder() + return new MockStoreBuilder() .withUserProfile({ username: name, email, diff --git a/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/index.tsx b/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/index.tsx index cb01f5ebc..1a2d3b639 100644 --- a/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/index.tsx +++ b/packages/dashboard-frontend/src/Layout/Header/Tools/UserMenu/index.tsx @@ -19,7 +19,7 @@ import { lazyInject } from '@/inversify.config'; import { ROUTE } from '@/Routes'; import { AppAlerts } from '@/services/alerts/appAlerts'; import { BrandingData } from '@/services/bootstrap/branding.constant'; -import * as InfrastructureNamespacesStore from '@/store/InfrastructureNamespaces'; +import { infrastructureNamespacesActionCreators } from '@/store/InfrastructureNamespaces'; type Props = MappedProps & { branding: BrandingData; @@ -104,7 +104,7 @@ export class UserMenu extends React.PureComponent { const mapStateToProps = () => ({}); -const connector = connect(mapStateToProps, InfrastructureNamespacesStore.actionCreators); +const connector = connect(mapStateToProps, infrastructureNamespacesActionCreators); type MappedProps = ConnectedProps; export default connector(UserMenu); diff --git a/packages/dashboard-frontend/src/Layout/Header/Tools/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/Header/Tools/__tests__/index.spec.tsx index c40621f67..8373ee7fe 100644 --- a/packages/dashboard-frontend/src/Layout/Header/Tools/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Header/Tools/__tests__/index.spec.tsx @@ -15,15 +15,13 @@ import { createHashHistory } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; import renderer from 'react-test-renderer'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; +import HeaderTools from '@/Layout/Header/Tools'; import { BRANDING_DEFAULT, BrandingData } from '@/services/bootstrap/branding.constant'; -import { che } from '@/services/models'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as InfrastructureNamespacesStore from '@/store/InfrastructureNamespaces'; - -import HeaderTools from '..'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { infrastructureNamespacesActionCreators } from '@/store/InfrastructureNamespaces'; jest.mock('gravatar-url', () => { return function () { @@ -34,12 +32,10 @@ jest.mock('gravatar-url', () => { jest.mock('@/store/InfrastructureNamespaces', () => { return { actionCreators: { - requestNamespaces: - (): AppThunk> => - async (): Promise => { - return Promise.resolve([]); - }, - } as InfrastructureNamespacesStore.ActionCreators, + requestNamespaces: (): AppThunk => async (): Promise => { + return Promise.resolve(); + }, + } as typeof infrastructureNamespacesActionCreators, }; }); @@ -68,7 +64,7 @@ describe('Page header tools', () => { }); function createStore(cheCliTool: string, name: string, email: string): Store { - return new FakeStoreBuilder() + return new MockStoreBuilder() .withUserProfile({ username: name, email, diff --git a/packages/dashboard-frontend/src/Layout/Header/Tools/index.tsx b/packages/dashboard-frontend/src/Layout/Header/Tools/index.tsx index e53b89bc4..d9dfd3598 100644 --- a/packages/dashboard-frontend/src/Layout/Header/Tools/index.tsx +++ b/packages/dashboard-frontend/src/Layout/Header/Tools/index.tsx @@ -24,7 +24,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { AboutMenu } from '@/Layout/Header/Tools/AboutMenu'; import { ApplicationsMenu } from '@/Layout/Header/Tools/ApplicationsMenu'; import UserMenu from '@/Layout/Header/Tools/UserMenu'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; import { selectApplications } from '@/store/ClusterInfo/selectors'; import { selectDashboardLogo } from '@/store/ServerConfig/selectors'; @@ -76,7 +76,7 @@ export class HeaderTools extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ userProfile: selectUserProfile(state), branding: selectBranding(state), dashboardLogo: selectDashboardLogo(state), diff --git a/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx b/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx index 60a675d82..d1e69420d 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx @@ -16,7 +16,7 @@ import { connect, ConnectedProps } from 'react-redux'; import NavigationMainItem from '@/Layout/Navigation/MainItem'; import { ROUTE } from '@/Routes'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; import { NavigationItemObject } from '.'; @@ -48,7 +48,7 @@ export class NavigationMainList extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), }); diff --git a/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/WorkspaceActions/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/WorkspaceActions/__tests__/index.spec.tsx index bfebd4917..3cf64c018 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/WorkspaceActions/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/WorkspaceActions/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import { RecentItemWorkspaceActions } from '@/Layout/Navigation/RecentItem/Works import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { constructWorkspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('@/contexts/WorkspaceActions/Dropdown'); @@ -44,7 +44,7 @@ describe('RecentItemWorkspaceActions', () => { }); function getComponent(item: NavigationRecentItemObject): React.ReactElement { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); return ( diff --git a/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/__tests__/index.spec.tsx index 17b37a965..cdefc299c 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/RecentItem/__tests__/index.spec.tsx @@ -21,7 +21,7 @@ import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentR import { WorkspaceStatus } from '@/services/helpers/types'; import { constructWorkspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('@/components/Workspace/Status/Indicator'); jest.mock('@/Layout/Navigation/RecentItem/WorkspaceActions'); @@ -111,7 +111,7 @@ describe('Navigation Item', () => { }); function getComponent(item: NavigationRecentItemObject, activeItem = ''): React.ReactElement { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); return ( diff --git a/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainItem.spec.tsx b/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainItem.spec.tsx index e30901994..4dd7dbcff 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainItem.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainItem.spec.tsx @@ -18,7 +18,7 @@ import { MemoryRouter } from 'react-router-dom'; import NavigationMainItem from '@/Layout/Navigation/MainItem'; import devfileApi from '@/services/devfileApi'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { NavigationItemObject } from '..'; @@ -31,7 +31,7 @@ describe('Navigation Item', () => { }; function renderComponent(workspaces: devfileApi.DevWorkspace[] = []): RenderResult { - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces }).build(); return render( @@ -69,7 +69,7 @@ describe('Navigation Item', () => { const { rerender } = renderComponent(); activeItem = '/home'; - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); rerender( diff --git a/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainList.spec.tsx b/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainList.spec.tsx index 6c9d9e75f..732c97780 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainList.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/__tests__/MainList.spec.tsx @@ -19,11 +19,11 @@ import { Store } from 'redux'; import NavigationMainList from '@/Layout/Navigation/MainList'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; describe('Navigation Main List', () => { it('should have correct number of main navigation items', () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); renderComponent(store); const navLinks = screen.getAllByRole('link'); @@ -31,7 +31,7 @@ describe('Navigation Main List', () => { }); it('should have correct navigation item labels', () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); renderComponent(store); const navLinks = screen.getAllByRole('link'); @@ -47,7 +47,7 @@ describe('Navigation Main List', () => { .withName('wksp-' + i) .build(), ); - let store = new FakeStoreBuilder().withDevWorkspaces({ workspaces }).build(); + let store = new MockStoreBuilder().withDevWorkspaces({ workspaces }).build(); const { rerender } = renderComponent(store); expect(screen.queryByRole('link', { name: 'Workspaces (5)' })).toBeInTheDocument(); @@ -58,7 +58,7 @@ describe('Navigation Main List', () => { .withName('wksp-' + i) .build(), ); - store = new FakeStoreBuilder().withDevWorkspaces({ workspaces }).build(); + store = new MockStoreBuilder().withDevWorkspaces({ workspaces }).build(); rerender(buildElement(store)); expect(screen.queryByRole('link', { name: 'Workspaces (3)' })).toBeInTheDocument(); diff --git a/packages/dashboard-frontend/src/Layout/Navigation/__tests__/RecentList.spec.tsx b/packages/dashboard-frontend/src/Layout/Navigation/__tests__/RecentList.spec.tsx index 2b85ec92b..985766976 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/__tests__/RecentList.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/__tests__/RecentList.spec.tsx @@ -21,7 +21,7 @@ import NavigationRecentList from '@/Layout/Navigation/RecentList'; import devfileApi from '@/services/devfileApi'; import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('react-tooltip', () => { return function DummyTooltip(): React.ReactElement { @@ -99,5 +99,5 @@ describe('Navigation Recent List', () => { }); function createFakeStore(): Store { - return new FakeStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); + return new MockStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); } diff --git a/packages/dashboard-frontend/src/Layout/Navigation/index.tsx b/packages/dashboard-frontend/src/Layout/Navigation/index.tsx index 5e230bb6e..b52f1fc77 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/index.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/index.tsx @@ -20,8 +20,8 @@ import NavigationRecentList from '@/Layout/Navigation/RecentList'; import { ROUTE } from '@/Routes'; import { buildGettingStartedLocation, buildWorkspacesLocation } from '@/services/helpers/location'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import * as WorkspacesStore from '@/store/Workspaces'; +import { RootState } from '@/store'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectAllWorkspaces, selectRecentWorkspaces } from '@/store/Workspaces/selectors'; export interface NavigationItemObject { @@ -113,11 +113,11 @@ export class Navigation extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ recentWorkspaces: selectRecentWorkspaces(state), allWorkspaces: selectAllWorkspaces(state), }); -const connector = connect(mapStateToProps, WorkspacesStore.actionCreators); +const connector = connect(mapStateToProps, workspacesActionCreators); type MappedProps = ConnectedProps; diff --git a/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/__tests__/index.spec.tsx index 0deb44963..d778cb7d3 100644 --- a/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ import { Store } from 'redux'; import AppAlertGroup from '@/components/AppAlertGroup'; import { container } from '@/inversify.config'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import StoreErrorsAlert from '..'; @@ -31,7 +31,7 @@ describe('StoreErrorAlert component', () => { }); it('should not show any alerts', () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); renderComponent(store); const alertHeading = screen.queryByRole('heading', { name: /danger alert/i }); @@ -39,7 +39,7 @@ describe('StoreErrorAlert component', () => { }); it('should show other preload alerts when sanity check passes', () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({}, false, 'expected error 1') .withDevfileRegistries({ registries: { @@ -89,7 +89,7 @@ describe('StoreErrorAlert component', () => { }); it('should show sanity check error and hide other errors', () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({}, false, 'expected error 1') .withDevfileRegistries({ registries: { diff --git a/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/index.tsx b/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/index.tsx index c63d4a532..8fdba3aff 100644 --- a/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/index.tsx +++ b/packages/dashboard-frontend/src/Layout/StoreErrorsAlert/index.tsx @@ -16,7 +16,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { lazyInject } from '@/inversify.config'; import { AppAlerts } from '@/services/alerts/appAlerts'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectRegistriesErrors } from '@/store/DevfileRegistries/selectors'; import { selectInfrastructureNamespacesError } from '@/store/InfrastructureNamespaces/selectors'; import { selectPluginsError } from '@/store/Plugins/chePlugins/selectors'; @@ -115,7 +115,7 @@ export class StoreErrorsAlert extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ registriesErrors: selectRegistriesErrors(state), pluginsError: selectPluginsError(state), sanityCheckError: selectSanityCheckError(state), diff --git a/packages/dashboard-frontend/src/Layout/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/__tests__/index.spec.tsx index 1ecaf9c38..6aa38d75f 100644 --- a/packages/dashboard-frontend/src/Layout/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/__tests__/index.spec.tsx @@ -22,8 +22,8 @@ import Layout from '@/Layout'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; import { IssuesReporterService } from '@/services/bootstrap/issuesReporter'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as SanityCheckStore from '@/store/SanityCheck'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { sanityCheckActionCreators } from '@/store/SanityCheck'; const issuesReporterService = container.get(IssuesReporterService); @@ -42,9 +42,9 @@ jest.mock('@/services/helpers/login.ts', () => ({ const mockTestBackends = jest.fn(); jest.mock('@/store/SanityCheck/index', () => ({ ...jest.requireActual('@/store/SanityCheck/index'), - actionCreators: { + sanityCheckActionCreators: { testBackends: () => async () => mockTestBackends(), - } as SanityCheckStore.ActionCreators, + } as typeof sanityCheckActionCreators, })); const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -53,7 +53,7 @@ describe('Layout component', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withBranding({ logoFile: 'logo-File', } as BrandingData) diff --git a/packages/dashboard-frontend/src/Layout/index.tsx b/packages/dashboard-frontend/src/Layout/index.tsx index 12ba2823c..5874351d2 100644 --- a/packages/dashboard-frontend/src/Layout/index.tsx +++ b/packages/dashboard-frontend/src/Layout/index.tsx @@ -30,9 +30,9 @@ import { AppAlerts } from '@/services/alerts/appAlerts'; import { IssuesReporterService } from '@/services/bootstrap/issuesReporter'; import { WarningsReporterService } from '@/services/bootstrap/warningsReporter'; import { signOut } from '@/services/helpers/login'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; -import * as SanityCheckStore from '@/store/SanityCheck'; +import { sanityCheckActionCreators } from '@/store/SanityCheck'; import { selectSanityCheckError } from '@/store/SanityCheck/selectors'; import { selectDashboardLogo } from '@/store/ServerConfig/selectors'; @@ -179,13 +179,13 @@ export class Layout extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ branding: selectBranding(state), dashboardLogo: selectDashboardLogo(state), sanityCheckError: selectSanityCheckError(state), }); -const connector = connect(mapStateToProps, SanityCheckStore.actionCreators); +const connector = connect(mapStateToProps, sanityCheckActionCreators); type MappedProps = ConnectedProps; export default connector(Layout); diff --git a/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx b/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx index 57c4193a9..7d39d27ef 100644 --- a/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx +++ b/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx @@ -11,6 +11,7 @@ */ import { api } from '@eclipse-che/common'; +import { Store } from '@reduxjs/toolkit'; import { render, screen, waitFor } from '@testing-library/react'; import mockAxios from 'axios'; import { Location } from 'history'; @@ -18,9 +19,6 @@ import { dump } from 'js-yaml'; import React, { Suspense } from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; import { CREATE_DEVWORKSPACE_DELAY, @@ -41,8 +39,7 @@ import { import Fallback from '@/components/Fallback'; import { AppRoutes } from '@/Routes'; import devfileApi from '@/services/devfileApi'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { FactoryResolverStateResolver } from '@/store/FactoryResolver'; // mute the outputs @@ -91,7 +88,7 @@ describe('Workspace creation time', () => { const { rerender } = render( getComponent( `/load-factory?url=${url}`, - new FakeStoreBuilder() + new MockStoreBuilder() .withInfrastructureNamespace([namespace]) .withDevWorkspacesCluster({ isRunningDevWorkspacesClusterLimitExceeded: false, @@ -180,7 +177,7 @@ describe('Workspace creation time', () => { rerender( getComponent( `/load-factory?url=${url}`, - new FakeStoreBuilder() + new MockStoreBuilder() .withInfrastructureNamespace([namespace]) .withDevWorkspacesCluster({ isRunningDevWorkspacesClusterLimitExceeded: false }) .withFactoryResolver({ @@ -245,10 +242,7 @@ describe('Workspace creation time', () => { }, 15000); }); -function getComponent( - locationOrPath: Location | string, - store: MockStoreEnhanced>, -): React.ReactElement { +function getComponent(locationOrPath: Location | string, store: Store): React.ReactElement { return ( diff --git a/packages/dashboard-frontend/src/components/BannerAlert/Branding/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/BannerAlert/Branding/__tests__/index.spec.tsx index 6a83940bc..b8088a571 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/Branding/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/Branding/__tests__/index.spec.tsx @@ -16,7 +16,7 @@ import { Provider } from 'react-redux'; import { Store } from 'redux'; import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import BannerAlertBranding from '..'; @@ -33,7 +33,7 @@ describe('BannerAlertBranding component', () => { }); it('should not show header warning message when no warning option is present', () => { - const component = renderComponent(, new FakeStoreBuilder().build()); + const component = renderComponent(, new MockStoreBuilder().build()); expect( component.queryAllByText(scheduledMaintenance, { exact: false, @@ -58,7 +58,7 @@ function renderComponent(component: React.ReactElement, store: Store): } function storeBuilder(message: string): Store { - return new FakeStoreBuilder() + return new MockStoreBuilder() .withBranding({ header: { warning: message, diff --git a/packages/dashboard-frontend/src/components/BannerAlert/Branding/index.tsx b/packages/dashboard-frontend/src/components/BannerAlert/Branding/index.tsx index 33f0db85c..d4fb083f7 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/Branding/index.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/Branding/index.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import sanitizeHtml from 'sanitize-html'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; type Props = MappedProps; @@ -43,7 +43,7 @@ class BannerAlertBranding extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ branding: selectBranding(state), }); diff --git a/packages/dashboard-frontend/src/components/BannerAlert/Custom/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/BannerAlert/Custom/__tests__/index.spec.tsx index 58f61f46d..9923acfc6 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/Custom/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/Custom/__tests__/index.spec.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { Store } from 'redux'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import BannerAlertCustomWarning from '..'; @@ -51,5 +51,5 @@ function renderComponent(store: Store): RenderResult { } function storeBuilder(messages: string[]): Store { - return new FakeStoreBuilder().withBannerAlert(messages).build(); + return new MockStoreBuilder().withBannerAlert(messages).build(); } diff --git a/packages/dashboard-frontend/src/components/BannerAlert/Custom/index.tsx b/packages/dashboard-frontend/src/components/BannerAlert/Custom/index.tsx index fdc6e7a9d..12499098d 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/Custom/index.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/Custom/index.tsx @@ -16,7 +16,7 @@ import { connect, ConnectedProps } from 'react-redux'; import sanitizeHtml from 'sanitize-html'; import styles from '@/components/BannerAlert/Custom/index.module.css'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBannerAlertMessages } from '@/store/BannerAlert/selectors'; type Props = MappedProps; @@ -48,7 +48,7 @@ class BannerAlertCustomWarning extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ bannerAlertMessages: selectBannerAlertMessages(state), }); diff --git a/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/__tests__/index.spec.tsx index 99e3cb30c..42eba40ed 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import { Store } from 'redux'; import BannerAlertNoNodeAvailable from '@/components/BannerAlert/NoNodeAvailable'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { renderComponent } = getComponentRenderer(getComponent); const text = @@ -26,7 +26,7 @@ const text = describe('BannerAlertNoNodeAvailable component', () => { it('should show alert when failedScheduling event is received and hide alert when workspace has started', async () => { - const { reRenderComponent } = renderComponent(new FakeStoreBuilder().build()); + const { reRenderComponent } = renderComponent(new MockStoreBuilder().build()); const events = [ { @@ -35,14 +35,14 @@ describe('BannerAlertNoNodeAvailable component', () => { metadata: { uid: 'uid' }, } as any, ]; - const store = new FakeStoreBuilder().withEvents({ events }).build(); + const store = new MockStoreBuilder().withEvents({ events }).build(); reRenderComponent(store); await waitFor(() => expect(screen.queryAllByText(text).length).toEqual(1)); }); it('should hide alert when workspace has started', async () => { - const { reRenderComponent } = renderComponent(new FakeStoreBuilder().build()); + const { reRenderComponent } = renderComponent(new MockStoreBuilder().build()); const events = [ { @@ -54,7 +54,7 @@ describe('BannerAlertNoNodeAvailable component', () => { const workspaces = [ new DevWorkspaceBuilder().withStatus({ phase: 'STARTING', devworkspaceId: 'id' }).build(), ]; - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withEvents({ events }) .withDevWorkspaces({ workspaces }) .build(); @@ -65,7 +65,7 @@ describe('BannerAlertNoNodeAvailable component', () => { const nextWorkspaces = [ new DevWorkspaceBuilder().withStatus({ phase: 'RUNNING', devworkspaceId: 'id' }).build(), ]; - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withEvents({ events }) .withDevWorkspaces({ workspaces: nextWorkspaces }) .build(); diff --git a/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/index.tsx b/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/index.tsx index a3f9a528d..bfaa74402 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/index.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/NoNodeAvailable/index.tsx @@ -17,7 +17,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { container } from '@/inversify.config'; import { WebsocketClient } from '@/services/backend-client/websocketClient'; import { DevWorkspaceStatus } from '@/services/helpers/types'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectAllEvents } from '@/store/Events/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -99,7 +99,7 @@ class BannerAlertNoNodeAvailable extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allEvents: selectAllEvents(state), allWorkspaces: selectAllWorkspaces(state), }); diff --git a/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/__tests__/index.spec.tsx index ac674710b..2be796490 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/__tests__/index.spec.tsx @@ -17,13 +17,13 @@ import { Provider } from 'react-redux'; import { container } from '@/inversify.config'; import { ConnectionEvent, WebsocketClient } from '@/services/backend-client/websocketClient'; import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import BannerAlertWebSocket from '..'; const failingMessage = 'WebSocket connections are failing'; -const store = new FakeStoreBuilder() +const store = new MockStoreBuilder() .withBranding({ docs: { webSocketTroubleshooting: 'http://sample_documentation', diff --git a/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/index.tsx b/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/index.tsx index cabad5bf7..ba936183f 100644 --- a/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/index.tsx +++ b/packages/dashboard-frontend/src/components/BannerAlert/WebSocket/index.tsx @@ -20,7 +20,7 @@ import { ConnectionListener, WebsocketClient, } from '@/services/backend-client/websocketClient'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; type Props = MappedProps; @@ -90,7 +90,7 @@ class BannerAlertWebSocket extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ branding: selectBranding(state), }); diff --git a/packages/dashboard-frontend/src/components/EditorSelector/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorSelector/__tests__/index.spec.tsx index 5eed6decc..149f97fa6 100644 --- a/packages/dashboard-frontend/src/components/EditorSelector/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/EditorSelector/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ import EditorSelector, { State } from '@/components/EditorSelector'; import mockPlugins from '@/pages/GetStarted/__tests__/plugins.json'; import getComponentRenderer, { screen, within } from '@/services/__mocks__/getComponentRenderer'; import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('@/components/EditorSelector/Definition'); jest.mock('@/components/EditorSelector/Gallery'); @@ -162,7 +162,7 @@ describe('Editor Selector', () => { }); function getComponent(localState?: State) { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withPlugins(plugins) .withDwServerConfig({ defaults: { diff --git a/packages/dashboard-frontend/src/components/EditorSelector/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/index.tsx index cf7533406..b65118736 100644 --- a/packages/dashboard-frontend/src/components/EditorSelector/index.tsx +++ b/packages/dashboard-frontend/src/components/EditorSelector/index.tsx @@ -29,7 +29,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { EditorDefinition } from '@/components/EditorSelector/Definition'; import { DocsLink } from '@/components/EditorSelector/DocsLink'; import { EditorGallery } from '@/components/EditorSelector/Gallery'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectEditors } from '@/store/Plugins/chePlugins/selectors'; type AccordionId = 'default' | 'selector' | 'definition'; @@ -215,7 +215,7 @@ class EditorSelector extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ editors: selectEditors(state), }); diff --git a/packages/dashboard-frontend/src/components/EditorTools/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorTools/__tests__/index.spec.tsx index fc09fc58a..b0b8386cc 100644 --- a/packages/dashboard-frontend/src/components/EditorTools/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/EditorTools/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import { Store } from 'redux'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; import devfileApi from '@/services/devfileApi'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import EditorTools from '..'; @@ -57,7 +57,7 @@ describe('EditorTools', () => { }; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withClusterInfo({ applications: [clusterConsole], }) diff --git a/packages/dashboard-frontend/src/components/EditorTools/index.tsx b/packages/dashboard-frontend/src/components/EditorTools/index.tsx index df28ff8da..c092519f0 100644 --- a/packages/dashboard-frontend/src/components/EditorTools/index.tsx +++ b/packages/dashboard-frontend/src/components/EditorTools/index.tsx @@ -31,8 +31,8 @@ import devfileApi, { isDevfileV2, isDevWorkspace } from '@/services/devfileApi'; import stringify from '@/services/helpers/editor'; import { AlertItem } from '@/services/helpers/types'; import { WorkspaceAdapter } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import { actionCreators } from '@/store/BannerAlert'; +import { RootState } from '@/store'; +import { bannerAlertActionCreators } from '@/store/BannerAlert'; import { selectApplications } from '@/store/ClusterInfo/selectors'; export type Props = MappedProps & { @@ -213,11 +213,11 @@ class EditorTools extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ applications: selectApplications(state), }); -const connector = connect(mapStateToProps, actionCreators); +const connector = connect(mapStateToProps, bannerAlertActionCreators); type MappedProps = ConnectedProps; diff --git a/packages/dashboard-frontend/src/components/Head/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/Head/__tests__/index.spec.tsx index bad18cf7b..70d833ade 100644 --- a/packages/dashboard-frontend/src/components/Head/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/Head/__tests__/index.spec.tsx @@ -16,7 +16,7 @@ import { Provider } from 'react-redux'; import Head from '@/components/Head'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { createSnapshot } = getComponentRenderer(getComponent); @@ -45,7 +45,7 @@ describe('The head component for setting document title', () => { }); function getComponent(pageName?: string): React.ReactElement { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withBranding({ title: 'Dummy product title' } as BrandingData) .build(); diff --git a/packages/dashboard-frontend/src/components/Head/index.tsx b/packages/dashboard-frontend/src/components/Head/index.tsx index 8bdc1b01d..014ec6f0b 100644 --- a/packages/dashboard-frontend/src/components/Head/index.tsx +++ b/packages/dashboard-frontend/src/components/Head/index.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { Helmet } from 'react-helmet'; import { connect, ConnectedProps } from 'react-redux'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; type Props = MappedProps & { @@ -36,7 +36,7 @@ class Head extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ branding: selectBranding(state), }); diff --git a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/__tests__/gitRemote.spec.tsx b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/__tests__/gitRemote.spec.tsx index c9b61f4e4..4c0c124d4 100644 --- a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/__tests__/gitRemote.spec.tsx +++ b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/__tests__/gitRemote.spec.tsx @@ -18,7 +18,7 @@ import { Provider } from 'react-redux'; import AdditionalGitRemote from '@/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/gitRemote'; import { GitRemote } from '@/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/getGitRemotes'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -150,7 +150,7 @@ describe('AdditionalGitRemote', () => { }); function getComponent(remote: GitRemote, keys: api.SshKey[] = []) { - const store = new FakeStoreBuilder().withSshKeys({ keys }).build(); + const store = new MockStoreBuilder().withSshKeys({ keys }).build(); return ( { }); function getComponent(remotes?: GitRemote[], keys: api.SshKey[] = []) { - const store = new FakeStoreBuilder().withSshKeys({ keys }).build(); + const store = new MockStoreBuilder().withSshKeys({ keys }).build(); return ( diff --git a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/gitRemote.tsx b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/gitRemote.tsx index a645b1b0b..9caaff6bc 100644 --- a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/gitRemote.tsx +++ b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions/AdditionalGitRemotes/gitRemote.tsx @@ -31,9 +31,9 @@ import { GitRemote } from '@/components/WorkspaceProgress/CreatingSteps/Apply/De import { ROUTE } from '@/Routes'; import { FactoryLocationAdapter } from '@/services/factory-location-adapter'; import { UserPreferencesTab } from '@/services/helpers/types'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectSshKeys } from '@/store/SshKeys/selectors'; -import * as WorkspacesStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; export type Props = MappedProps & { onDelete: () => void; @@ -226,11 +226,11 @@ class AdditionalGitRemote extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ sshKeys: selectSshKeys(state), }); -const connector = connect(mapStateToProps, WorkspacesStore.actionCreators); +const connector = connect(mapStateToProps, workspacesActionCreators); type MappedProps = ConnectedProps; export default connector(AdditionalGitRemote); diff --git a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/__tests__/index.spec.tsx index 7eabde86d..6da9204e9 100644 --- a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ import { Store } from 'redux'; import RepoOptionsAccordion from '@/components/ImportFromGit/RepoOptionsAccordion'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -30,7 +30,7 @@ describe('RepoOptionsAccordion', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withSshKeys({ keys: [{ name: 'key1', keyPub: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD' }], }) diff --git a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/index.tsx b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/index.tsx index 84c4c56d2..a5e38e455 100644 --- a/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/index.tsx +++ b/packages/dashboard-frontend/src/components/ImportFromGit/RepoOptionsAccordion/index.tsx @@ -33,7 +33,7 @@ import { import { AdvancedOptions } from '@/components/ImportFromGit/RepoOptionsAccordion/AdvancedOptions'; import { GitRepoOptions } from '@/components/ImportFromGit/RepoOptionsAccordion/GitRepoOptions'; import { GitRemote } from '@/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/getGitRemotes'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectSshKeys } from '@/store/SshKeys/selectors'; type AccordionId = 'git-repo-options' | 'advanced-options'; @@ -264,7 +264,7 @@ class RepoOptionsAccordion extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ sshKeys: selectSshKeys(state), }); diff --git a/packages/dashboard-frontend/src/components/ImportFromGit/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/ImportFromGit/__tests__/index.spec.tsx index 786162861..94a463873 100644 --- a/packages/dashboard-frontend/src/components/ImportFromGit/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/ImportFromGit/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ import { Store } from 'redux'; import ImportFromGit from '@/components/ImportFromGit'; import getComponentRenderer, { screen, waitFor } from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -38,7 +38,7 @@ describe('GitRepoLocationInput', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDwServerConfig({ defaults: { editor: defaultEditorId, @@ -319,7 +319,7 @@ describe('GitRepoLocationInput', () => { }); test('with SSH keys, the `che-editor` parameter is omitted', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withSshKeys({ keys: [{ name: 'key1', keyPub: 'publicKey' }] }) .withWorkspacePreferences({ 'trusted-sources': '*', @@ -353,7 +353,7 @@ describe('GitRepoLocationInput', () => { }); test('with SSH keys, the `che-editor` parameter is set', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withSshKeys({ keys: [{ name: 'key1', keyPub: 'publicKey' }] }) .withWorkspacePreferences({ 'trusted-sources': '*', diff --git a/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx b/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx index 3b8e7664d..d5b8caa2f 100644 --- a/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx +++ b/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx @@ -38,9 +38,9 @@ import { FactoryLocationAdapter } from '@/services/factory-location-adapter'; import { EDITOR_ATTR, EDITOR_IMAGE_ATTR } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { buildUserPreferencesLocation } from '@/services/helpers/location'; import { UserPreferencesTab } from '@/services/helpers/types'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectSshKeys } from '@/store/SshKeys/selectors'; -import * as WorkspacesStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; const FIELD_ID = 'git-repo-url'; @@ -246,11 +246,11 @@ class ImportFromGit extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ sshKeys: selectSshKeys(state), }); -const connector = connect(mapStateToProps, WorkspacesStore.actionCreators); +const connector = connect(mapStateToProps, workspacesActionCreators); type MappedProps = ConnectedProps; export default connector(ImportFromGit); diff --git a/packages/dashboard-frontend/src/components/UntrustedSourceModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/UntrustedSourceModal/__tests__/index.spec.tsx index c97c53913..f6e3eebf5 100644 --- a/packages/dashboard-frontend/src/components/UntrustedSourceModal/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/UntrustedSourceModal/__tests__/index.spec.tsx @@ -12,13 +12,13 @@ import React from 'react'; import { Provider } from 'react-redux'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import UntrustedSourceModal from '@/components/UntrustedSourceModal'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { WorkspacePreferencesActionCreators } from '@/store/Workspaces/Preferences'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { workspacePreferencesActionCreators } from '@/store/Workspaces/Preferences'; const mockRequestPreferences = jest.fn(); const mockAddTrustedSource = jest.fn(); @@ -28,10 +28,10 @@ jest.mock('@/store/Workspaces/Preferences', () => { workspacePreferencesActionCreators: { requestPreferences: () => () => mockRequestPreferences(), addTrustedSource: - (source: unknown): AppThunk> => - async (): Promise => + (source: unknown): AppThunk => + async () => mockAddTrustedSource(source), - } as WorkspacePreferencesActionCreators, + } as typeof workspacePreferencesActionCreators, }; }); @@ -41,10 +41,10 @@ const mockOnClose = jest.fn(); const { renderComponent } = getComponentRenderer(getComponent); describe('Untrusted Repo Warning Modal', () => { - let storeBuilder: FakeStoreBuilder; + let storeBuilder: MockStoreBuilder; beforeEach(() => { - storeBuilder = new FakeStoreBuilder(); + storeBuilder = new MockStoreBuilder(); }); afterEach(() => { @@ -135,7 +135,7 @@ describe('Untrusted Repo Warning Modal', () => { continueButton.click(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withWorkspacePreferences({ 'trusted-sources': ['repo1', 'repo2', 'source-location'], }) diff --git a/packages/dashboard-frontend/src/components/UntrustedSourceModal/index.tsx b/packages/dashboard-frontend/src/components/UntrustedSourceModal/index.tsx index 3c23e6325..d078f79a7 100644 --- a/packages/dashboard-frontend/src/components/UntrustedSourceModal/index.tsx +++ b/packages/dashboard-frontend/src/components/UntrustedSourceModal/index.tsx @@ -25,7 +25,7 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { AppAlerts } from '@/services/alerts/appAlerts'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectIsAllowedSourcesConfigured } from '@/store/ServerConfig/selectors'; import { workspacePreferencesActionCreators } from '@/store/Workspaces/Preferences'; import { isTrustedRepo } from '@/store/Workspaces/Preferences/helpers'; @@ -233,7 +233,7 @@ class UntrustedSourceModal extends React.Component { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ trustedSources: selectPreferencesTrustedSources(state), isAllowedSourcesConfigured: selectIsAllowedSourcesConfigured(state), }); diff --git a/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx index 154678cc7..f64ed091f 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import devfileApi from '@/services/devfileApi'; import { constructWorkspace, Workspace, WorkspaceAdapter } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import WorkspaceEvents from '..'; @@ -74,21 +74,21 @@ describe('The WorkspaceEvents component', () => { }); test('snapshot - empty state', () => { - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces: [] }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces: [] }).build(); const snapshot = createSnapshot(store, undefined); expect(snapshot.toJSON()).toMatchSnapshot(); }); test('snapshot - no events', () => { const devWorkspace = devWorkspaceBuilder.withStatus({ phase: 'STARTING' }).build(); - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces: [devWorkspace] }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces: [devWorkspace] }).build(); const component = createSnapshot(store, devWorkspace); expect(component.toJSON()).toMatchSnapshot(); }); test('snapshot - with events', () => { const devWorkspace = devWorkspaceBuilder.withStatus({ phase: 'STARTING' }).build(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], startedWorkspaces: { [WorkspaceAdapter.getUID(devWorkspace)]: '1' }, @@ -101,7 +101,7 @@ describe('The WorkspaceEvents component', () => { it('should show a correct number of events', () => { const devWorkspace = devWorkspaceBuilder.withStatus({ phase: 'STARTING' }).build(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], startedWorkspaces: { [WorkspaceAdapter.getUID(devWorkspace)]: '1' }, @@ -113,7 +113,7 @@ describe('The WorkspaceEvents component', () => { expect(screen.getByText('1 event')).toBeTruthy(); expect(screen.getAllByTestId('event-item').length).toEqual(1); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], startedWorkspaces: { [WorkspaceAdapter.getUID(devWorkspace)]: '1' }, @@ -128,7 +128,7 @@ describe('The WorkspaceEvents component', () => { it('should sort events by timestamp', () => { const devWorkspace = devWorkspaceBuilder.withStatus({ phase: 'STARTING' }).build(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], startedWorkspaces: { [WorkspaceAdapter.getUID(devWorkspace)]: '1' }, @@ -148,7 +148,7 @@ describe('The WorkspaceEvents component', () => { event2.lastTimestamp = undefined; const devWorkspace = devWorkspaceBuilder.withStatus({ phase: 'STARTING' }).build(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], startedWorkspaces: { [WorkspaceAdapter.getUID(devWorkspace)]: '1' }, diff --git a/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx index 3a4f8846f..67110ccbf 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx @@ -36,7 +36,7 @@ import styles from '@/components/WorkspaceEvents/index.module.css'; import { WorkspaceEventsItem } from '@/components/WorkspaceEvents/Item'; import { DevWorkspaceStatus } from '@/services/helpers/types'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectAllEvents, selectEventsFromResourceVersion } from '@/store/Events/selectors'; import { selectStartedWorkspaces } from '@/store/Workspaces/devWorkspaces/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -135,7 +135,7 @@ class WorkspaceEvents extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allEvents: selectAllEvents(state), allWorkspaces: selectAllWorkspaces(state), eventsFromResourceVersionFn: selectEventsFromResourceVersion(state), diff --git a/packages/dashboard-frontend/src/components/WorkspaceLogs/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceLogs/__tests__/index.spec.tsx index 9186d51c4..88fa0dbb9 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceLogs/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceLogs/__tests__/index.spec.tsx @@ -17,15 +17,15 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import devfileApi from '@/services/devfileApi'; import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; import { AppThunk } from '@/store'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators, State } from '@/store/Pods/Logs'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { podLogsActionCreators, PodLogsState } from '@/store/Pods/Logs'; import WorkspaceLogs from '..'; @@ -36,16 +36,17 @@ jest.mock('../Viewer'); const mockWatchPodLogs = jest.fn(); const mockStopWatchingPodLogs = jest.fn(); jest.mock('@/store/Pods/Logs', () => ({ - actionCreators: { + ...jest.requireActual('@/store/Pods/Logs'), + podLogsActionCreators: { watchPodLogs: - (pod: V1Pod): AppThunk> => + (pod: V1Pod): AppThunk => async (): Promise => mockWatchPodLogs(pod), stopWatchingPodLogs: - (pod: V1Pod): AppThunk> => + (pod: V1Pod): AppThunk => async (): Promise => mockStopWatchingPodLogs(pod), - } as ActionCreators, + } as typeof podLogsActionCreators, })); const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -58,7 +59,7 @@ describe('The WorkspaceLogs component', () => { let devWorkspace: devfileApi.DevWorkspace; let workspace: Workspace; let pod: V1Pod; - let logs: State['logs']; + let logs: PodLogsState['logs']; let devWorkspaceBuilder = new DevWorkspaceBuilder().withId(workspaceId); @@ -102,7 +103,7 @@ describe('The WorkspaceLogs component', () => { }); test('snapshot - empty state', () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -114,7 +115,7 @@ describe('The WorkspaceLogs component', () => { }); test('snapshot - with logs', () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -133,7 +134,7 @@ describe('The WorkspaceLogs component', () => { it('should call `watchPodLogs`', async () => { /* no pods to watch */ - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -146,7 +147,7 @@ describe('The WorkspaceLogs component', () => { /* pod added in the store */ - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -165,7 +166,7 @@ describe('The WorkspaceLogs component', () => { it('should show logs viewer component', async () => { /* no pods to watch */ - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -177,7 +178,7 @@ describe('The WorkspaceLogs component', () => { /* pod added in the store */ - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -194,7 +195,7 @@ describe('The WorkspaceLogs component', () => { it('should show logs', async () => { /* pod but no logs in store */ - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -211,7 +212,7 @@ describe('The WorkspaceLogs component', () => { /* logs added in the store */ - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -229,7 +230,7 @@ describe('The WorkspaceLogs component', () => { it('should stop and then start watching logs', async () => { /* pod in store */ - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -242,7 +243,7 @@ describe('The WorkspaceLogs component', () => { /* no pods in store */ - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -257,7 +258,7 @@ describe('The WorkspaceLogs component', () => { /* pod added in the store */ - const nextNextStore = new FakeStoreBuilder() + const nextNextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -279,7 +280,7 @@ describe('The WorkspaceLogs component', () => { it('should call `stopWatchingPodLogs`', async () => { /* pod in store */ - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -294,7 +295,7 @@ describe('The WorkspaceLogs component', () => { /* no pods in store */ - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -310,7 +311,7 @@ describe('The WorkspaceLogs component', () => { it('should show the empty-screen component', async () => { /* pod in store */ - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -325,7 +326,7 @@ describe('The WorkspaceLogs component', () => { /* no pods in store */ - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -338,7 +339,7 @@ describe('The WorkspaceLogs component', () => { }); test('test container selector', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -368,7 +369,7 @@ describe('The WorkspaceLogs component', () => { }); test('test viewer tools', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) diff --git a/packages/dashboard-frontend/src/components/WorkspaceLogs/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceLogs/index.tsx index 9dcf2022e..d562f1dc7 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceLogs/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceLogs/index.tsx @@ -28,8 +28,8 @@ import { WorkspaceLogsToolsPanel } from '@/components/WorkspaceLogs/ToolsPanel'; import { WorkspaceLogsViewer } from '@/components/WorkspaceLogs/Viewer'; import { WorkspaceLogsViewerTools } from '@/components/WorkspaceLogs/ViewerTools'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import * as LogsStore from '@/store/Pods/Logs'; +import { RootState } from '@/store'; +import { ContainerLogs, podLogsActionCreators } from '@/store/Pods/Logs'; import { selectPodLogs } from '@/store/Pods/Logs/selectors'; import { selectAllPods } from '@/store/Pods/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -130,7 +130,7 @@ class WorkspaceLogs extends React.PureComponent { this.setState({ containerName }); } - private getContainerLogs(props: Props, state: State): LogsStore.ContainerLogs | undefined { + private getContainerLogs(props: Props, state: State): ContainerLogs | undefined { const { pod, containerName } = state; if (pod === undefined || containerName === undefined) { return; @@ -216,13 +216,13 @@ class WorkspaceLogs extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), allPods: selectAllPods(state), podLogsFn: selectPodLogs(state), }); -const connector = connect(mapStateToProps, LogsStore.actionCreators); +const connector = connect(mapStateToProps, podLogsActionCreators); type MappedProps = ConnectedProps; export default connector(WorkspaceLogs); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/__tests__/index.spec.tsx index b64e754e9..c6d8abb67 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/__tests__/index.spec.tsx @@ -16,7 +16,7 @@ import userEvent, { UserEvent } from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { Location } from 'react-router-dom'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { MIN_STEP_DURATION_MS, TIMEOUT_TO_STOP_SEC } from '@/components/WorkspaceProgress/const'; import { container } from '@/inversify.config'; @@ -29,9 +29,9 @@ import { TabManager } from '@/services/tabManager'; import { constructWorkspace } from '@/services/workspace-adapter'; import { AppThunk } from '@/store'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators as DevWorkspaceClusterActionCreators } from '@/store/DevWorkspacesCluster'; -import { ActionCreators } from '@/store/Workspaces'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { devWorkspacesClusterActionCreators } from '@/store/DevWorkspacesCluster'; +import { workspacesActionCreators } from '@/store/Workspaces'; import CommonStepCheckRunningWorkspacesLimit, { State } from '..'; @@ -41,33 +41,33 @@ const mockStartWorkspace = jest.fn(); const mockStopWorkspace = jest.fn(); const mockRequestRunningDevWorkspacesClusterLimitExceeded = jest.fn(); -jest.mock('@/store/Workspaces/index', () => { +jest.mock('@/store/Workspaces', () => { return { - actionCreators: { + ...jest.requireActual('@/store/Workspaces'), + workspacesActionCreators: { startWorkspace: - (...args: Parameters): AppThunk> => + (...args: Parameters<(typeof workspacesActionCreators)['startWorkspace']>): AppThunk => async (): Promise => { return mockStartWorkspace(...args); }, stopWorkspace: - (...args: Parameters): AppThunk> => + (...args: Parameters<(typeof workspacesActionCreators)['startWorkspace']>): AppThunk => async (): Promise => { return mockStopWorkspace(...args); }, - } as ActionCreators, + } as typeof workspacesActionCreators, }; }); jest.mock('@/store/DevWorkspacesCluster', () => { - const requireActual = jest.requireActual('@/store/DevWorkspacesCluster'); + const realModule = jest.requireActual('@/store/DevWorkspacesCluster'); return { - ...requireActual, - actionCreators: { - requestRunningDevWorkspacesClusterLimitExceeded: - (): AppThunk> => async (): Promise => { - return mockRequestRunningDevWorkspacesClusterLimitExceeded(); - }, - } as DevWorkspaceClusterActionCreators, + ...realModule, + devWorkspacesClusterActionCreators: { + requestRunningDevWorkspacesClusterLimitExceeded: (): AppThunk => async (): Promise => { + return mockRequestRunningDevWorkspacesClusterLimitExceeded(); + }, + } as typeof devWorkspacesClusterActionCreators, }; }); @@ -141,7 +141,7 @@ describe('Common steps, check running workspaces limit', () => { test('number of running workspaces is below the limit', async () => { const runningDevworkspace = runningDevworkspaceBuilder1.build(); const stoppedDevworkspace = stoppedDevworkspaceBuilder.build(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [runningDevworkspace, stoppedDevworkspace], }) @@ -165,7 +165,7 @@ describe('Common steps, check running workspaces limit', () => { test('should check cluster limit of running workspaces', async () => { const runningDevworkspace = runningDevworkspaceBuilder1.build(); const stoppedDevworkspace = stoppedDevworkspaceBuilder.build(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [runningDevworkspace, stoppedDevworkspace], }) @@ -189,7 +189,7 @@ describe('Common steps, check running workspaces limit', () => { test('should throw error if cluster limit of running workspaces exceeded', async () => { const runningDevworkspace = runningDevworkspaceBuilder1.build(); const stoppedDevworkspace = stoppedDevworkspaceBuilder.build(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [runningDevworkspace, stoppedDevworkspace], }) @@ -204,7 +204,7 @@ describe('Common steps, check running workspaces limit', () => { name: 'Checking for the limit of running workspaces', }); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // need to flush promises await Promise.resolve(); @@ -237,7 +237,7 @@ describe('Common steps, check running workspaces limit', () => { beforeEach(() => { runningDevworkspace = runningDevworkspaceBuilder1.build(); stoppedDevworkspace = stoppedDevworkspaceBuilder.build(); - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [targetDevworkspace, runningDevworkspace, stoppedDevworkspace], }) @@ -253,7 +253,7 @@ describe('Common steps, check running workspaces limit', () => { shouldStop: false, name: 'Checking for the limit of running workspaces', }); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // need to flush promises await Promise.resolve(); @@ -359,20 +359,27 @@ describe('Common steps, check running workspaces limit', () => { describe('stopping the redundant workspace', () => { let localState: Partial; let redundantDevworkspace: devfileApi.DevWorkspace; + let workspaceLimitStore: Store; beforeEach(() => { - redundantDevworkspace = runningDevworkspaceBuilder1 + redundantDevworkspace = new DevWorkspaceBuilder() .withStatus({ phase: 'STOPPING' }) + .withId(runningDevworkspace.metadata.uid) .build(); localState = { shouldStop: true, shouldCheckLimits: false, redundantWorkspaceUID: constructWorkspace(redundantDevworkspace).uid, }; + workspaceLimitStore = new MockStoreBuilder(store.getState()) + .withDevWorkspaces({ + workspaces: [targetDevworkspace, redundantDevworkspace, stoppedDevworkspace], + }) + .build(); }); test('timeout expired alert notification', async () => { - renderComponent(store, localState); + renderComponent(workspaceLimitStore, localState); // imitate the timeout has been expired const timeoutButton = screen.getByRole('button', { name: 'onTimeout' }); @@ -437,7 +444,7 @@ describe('Common steps, check running workspaces limit', () => { test('the redundant workspace has been stopped', async () => { mockStopWorkspace.mockResolvedValue(undefined); - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [targetDevworkspace, redundantDevworkspace, stoppedDevworkspace], }) @@ -448,11 +455,12 @@ describe('Common steps, check running workspaces limit', () => { const { reRenderComponent } = renderComponent(store, localState); await jest.advanceTimersByTimeAsync(MIN_STEP_DURATION_MS); - const nextRedundantDevworkspace = runningDevworkspaceBuilder1 + const nextRedundantDevworkspace = new DevWorkspaceBuilder() .withStatus({ phase: 'STOPPED' }) + .withId(redundantDevworkspace.metadata.uid) .withSpec({ started: false }) .build(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [targetDevworkspace, nextRedundantDevworkspace, stoppedDevworkspace], }) @@ -474,14 +482,14 @@ describe('Common steps, check running workspaces limit', () => { }); describe('start a workspace above the cluster limit, limit equals 2', () => { - let storeBuilder: FakeStoreBuilder; + let storeBuilder: MockStoreBuilder; let runningDevworkspace1: devfileApi.DevWorkspace; let runningDevworkspace2: devfileApi.DevWorkspace; beforeEach(() => { runningDevworkspace1 = runningDevworkspaceBuilder1.build(); runningDevworkspace2 = runningDevworkspaceBuilder1.build(); - storeBuilder = new FakeStoreBuilder() + storeBuilder = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [targetDevworkspace, runningDevworkspace1, runningDevworkspace2], }) @@ -543,7 +551,7 @@ describe('Common steps, check running workspaces limit', () => { runningDevworkspace1 = runningDevworkspaceBuilder1.build(); runningDevworkspace2 = runningDevworkspaceBuilder2.build(); stoppedDevworkspace = stoppedDevworkspaceBuilder.build(); - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ targetDevworkspace, diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/index.tsx index f20c05421..193eacdac 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CommonSteps/CheckRunningWorkspacesLimit/index.tsx @@ -33,17 +33,17 @@ import { buildHomeLocation, buildIdeLoaderLocation, toHref } from '@/services/he import { AlertItem, DevWorkspaceStatus, LoaderTab } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectRunningWorkspacesLimit } from '@/store/ClusterConfig/selectors'; import { - actionCreators as DevWorkspaceClusterActionCreators, + devWorkspacesClusterActionCreators, RunningDevWorkspacesClusterLimitExceededError, throwRunningDevWorkspacesClusterLimitExceededError, } from '@/store/DevWorkspacesCluster'; import { selectRunningDevWorkspacesClusterLimitExceeded } from '@/store/DevWorkspacesCluster/selectors'; -import * as WorkspaceStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { RunningWorkspacesExceededError } from '@/store/Workspaces/devWorkspaces'; -import { throwRunningWorkspacesExceededError } from '@/store/Workspaces/devWorkspaces/checkRunningWorkspacesLimit'; +import { throwRunningWorkspacesExceededError } from '@/store/Workspaces/devWorkspaces'; import { selectRunningDevWorkspacesLimitExceeded } from '@/store/Workspaces/devWorkspaces/selectors'; import { selectAllWorkspaces, selectRunningWorkspaces } from '@/store/Workspaces/selectors'; @@ -373,7 +373,7 @@ class CommonStepCheckRunningWorkspacesLimit extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), runningDevWorkspacesLimitExceeded: selectRunningDevWorkspacesLimitExceeded(state), runningDevWorkspacesClusterLimitExceeded: selectRunningDevWorkspacesClusterLimitExceeded(state), @@ -383,7 +383,7 @@ const mapStateToProps = (state: AppState) => ({ const connector = connect( mapStateToProps, - { ...WorkspaceStore.actionCreators, ...DevWorkspaceClusterActionCreators }, + { ...workspacesActionCreators, ...devWorkspacesClusterActionCreators }, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/__tests__/index.spec.tsx index 51822a0bd..f7549e68b 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import { dump } from 'js-yaml'; import React from 'react'; import { Provider } from 'react-redux'; import { Location } from 'react-router-dom'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import ExpandableWarning from '@/components/ExpandableWarning'; import { MIN_STEP_DURATION_MS } from '@/components/WorkspaceProgress/const'; @@ -39,23 +39,24 @@ import { AlertItem } from '@/services/helpers/types'; import { che } from '@/services/models'; import { AppThunk } from '@/store'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/Workspaces'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { workspacesActionCreators } from '@/store/Workspaces'; jest.mock('@/components/WorkspaceProgress/TimeLimit'); jest.mock('@/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/prepareDevfile.ts'); let mockCreateWorkspaceFromDevfile; -jest.mock('@/store/Workspaces/index', () => { +jest.mock('@/store/Workspaces', () => { return { - actionCreators: { + ...jest.requireActual('@/store/Workspaces'), + workspacesActionCreators: { createWorkspaceFromDevfile: ( - ...args: Parameters - ): AppThunk> => + ...args: Parameters<(typeof workspacesActionCreators)['createWorkspaceFromDevfile']> + ): AppThunk => async (): Promise => mockCreateWorkspaceFromDevfile(...args), - } as ActionCreators, + } as typeof workspacesActionCreators, }; }); @@ -115,7 +116,7 @@ describe('Creating steps, applying a devfile', () => { const expectAlertItem = expect.objectContaining({ title: 'Failed to create the workspace', - children: 'Failed to resolve the devfile.', + children: 'Failed to resolve the default devfile.', actionCallbacks: [ expect.objectContaining({ title: 'Continue with default devfile', @@ -224,8 +225,14 @@ describe('Creating steps, applying a devfile', () => { } as api.IServerConfig) .build(); - renderComponent(store, searchParams); - jest.runAllTimers(); + const { reRenderComponent } = renderComponent(store, searchParams); + await jest.runAllTimersAsync(); + + reRenderComponent(store, searchParams, { + // user has chosen to continue with the default devfile + continueWithDefaultDevfile: true, + factoryParams: buildFactoryParams(searchParams), + }); await waitFor(() => expect(prepareDevfile).toHaveBeenCalledWith( @@ -305,8 +312,16 @@ describe('Creating steps, applying a devfile', () => { } as api.IServerConfig) .build(); - renderComponent(store, searchParams); - jest.runAllTimers(); + const { reRenderComponent } = renderComponent(store, searchParams); + await jest.runAllTimersAsync(); + + reRenderComponent(store, searchParams, { + // user has chosen to continue with the default devfile + continueWithDefaultDevfile: true, + factoryParams: buildFactoryParams(searchParams), + }); + + await jest.runAllTimersAsync(); await waitFor(() => expect(prepareDevfile).toHaveBeenCalledWith( @@ -381,7 +396,7 @@ describe('Creating steps, applying a devfile', () => { .build(); renderComponent(store, searchParams); - jest.runAllTimers(); + await jest.runAllTimersAsync(); await waitFor(() => expect(prepareDevfile).toHaveBeenCalledWith( @@ -460,7 +475,7 @@ describe('Creating steps, applying a devfile', () => { }; renderComponent(store, searchParams, localState); - jest.runAllTimers(); + await jest.runAllTimersAsync(); await waitFor(() => expect(prepareDevfile).toHaveBeenCalledWith( @@ -827,8 +842,8 @@ describe('Creating steps, applying a devfile', () => { }); }); -function getStoreBuilder(): FakeStoreBuilder { - return new FakeStoreBuilder().withInfrastructureNamespace([ +function getStoreBuilder(): MockStoreBuilder { + return new MockStoreBuilder().withInfrastructureNamespace([ { attributes: { phase: 'Active' }, name: 'user-che', diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/index.tsx index b351bb432..4d3729608 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Devfile/index.tsx @@ -42,11 +42,11 @@ import { buildIdeLoaderLocation, toHref } from '@/services/helpers/location'; import { AlertItem } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectDefaultDevfile } from '@/store/DevfileRegistries/selectors'; import { selectFactoryResolver } from '@/store/FactoryResolver/selectors'; import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import * as WorkspacesStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectDevWorkspaceWarnings } from '@/store/Workspaces/devWorkspaces/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -463,7 +463,7 @@ class CreatingStepApplyDevfile extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), defaultNamespace: selectDefaultNamespace(state), factoryResolver: selectFactoryResolver(state), @@ -474,7 +474,7 @@ const mapStateToProps = (state: AppState) => ({ const connector = connect( mapStateToProps, { - ...WorkspacesStore.actionCreators, + ...workspacesActionCreators, }, null, { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/__tests__/index.spec.tsx index d68a8875c..edad4c956 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/__tests__/index.spec.tsx @@ -15,7 +15,7 @@ import userEvent, { UserEvent } from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { Location } from 'react-router-dom'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { MIN_STEP_DURATION_MS, TIMEOUT_TO_CREATE_SEC } from '@/components/WorkspaceProgress/const'; import prepareResources from '@/components/WorkspaceProgress/CreatingSteps/Apply/Resources/prepareResources'; @@ -33,9 +33,9 @@ import { AlertItem } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { AppThunk } from '@/store'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { DevWorkspaceResources } from '@/store/DevfileRegistries'; -import { ActionCreators } from '@/store/Workspaces/devWorkspaces'; +import { devWorkspacesActionCreators } from '@/store/Workspaces/devWorkspaces'; import CreatingStepApplyResources from '..'; @@ -45,14 +45,15 @@ jest.mock('@/components/WorkspaceProgress/CreatingSteps/Apply/Resources/prepareR const mockCreateWorkspaceFromResources = jest.fn().mockResolvedValue(undefined); jest.mock('@/store/Workspaces/devWorkspaces', () => { return { - actionCreators: { + ...jest.requireActual('@/store/Workspaces/devWorkspaces'), + devWorkspacesActionCreators: { createWorkspaceFromResources: ( - ...args: Parameters - ): AppThunk> => + ...args: Parameters<(typeof devWorkspacesActionCreators)['createWorkspaceFromResources']> + ): AppThunk => async (): Promise => mockCreateWorkspaceFromResources(...args), - } as ActionCreators, + } as typeof devWorkspacesActionCreators, }; }); @@ -218,12 +219,12 @@ describe('Creating steps, applying resources', () => { let emptyStore: Store; beforeEach(() => { - emptyStore = new FakeStoreBuilder().build(); + emptyStore = new MockStoreBuilder().build(); }); test('notification alert', async () => { renderComponent(emptyStore, searchParams); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // trigger timeout const timeoutButton = screen.getByRole('button', { @@ -391,8 +392,8 @@ describe('Creating steps, applying resources', () => { }); }); -function getStoreBuilder(): FakeStoreBuilder { - return new FakeStoreBuilder().withInfrastructureNamespace([ +function getStoreBuilder(): MockStoreBuilder { + return new MockStoreBuilder().withInfrastructureNamespace([ { attributes: { phase: 'Active' }, name: 'user-che', diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/index.tsx index 8d872511d..8c5dee19d 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Apply/Resources/index.tsx @@ -35,15 +35,14 @@ import { buildIdeLoaderLocation, toHref } from '@/services/helpers/location'; import { AlertItem } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; -import { DevWorkspaceResources } from '@/store/DevfileRegistries'; +import { RootState } from '@/store'; +import { devfileRegistriesActionCreators, DevWorkspaceResources } from '@/store/DevfileRegistries'; import { selectDevWorkspaceResources } from '@/store/DevfileRegistries/selectors'; import { factoryResolverActionCreators } from '@/store/FactoryResolver'; import { selectFactoryResolver } from '@/store/FactoryResolver/selectors'; import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import * as WorkspacesStore from '@/store/Workspaces'; -import * as DevWorkspacesStore from '@/store/Workspaces/devWorkspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; +import { devWorkspacesActionCreators } from '@/store/Workspaces/devWorkspaces'; import { selectDevWorkspaceWarnings } from '@/store/Workspaces/devWorkspaces/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -293,7 +292,7 @@ class CreatingStepApplyResources extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), defaultNamespace: selectDefaultNamespace(state), factoryResolver: selectFactoryResolver(state), @@ -304,10 +303,10 @@ const mapStateToProps = (state: AppState) => ({ const connector = connect( mapStateToProps, { - ...DevfileRegistriesStore.actionCreators, + ...devfileRegistriesActionCreators, ...factoryResolverActionCreators, - ...WorkspacesStore.actionCreators, - createWorkspaceFromResources: DevWorkspacesStore.actionCreators.createWorkspaceFromResources, + ...workspacesActionCreators, + createWorkspaceFromResources: devWorkspacesActionCreators.createWorkspaceFromResources, }, null, { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/__tests__/index.spec.tsx index ab113261e..39e8144fc 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/__tests__/index.spec.tsx @@ -30,7 +30,7 @@ import { buildFactoryLocation } from '@/services/helpers/location'; import { AlertItem } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { DevWorkspaceResources } from '@/store/DevfileRegistries'; import CreatingStepCheckExistingWorkspaces from '..'; @@ -89,7 +89,7 @@ describe('Creating steps, checking existing workspaces', () => { [POLICIES_CREATE_ATTR]: 'perclick', [DEV_WORKSPACE_ATTR]: resourcesUrl, }); - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); renderComponent(store, searchParams); await jest.advanceTimersByTimeAsync(MIN_STEP_DURATION_MS); @@ -112,7 +112,7 @@ describe('Creating steps, checking existing workspaces', () => { } as devfileApi.DevWorkspace, {} as devfileApi.DevWorkspaceTemplate, ]; - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder().withName('project-1').withNamespace('user-che').build(), @@ -134,7 +134,7 @@ describe('Creating steps, checking existing workspaces', () => { await jest.advanceTimersByTimeAsync(MIN_STEP_DURATION_MS); - jest.runOnlyPendingTimers(); + await jest.runOnlyPendingTimersAsync(); await waitFor(() => expect(mockOnNextStep).toHaveBeenCalled()); expect(mockOnError).not.toHaveBeenCalled(); @@ -155,7 +155,7 @@ describe('Creating steps, checking existing workspaces', () => { } as devfileApi.DevWorkspace, {} as devfileApi.DevWorkspaceTemplate, ]; - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder().withName(workspaceName).withNamespace('user-che').build(), @@ -291,7 +291,7 @@ describe('Creating steps, checking existing workspaces', () => { [FACTORY_URL_ATTR]: factoryUrl, }); - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder().withName(workspaceName).withNamespace('user-che').build(), diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx index a30628082..44139bcd3 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx @@ -31,7 +31,7 @@ import { buildIdeLoaderLocation, toHref } from '@/services/helpers/location'; import { AlertItem } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectDevWorkspaceResources } from '@/store/DevfileRegistries/selectors'; import { selectFactoryResolver } from '@/store/FactoryResolver/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -236,7 +236,7 @@ class CreatingStepCheckExistingWorkspaces extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), devWorkspaceResources: selectDevWorkspaceResources(state), factoryResolver: selectFactoryResolver(state), diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CreateWorkspace/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CreateWorkspace/__tests__/index.spec.tsx index 5218cf0ed..31c165877 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CreateWorkspace/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CreateWorkspace/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import { Store } from 'redux'; import { MIN_STEP_DURATION_MS } from '@/components/WorkspaceProgress/const'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import CreateWorkspace from '..'; @@ -43,7 +43,7 @@ describe('Creating steps, creating a workspace', () => { }); it('should switch to the next step', async () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); renderComponent(store, searchParams); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/buildStepName.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/buildStepName.spec.tsx index 2de88aaec..1bc90851f 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/buildStepName.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/buildStepName.spec.tsx @@ -18,7 +18,7 @@ import { buildFactoryParams, FACTORY_URL_ATTR, } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const factoryUrl = 'https://factory-url'; @@ -42,7 +42,7 @@ describe('Factory flow: step Fetch Devfile', () => { describe('step title', () => { test('direct link to devfile', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withFactoryResolver({ resolver: { devfile, @@ -62,7 +62,7 @@ describe('Factory flow: step Fetch Devfile', () => { }); test('devfile not found', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withFactoryResolver({ resolver: { devfile, @@ -84,7 +84,7 @@ describe('Factory flow: step Fetch Devfile', () => { }); test('devfile found', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withFactoryResolver({ resolver: { devfile, diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/index.spec.tsx index 9dc15969f..7bc996361 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import userEvent, { UserEvent } from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { Location } from 'react-router-dom'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import ExpandableWarning from '@/components/ExpandableWarning'; import { MIN_STEP_DURATION_MS, TIMEOUT_TO_RESOLVE_SEC } from '@/components/WorkspaceProgress/const'; @@ -33,23 +33,23 @@ import { } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { AlertItem } from '@/services/helpers/types'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { FactoryResolverActionCreators, OAuthResponse } from '@/store/FactoryResolver'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { factoryResolverActionCreators, OAuthResponse } from '@/store/FactoryResolver'; jest.mock('@/components/WorkspaceProgress/TimeLimit'); const mockRequestFactoryResolver = jest.fn(); -jest.mock('@/store/FactoryResolver/actions', () => { +jest.mock('@/store/FactoryResolver', () => { return { ...jest.requireActual('@/store/FactoryResolver'), - actionCreators: { + factoryResolverActionCreators: { requestFactoryResolver: ( - ...args: Parameters - ): AppThunk> => - async (): Promise => + ...args: Parameters<(typeof factoryResolverActionCreators)['requestFactoryResolver']> + ): AppThunk => + async () => mockRequestFactoryResolver(...args), - } as FactoryResolverActionCreators, + } as typeof factoryResolverActionCreators, }; }); @@ -90,7 +90,7 @@ describe('Creating steps, fetching a devfile', () => { generateName: 'my-project-', }, }; - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withFactoryResolver({ resolver: { devfile, @@ -140,7 +140,7 @@ describe('Creating steps, fetching a devfile', () => { }); test('no project url, remotes exist', async () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); const remotesAttr = '{{test-1,http://git-test-1.git},{test-2,http://git-test-2.git},{test-3,http://git-test-3.git}}'; @@ -161,7 +161,7 @@ describe('Creating steps, fetching a devfile', () => { const rejectReason = '... schema validation failed ...'; beforeEach(() => { - emptyStore = new FakeStoreBuilder().build(); + emptyStore = new MockStoreBuilder().build(); mockRequestFactoryResolver.mockRejectedValueOnce(rejectReason); }); @@ -283,12 +283,12 @@ describe('Creating steps, fetching a devfile', () => { let emptyStore: Store; beforeEach(() => { - emptyStore = new FakeStoreBuilder().build(); + emptyStore = new MockStoreBuilder().build(); }); test('notification alert', async () => { renderComponent(emptyStore, searchParams); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // trigger timeout const timeoutButton = screen.getByRole('button', { @@ -365,7 +365,7 @@ describe('Creating steps, fetching a devfile', () => { const rejectReason = 'Failed to fetch devfile'; beforeEach(() => { - emptyStore = new FakeStoreBuilder().build(); + emptyStore = new MockStoreBuilder().build(); mockRequestFactoryResolver.mockRejectedValueOnce(rejectReason); }); @@ -482,7 +482,7 @@ describe('Creating steps, fetching a devfile', () => { }); test('request factory resolver', async () => { - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); renderComponent(emptyStore, searchParams); await jest.advanceTimersByTimeAsync(MIN_STEP_DURATION_MS); @@ -496,7 +496,7 @@ describe('Creating steps, fetching a devfile', () => { const expectedOverrideParams = { [attrName]: attrValue }; // add override param searchParams.append(attrName, attrValue); - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); renderComponent(emptyStore, searchParams); @@ -513,7 +513,7 @@ describe('Creating steps, fetching a devfile', () => { }); test('devfile resolved successfully', async () => { - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); const { reRenderComponent } = renderComponent(emptyStore, searchParams); @@ -528,7 +528,7 @@ describe('Creating steps, fetching a devfile', () => { await jest.advanceTimersByTimeAsync(time); // build next store - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withFactoryResolver({ resolver: { location: factoryUrl, @@ -580,7 +580,7 @@ describe('Creating steps, fetching a devfile', () => { }); test('redirect to an authentication URL', async () => { - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); renderComponent(emptyStore, searchParams, location); @@ -599,7 +599,7 @@ describe('Creating steps, fetching a devfile', () => { }); test('authentication fails', async () => { - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); renderComponent(emptyStore, searchParams, location); @@ -660,7 +660,7 @@ describe('Creating steps, fetching a devfile', () => { }); test('authentication passes', async () => { - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); renderComponent(emptyStore, searchParams, location); @@ -682,7 +682,7 @@ describe('Creating steps, fetching a devfile', () => { await waitFor(() => expect(mockRequestFactoryResolver).toHaveBeenCalled()); // build next store - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withFactoryResolver({ resolver: { location: factoryUrl, @@ -713,7 +713,7 @@ describe('Creating steps, fetching a devfile', () => { let location: Location; beforeEach(() => { - store = new FakeStoreBuilder().build(); + store = new MockStoreBuilder().build(); searchParams = new URLSearchParams({ [FACTORY_URL_ATTR]: factoryUrl, @@ -734,7 +734,7 @@ describe('Creating steps, fetching a devfile', () => { }); it('should go to next step', async () => { - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); renderComponent(emptyStore, searchParams, location); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/index.tsx index bb5427b88..63e4582e0 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Devfile/index.tsx @@ -35,7 +35,7 @@ import { import { AlertItem } from '@/services/helpers/types'; import { isOAuthResponse, OAuthService } from '@/services/oauth'; import SessionStorageService, { SessionStorageKey } from '@/services/session-storage'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { factoryResolverActionCreators, selectFactoryResolver } from '@/store/FactoryResolver'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -423,7 +423,7 @@ class CreatingStepFetchDevfile extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), factoryResolver: selectFactoryResolver(state), }); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/__tests__/index.spec.tsx index 5ed5f2194..88d914209 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/__tests__/index.spec.tsx @@ -15,9 +15,10 @@ import userEvent, { UserEvent } from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { Location } from 'react-router-dom'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { MIN_STEP_DURATION_MS, TIMEOUT_TO_RESOLVE_SEC } from '@/components/WorkspaceProgress/const'; +import CreatingStepFetchResources from '@/components/WorkspaceProgress/CreatingSteps/Fetch/Resources'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import devfileApi from '@/services/devfileApi'; import { getDefer } from '@/services/helpers/deferred'; @@ -27,27 +28,24 @@ import { } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { AlertItem } from '@/services/helpers/types'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/DevfileRegistries'; - -import CreatingStepFetchResources from '..'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { devfileRegistriesActionCreators } from '@/store/DevfileRegistries'; jest.mock('@/components/WorkspaceProgress/TimeLimit'); const mockRequestResources = jest.fn(); jest.mock('@/store/DevfileRegistries', () => { - /* eslint-disable @typescript-eslint/no-unused-vars */ return { - actionCreators: { + ...jest.requireActual('@/store/DevfileRegistries'), + devfileRegistriesActionCreators: { requestResources: ( - ...args: Parameters - ): AppThunk> => + ...args: Parameters<(typeof devfileRegistriesActionCreators)['requestResources']> + ): AppThunk => async (): Promise => mockRequestResources(...args), - } as ActionCreators, + } as typeof devfileRegistriesActionCreators, }; - /* eslint-enable @typescript-eslint/no-unused-vars */ }); const { renderComponent } = getComponentRenderer(getComponent); @@ -82,7 +80,7 @@ describe('Creating steps, fetching resources', () => { }); test('resources are already resolved', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevfileRegistries({ devWorkspaceResources: { [resourcesUrl]: { @@ -104,7 +102,7 @@ describe('Creating steps, fetching resources', () => { }); test('fetch pre-built resources', async () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); renderComponent(store, searchParams); await jest.advanceTimersByTimeAsync(MIN_STEP_DURATION_MS); @@ -117,7 +115,7 @@ describe('Creating steps, fetching resources', () => { }); test('fetch a broken url', async () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); const rejectReason = 'Not found.'; mockRequestResources.mockRejectedValueOnce(rejectReason); @@ -143,7 +141,7 @@ describe('Creating steps, fetching resources', () => { }); test('resources fetched successfully', async () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); const { reRenderComponent } = renderComponent(store, searchParams); @@ -156,7 +154,7 @@ describe('Creating steps, fetching resources', () => { expect(mockOnError).not.toHaveBeenCalled(); // build next store - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevfileRegistries({ devWorkspaceResources: { [resourcesUrl]: { @@ -178,12 +176,12 @@ describe('Creating steps, fetching resources', () => { let emptyStore: Store; beforeEach(() => { - emptyStore = new FakeStoreBuilder().build(); + emptyStore = new MockStoreBuilder().build(); }); test('notification alert', async () => { renderComponent(emptyStore, searchParams); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // trigger timeout const timeoutButton = screen.getByRole('button', { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/index.tsx index 03c3e3f38..c9b830d91 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Fetch/Resources/index.tsx @@ -29,8 +29,8 @@ import { FactoryParams, } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { AlertItem } from '@/services/helpers/types'; -import { AppState } from '@/store'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; +import { RootState } from '@/store'; +import { devfileRegistriesActionCreators } from '@/store/DevfileRegistries'; import { selectDevWorkspaceResources } from '@/store/DevfileRegistries/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -197,7 +197,7 @@ class CreatingStepFetchResources extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), devWorkspaceResources: selectDevWorkspaceResources(state), }); @@ -205,7 +205,7 @@ const mapStateToProps = (state: AppState) => ({ const connector = connect( mapStateToProps, { - ...DevfileRegistriesStore.actionCreators, + ...devfileRegistriesActionCreators, }, null, { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx index d1020ab69..3d29d11db 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx @@ -25,7 +25,7 @@ import { } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { AlertItem, UserPreferencesTab } from '@/services/helpers/types'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import CreatingStepInitialize from '..'; @@ -48,7 +48,7 @@ describe('Creating steps, initializing', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withSshKeys({ keys: [{ name: 'key1', keyPub: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD' }], @@ -201,7 +201,7 @@ describe('Creating steps, initializing', () => { }); test('no pre-created infrastructure namespaces', async () => { - const storeNoNamespace = new FakeStoreBuilder() + const storeNoNamespace = new MockStoreBuilder() .withWorkspacePreferences({ 'trusted-sources': '*' }) .build(); const searchParams = new URLSearchParams({ @@ -230,7 +230,7 @@ describe('Creating steps, initializing', () => { }); test('all workspaces limit exceeded', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withClusterConfig({ allWorkspacesLimit: 1 }) .withDevWorkspaces({ workspaces: [new DevWorkspaceBuilder().build()] }) @@ -263,7 +263,7 @@ describe('Creating steps, initializing', () => { }); test('no SSH keys with Git+HTTPS factory URL', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withWorkspacePreferences({ 'trusted-sources': '*' }) .build(); @@ -282,7 +282,7 @@ describe('Creating steps, initializing', () => { test('no SSH keys with Git+SSH factory URL', async () => { const factoryUrl = 'git@github.com:eclipse-che/che-dashboard.git'; - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withWorkspacePreferences({ 'trusted-sources': '*' }) .build(); @@ -325,7 +325,7 @@ describe('Creating steps, initializing', () => { }); test('source URL is not trusted', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withSshKeys({ keys: [{ name: 'key1', keyPub: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD' }], @@ -351,7 +351,7 @@ describe('Creating steps, initializing', () => { expect(mockOnNextStep).not.toHaveBeenCalled(); // add factory URL to trusted sources - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withSshKeys({ keys: [{ name: 'key1', keyPub: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD' }], @@ -372,7 +372,7 @@ describe('Creating steps, initializing', () => { }); test('source URL is not allowed', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDwServerConfig({ allowedSourceUrls: ['allowed-source'] }) .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withSshKeys({ @@ -398,7 +398,7 @@ describe('Creating steps, initializing', () => { }); test('samples are trusted', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withSshKeys({ keys: [{ name: 'key1', keyPub: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD' }], diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx index ef571066d..37934c1fe 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx @@ -32,7 +32,7 @@ import { } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { buildUserPreferencesLocation, toHref } from '@/services/helpers/location'; import { AlertItem, UserPreferencesTab } from '@/services/helpers/types'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectAllWorkspacesLimit } from '@/store/ClusterConfig/selectors'; import { selectIsRegistryDevfile } from '@/store/DevfileRegistries/selectors'; import { selectInfrastructureNamespaces } from '@/store/InfrastructureNamespaces/selectors'; @@ -324,7 +324,7 @@ export class NoSshKeysError extends Error { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), allWorkspacesLimit: selectAllWorkspacesLimit(state), infrastructureNamespaces: selectInfrastructureNamespaces(state), diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/__tests__/index.spec.tsx index fea9b2213..f45f31776 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/__tests__/index.spec.tsx @@ -23,7 +23,7 @@ import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { getDefer } from '@/services/helpers/deferred'; import { AlertItem } from '@/services/helpers/types'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import StartingStepInitialize from '..'; @@ -64,7 +64,7 @@ describe('Starting steps, initializing', () => { let paramsWithWrongName: WorkspaceRouteParams; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -143,7 +143,7 @@ describe('Starting steps, initializing', () => { }); test('workspace is STOPPING then STOPPED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -162,7 +162,7 @@ describe('Starting steps, initializing', () => { // no errors at this moment expect(mockOnError).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -186,7 +186,7 @@ describe('Starting steps, initializing', () => { }); test('workspace is STOPPED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -212,7 +212,7 @@ describe('Starting steps, initializing', () => { }); test('workspace is FAILING then FAILED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -231,7 +231,7 @@ describe('Starting steps, initializing', () => { // no errors at this moment expect(mockOnError).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -254,7 +254,7 @@ describe('Starting steps, initializing', () => { }); test('workspace is FAILED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -282,7 +282,7 @@ describe('Starting steps, initializing', () => { }); test('workspace is TERMINATING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -316,7 +316,7 @@ describe('Starting steps, initializing', () => { }); test('workspace is RUNNING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -344,7 +344,7 @@ describe('Starting steps, initializing', () => { }); test('workspace is STARTING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -375,7 +375,7 @@ describe('Starting steps, initializing', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/index.tsx index 478b35d0a..4bdd69a3c 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/Initialize/index.tsx @@ -29,8 +29,8 @@ import { delay } from '@/services/helpers/delay'; import { findTargetWorkspace } from '@/services/helpers/factoryFlow/findTargetWorkspace'; import { AlertItem, DevWorkspaceStatus, LoaderTab } from '@/services/helpers/types'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import * as WorkspaceStore from '@/store/Workspaces'; +import { RootState } from '@/store'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; export type Props = MappedProps & @@ -210,11 +210,11 @@ class StartingStepInitialize extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), }); -const connector = connect(mapStateToProps, WorkspaceStore.actionCreators, null, { +const connector = connect(mapStateToProps, workspacesActionCreators, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests forwardRef: true, }); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/__tests__/index.spec.tsx index 7dacc1638..645ac7883 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/__tests__/index.spec.tsx @@ -25,7 +25,7 @@ import { getDefer } from '@/services/helpers/deferred'; import { AlertItem } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import StartingStepOpenWorkspace from '..'; @@ -77,7 +77,7 @@ describe('Starting steps, opening an editor', () => { let paramsWithWrongName: WorkspaceRouteParams; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -161,7 +161,7 @@ describe('Starting steps, opening an editor', () => { test('workspace status change from STOPPING to RUNNING', async () => { isAvailableEndpointMock.mockResolvedValue(true); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -180,7 +180,7 @@ describe('Starting steps, opening an editor', () => { expect(mockOnNextStep).not.toHaveBeenCalled(); expect(mockOnRestart).not.toHaveBeenCalled(); - const storeNext = new FakeStoreBuilder() + const storeNext = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -203,7 +203,7 @@ describe('Starting steps, opening an editor', () => { }); test('workspace is FAILED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -246,7 +246,7 @@ describe('Starting steps, opening an editor', () => { }); test('mainUrl is present', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -267,7 +267,7 @@ describe('Starting steps, opening an editor', () => { }); test(`mainUrl is propagated after some time`, async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -286,7 +286,7 @@ describe('Starting steps, opening an editor', () => { // no errors at this moment expect(mockOnError).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -314,7 +314,7 @@ describe('Starting steps, opening an editor', () => { }); test('mainUrl is present', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -335,7 +335,7 @@ describe('Starting steps, opening an editor', () => { }); test('mainUrl is propagated after some time', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -354,7 +354,7 @@ describe('Starting steps, opening an editor', () => { // no errors at this moment expect(mockOnError).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -378,7 +378,7 @@ describe('Starting steps, opening an editor', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -392,7 +392,7 @@ describe('Starting steps, opening an editor', () => { }); test('should not show notification alert if STARTING', async () => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -404,7 +404,7 @@ describe('Starting steps, opening an editor', () => { }) .build(); renderComponent(store); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // trigger timeout const timeoutButton = screen.queryByRole('button', { @@ -416,7 +416,7 @@ describe('Starting steps, opening an editor', () => { test('notification alert', async () => { renderComponent(store); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // trigger timeout const timeoutButton = screen.getByRole('button', { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/index.tsx index db8046efc..04193014f 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/OpenWorkspace/index.tsx @@ -35,9 +35,9 @@ import { findTargetWorkspace } from '@/services/helpers/factoryFlow/findTargetWo import { AlertItem, DevWorkspaceStatus, LoaderTab } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { Workspace, WorkspaceAdapter } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectApplications } from '@/store/ClusterInfo/selectors'; -import * as WorkspaceStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; export type Props = MappedProps & @@ -260,12 +260,12 @@ class StartingStepOpenWorkspace extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), applications: selectApplications(state), }); -const connector = connect(mapStateToProps, WorkspaceStore.actionCreators, null, { +const connector = connect(mapStateToProps, workspacesActionCreators, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests forwardRef: true, }); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/__tests__/index.spec.tsx index f9eaf4d9b..9143bd215 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/__tests__/index.spec.tsx @@ -16,7 +16,7 @@ import userEvent, { UserEvent } from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { Location } from 'react-router-dom'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { MIN_STEP_DURATION_MS } from '@/components/WorkspaceProgress/const'; import { WorkspaceRouteParams } from '@/Routes'; @@ -25,23 +25,24 @@ import { getDefer } from '@/services/helpers/deferred'; import { AlertItem } from '@/services/helpers/types'; import { AppThunk } from '@/store'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/Workspaces'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { workspacesActionCreators } from '@/store/Workspaces'; import StartingStepStartWorkspace from '..'; jest.mock('@/components/WorkspaceProgress/TimeLimit'); const mockStartWorkspace = jest.fn(); -jest.mock('@/store/Workspaces/index', () => { +jest.mock('@/store/Workspaces', () => { return { - actionCreators: { + ...jest.requireActual('@/store/Workspaces'), + workspacesActionCreators: { startWorkspace: - (...args: Parameters): AppThunk> => - async (): Promise => { + (...args: Parameters<(typeof workspacesActionCreators)['startWorkspace']>): AppThunk => + async () => { return mockStartWorkspace(...args); }, - } as ActionCreators, + } as typeof workspacesActionCreators, }; }); @@ -110,7 +111,7 @@ describe('Starting steps, starting a workspace', () => { let paramsWithWrongName: WorkspaceRouteParams; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -192,7 +193,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is STOPPED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -219,7 +220,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is STOPPED and it fails to start', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -264,7 +265,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is FAILED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -291,7 +292,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is RUNNING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -320,7 +321,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is STARTING then RUNNING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDwServerConfig(serverConfig) .withDevWorkspaces({ workspaces: [ @@ -342,7 +343,7 @@ describe('Starting steps, starting a workspace', () => { expect(mockOnRestart).not.toHaveBeenCalled(); expect(mockOnNextStep).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -364,7 +365,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is STARTING then STOPPING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDwServerConfig(serverConfig) .withDevWorkspaces({ workspaces: [ @@ -386,7 +387,7 @@ describe('Starting steps, starting a workspace', () => { expect(mockOnRestart).not.toHaveBeenCalled(); expect(mockOnNextStep).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -412,7 +413,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is STARTING then FAILING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDwServerConfig(serverConfig) .withDevWorkspaces({ workspaces: [ @@ -432,7 +433,7 @@ describe('Starting steps, starting a workspace', () => { // no errors at this moment expect(mockOnError).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -456,7 +457,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is STARTING then FAILED', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDwServerConfig(serverConfig) .withDevWorkspaces({ workspaces: [ @@ -476,7 +477,7 @@ describe('Starting steps, starting a workspace', () => { // no errors at this moment expect(mockOnError).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -516,7 +517,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is FAILING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -540,7 +541,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is STOPPING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -564,7 +565,7 @@ describe('Starting steps, starting a workspace', () => { }); test('workspace is TERMINATING', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -605,7 +606,7 @@ describe('Starting steps, starting a workspace', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() @@ -620,7 +621,7 @@ describe('Starting steps, starting a workspace', () => { test('notification alert', async () => { renderComponent(store); - jest.runAllTimers(); + await jest.runAllTimersAsync(); // trigger timeout const timeoutButton = screen.getByRole('button', { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx index 8d0ee44d7..36486da59 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx @@ -36,10 +36,10 @@ import { AppAlerts } from '@/services/alerts/appAlerts'; import { findTargetWorkspace } from '@/services/helpers/factoryFlow/findTargetWorkspace'; import { AlertItem, DevWorkspaceStatus, LoaderTab } from '@/services/helpers/types'; import { Workspace, WorkspaceAdapter } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectApplications } from '@/store/ClusterInfo/selectors'; import { selectStartTimeout } from '@/store/ServerConfig/selectors'; -import * as WorkspaceStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectDevWorkspaceWarnings } from '@/store/Workspaces/devWorkspaces/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -329,14 +329,14 @@ class StartingStepStartWorkspace extends ProgressStep { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), applications: selectApplications(state), startTimeout: selectStartTimeout(state), devWorkspaceWarnings: selectDevWorkspaceWarnings(state), }); -const connector = connect(mapStateToProps, WorkspaceStore.actionCreators, null, { +const connector = connect(mapStateToProps, workspacesActionCreators, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests forwardRef: true, }); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx index 43b7e1a37..9fcde486a 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx @@ -33,7 +33,7 @@ import { import { buildFactoryLocation, buildIdeLoaderLocation } from '@/services/helpers/location'; import { constructWorkspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import Progress, { State, Step } from '..'; @@ -61,7 +61,7 @@ describe('LoaderProgress', () => { let user: UserEvent; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withWorkspacePreferences({ 'trusted-sources': '*', }) @@ -366,7 +366,7 @@ describe('LoaderProgress', () => { }); test('untrusted source', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withWorkspacePreferences({ 'trusted-sources': ['some-trusted-source'], }) @@ -384,7 +384,7 @@ describe('LoaderProgress', () => { test('samples are trusted', async () => { const registryLocation = 'https://external-registries-location/'; - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevfileRegistries({ registries: { [registryLocation]: {}, @@ -446,7 +446,7 @@ describe('LoaderProgress', () => { describe('steps number', () => { test('no condition steps', () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); renderComponent(location, store, searchParams, false); @@ -463,7 +463,7 @@ describe('LoaderProgress', () => { }); test('with condition steps', async () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace] }) .build(); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/utils.spec.ts b/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/utils.spec.ts index dd703eb81..b1375464f 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/utils.spec.ts +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/utils.spec.ts @@ -50,7 +50,7 @@ describe('WorkspaceProgress utils', () => { }); it('should return default value if one of conditions has special type that is shown when workspace start failed', () => { const status = { - phase: 'Starting', + phase: 'STARTING', conditions: [ { message: 'Resolved plugins and parents from DevWorkspace', diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx index d6dd0810c..35c241b42 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx @@ -46,9 +46,9 @@ import { findTargetWorkspace } from '@/services/helpers/factoryFlow/findTargetWo import { getLoaderMode, LoaderMode } from '@/services/helpers/factoryFlow/getLoaderMode'; import { AlertItem, DevWorkspaceStatus, LoaderTab } from '@/services/helpers/types'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectIsRegistryDevfile } from '@/store/DevfileRegistries/selectors'; -import * as WorkspaceStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectPreferencesTrustedSources } from '@/store/Workspaces/Preferences'; import { isTrustedRepo } from '@/store/Workspaces/Preferences/helpers'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -720,13 +720,13 @@ class Progress extends React.Component { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), isRegistryDevfile: selectIsRegistryDevfile(state), trustedSources: selectPreferencesTrustedSources(state), }); -const connector = connect(mapStateToProps, WorkspaceStore.actionCreators, null, { +const connector = connect(mapStateToProps, workspacesActionCreators, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests forwardRef: true, }); diff --git a/packages/dashboard-frontend/src/containers/Loader/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/containers/Loader/__tests__/index.spec.tsx index 642ec4394..810c45fa1 100644 --- a/packages/dashboard-frontend/src/containers/Loader/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/__tests__/index.spec.tsx @@ -21,7 +21,7 @@ import { Store } from 'redux'; import LoaderContainer from '@/containers/Loader'; import { ROUTE } from '@/Routes'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('@/pages/Loader'); @@ -40,7 +40,7 @@ describe('Loader container', () => { let emptyStore: Store; beforeEach(() => { - emptyStore = new FakeStoreBuilder().build(); + emptyStore = new MockStoreBuilder().build(); }); afterEach(() => { diff --git a/packages/dashboard-frontend/src/containers/Loader/index.tsx b/packages/dashboard-frontend/src/containers/Loader/index.tsx index b95c02f14..e0d0d26d6 100644 --- a/packages/dashboard-frontend/src/containers/Loader/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/index.tsx @@ -21,7 +21,7 @@ import { findTargetWorkspace } from '@/services/helpers/factoryFlow/findTargetWo import { getLoaderMode } from '@/services/helpers/factoryFlow/getLoaderMode'; import { LoaderTab } from '@/services/helpers/types'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; type RouteParams = Partial | undefined; @@ -101,7 +101,7 @@ function ContainerWrapper(props: MappedProps) { ); } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), }); diff --git a/packages/dashboard-frontend/src/containers/UserPreferences/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/containers/UserPreferences/__tests__/index.spec.tsx index 5affd08ef..61ddff7cc 100644 --- a/packages/dashboard-frontend/src/containers/UserPreferences/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/containers/UserPreferences/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; import UserPreferencesContainer from '@/containers/UserPreferences'; import { ROUTE } from '@/Routes'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { renderComponent } = getComponentRenderer(getComponent); @@ -32,7 +32,7 @@ describe('UserPreferencesContainer', () => { }); function getComponent(): React.ReactElement { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); return ( diff --git a/packages/dashboard-frontend/src/containers/WorkspaceDetails/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/containers/WorkspaceDetails/__tests__/index.spec.tsx index 8943aad7b..fe37b7e22 100644 --- a/packages/dashboard-frontend/src/containers/WorkspaceDetails/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/containers/WorkspaceDetails/__tests__/index.spec.tsx @@ -22,20 +22,18 @@ import { ROUTE } from '@/Routes'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { constructWorkspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { actionCreators as workspacesActionCreators } from '@/store/Workspaces'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { workspacesActionCreators } from '@/store/Workspaces'; import WorkspaceDetailsContainer from '..'; -const mockUpdateWorkspace = jest.fn(); - const { renderComponent } = getComponentRenderer(getComponent); -jest.mock('@/store/Workspaces'); -(workspacesActionCreators.requestWorkspaces as jest.Mock).mockImplementation(() => async () => { +jest.spyOn(workspacesActionCreators, 'requestWorkspaces').mockImplementation(() => async () => { // no-op }); -(workspacesActionCreators.updateWorkspace as jest.Mock).mockImplementation( +const mockUpdateWorkspace = jest.fn(); +jest.spyOn(workspacesActionCreators, 'updateWorkspace').mockImplementation( (...args) => async () => mockUpdateWorkspace(...args), @@ -61,8 +59,8 @@ describe('Workspace Details container', () => { let workspaceBuilder_1: DevWorkspaceBuilder; let workspaceBuilder_2: DevWorkspaceBuilder; - let prevStoreBuilder: FakeStoreBuilder; - let nextStoreBuilder: FakeStoreBuilder; + let prevStoreBuilder: MockStoreBuilder; + let nextStoreBuilder: MockStoreBuilder; beforeEach(() => { workspaceBuilder_1 = new DevWorkspaceBuilder() @@ -73,11 +71,11 @@ describe('Workspace Details container', () => { .withId(workspaceId_2) .withName(workspaceName_2) .withNamespace(namespace); - prevStoreBuilder = new FakeStoreBuilder().withInfrastructureNamespace( + prevStoreBuilder = new MockStoreBuilder().withInfrastructureNamespace( [{ name: namespace, attributes: { phase: 'Active' } }], false, ); - nextStoreBuilder = new FakeStoreBuilder().withInfrastructureNamespace( + nextStoreBuilder = new MockStoreBuilder().withInfrastructureNamespace( [{ name: namespace, attributes: { phase: 'Active' } }], false, ); @@ -159,7 +157,7 @@ describe('Workspace Details container', () => { const workspace1 = workspaceBuilder_1.build(); const workspace2 = workspaceBuilder_2.build(); - const prevStore = new FakeStoreBuilder() + const prevStore = new MockStoreBuilder() .withInfrastructureNamespace([{ name: namespace, attributes: { phase: 'Active' } }], false) .withDevWorkspaces({ workspaces: [workspace1, workspace2] }) .build(); @@ -168,7 +166,7 @@ describe('Workspace Details container', () => { ]); // remove workspace1 from store - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withInfrastructureNamespace([{ name: namespace, attributes: { phase: 'Active' } }], false) .withDevWorkspaces({ workspaces: [workspace2] }) .build(); diff --git a/packages/dashboard-frontend/src/containers/WorkspaceDetails/index.tsx b/packages/dashboard-frontend/src/containers/WorkspaceDetails/index.tsx index eaa8c4069..345859792 100644 --- a/packages/dashboard-frontend/src/containers/WorkspaceDetails/index.tsx +++ b/packages/dashboard-frontend/src/containers/WorkspaceDetails/index.tsx @@ -22,9 +22,9 @@ import { DEVWORKSPACE_ID_OVERRIDE_ANNOTATION } from '@/services/devfileApi/devWo import { buildDetailsLocation, buildWorkspacesLocation, toHref } from '@/services/helpers/location'; import { WorkspaceDetailsTab } from '@/services/helpers/types'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import * as WorkspacesStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectAllWorkspaces, selectIsLoading } from '@/store/Workspaces/selectors'; type RouteParams = Partial; @@ -155,13 +155,13 @@ function ContainerWrapper(props: MappedProps) { ); } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), defaultNamespace: selectDefaultNamespace(state), isLoading: selectIsLoading(state), }); -const connector = connect(mapStateToProps, WorkspacesStore.actionCreators, null, { +const connector = connect(mapStateToProps, workspacesActionCreators, null, { forwardRef: true, }); diff --git a/packages/dashboard-frontend/src/containers/WorkspacesList/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/containers/WorkspacesList/__tests__/index.spec.tsx index ad80782f0..db403ec04 100644 --- a/packages/dashboard-frontend/src/containers/WorkspacesList/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/containers/WorkspacesList/__tests__/index.spec.tsx @@ -17,23 +17,23 @@ import { InitialEntry } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import WorkspacesList from '@/containers/WorkspacesList'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { Workspace } from '@/services/workspace-adapter'; import { AppThunk } from '@/store'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/Workspaces'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { workspacesActionCreators } from '@/store/Workspaces'; jest.mock('@/store/Workspaces/index', () => { return { actionCreators: { - requestWorkspaces: (): AppThunk> => async (): Promise => { + requestWorkspaces: (): AppThunk => async () => { return Promise.resolve(); }, - } as ActionCreators, + } as typeof workspacesActionCreators, }; }); jest.mock('@/pages/WorkspacesList', () => { @@ -70,7 +70,7 @@ describe('Workspaces List Container', () => { .withName('workspace-' + i) .build(), ); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces }, false) .withWorkspaces({}, false) .build(); @@ -82,7 +82,7 @@ describe('Workspaces List Container', () => { describe('while fetching workspaces', () => { it('should show the fallback', () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [] }, true) .withWorkspaces({}, true) .build(); diff --git a/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx b/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx index e2521666a..804dec364 100644 --- a/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx +++ b/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx @@ -16,9 +16,9 @@ import { Location, NavigateFunction, useLocation, useNavigate } from 'react-rout import Fallback from '@/components/Fallback'; import WorkspacesList from '@/pages/WorkspacesList'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; -import * as WorkspacesStore from '@/store/Workspaces'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectAllWorkspaces, selectIsLoading } from '@/store/Workspaces/selectors'; type Props = MappedProps & { @@ -52,7 +52,7 @@ function ContainerWrapper(props: MappedProps) { return ; } -const mapStateToProps = (state: AppState) => { +const mapStateToProps = (state: RootState) => { return { branding: selectBranding(state), allWorkspaces: selectAllWorkspaces(state), @@ -60,7 +60,7 @@ const mapStateToProps = (state: AppState) => { }; }; -const connector = connect(mapStateToProps, WorkspacesStore.actionCreators); +const connector = connect(mapStateToProps, workspacesActionCreators); type MappedProps = ConnectedProps; export default connector(ContainerWrapper); diff --git a/packages/dashboard-frontend/src/contexts/WorkspaceActions/Provider.tsx b/packages/dashboard-frontend/src/contexts/WorkspaceActions/Provider.tsx index 476a1db21..8756ebb9a 100644 --- a/packages/dashboard-frontend/src/contexts/WorkspaceActions/Provider.tsx +++ b/packages/dashboard-frontend/src/contexts/WorkspaceActions/Provider.tsx @@ -25,8 +25,8 @@ import { import { LoaderTab, WorkspaceAction } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import * as WorkspacesStore from '@/store/Workspaces'; +import { RootState } from '@/store'; +import { workspacesActionCreators } from '@/store/Workspaces'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; import { WantDelete, WorkspaceActionsContext } from '.'; @@ -230,11 +230,11 @@ class WorkspaceActionsProvider extends React.Component { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), }); -const connector = connect(mapStateToProps, WorkspacesStore.actionCreators); +const connector = connect(mapStateToProps, workspacesActionCreators); type MappedProps = ConnectedProps; export default connector(WorkspaceActionsProvider); diff --git a/packages/dashboard-frontend/src/contexts/WorkspaceActions/__tests__/Provider.spec.tsx b/packages/dashboard-frontend/src/contexts/WorkspaceActions/__tests__/Provider.spec.tsx index 210f03b32..926efae27 100644 --- a/packages/dashboard-frontend/src/contexts/WorkspaceActions/__tests__/Provider.spec.tsx +++ b/packages/dashboard-frontend/src/contexts/WorkspaceActions/__tests__/Provider.spec.tsx @@ -14,7 +14,7 @@ import userEvent, { UserEvent } from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { ActionContextType, @@ -28,8 +28,8 @@ import { WorkspaceAction } from '@/services/helpers/types'; import { TabManager } from '@/services/tabManager'; import { AppThunk } from '@/store'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/Workspaces'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { workspacesActionCreators } from '@/store/Workspaces'; jest.mock('@/contexts/WorkspaceActions/DeleteConfirmation'); @@ -40,26 +40,24 @@ const mockRestartWorkspace = jest.fn(); jest.mock('@/store/Workspaces', () => { return { ...jest.requireActual('@/store/Workspaces'), - actionCreators: { + workspacesActionCreators: { deleteWorkspace: - (...args: Parameters): AppThunk> => - async (): Promise => + (...args: Parameters<(typeof workspacesActionCreators)['deleteWorkspace']>): AppThunk => + async () => mockDeleteWorkspace(...args), startWorkspace: - (...args: Parameters): AppThunk> => - async (): Promise => + (...args: Parameters<(typeof workspacesActionCreators)['startWorkspace']>): AppThunk => + async () => mockStartWorkspace(...args), stopWorkspace: - (...args: Parameters): AppThunk> => - async (): Promise => + (...args: Parameters<(typeof workspacesActionCreators)['stopWorkspace']>): AppThunk => + async () => mockStopWorkspace(...args), restartWorkspace: - ( - ...args: Parameters - ): AppThunk> => - async (): Promise => + (...args: Parameters<(typeof workspacesActionCreators)['restartWorkspace']>): AppThunk => + async () => mockRestartWorkspace(...args), - } as ActionCreators, + } as typeof workspacesActionCreators, }; }); @@ -78,7 +76,7 @@ describe('WorkspaceActionsProvider', () => { let user: UserEvent; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [ new DevWorkspaceBuilder() diff --git a/packages/dashboard-frontend/src/index.tsx b/packages/dashboard-frontend/src/index.tsx index 4b7f2ca1d..bd8867528 100644 --- a/packages/dashboard-frontend/src/index.tsx +++ b/packages/dashboard-frontend/src/index.tsx @@ -22,13 +22,12 @@ import { Provider } from 'react-redux'; import App from '@/App'; import WorkspaceActionsProvider from '@/contexts/WorkspaceActions/Provider'; import PreloadData from '@/services/bootstrap'; -import configureStore from '@/store/configureStore'; +import { store } from '@/store'; startApp(); async function startApp(): Promise { const history = createHashHistory(); - const store = configureStore(history); const container = document.getElementById('ui-container')!; const root = ReactDOM.createRoot(container); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/index.spec.tsx index 2cbc94bb4..6d66198ab 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/index.spec.tsx @@ -21,7 +21,7 @@ import SamplesListGallery from '@/pages/GetStarted/SamplesList/Gallery'; import getComponentRenderer, { screen, within } from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { DevfileRegistryMetadata } from '@/store/DevfileRegistries/selectors'; jest.mock('@/pages/GetStarted/SamplesList/Gallery/Card'); @@ -37,7 +37,7 @@ describe('Samples List Gallery', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withBranding({ docs: { storageTypes: 'https://docs.location', diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx index f57abb608..3ce4bdd09 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx @@ -26,7 +26,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { SampleCard } from '@/pages/GetStarted/SamplesList/Gallery/Card'; import { che } from '@/services/models'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; +import { devfileRegistriesActionCreators } from '@/store/DevfileRegistries'; import { DevfileRegistryMetadata, EMPTY_WORKSPACE_TAG } from '@/store/DevfileRegistries/selectors'; export type PluginEditor = che.Plugin & { @@ -124,9 +124,7 @@ export class SamplesListGallery extends React.PureComponent { } } -const connector = connect(null, { - ...DevfileRegistriesStore.actionCreators, -}); +const connector = connect(null, devfileRegistriesActionCreators); type MappedProps = ConnectedProps; export default connector(SamplesListGallery); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/index.spec.tsx index c3a1fc8a6..0157dbb12 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import { Store } from 'redux'; import TemporaryStorageSwitch from '@/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { renderComponent, createSnapshot } = getComponentRenderer(getComponent); @@ -28,7 +28,7 @@ describe('Temporary Storage Switch', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withBranding({ docs: { storageTypes: 'https://docs.location', diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/index.tsx index 2a32fe5eb..2c7143e08 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/index.tsx @@ -15,7 +15,7 @@ import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; export type Props = MappedProps & { @@ -79,7 +79,7 @@ class TemporaryStorageSwitch extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ branding: selectBranding(state), }); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/index.spec.tsx index cd9c6eb89..219867384 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/index.spec.tsx @@ -10,18 +10,18 @@ * Red Hat, Inc. - initial API and implementation */ +import { Store } from '@reduxjs/toolkit'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; import mockMetadata from '@/pages/GetStarted/__tests__/devfileMetadata.json'; import SamplesListToolbar from '@/pages/GetStarted/SamplesList/Toolbar'; import getComponentRenderer, { screen, waitFor } from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { devfileRegistriesActionCreators } from '@/store/DevfileRegistries'; jest.mock('@/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch'); @@ -50,9 +50,7 @@ describe('Samples List Toolbar', () => { }); it('should call "setFilter" action', async () => { - // mock "setFilter" action - const setFilter = DevfileRegistriesStore.actionCreators.setFilter; - DevfileRegistriesStore.actionCreators.setFilter = jest.fn(arg => setFilter(arg)); + jest.spyOn(devfileRegistriesActionCreators, 'setFilter'); renderComponent(); @@ -60,27 +58,26 @@ describe('Samples List Toolbar', () => { await userEvent.click(filterInput); await userEvent.paste('bash'); + await waitFor(() => expect(devfileRegistriesActionCreators.setFilter).toHaveBeenCalledTimes(1)); await waitFor(() => - expect(DevfileRegistriesStore.actionCreators.setFilter).toHaveBeenCalledTimes(1), - ); - await waitFor(() => - expect(DevfileRegistriesStore.actionCreators.setFilter).toHaveBeenCalledWith('bash'), + expect(devfileRegistriesActionCreators.setFilter).toHaveBeenCalledWith('bash'), ); }); it('should show the results counter', async () => { const store = createFakeStore(mockMetadata); - const storeNext = new FakeStoreBuilder(store) + const storeNext = new MockStoreBuilder(store.getState()) .withDevfileRegistries({ filter: 'bash', }) .build(); + renderComponent(storeNext); const filterInput = screen.getByPlaceholderText('Filter by') as HTMLInputElement; await userEvent.click(filterInput); await userEvent.paste('bash'); - await waitFor(() => screen.findByText('1 item')); + await waitFor(() => screen.queryByText('1 item')); }); test('switch temporary storage toggle', async () => { @@ -102,7 +99,7 @@ function createFakeStore(metadata?: che.DevfileMetaData[]) { metadata, }; } - return new FakeStoreBuilder() + return new MockStoreBuilder() .withBranding({ docs: { storageTypes: 'https://docs.location', @@ -112,7 +109,7 @@ function createFakeStore(metadata?: che.DevfileMetaData[]) { .build(); } -function getComponent(store?: MockStoreEnhanced) { +function getComponent(store?: Store) { store ||= createFakeStore(mockMetadata); return ( diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/index.tsx index 8c6a0196c..398b7ff34 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/index.tsx @@ -16,8 +16,8 @@ import Pluralize from 'react-pluralize'; import { connect, ConnectedProps } from 'react-redux'; import TemporaryStorageSwitch from '@/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch'; -import { AppState } from '@/store'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; +import { RootState } from '@/store'; +import { devfileRegistriesActionCreators } from '@/store/DevfileRegistries'; import { selectFilterValue, selectMetadataFiltered } from '@/store/DevfileRegistries/selectors'; export type Props = MappedProps & { @@ -74,12 +74,12 @@ class SamplesListToolbar extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ filterValue: selectFilterValue(state), metadataFiltered: selectMetadataFiltered(state), }); -const connector = connect(mapStateToProps, DevfileRegistriesStore.actionCreators); +const connector = connect(mapStateToProps, devfileRegistriesActionCreators); type MappedProps = ConnectedProps; export default connector(SamplesListToolbar); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx index b27f196e2..d52f53943 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ import { Store } from 'redux'; import SamplesList from '@/pages/GetStarted/SamplesList'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('@/pages/GetStarted/SamplesList/Gallery'); jest.mock('@/pages/GetStarted/SamplesList/Toolbar'); @@ -32,11 +32,11 @@ const editorImage = 'custom-editor-image'; describe('Samples List', () => { const sampleUrl = 'https://github.com/che-samples/quarkus-quickstarts/tree/devfilev2'; const origin = window.location.origin; - let storeBuilder: FakeStoreBuilder; + let storeBuilder: MockStoreBuilder; let mockWindowOpen: jest.Mock; beforeEach(() => { - storeBuilder = new FakeStoreBuilder() + storeBuilder = new MockStoreBuilder() .withBranding({ docs: { storageTypes: 'storage-types-docs', diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx index cebb7799b..9b4f8464a 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx @@ -26,7 +26,7 @@ import SamplesListGallery from '@/pages/GetStarted/SamplesList/Gallery'; import SamplesListToolbar from '@/pages/GetStarted/SamplesList/Toolbar'; import { EDITOR_ATTR, EDITOR_IMAGE_ATTR } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { che } from '@/services/models'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { DevfileRegistryMetadata, selectMetadataFiltered, @@ -130,7 +130,7 @@ class SamplesList extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ metadataFiltered: selectMetadataFiltered(state), preferredStorageType: selectPvcStrategy(state), defaultEditorId: selectDefaultEditor(state), diff --git a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/index.spec.tsx index 0efde131a..786bb024d 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ import getComponentRenderer, { waitFor, within, } from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('@/components/EditorSelector'); jest.mock('@/pages/GetStarted/SamplesList'); @@ -79,7 +79,7 @@ describe('GetStarted', () => { }); function getComponent() { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); return ( diff --git a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx index 553bce4d8..3f5cfd40e 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx @@ -20,7 +20,7 @@ import Head from '@/components/Head'; import ImportFromGit from '@/components/ImportFromGit'; import { Spacer } from '@/components/Spacer'; import SamplesList from '@/pages/GetStarted/SamplesList'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectDefaultEditor } from '@/store/ServerConfig/selectors'; type Props = MappedProps & { @@ -92,7 +92,7 @@ export class GetStarted extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ defaultEditor: selectDefaultEditor(state), }); diff --git a/packages/dashboard-frontend/src/pages/Loader/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/Loader/__tests__/index.spec.tsx index 253fe3008..dbae18500 100644 --- a/packages/dashboard-frontend/src/pages/Loader/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/Loader/__tests__/index.spec.tsx @@ -22,7 +22,7 @@ import devfileApi from '@/services/devfileApi'; import { LoaderTab } from '@/services/helpers/types'; import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { LoaderPage, Props } from '..'; @@ -55,7 +55,7 @@ describe('Loader page', () => { .withName(workspaceName) .withStatus({ phase: 'STARTING' }) .build(); - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace], }) @@ -95,7 +95,7 @@ describe('Loader page', () => { }); it('should update the section header when the workspace is ready', () => { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); const { reRenderComponent } = renderComponent(store, { tabParam, workspace: undefined, @@ -108,7 +108,7 @@ describe('Loader page', () => { .withName(workspaceName) .withStatus({ phase: 'RUNNING' }) .build(); - const storeReady = new FakeStoreBuilder() + const storeReady = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspaceReady], }) diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/DeleteRegistriesModal.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/DeleteRegistriesModal.tsx index 594b0120a..57a816471 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/DeleteRegistriesModal.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/DeleteRegistriesModal.tsx @@ -21,7 +21,7 @@ import { } from '@patternfly/react-core'; import React from 'react'; -import { RegistryEntry } from '@/store/DockerConfig/types'; +import { RegistryEntry } from '@/store/DockerConfig'; type Props = { registry?: RegistryEntry; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/EditRegistryModal.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/EditRegistryModal.tsx index 2c24c2393..4e0abc93c 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/EditRegistryModal.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/EditRegistryModal.tsx @@ -23,7 +23,7 @@ import React from 'react'; import { RegistryPasswordFormGroup } from '@/pages/UserPreferences/ContainerRegistriesTab/RegistryPassword'; import { RegistryUrlFormGroup } from '@/pages/UserPreferences/ContainerRegistriesTab/RegistryUrl'; import { RegistryUsernameFormGroup } from '@/pages/UserPreferences/ContainerRegistriesTab/RegistryUsername'; -import { RegistryEntry } from '@/store/DockerConfig/types'; +import { RegistryEntry } from '@/store/DockerConfig'; type Props = { registry: RegistryEntry; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/DeleteRegistriesModal.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/DeleteRegistriesModal.spec.tsx index ec26a1c2a..d0dfca634 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/DeleteRegistriesModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/DeleteRegistriesModal.spec.tsx @@ -17,7 +17,7 @@ import renderer from 'react-test-renderer'; import { FakeRegistryBuilder } from '@/pages/UserPreferences/ContainerRegistriesTab/__tests__/__mocks__/registryRowBuilder'; import DeleteRegistriesModal from '@/pages/UserPreferences/ContainerRegistriesTab/Modals/DeleteRegistriesModal'; -import { RegistryEntry } from '@/store/DockerConfig/types'; +import { RegistryEntry } from '@/store/DockerConfig'; describe('Delete Registries Modal', () => { const mockOnDelete = jest.fn(); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/EditRegistryModal.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/EditRegistryModal.spec.tsx index bbdc9f188..8452b6fc9 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/EditRegistryModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/Modals/__tests__/EditRegistryModal.spec.tsx @@ -17,7 +17,7 @@ import renderer from 'react-test-renderer'; import { FakeRegistryBuilder } from '@/pages/UserPreferences/ContainerRegistriesTab/__tests__/__mocks__/registryRowBuilder'; import EditRegistryModal from '@/pages/UserPreferences/ContainerRegistriesTab/Modals/EditRegistryModal'; -import { RegistryEntry } from '@/store/DockerConfig/types'; +import { RegistryEntry } from '@/store/DockerConfig'; describe('Edit Registry Modal', () => { const mockOnChange = jest.fn(); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/__mocks__/registryRowBuilder.ts b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/__mocks__/registryRowBuilder.ts index 1a6ca8dd6..bd6daaf05 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/__mocks__/registryRowBuilder.ts +++ b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/__mocks__/registryRowBuilder.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -import { RegistryEntry } from '@/store/DockerConfig/types'; +import { RegistryEntry } from '@/store/DockerConfig'; export class FakeRegistryBuilder { private registry: RegistryEntry = { url: '', password: '', username: '' }; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/index.spec.tsx index 1d6c72138..356e4fde1 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; @@ -18,7 +18,7 @@ import renderer from 'react-test-renderer'; import { Store } from 'redux'; import { FakeRegistryBuilder } from '@/pages/UserPreferences/ContainerRegistriesTab/__tests__/__mocks__/registryRowBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { selectIsLoading, selectRegistries } from '@/store/DockerConfig/selectors'; import { ContainerRegistries } from '..'; @@ -48,7 +48,7 @@ describe('ContainerRegistries', () => { }); it('should correctly render the component without registries', () => { - const component = getComponent(new FakeStoreBuilder().build()); + const component = getComponent(new MockStoreBuilder().build()); render(component); const addRegistryButton = screen.queryByLabelText('add-registry'); @@ -61,7 +61,7 @@ describe('ContainerRegistries', () => { it('should correctly render the component which contains two registries', () => { const component = getComponent( - new FakeStoreBuilder() + new MockStoreBuilder() .withDockerConfig([ new FakeRegistryBuilder().withUrl('http://test.reg').withPassword('qwerty').build(), new FakeRegistryBuilder().withUrl('https://tstreg.com').withPassword('123').build(), @@ -79,19 +79,21 @@ describe('ContainerRegistries', () => { }); it('should add a new registry', async () => { - const component = getComponent(new FakeStoreBuilder().build()); + const component = getComponent(new MockStoreBuilder().build()); render(component); - const addRegistryButton = screen.getByLabelText('add-registry'); + const addRegistryButton = screen.getByRole('button', { name: 'add-registry' }); await userEvent.click(addRegistryButton); - const editButton = screen.getByTestId('edit-button'); + const dialog = await screen.findByRole('dialog'); + + const editButton = screen.getByRole('button', { name: 'Add' }); expect(editButton).toBeDisabled(); - const urlInput = screen.getByLabelText('Url input'); + const urlInput = within(dialog).getByRole('textbox', { name: 'Url input' }); await userEvent.type(urlInput, 'http://tst'); - const passwordInput = screen.getByTestId('registry-password-input'); + const passwordInput = within(dialog).getByTestId('registry-password-input'); await userEvent.type(passwordInput, 'qwe'); expect(editButton).toBeEnabled(); @@ -108,7 +110,7 @@ describe('ContainerRegistries', () => { it('should delete a registry', async () => { const component = getComponent( - new FakeStoreBuilder() + new MockStoreBuilder() .withDockerConfig([ new FakeRegistryBuilder().withUrl('http://test.reg').withPassword('qwerty').build(), ]) diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/index.tsx index e793a6a66..516f1c6aa 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/ContainerRegistriesTab/index.tsx @@ -22,6 +22,7 @@ import { } from '@patternfly/react-core'; import { PlusCircleIcon } from '@patternfly/react-icons'; import { Table, TableBody, TableHeader } from '@patternfly/react-table'; +import { cloneDeep } from 'lodash'; import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; @@ -32,10 +33,9 @@ import DeleteRegistriesModal from '@/pages/UserPreferences/ContainerRegistriesTa import EditRegistryModal from '@/pages/UserPreferences/ContainerRegistriesTab/Modals/EditRegistryModal'; import { AppAlerts } from '@/services/alerts/appAlerts'; import { AlertItem } from '@/services/helpers/types'; -import { AppState } from '@/store'; -import * as DockerConfigStore from '@/store/DockerConfig'; +import { RootState } from '@/store'; +import { dockerConfigActionCreators, RegistryEntry } from '@/store/DockerConfig'; import { selectIsLoading, selectRegistries } from '@/store/DockerConfig/selectors'; -import { RegistryEntry } from '@/store/DockerConfig/types'; type Props = MappedProps; @@ -246,7 +246,7 @@ export class ContainerRegistries extends React.PureComponent { } private handleRegistryChange(editRegistry: RegistryEntry): void { - const { registries } = this.props; + const registries = cloneDeep(this.props.registries); const { currentRegistryIndex } = this.state; if (this.isEditMode) { registries[currentRegistryIndex] = editRegistry; @@ -366,12 +366,12 @@ export class ContainerRegistries extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ registries: selectRegistries(state), isLoading: selectIsLoading(state), }); -const connector = connect(mapStateToProps, DockerConfigStore.actionCreators); +const connector = connect(mapStateToProps, dockerConfigActionCreators); type MappedProps = ConnectedProps; export default connector(ContainerRegistries); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx index 9e763b2ab..69dd05677 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/__tests__/index.spec.tsx @@ -12,20 +12,19 @@ import { AlertVariant } from '@patternfly/react-core'; import userEvent from '@testing-library/user-event'; -import * as React from 'react'; +import React from 'react'; import { Provider } from 'react-redux'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { container } from '@/inversify.config'; +import GitConfig from '@/pages/UserPreferences/GitConfig'; import { mockShowAlert } from '@/pages/WorkspaceDetails/__mocks__'; import getComponentRenderer, { screen, waitFor } from '@/services/__mocks__/getComponentRenderer'; import { AppAlerts } from '@/services/alerts/appAlerts'; import { AlertItem } from '@/services/helpers/types'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/GitConfig'; - -import GitConfig from '..'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { gitConfigActionCreators } from '@/store/GitConfig'; jest.mock('@/pages/UserPreferences/GitConfig/Form'); @@ -35,16 +34,17 @@ console.error = jest.fn(); const mockRequestGitConfig = jest.fn(); const mockUpdateGitConfig = jest.fn(); jest.mock('@/store/GitConfig', () => ({ - actionCreators: { + ...jest.requireActual('@/store/GitConfig'), + gitConfigActionCreators: { requestGitConfig: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockRequestGitConfig(...args), updateGitConfig: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockUpdateGitConfig(...args), - } as ActionCreators, + } as typeof gitConfigActionCreators, })); const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -54,7 +54,7 @@ let storeEmpty: Store; describe('GitConfig', () => { beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withGitConfig({ config: { gitconfig: { @@ -66,7 +66,7 @@ describe('GitConfig', () => { }, }) .build(); - storeEmpty = new FakeStoreBuilder().build(); + storeEmpty = new MockStoreBuilder().build(); class MockAppAlerts extends AppAlerts { showAlert(alert: AlertItem): void { @@ -111,7 +111,7 @@ describe('GitConfig', () => { describe('while loading', () => { it('should not request gitconfig', () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withGitConfig( { config: undefined, @@ -159,7 +159,7 @@ describe('GitConfig', () => { // error alert should not be shown expect(mockShowAlert).not.toHaveBeenCalled(); - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withGitConfig({ config: { gitconfig: { diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx index 36cdd984a..ad717ff0b 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/index.tsx @@ -22,8 +22,9 @@ import { GitConfigEmptyState } from '@/pages/UserPreferences/GitConfig/EmptyStat import { GitConfigForm } from '@/pages/UserPreferences/GitConfig/Form'; import { GitConfigToolbar } from '@/pages/UserPreferences/GitConfig/Toolbar'; import { AppAlerts } from '@/services/alerts/appAlerts'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import * as GitConfigStore from '@/store/GitConfig'; +import { gitConfigActionCreators } from '@/store/GitConfig'; import { selectGitConfig, selectGitConfigError, @@ -146,13 +147,13 @@ class GitConfig extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ gitConfig: selectGitConfig(state), gitConfigIsLoading: selectGitConfigIsLoading(state), gitConfigError: selectGitConfigError(state), }); -const connector = connect(mapStateToProps, GitConfigStore.actionCreators, null, { +const connector = connect(mapStateToProps, gitConfigActionCreators, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests forwardRef: true, }); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/List/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/List/index.tsx index 33c4f3d57..437d400b5 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/List/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/List/index.tsx @@ -29,7 +29,7 @@ import { GIT_OAUTH_PROVIDERS } from '@/pages/UserPreferences/const'; import { GitServiceStatusIcon } from '@/pages/UserPreferences/GitServices/List/StatusIcon'; import { GitServiceTooltip } from '@/pages/UserPreferences/GitServices/List/Tooltip'; import { GitServicesToolbar } from '@/pages/UserPreferences/GitServices/Toolbar'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; +import { IGitOauth } from '@/store/GitOauthConfig'; export const CAN_REVOKE_FROM_DASHBOARD: ReadonlyArray = [ 'github', diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/__tests__/index.spec.tsx index d895b5889..45e8df8d3 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/__tests__/index.spec.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { GitServicesRevokeModal } from '@/pages/UserPreferences/GitServices/RevokeModal'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; +import { IGitOauth } from '@/store/GitOauthConfig'; const mockOnRevoke = jest.fn(); const mockOnCancel = jest.fn(); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/index.tsx index cb0f4ca4e..daaf0ed2b 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/RevokeModal/index.tsx @@ -22,7 +22,7 @@ import { import React from 'react'; import { GIT_OAUTH_PROVIDERS } from '@/pages/UserPreferences/const'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; +import { IGitOauth } from '@/store/GitOauthConfig'; export type Props = { selectedItems: IGitOauth[]; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/__tests__/index.spec.tsx index 204afa05b..c30717142 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/__tests__/index.spec.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { GitServicesToolbar } from '@/pages/UserPreferences/GitServices/Toolbar'; import getComponentRenderer, { screen, waitFor } from '@/services/__mocks__/getComponentRenderer'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; +import { IGitOauth } from '@/store/GitOauthConfig'; const mockOnRevokeButton = jest.fn(); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/index.tsx index 690d92b3f..991af49be 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/Toolbar/index.tsx @@ -19,7 +19,7 @@ import { } from '@patternfly/react-core'; import React from 'react'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; +import { IGitOauth } from '@/store/GitOauthConfig'; export type Props = { isDisabled: boolean; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/__tests__/index.spec.tsx index f314c3dee..1da54be73 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/__tests__/index.spec.tsx @@ -14,7 +14,7 @@ import { api } from '@eclipse-che/common'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { container } from '@/inversify.config'; import GitServices from '@/pages/UserPreferences/GitServices'; @@ -26,8 +26,8 @@ import getComponentRenderer, { import { AppAlerts } from '@/services/alerts/appAlerts'; import { AlertItem } from '@/services/helpers/types'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as GitOauthConfigStore from '@/store/GitOauthConfig'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { gitOauthConfigActionCreators } from '@/store/GitOauthConfig'; const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -41,14 +41,13 @@ const mockRevokeOauth = jest.fn().mockImplementation(() => Promise.resolve()); const mockDeleteSkipOauth = jest.fn().mockImplementation(() => Promise.resolve()); jest.mock('@/store/GitOauthConfig', () => { return { - actionCreators: { + ...jest.requireActual('@/store/GitOauthConfig'), + gitOauthConfigActionCreators: { requestGitOauthConfig: () => async () => mockRequestGitOauthConfig(), requestSkipAuthorizationProviders: () => async () => mockRequestSkipAuthorizationProviders(), revokeOauth: - ( - ...args: Parameters - ): AppThunk> => - async (): Promise => + (...args: Parameters<(typeof gitOauthConfigActionCreators)['revokeOauth']>): AppThunk => + async () => mockRevokeOauth(...args), deleteSkipOauth: () => async () => mockDeleteSkipOauth, }, @@ -61,7 +60,7 @@ describe('GitServices', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withGitOauthConfig( [ { @@ -110,7 +109,7 @@ describe('GitServices', () => { }); test('empty state text', () => { - const emptyStore = new FakeStoreBuilder().build(); + const emptyStore = new MockStoreBuilder().build(); renderComponent(emptyStore); const emptyStateText = screen.queryByText('No Git Services'); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/index.tsx index 6139a7fca..ef5ab3b4a 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitServices/index.tsx @@ -21,16 +21,15 @@ import { GitServicesEmptyState } from '@/pages/UserPreferences/GitServices/Empty import { GitServicesList } from '@/pages/UserPreferences/GitServices/List'; import { GitServicesRevokeModal } from '@/pages/UserPreferences/GitServices/RevokeModal'; import { AppAlerts } from '@/services/alerts/appAlerts'; -import { AppState } from '@/store'; -import * as GitOauthConfigStore from '@/store/GitOauthConfig'; +import { RootState } from '@/store'; +import { gitOauthConfigActionCreators, IGitOauth } from '@/store/GitOauthConfig'; import { selectGitOauth, selectIsLoading, selectProvidersWithToken, selectSkipOauthProviders, } from '@/store/GitOauthConfig/selectors'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; -import * as PersonalAccessTokenStore from '@/store/PersonalAccessToken'; +import { personalAccessTokenActionCreators } from '@/store/PersonalAccessTokens'; type Props = MappedProps; @@ -166,7 +165,7 @@ export class GitServices extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ gitOauth: selectGitOauth(state), isLoading: selectIsLoading(state), providersWithToken: selectProvidersWithToken(state), @@ -174,8 +173,8 @@ const mapStateToProps = (state: AppState) => ({ }); const connector = connect(mapStateToProps, { - ...GitOauthConfigStore.actionCreators, - ...PersonalAccessTokenStore.actionCreators, + ...gitOauthConfigActionCreators, + ...personalAccessTokenActionCreators, }); type MappedProps = ConnectedProps; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/AddEditModal/Form/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/AddEditModal/Form/index.tsx index 136f18064..5d6c0101f 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/AddEditModal/Form/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/AddEditModal/Form/index.tsx @@ -27,7 +27,7 @@ export type Props = EditTokenProps & { onChange: (token: api.PersonalAccessToken, isValid: boolean) => void; }; export type State = { - gitProvider: api.GitProvider; + gitProvider: api.PersonalAccessToken['gitProvider']; defaultGitProviderEndpoint: string; gitProviderEndpoint: string | undefined; gitProviderEndpointIsValid: boolean; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/__tests__/index.spec.tsx index afb7f0c13..d12faf3ee 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/__tests__/index.spec.tsx @@ -13,9 +13,10 @@ import { StateMock } from '@react-mock/state'; import React from 'react'; import { Provider } from 'react-redux'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { container } from '@/inversify.config'; +import PersonalAccessTokens, { State } from '@/pages/UserPreferences/PersonalAccessTokens'; import { token1, token2 } from '@/pages/UserPreferences/PersonalAccessTokens/__tests__/stub'; import getComponentRenderer, { fireEvent, @@ -26,14 +27,12 @@ import getComponentRenderer, { import { AppAlerts } from '@/services/alerts/appAlerts'; import { AlertItem } from '@/services/helpers/types'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/PersonalAccessToken'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { personalAccessTokenActionCreators } from '@/store/PersonalAccessTokens'; -import PersonalAccessTokens, { State } from '..'; - -jest.mock('../AddEditModal'); -jest.mock('../DeleteModal'); -jest.mock('../List'); +jest.mock('@/pages/UserPreferences/PersonalAccessTokens/AddEditModal'); +jest.mock('@/pages/UserPreferences/PersonalAccessTokens/DeleteModal'); +jest.mock('@/pages/UserPreferences/PersonalAccessTokens/List'); // mute console.error console.error = jest.fn(); @@ -44,35 +43,36 @@ const mockRequestTokens = jest.fn(); const mockAddToken = jest.fn(); const mockUpdateToken = jest.fn(); const mockRemoveToken = jest.fn(); -jest.mock('@/store/PersonalAccessToken', () => ({ - actionCreators: { +jest.mock('@/store/PersonalAccessTokens', () => ({ + ...jest.requireActual('@/store/PersonalAccessTokens'), + personalAccessTokenActionCreators: { addToken: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockAddToken(...args), requestTokens: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockRequestTokens(...args), updateToken: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockUpdateToken(...args), removeToken: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockRemoveToken(...args), - } as ActionCreators, + } as typeof personalAccessTokenActionCreators, })); const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); describe('PersonalAccessTokens', () => { - let storeBuilder: FakeStoreBuilder; + let storeBuilder: MockStoreBuilder; let localState: Partial; beforeEach(() => { - storeBuilder = new FakeStoreBuilder(); + storeBuilder = new MockStoreBuilder(); class MockAppAlerts extends AppAlerts { showAlert(alert: AlertItem): void { @@ -409,7 +409,7 @@ describe('PersonalAccessTokens', () => { const { reRenderComponent } = renderComponent(store); const errorMessage = 'fetch-user-id-error'; - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withCheUserId({ cheUserId: '', error: errorMessage }, false) .build(); reRenderComponent(nextStore); @@ -427,7 +427,7 @@ describe('PersonalAccessTokens', () => { const { reRenderComponent } = renderComponent(store); const errorMessage = 'fetch-tokens-error'; - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withPersonalAccessTokens({ tokens: [], error: errorMessage }, false) .build(); reRenderComponent(nextStore); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/index.tsx index 1044a9a1a..58f69fb0e 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/PersonalAccessTokens/index.tsx @@ -23,14 +23,14 @@ import { PersonalAccessTokenEmptyState } from '@/pages/UserPreferences/PersonalA import { PersonalAccessTokenList } from '@/pages/UserPreferences/PersonalAccessTokens/List'; import { EditTokenProps } from '@/pages/UserPreferences/PersonalAccessTokens/types'; import { AppAlerts } from '@/services/alerts/appAlerts'; -import { AppState } from '@/store'; -import * as PersonalAccessTokenStore from '@/store/PersonalAccessToken'; +import { RootState } from '@/store'; +import { personalAccessTokenActionCreators } from '@/store/PersonalAccessTokens'; import { selectPersonalAccessTokens, selectPersonalAccessTokensError, selectPersonalAccessTokensIsLoading, -} from '@/store/PersonalAccessToken/selectors'; -import * as UserIdStore from '@/store/User/Id'; +} from '@/store/PersonalAccessTokens/selectors'; +import { userIdActionCreators } from '@/store/User/Id'; import { selectCheUserId, selectCheUserIdError, @@ -258,7 +258,7 @@ class PersonalAccessTokens extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ patState: state.personalAccessToken, cheUserId: selectCheUserId(state), cheUserIdError: selectCheUserIdError(state), @@ -270,7 +270,7 @@ const mapStateToProps = (state: AppState) => ({ const connector = connect( mapStateToProps, - { ...PersonalAccessTokenStore.actionCreators, ...UserIdStore.actionCreators }, + { ...personalAccessTokenActionCreators, ...userIdActionCreators }, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/__tests__/index.spec.tsx index a95e1da3d..7160d7ef5 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/__tests__/index.spec.tsx @@ -13,9 +13,10 @@ import { StateMock } from '@react-mock/state'; import React from 'react'; import { Provider } from 'react-redux'; -import { Action, Store } from 'redux'; +import { Store } from 'redux'; import { container } from '@/inversify.config'; +import SshKeys, { State } from '@/pages/UserPreferences/SshKeys'; import { sshKey1, sshKey2 } from '@/pages/UserPreferences/SshKeys/__tests__/stub'; import { MODAL_ADD_CLOSE_BUTTON_TEST_ID, @@ -31,14 +32,12 @@ import getComponentRenderer, { import { AppAlerts } from '@/services/alerts/appAlerts'; import { AlertItem } from '@/services/helpers/types'; import { AppThunk } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ActionCreators } from '@/store/SshKeys'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { sshKeysActionCreators } from '@/store/SshKeys'; -import SshKeys, { State } from '..'; - -jest.mock('../AddModal'); -jest.mock('../DeleteModal'); -jest.mock('../List'); +jest.mock('@/pages/UserPreferences/SshKeys/AddModal'); +jest.mock('@/pages/UserPreferences/SshKeys/DeleteModal'); +jest.mock('@/pages/UserPreferences/SshKeys/List'); // mute console.error console.error = jest.fn(); @@ -49,30 +48,31 @@ const mockRequestSshKeys = jest.fn(); const mockAddSshKeys = jest.fn(); const mockRemoveSshKey = jest.fn(); jest.mock('@/store/SshKeys', () => ({ - actionCreators: { + ...jest.requireActual('@/store/SshKeys'), + sshKeysActionCreators: { addSshKey: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockAddSshKeys(...args), requestSshKeys: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockRequestSshKeys(...args), removeSshKey: - (...args): AppThunk> => - async (): Promise => + (...args): AppThunk => + async () => mockRemoveSshKey(...args), - } as ActionCreators, + } as typeof sshKeysActionCreators, })); const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); describe('SshKeys', () => { - let storeBuilder: FakeStoreBuilder; + let storeBuilder: MockStoreBuilder; let localState: Partial; beforeEach(() => { - storeBuilder = new FakeStoreBuilder(); + storeBuilder = new MockStoreBuilder(); class MockAppAlerts extends AppAlerts { showAlert(alert: AlertItem): void { @@ -277,7 +277,7 @@ describe('SshKeys', () => { const { reRenderComponent } = renderComponent(store); const errorMessage = 'fetch-ssh-keys-error'; - const nextStore = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withSshKeys({ keys: [], error: errorMessage }, false) .build(); reRenderComponent(nextStore); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/index.tsx index 2af26002b..750adca7d 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/index.tsx @@ -22,14 +22,14 @@ import { SshKeysDeleteModal } from '@/pages/UserPreferences/SshKeys/DeleteModal' import { SshKeysEmptyState } from '@/pages/UserPreferences/SshKeys/EmptyState'; import { SshKeysList } from '@/pages/UserPreferences/SshKeys/List'; import { AppAlerts } from '@/services/alerts/appAlerts'; -import { AppState } from '@/store'; -import * as SshKeysStore from '@/store/SshKeys'; +import { RootState } from '@/store'; +import { sshKeysActionCreators } from '@/store/SshKeys'; import { selectSshKeys, selectSshKeysError, selectSshKeysIsLoading, } from '@/store/SshKeys/selectors'; -import * as UserIdStore from '@/store/User/Id'; +import { userIdActionCreators } from '@/store/User/Id'; export type Props = MappedProps; export type State = { @@ -209,7 +209,7 @@ class SshKeys extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ sshKeys: selectSshKeys(state), sshKeysError: selectSshKeysError(state), sshKeysIsLoading: selectSshKeysIsLoading(state), @@ -217,7 +217,7 @@ const mapStateToProps = (state: AppState) => ({ const connector = connect( mapStateToProps, - { ...SshKeysStore.actionCreators, ...UserIdStore.actionCreators }, + { ...sshKeysActionCreators, ...userIdActionCreators }, null, { // forwardRef is mandatory for using `@react-mock/state` in unit tests diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx index df2e6923f..6bdde3c9a 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ import UserPreferences from '@/pages/UserPreferences'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; import { buildUserPreferencesLocation } from '@/services/helpers/location'; import { UserPreferencesTab } from '@/services/helpers/types'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('../ContainerRegistriesTab'); jest.mock('../GitConfig'); @@ -32,7 +32,7 @@ const { renderComponent } = getComponentRenderer(getComponent); const mockNavigate = jest.fn(); function getComponent(location: Location): React.ReactElement { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); return ( diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx index 9e8f7556b..156477788 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx @@ -23,8 +23,8 @@ import PersonalAccessTokens from '@/pages/UserPreferences/PersonalAccessTokens'; import SshKeys from '@/pages/UserPreferences/SshKeys'; import { ROUTE } from '@/Routes'; import { UserPreferencesTab } from '@/services/helpers/types'; -import { AppState } from '@/store'; -import { actionCreators } from '@/store/GitOauthConfig'; +import { RootState } from '@/store'; +import { gitOauthConfigActionCreators } from '@/store/GitOauthConfig'; import { selectIsLoading } from '@/store/GitOauthConfig/selectors'; export type Props = { @@ -118,11 +118,11 @@ class UserPreferences extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ isLoading: selectIsLoading(state), }); -const connector = connect(mapStateToProps, actionCreators); +const connector = connect(mapStateToProps, gitOauthConfigActionCreators); type MappedProps = ConnectedProps; export default connector(UserPreferences); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/index.spec.tsx index 8b3233487..340a9f5e5 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ import StorageTypeFormGroup, { State } from '@/pages/WorkspaceDetails/OverviewTa import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); @@ -30,7 +30,7 @@ describe('StorageTypeFormGroup', () => { let store: Store; beforeEach(() => { - store = new FakeStoreBuilder() + store = new MockStoreBuilder() .withBranding({ docs: { storageTypes: 'storage-types-docs', diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/index.tsx index 5d67c8c5c..8011e25b6 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/index.tsx @@ -30,7 +30,7 @@ import overviewStyles from '@/pages/WorkspaceDetails/OverviewTab/index.module.cs import styles from '@/pages/WorkspaceDetails/OverviewTab/StorageType/index.module.css'; import { che } from '@/services/models'; import * as storageTypeService from '@/services/storageTypes'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; import { selectPvcStrategy } from '@/store/ServerConfig/selectors'; @@ -356,7 +356,7 @@ class StorageTypeFormGroup extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ branding: selectBranding(state), preferredStorageType: selectPvcStrategy(state), }); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/__tests__/index.spec.tsx index dcdf6147a..4cfe1121c 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/__tests__/index.spec.tsx @@ -20,19 +20,19 @@ import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import devfileApi from '@/services/devfileApi'; import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const { createSnapshot } = getComponentRenderer(getComponent); describe('WorkspaceNameLink', () => { - let storeBuilder: FakeStoreBuilder; + let storeBuilder: MockStoreBuilder; let devWorkspace: devfileApi.DevWorkspace; let workspace: Workspace; beforeEach(() => { devWorkspace = new DevWorkspaceBuilder().withName('my-project').build(); workspace = constructWorkspace(devWorkspace); - storeBuilder = new FakeStoreBuilder().withDevWorkspaces({ workspaces: [devWorkspace] }); + storeBuilder = new MockStoreBuilder().withDevWorkspaces({ workspaces: [devWorkspace] }); }); test('screenshot when cluster console is available', () => { diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/index.tsx index e2903d55f..bdea4f5f3 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/WorkspaceName/index.tsx @@ -18,8 +18,8 @@ import { connect, ConnectedProps } from 'react-redux'; import overviewStyles from '@/pages/WorkspaceDetails/OverviewTab/index.module.css'; import { Workspace, WorkspaceAdapter } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import { actionCreators } from '@/store/BannerAlert'; +import { RootState } from '@/store'; +import { bannerAlertActionCreators } from '@/store/BannerAlert'; import { selectApplications } from '@/store/ClusterInfo/selectors'; export type Props = MappedProps & { @@ -65,11 +65,11 @@ class WorkspaceNameFormGroup extends React.PureComponent { } } -const mapStateToProps = (state: AppState) => ({ +const mapStateToProps = (state: RootState) => ({ applications: selectApplications(state), }); -const connector = connect(mapStateToProps, actionCreators); +const connector = connect(mapStateToProps, bannerAlertActionCreators); type MappedProps = ConnectedProps; diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx index 6eff72f8b..ce55fa48a 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ import { OverviewTab } from '@/pages/WorkspaceDetails/OverviewTab'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; jest.mock('@/pages/WorkspaceDetails/OverviewTab/InfrastructureNamespace'); jest.mock('@/pages/WorkspaceDetails/OverviewTab/Projects'); @@ -61,7 +61,7 @@ describe('OverviewTab', () => { }); function getComponent(workspace: Workspace) { - const store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); return ( diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/__tests__/index.spec.tsx index c58779a06..94c342f02 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ import { Props, WorkspaceDetails } from '@/pages/WorkspaceDetails'; import devfileApi from '@/services/devfileApi'; import { constructWorkspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const mockOnSave = jest.fn(); @@ -138,7 +138,7 @@ describe('Workspace Details page', () => { function renderComponent(props?: Partial): void { const workspaces = props?.workspace ? [props.workspace.ref as devfileApi.DevWorkspace] : []; - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces }).build(); const location = { key: 'workspace-details-key', pathname: `/workspace/${namespace}/${workspaceName}`, diff --git a/packages/dashboard-frontend/src/services/assets/branding.ts b/packages/dashboard-frontend/src/services/assets/branding.ts index 1a4a18d46..1745e0011 100644 --- a/packages/dashboard-frontend/src/services/assets/branding.ts +++ b/packages/dashboard-frontend/src/services/assets/branding.ts @@ -15,10 +15,20 @@ import { BrandingData } from '@/services/bootstrap/branding.constant'; export async function fetchBranding(url: string): Promise { const axiosInstance = getAxiosInstance(); - try { - const response = await axiosInstance.get(url); - return response.data; - } catch (e) { - throw new Error(`Failed to fetch branding data by URL: ${url}`); - } + const response = await axiosInstance.get(url); + return response.data; +} + +export async function fetchApiInfo(): Promise<{ + buildInfo: string; + implementationVendor: string; + implementationVersion: string; + scmRevision: string; + specificationTitle: string; + specificationVendor: string; + specificationVersion: string; +}> { + const axiosInstance = getAxiosInstance(); + const { data } = await axiosInstance.options('/api/'); + return data; } diff --git a/packages/dashboard-frontend/src/services/backend-client/__tests__/oAuthApi.spec.tsx b/packages/dashboard-frontend/src/services/backend-client/__tests__/oAuthApi.spec.tsx index 5a6de880f..a3de98c0b 100644 --- a/packages/dashboard-frontend/src/services/backend-client/__tests__/oAuthApi.spec.tsx +++ b/packages/dashboard-frontend/src/services/backend-client/__tests__/oAuthApi.spec.tsx @@ -17,7 +17,7 @@ import { getOAuthProviders, getOAuthToken, } from '@/services/backend-client/oAuthApi'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; +import { IGitOauth } from '@/store/GitOauthConfig'; describe('Open Authorization API', () => { const mockGet = mockAxios.get as jest.Mock; diff --git a/packages/dashboard-frontend/src/services/backend-client/oAuthApi.ts b/packages/dashboard-frontend/src/services/backend-client/oAuthApi.ts index f3f14adf7..7d239c5ca 100644 --- a/packages/dashboard-frontend/src/services/backend-client/oAuthApi.ts +++ b/packages/dashboard-frontend/src/services/backend-client/oAuthApi.ts @@ -14,7 +14,7 @@ import { api } from '@eclipse-che/common'; import axios from 'axios'; import { cheServerPrefix } from '@/services/backend-client/const'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; +import { IGitOauth } from '@/store/GitOauthConfig'; export async function getOAuthProviders(): Promise { const response = await axios.get(`${cheServerPrefix}/oauth`); diff --git a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx index c92810563..8912b6f03 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx @@ -10,162 +10,170 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; -import { waitFor } from '@testing-library/react'; -import mockAxios from 'axios'; -import WS from 'jest-websocket-mock'; -import { Store } from 'redux'; - -import PreloadData from '@/services/bootstrap'; -import { BrandingData } from '@/services/bootstrap/branding.constant'; -import devfileApi from '@/services/devfileApi'; -import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; - -jest.mock('@/services/helpers/delay', () => ({ delay: jest.fn() })); - -const mockAppendLink = jest.fn().mockResolvedValue(undefined); -jest.mock('@/services/resource-fetcher/appendLink', () => { - return { - appendLink: (url: string) => mockAppendLink(url), - }; +import { container } from '@/inversify.config'; +import { WebsocketClient } from '@/services/backend-client/websocketClient'; +import Bootstrap from '@/services/bootstrap'; +import { ResourceFetcherService } from '@/services/resource-fetcher'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { bannerAlertActionCreators } from '@/store/BannerAlert'; +import { brandingActionCreators } from '@/store/Branding'; +import { clusterConfigActionCreators } from '@/store/ClusterConfig'; +import { clusterInfoActionCreators } from '@/store/ClusterInfo'; +import { devfileRegistriesActionCreators } from '@/store/DevfileRegistries'; +import { eventsActionCreators } from '@/store/Events'; +import { infrastructureNamespacesActionCreators } from '@/store/InfrastructureNamespaces'; +import { chePluginsActionCreators } from '@/store/Plugins/chePlugins'; +import { devWorkspacePluginsActionCreators } from '@/store/Plugins/devWorkspacePlugins'; +import { podsActionCreators } from '@/store/Pods'; +import { sanityCheckActionCreators } from '@/store/SanityCheck'; +import { serverConfigActionCreators } from '@/store/ServerConfig'; +import { sshKeysActionCreators } from '@/store/SshKeys'; +import { userProfileActionCreators } from '@/store/User/Profile'; +import { workspacesActionCreators } from '@/store/Workspaces'; +import { workspacePreferencesActionCreators } from '@/store/Workspaces/Preferences'; + +const mockPrefetchResources = jest.fn().mockResolvedValue(undefined); +jest.spyOn(ResourceFetcherService.prototype, 'prefetchResources').mockImplementation(() => { + return mockPrefetchResources(); }); +jest.spyOn(serverConfigActionCreators, 'requestServerConfig').mockImplementation(() => jest.fn()); +jest.spyOn(sanityCheckActionCreators, 'testBackends').mockImplementation(() => jest.fn()); +jest.spyOn(brandingActionCreators, 'requestBranding').mockImplementation(() => jest.fn()); +jest.mock('@/store/InfrastructureNamespaces', () => ({ + ...jest.requireActual('@/store/InfrastructureNamespaces'), + selectDefaultNamespace: jest + .fn() + .mockReturnValue({ name: 'test-che', attributes: { phase: 'Active' } }), +})); +jest + .spyOn(infrastructureNamespacesActionCreators, 'requestNamespaces') + .mockImplementation(() => jest.fn()); +jest.spyOn(clusterInfoActionCreators, 'requestClusterInfo').mockImplementation(() => jest.fn()); +jest.spyOn(userProfileActionCreators, 'requestUserProfile').mockImplementation(() => jest.fn()); +jest.spyOn(chePluginsActionCreators, 'requestPlugins').mockImplementation(() => jest.fn()); +jest + .spyOn(devWorkspacePluginsActionCreators, 'requestDwDefaultEditor') + .mockImplementation(() => jest.fn()); +jest + .spyOn(devWorkspacePluginsActionCreators, 'requestDwDefaultPlugins') + .mockImplementation(() => jest.fn()); +jest.mock('@/store/DevfileRegistries', () => ({ + ...jest.requireActual('@/store/DevfileRegistries'), + selectEmptyWorkspaceUrl: jest.fn().mockReturnValue('empty-workspace-url'), +})); +jest + .spyOn(devfileRegistriesActionCreators, 'requestRegistriesMetadata') + .mockImplementation(() => jest.fn()); +jest.spyOn(devfileRegistriesActionCreators, 'requestDevfile').mockImplementation(() => jest.fn()); +jest.spyOn(workspacesActionCreators, 'requestWorkspaces').mockImplementation(() => jest.fn()); +jest.mock('@/store/Workspaces/DevWorkspaces', () => ({ + ...jest.requireActual('@/store/Workspaces/DevWorkspaces'), + selectDevWorkspacesResourceVersion: jest.fn(), +})); +jest.mock('@/store/Events', () => ({ + ...jest.requireActual('@/store/Events'), + selectEventsResourceVersion: jest.fn(), +})); +jest.spyOn(eventsActionCreators, 'requestEvents').mockImplementation(() => jest.fn()); +jest.spyOn(eventsActionCreators, 'handleWebSocketMessage').mockImplementation(() => jest.fn()); +jest.spyOn(podsActionCreators, 'requestPods').mockImplementation(() => jest.fn()); +jest.mock('@/store/ClusterConfig', () => ({ + ...jest.requireActual('@/store/ClusterConfig'), + selectDashboardFavicon: jest.fn(), +})); +jest.spyOn(clusterConfigActionCreators, 'requestClusterConfig').mockImplementation(() => jest.fn()); +jest.spyOn(sshKeysActionCreators, 'requestSshKeys').mockImplementation(() => jest.fn()); +jest + .spyOn(workspacePreferencesActionCreators, 'requestPreferences') + .mockImplementation(() => jest.fn()); +jest.spyOn(bannerAlertActionCreators, 'addBanner').mockImplementation(() => jest.fn()); + +const mockWebsocketClient = { + connect: jest.fn(), + addChannelMessageListener: jest.fn(), + subscribeToChannel: jest.fn(), +}; + // mute the outputs console.error = jest.fn(); console.log = jest.fn(); describe('Dashboard bootstrap', () => { - const namespace = { - name: 'test-che', - attributes: { - phase: 'Active', - }, - } as che.KubernetesNamespace; - - const mockGet = mockAxios.get as jest.Mock; - const mockPost = mockAxios.post as jest.Mock; - - let server: WS; - let preloadData: PreloadData; - - beforeAll(() => { - server = new WS('ws://localhost/dashboard/api/websocket'); - }); - beforeEach(() => { - const store = getStore(namespace); - preloadData = new PreloadData(store); + container.snapshot(); + container + .rebind(WebsocketClient) + .toConstantValue(mockWebsocketClient as unknown as WebsocketClient); }); afterEach(() => { - WS.clean(); - jest.resetAllMocks(); + jest.clearAllMocks(); + container.restore(); }); - afterAll(() => { - server.close(); - }); + test('all resources fetched successfully', async () => { + const store = new MockStoreBuilder().build(); + const bootstrap = new Bootstrap(store); - test('requests which should be sent', async () => { - prepareMocks(mockPost, 1, namespace); // provisionNamespace - prepareMocks(mockGet, 16, []); // branding, namespace, prefetch, server-config, cluster-info, userprofile, default-editor, devfile-registry, getting-started-sample, devworkspaces, events, pods, cluster-config, ssh-key, workspace-preferences + await expect(bootstrap.init()).resolves.toBeUndefined(); + // await bootstrap.init(); - await preloadData.init(); + expect(serverConfigActionCreators.requestServerConfig).toHaveBeenCalledTimes(1); + expect(sanityCheckActionCreators.testBackends).toHaveBeenCalledTimes(1); - // wait for all POST requests to be sent - await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1)); - expect(mockPost).toHaveBeenCalledWith( - '/api/kubernetes/namespace/provision', - undefined, - undefined, - ); - // wait for all GET requests to be sent - await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(16)); + expect(mockPrefetchResources).toHaveBeenCalledTimes(1); - await waitFor(() => - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/namespace/test-che/ssh-key', undefined), - ); - expect(mockGet).toHaveBeenCalledWith('./assets/branding/product.json'); - expect(mockGet).toHaveBeenCalledWith('/api/kubernetes/namespace', undefined); - expect(mockGet).toHaveBeenCalledWith('https://prefetch-che-cdn.test', { - headers: { 'Cache-Control': 'no-cache', Expires: '0' }, - }); - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/server-config', undefined); - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/cluster-info'); - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/userprofile/test-che', undefined); - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/editors', undefined); - expect(mockGet).toHaveBeenCalledWith( - 'http://localhost/dashboard/devfile-registry/devfiles/index.json', - ); - expect(mockGet).toHaveBeenCalledWith('http://localhost/dashboard/api/getting-started-sample'); - expect(mockGet).toHaveBeenCalledWith( - '/dashboard/api/namespace/test-che/devworkspaces', - undefined, - ); - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/namespace/test-che/events', undefined); - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/namespace/test-che/pods', undefined); - expect(mockGet).toHaveBeenCalledWith('/dashboard/api/cluster-config'); - // wait for all WS messages to be sent - await waitFor(() => - expect(server).toHaveReceivedMessages([ - '{"method":"SUBSCRIBE","channel":"devWorkspace","params":{"namespace":"test-che","resourceVersion":"0"}}', - ]), + expect(brandingActionCreators.requestBranding).toHaveBeenCalledTimes(1); + expect(infrastructureNamespacesActionCreators.requestNamespaces).toHaveBeenCalledTimes(1); + expect(clusterInfoActionCreators.requestClusterInfo).toHaveBeenCalledTimes(1); + + expect(userProfileActionCreators.requestUserProfile).toHaveBeenCalledTimes(1); + expect(chePluginsActionCreators.requestPlugins).toHaveBeenCalledTimes(1); + expect(devWorkspacePluginsActionCreators.requestDwDefaultEditor).toHaveBeenCalledTimes(1); + expect(devWorkspacePluginsActionCreators.requestDwDefaultPlugins).toHaveBeenCalledTimes(1); + expect(devfileRegistriesActionCreators.requestRegistriesMetadata).toHaveBeenCalledTimes(3); + expect(devfileRegistriesActionCreators.requestDevfile).toHaveBeenCalledTimes(1); + expect(workspacesActionCreators.requestWorkspaces).toHaveBeenCalledTimes(1); + expect(eventsActionCreators.requestEvents).toHaveBeenCalledTimes(1); + expect(podsActionCreators.requestPods).toHaveBeenCalledTimes(1); + expect(clusterConfigActionCreators.requestClusterConfig).toHaveBeenCalledTimes(1); + expect(sshKeysActionCreators.requestSshKeys).toHaveBeenCalledTimes(1); + expect(workspacePreferencesActionCreators.requestPreferences).toHaveBeenCalledTimes(1); + + /* WebSocket Client */ + + expect(mockWebsocketClient.connect).toHaveBeenCalledTimes(3); + + expect(mockWebsocketClient.addChannelMessageListener).toHaveBeenCalledTimes(3); + expect(mockWebsocketClient.addChannelMessageListener).toHaveBeenNthCalledWith( + 1, + 'devWorkspace', + expect.any(Function), ); - await waitFor(() => - expect(server).toHaveReceivedMessages([ - '{"method":"SUBSCRIBE","channel":"event","params":{"namespace":"test-che","resourceVersion":"0"}}', - ]), + expect(mockWebsocketClient.addChannelMessageListener).toHaveBeenNthCalledWith( + 2, + 'event', + expect.any(Function), ); - await waitFor(() => - expect(server).toHaveReceivedMessages([ - '{"method":"SUBSCRIBE","channel":"pod","params":{"namespace":"test-che","resourceVersion":"0"}}', - ]), + expect(mockWebsocketClient.addChannelMessageListener).toHaveBeenNthCalledWith( + 3, + 'pod', + expect.any(Function), ); - // wait for all appendLink calls - expect(mockAppendLink.mock.calls).toEqual([ - ['https://prefetch-resource-1.test'], - ['https://prefetch-resource-2.test'], - ]); + expect(mockWebsocketClient.subscribeToChannel).toHaveBeenCalledTimes(3); + expect(mockWebsocketClient.subscribeToChannel).toHaveBeenNthCalledWith( + 1, + 'devWorkspace', + 'test-che', + { getResourceVersion: expect.any(Function) }, + ); + expect(mockWebsocketClient.subscribeToChannel).toHaveBeenNthCalledWith(2, 'event', 'test-che', { + getResourceVersion: expect.any(Function), + }); + expect(mockWebsocketClient.subscribeToChannel).toHaveBeenNthCalledWith(3, 'pod', 'test-che', { + getResourceVersion: expect.any(Function), + }); }); }); - -function getStore(namespace: che.KubernetesNamespace): Store { - const editors = [ - { - metadata: { - name: 'default-editor', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - return new FakeStoreBuilder() - .withBranding({ - configuration: { - prefetch: { - cheCDN: 'https://prefetch-che-cdn.test', - resources: ['https://prefetch-resource-1.test', 'https://prefetch-resource-2.test'], - }, - }, - } as BrandingData) - .withInfrastructureNamespace([namespace]) - .withDwServerConfig({ - defaults: { - editor: 'che-incubator/default-editor/latest', - }, - pluginRegistryURL: 'http://localhost/plugin-registry/v3', - } as api.IServerConfig) - .withDwPlugins({}, {}, false, editors, 'che-incubator/default-editor/latest') - .build(); -} - -function prepareMocks(mock: jest.Mock, quantity: number, value: [] | object): void { - for (let i = 0; i < quantity; i++) { - mock.mockResolvedValueOnce({ data: value }); - } -} diff --git a/packages/dashboard-frontend/src/services/bootstrap/__tests__/namespaceProvisionWarnings.spec.tsx b/packages/dashboard-frontend/src/services/bootstrap/__tests__/namespaceProvisionWarnings.spec.tsx index 7bd8f31ac..d56c23801 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/__tests__/namespaceProvisionWarnings.spec.tsx +++ b/packages/dashboard-frontend/src/services/bootstrap/__tests__/namespaceProvisionWarnings.spec.tsx @@ -13,7 +13,7 @@ import { container } from '@/inversify.config'; import { checkNamespaceProvisionWarnings } from '@/services/bootstrap/namespaceProvisionWarnings'; import { WarningsReporterService } from '@/services/bootstrap/warningsReporter'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; const warningsReporterService = container.get(WarningsReporterService); @@ -24,13 +24,13 @@ describe('Check namespace provision warnings', () => { }); it('should not register any warning', () => { - let store = new FakeStoreBuilder().build(); + const store = new MockStoreBuilder().build(); checkNamespaceProvisionWarnings(store.getState); expect(warningsReporterService.hasWarning).toBeFalsy(); - store = new FakeStoreBuilder() + const nextStore = new MockStoreBuilder() .withDwServerConfig({ networking: { auth: { @@ -40,13 +40,13 @@ describe('Check namespace provision warnings', () => { }) .build(); - checkNamespaceProvisionWarnings(store.getState); + checkNamespaceProvisionWarnings(nextStore.getState); expect(warningsReporterService.hasWarning).toBeFalsy(); }); it('should register the autoProvision warning', () => { - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDwServerConfig({ defaultNamespace: { autoProvision: false, diff --git a/packages/dashboard-frontend/src/services/bootstrap/__tests__/workspaceStoppedDetector.spec.ts b/packages/dashboard-frontend/src/services/bootstrap/__tests__/workspaceStoppedDetector.spec.ts index 15ba1ba3f..f40a0658f 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/__tests__/workspaceStoppedDetector.spec.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/__tests__/workspaceStoppedDetector.spec.ts @@ -18,7 +18,7 @@ import { import SessionStorageService, { SessionStorageKey } from '@/services/session-storage'; import { constructWorkspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; describe('WorkspaceStoppedDetector', () => { describe('checkWorkspaceStopped()', () => { @@ -42,7 +42,7 @@ describe('WorkspaceStoppedDetector', () => { .build(), ]; - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); const workspace = new WorkspaceStoppedDetector().checkWorkspaceStopped(store.getState()); @@ -62,7 +62,7 @@ describe('WorkspaceStoppedDetector', () => { .build(), ]; - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); const workspace = new WorkspaceStoppedDetector().checkWorkspaceStopped(store.getState()); @@ -84,7 +84,7 @@ describe('WorkspaceStoppedDetector', () => { .build(), ]; - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces: devWorkspaces }).build(); const workspace = new WorkspaceStoppedDetector().checkWorkspaceStopped(store.getState()); @@ -103,7 +103,7 @@ describe('WorkspaceStoppedDetector', () => { .build(); devWorkspace.spec.started = true; - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace] }) .build(); const checkWorkspaceStopped = () => { @@ -126,7 +126,7 @@ describe('WorkspaceStoppedDetector', () => { .build(); devWorkspace.spec.started = true; - const store = new FakeStoreBuilder().withDevWorkspaces({ workspaces: [devWorkspace] }).build(); + const store = new MockStoreBuilder().withDevWorkspaces({ workspaces: [devWorkspace] }).build(); const checkWorkspaceStopped = () => { new WorkspaceStoppedDetector().checkWorkspaceStopped(store.getState()); }; diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index 34aeb84f6..d4b924ab8 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -30,30 +30,33 @@ import { isAvailableEndpoint } from '@/services/helpers/api-ping'; import { buildDetailsLocation, buildIdeLoaderLocation } from '@/services/helpers/location'; import { ResourceFetcherService } from '@/services/resource-fetcher'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; -import * as BannerAlertStore from '@/store/BannerAlert'; -import * as BrandingStore from '@/store/Branding'; -import * as ClusterConfigStore from '@/store/ClusterConfig'; -import { selectDashboardFavicon } from '@/store/ClusterConfig/selectors'; -import * as ClusterInfoStore from '@/store/ClusterInfo'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; -import { DEFAULT_REGISTRY } from '@/store/DevfileRegistries'; -import { selectEmptyWorkspaceUrl } from '@/store/DevfileRegistries/selectors'; -import * as EventsStore from '@/store/Events'; -import { selectEventsResourceVersion } from '@/store/Events/selectors'; -import * as InfrastructureNamespacesStore from '@/store/InfrastructureNamespaces'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import * as PluginsStore from '@/store/Plugins/chePlugins'; -import * as DwPluginsStore from '@/store/Plugins/devWorkspacePlugins'; -import * as PodsStore from '@/store/Pods'; -import { selectPodsResourceVersion } from '@/store/Pods/selectors'; -import * as SanityCheckStore from '@/store/SanityCheck'; -import * as ServerConfigStore from '@/store/ServerConfig'; -import * as SshKeysStore from '@/store/SshKeys'; -import * as UserProfileStore from '@/store/User/Profile'; -import * as WorkspacesStore from '@/store/Workspaces'; -import * as DevWorkspacesStore from '@/store/Workspaces/devWorkspaces'; -import { selectDevWorkspacesResourceVersion } from '@/store/Workspaces/devWorkspaces/selectors'; +import { RootState } from '@/store'; +import { bannerAlertActionCreators } from '@/store/BannerAlert'; +import { brandingActionCreators } from '@/store/Branding'; +import { clusterConfigActionCreators, selectDashboardFavicon } from '@/store/ClusterConfig'; +import { clusterInfoActionCreators } from '@/store/ClusterInfo'; +import { + DEFAULT_REGISTRY, + devfileRegistriesActionCreators, + selectEmptyWorkspaceUrl, +} from '@/store/DevfileRegistries'; +import { eventsActionCreators, selectEventsResourceVersion } from '@/store/Events'; +import { + infrastructureNamespacesActionCreators, + selectDefaultNamespace, +} from '@/store/InfrastructureNamespaces'; +import { chePluginsActionCreators } from '@/store/Plugins/chePlugins'; +import { devWorkspacePluginsActionCreators } from '@/store/Plugins/devWorkspacePlugins'; +import { podsActionCreators, selectPodsResourceVersion } from '@/store/Pods'; +import { sanityCheckActionCreators } from '@/store/SanityCheck'; +import { serverConfigActionCreators } from '@/store/ServerConfig'; +import { sshKeysActionCreators } from '@/store/SshKeys'; +import { userProfileActionCreators } from '@/store/User/Profile'; +import { workspacesActionCreators } from '@/store/Workspaces'; +import { + devWorkspacesActionCreators, + selectDevWorkspacesResourceVersion, +} from '@/store/Workspaces/devWorkspaces'; import { workspacePreferencesActionCreators } from '@/store/Workspaces/Preferences'; /** @@ -70,11 +73,11 @@ export default class Bootstrap { @lazyInject(WorkspaceStoppedDetector) private readonly workspaceStoppedDetector: WorkspaceStoppedDetector; - private store: Store; + private store: Store; private resourceFetcher: ResourceFetcherService; - constructor(store: Store) { + constructor(store: Store) { this.store = store; this.resourceFetcher = new ResourceFetcherService(); } @@ -91,7 +94,7 @@ export default class Bootstrap { const results = await Promise.allSettled([ this.fetchUserProfile(), - this.fetchPlugins().then(() => this.fetchDwPlugins()), + this.fetchPlugins().then(() => this.fetchDwDefaultEditor()), this.fetchDefaultDwPlugins(), this.fetchRegistriesMetadata().then(() => this.fetchEmptyWorkspace()), this.fetchWorkspaces().then(() => { @@ -125,7 +128,7 @@ export default class Bootstrap { } private async doBackendsSanityCheck(): Promise { - const { testBackends } = SanityCheckStore.actionCreators; + const { testBackends } = sanityCheckActionCreators; try { await testBackends()(this.store.dispatch, this.store.getState, undefined); } catch (e) { @@ -140,7 +143,7 @@ export default class Bootstrap { } private async fetchClusterConfig(): Promise { - const { requestClusterConfig } = ClusterConfigStore.actionCreators; + const { requestClusterConfig } = clusterConfigActionCreators; try { await requestClusterConfig()(this.store.dispatch, this.store.getState, undefined); } catch (e) { @@ -151,7 +154,7 @@ export default class Bootstrap { } private async fetchClusterInfo(): Promise { - const { requestClusterInfo } = ClusterInfoStore.actionCreators; + const { requestClusterInfo } = clusterInfoActionCreators; try { await requestClusterInfo()(this.store.dispatch, this.store.getState, undefined); } catch (e) { @@ -162,7 +165,7 @@ export default class Bootstrap { } private async fetchBranding(): Promise { - const { requestBranding } = BrandingStore.actionCreators; + const { requestBranding } = brandingActionCreators; try { await requestBranding()(this.store.dispatch, this.store.getState, undefined); } catch (e) { @@ -174,7 +177,7 @@ export default class Bootstrap { private async watchWebSocketDevWorkspaces(): Promise { const defaultKubernetesNamespace = selectDefaultNamespace(this.store.getState()); const namespace = defaultKubernetesNamespace.name; - const { handleWebSocketMessage } = DevWorkspacesStore.actionCreators; + const { handleWebSocketMessage } = devWorkspacesActionCreators; const dispatch = this.store.dispatch; const getState = this.store.getState; @@ -201,7 +204,7 @@ export default class Bootstrap { private async watchWebSocketEvents(): Promise { const defaultKubernetesNamespace = selectDefaultNamespace(this.store.getState()); const namespace = defaultKubernetesNamespace.name; - const { handleWebSocketMessage } = EventsStore.actionCreators; + const { handleWebSocketMessage } = eventsActionCreators; const dispatch = this.store.dispatch; const getState = this.store.getState; @@ -228,7 +231,7 @@ export default class Bootstrap { private async watchWebSocketPods(): Promise { const defaultKubernetesNamespace = selectDefaultNamespace(this.store.getState()); const namespace = defaultKubernetesNamespace.name; - const { handleWebSocketMessage } = PodsStore.actionCreators; + const { handleWebSocketMessage } = podsActionCreators; const dispatch = this.store.dispatch; const getState = this.store.getState; @@ -253,32 +256,32 @@ export default class Bootstrap { } private async fetchWorkspaces(): Promise { - const { requestWorkspaces } = WorkspacesStore.actionCreators; + const { requestWorkspaces } = workspacesActionCreators; await requestWorkspaces()(this.store.dispatch, this.store.getState, undefined); } private async fetchEvents(): Promise { - const { requestEvents } = EventsStore.actionCreators; + const { requestEvents } = eventsActionCreators; await requestEvents()(this.store.dispatch, this.store.getState, undefined); } private async fetchPods(): Promise { - const { requestPods } = PodsStore.actionCreators; + const { requestPods } = podsActionCreators; await requestPods()(this.store.dispatch, this.store.getState, undefined); } private async fetchPlugins(): Promise { - const { requestPlugins } = PluginsStore.actionCreators; + const { requestPlugins } = chePluginsActionCreators; await requestPlugins()(this.store.dispatch, this.store.getState, undefined); } - private async fetchDwPlugins(): Promise { - const { requestDwDefaultEditor } = DwPluginsStore.actionCreators; + private async fetchDwDefaultEditor(): Promise { + const { requestDwDefaultEditor } = devWorkspacePluginsActionCreators; try { await requestDwDefaultEditor()(this.store.dispatch, this.store.getState, undefined); } catch (e) { const message = `Required sources failed when trying to create the workspace: ${e}`; - const { addBanner } = BannerAlertStore.actionCreators; + const { addBanner } = bannerAlertActionCreators; addBanner(message)(this.store.dispatch, this.store.getState, undefined); throw e; @@ -286,7 +289,7 @@ export default class Bootstrap { } private async fetchDefaultDwPlugins(): Promise { - const { requestDwDefaultPlugins } = DwPluginsStore.actionCreators; + const { requestDwDefaultPlugins } = devWorkspacePluginsActionCreators; try { await requestDwDefaultPlugins()(this.store.dispatch, this.store.getState, undefined); } catch (e) { @@ -295,7 +298,7 @@ export default class Bootstrap { } private async fetchInfrastructureNamespaces(): Promise { - const { requestNamespaces } = InfrastructureNamespacesStore.actionCreators; + const { requestNamespaces } = infrastructureNamespacesActionCreators; try { await requestNamespaces()(this.store.dispatch, this.store.getState, undefined); } catch (e) { @@ -304,7 +307,7 @@ export default class Bootstrap { } private async fetchServerConfig(): Promise { - const { requestServerConfig } = ServerConfigStore.actionCreators; + const { requestServerConfig } = serverConfigActionCreators; try { await requestServerConfig()(this.store.dispatch, this.store.getState, undefined); } catch (e) { @@ -313,7 +316,7 @@ export default class Bootstrap { } private async fetchRegistriesMetadata(): Promise { - const { requestRegistriesMetadata } = DevfileRegistriesStore.actionCreators; + const { requestRegistriesMetadata } = devfileRegistriesActionCreators; const defaultRegistry = DEFAULT_REGISTRY.startsWith('http') ? DEFAULT_REGISTRY : new URL(DEFAULT_REGISTRY, window.location.origin).href; @@ -361,7 +364,7 @@ export default class Bootstrap { } private async fetchEmptyWorkspace(): Promise { - const { requestDevfile } = DevfileRegistriesStore.actionCreators; + const { requestDevfile } = devfileRegistriesActionCreators; const state = this.store.getState(); const emptyWorkspaceUrl = selectEmptyWorkspaceUrl(state); if (emptyWorkspaceUrl) { @@ -373,7 +376,7 @@ export default class Bootstrap { const defaultKubernetesNamespace = selectDefaultNamespace(this.store.getState()); const defaultNamespace = defaultKubernetesNamespace.name; - const { requestUserProfile } = UserProfileStore.actionCreators; + const { requestUserProfile } = userProfileActionCreators; return requestUserProfile(defaultNamespace)( this.store.dispatch, this.store.getState, @@ -443,7 +446,7 @@ export default class Bootstrap { } private async fetchSshKeys(): Promise { - const { requestSshKeys } = SshKeysStore.actionCreators; + const { requestSshKeys } = sshKeysActionCreators; await requestSshKeys()(this.store.dispatch, this.store.getState, undefined); } diff --git a/packages/dashboard-frontend/src/services/bootstrap/namespaceProvisionWarnings.ts b/packages/dashboard-frontend/src/services/bootstrap/namespaceProvisionWarnings.ts index 6c9658711..1fb0f4910 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/namespaceProvisionWarnings.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/namespaceProvisionWarnings.ts @@ -12,12 +12,12 @@ import { container } from '@/inversify.config'; import { WarningsReporterService } from '@/services/bootstrap/warningsReporter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectAutoProvision } from '@/store/ServerConfig/selectors'; const warningsReporterService = container.get(WarningsReporterService); -export function checkNamespaceProvisionWarnings(getState: () => AppState): void { +export function checkNamespaceProvisionWarnings(getState: () => RootState): void { const state = getState(); const autoProvision = selectAutoProvision(state); if (autoProvision === false) { diff --git a/packages/dashboard-frontend/src/services/bootstrap/workspaceStoppedDetector.ts b/packages/dashboard-frontend/src/services/bootstrap/workspaceStoppedDetector.ts index a04c5e4b3..7c4696545 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/workspaceStoppedDetector.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/workspaceStoppedDetector.ts @@ -17,7 +17,7 @@ import devfileApi from '@/services/devfileApi'; import { DevWorkspaceStatus } from '@/services/helpers/types'; import SessionStorageService, { SessionStorageKey } from '@/services/session-storage'; import { Workspace } from '@/services/workspace-adapter'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; export class WorkspaceRunningError extends Error { @@ -64,7 +64,7 @@ export class WorkspaceStoppedDetector { * @param state the current app state * @returns the non-running (stopped) workspace */ - public checkWorkspaceStopped(state: AppState): Workspace | undefined { + public checkWorkspaceStopped(state: RootState): Workspace | undefined { if (!this.isRedirectedFromNonDashboardUrl()) { return; } diff --git a/packages/dashboard-frontend/src/services/resource-fetcher/__tests__/index.spec.ts b/packages/dashboard-frontend/src/services/resource-fetcher/__tests__/index.spec.ts index 60e8165d9..492898ebd 100644 --- a/packages/dashboard-frontend/src/services/resource-fetcher/__tests__/index.spec.ts +++ b/packages/dashboard-frontend/src/services/resource-fetcher/__tests__/index.spec.ts @@ -11,7 +11,7 @@ */ import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { ResourceFetcherService } from '..'; @@ -42,7 +42,7 @@ describe('Resource fetcher', () => { it('should not request resources if nothing configured', () => { const service = new ResourceFetcherService(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withBranding({ configuration: {}, } as BrandingData) @@ -64,7 +64,7 @@ describe('Resource fetcher', () => { ], }); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withBranding({ configuration: { prefetch: { @@ -87,7 +87,7 @@ describe('Resource fetcher', () => { it('should request other resources', async () => { const service = new ResourceFetcherService(); - const store = new FakeStoreBuilder() + const store = new MockStoreBuilder() .withBranding({ configuration: { prefetch: { diff --git a/packages/dashboard-frontend/src/services/resource-fetcher/index.ts b/packages/dashboard-frontend/src/services/resource-fetcher/index.ts index 64d3f8a1e..d85d652be 100644 --- a/packages/dashboard-frontend/src/services/resource-fetcher/index.ts +++ b/packages/dashboard-frontend/src/services/resource-fetcher/index.ts @@ -12,7 +12,7 @@ import { getAxiosInstance } from '@/services/axios-wrapper/getAxiosInstance'; import { appendLink } from '@/services/resource-fetcher/appendLink'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; // source: https://github.com/eclipse/che-dashboard/blob/381ff548a9fff3537f1a29ce8e9b228f6c145338/src/components/service/resource-fetcher/resource-fetcher.service.ts @@ -22,7 +22,7 @@ export type ResourceEntry = { }; export class ResourceFetcherService { - public async prefetchResources(state: AppState): Promise { + public async prefetchResources(state: RootState): Promise { const prefetch = state.branding.data.configuration.prefetch; if (!prefetch) { return; diff --git a/packages/dashboard-frontend/src/services/workspace-client/__tests__/helpers.spec.ts b/packages/dashboard-frontend/src/services/workspace-client/__tests__/helpers.spec.ts index faa0d9087..9d0970352 100644 --- a/packages/dashboard-frontend/src/services/workspace-client/__tests__/helpers.spec.ts +++ b/packages/dashboard-frontend/src/services/workspace-client/__tests__/helpers.spec.ts @@ -10,20 +10,13 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { dump } from 'js-yaml'; - -import devfileApi from '@/services/devfileApi'; import { - CHE_EDITOR_YAML_PATH, - getCustomEditor, getErrorMessage, hasLoginPage, isForbidden, isInternalServerError, isUnauthorized, } from '@/services/workspace-client/helpers'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; // mute console.error console.error = jest.fn(); @@ -267,357 +260,4 @@ describe('Workspace-client helpers', () => { ).toBeTruthy(); }); }); - - describe('Look for the custom editor', () => { - let optionalFilesContent: { [fileName: string]: string }; - let editor: devfileApi.Devfile; - - beforeEach(() => { - optionalFilesContent = {}; - editor = buildEditor(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return undefined without optionalFilesContent', async () => { - const store = new FakeStoreBuilder().build(); - const customEditor = await getCustomEditor( - optionalFilesContent, - store.dispatch, - store.getState, - ); - - expect(customEditor).toBeUndefined(); - }); - - describe('inlined editor', () => { - it('should return inlined editor without changes', async () => { - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ inline: editor }); - const store = new FakeStoreBuilder().build(); - - const customEditor = await getCustomEditor( - optionalFilesContent, - store.dispatch, - store.getState, - ); - - expect(customEditor).toEqual(dump(editor)); - }); - - it('should return an overridden devfile', async () => { - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ - inline: editor, - override: { - containers: [ - { - name: 'eclipse-ide', - memoryLimit: '1234Mi', - }, - ], - }, - }); - const store = new FakeStoreBuilder().build(); - - const customEditor = await getCustomEditor( - optionalFilesContent, - store.dispatch, - store.getState, - ); - - expect(customEditor).toEqual(expect.stringContaining('memoryLimit: 1234Mi')); - }); - - it('should throw the "missing metadata.name" error message', async () => { - // set an empty value as a name - editor.metadata.name = ''; - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ inline: editor }); - const store = new FakeStoreBuilder().build(); - - let errorText: string | undefined; - - try { - await getCustomEditor(optionalFilesContent, store.dispatch, store.getState); - } catch (e) { - errorText = common.helpers.errors.getMessage(e); - } - - expect(errorText).toEqual( - 'Failed to analyze the editor devfile, reason: Missing metadata.name attribute in the editor yaml file.', - ); - }); - }); - - describe('get editor by id ', () => { - describe('from the default registry', () => { - it('should return an editor without changes', async () => { - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ - id: 'che-incubator/che-idea/next', - }); - - const editors = [ - { - schemaVersion: '2.1.0', - metadata: { - name: 'che-idea', - namespace: 'che', - attributes: { - publisher: 'che-incubator', - version: 'next', - }, - }, - components: [ - { - name: 'eclipse-ide', - container: { - image: 'docker.io/wsskeleton/eclipse-broadway', - mountSources: true, - memoryLimit: '2048M', - }, - }, - ], - } as devfileApi.Devfile, - ]; - const store = new FakeStoreBuilder() - .withDwPlugins({}, {}, false, editors, 'che-incubator/che-idea/next') - .build(); - - const customEditor = await getCustomEditor( - optionalFilesContent, - store.dispatch, - store.getState, - ); - - expect(customEditor).toEqual(dump(editor)); - }); - - it('should return an overridden devfile', async () => { - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ - id: 'che-incubator/che-idea/next', - override: { - containers: [ - { - name: 'eclipse-ide', - memoryLimit: '1234Mi', - }, - ], - }, - }); - - const editors = [ - { - schemaVersion: '2.1.0', - metadata: { - name: 'che-idea', - namespace: 'che', - attributes: { - publisher: 'che-incubator', - version: 'next', - }, - }, - components: [ - { - name: 'eclipse-ide', - container: { - image: 'docker.io/wsskeleton/eclipse-broadway', - mountSources: true, - memoryLimit: '2048M', - }, - }, - ], - } as devfileApi.Devfile, - ]; - - const store = new FakeStoreBuilder() - .withDevfileRegistries({ - devfiles: { - ['https://dummy-plugin-registry/plugins/che-incubator/che-idea/next/devfile.yaml']: - { - content: dump(editor), - }, - }, - }) - .withDwPlugins({}, {}, false, editors, 'che-incubator/che-idea/next') - .build(); - - const customEditor = await getCustomEditor( - optionalFilesContent, - store.dispatch, - store.getState, - ); - - expect(customEditor).toEqual(expect.stringContaining('memoryLimit: 1234Mi')); - }); - - it('should failed fetching editor without metadata.name attribute', async () => { - // set an empty value as a name - editor.metadata.name = ''; - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ - id: 'che-incubator/che-idea/next', - }); - - const editors = [ - { - schemaVersion: '2.1.0', - metadata: { - namespace: 'che', - attributes: { - publisher: 'che-incubator', - version: 'next', - }, - }, - components: [ - { - name: 'eclipse-ide', - container: { - image: 'docker.io/wsskeleton/eclipse-broadway', - mountSources: true, - memoryLimit: '2048M', - }, - }, - ], - } as devfileApi.Devfile, - ]; - - const store = new FakeStoreBuilder() - .withDevfileRegistries({ - devfiles: { - ['https://dummy-plugin-registry/plugins/che-incubator/che-idea/next/devfile.yaml']: - { - content: dump(editor), - }, - }, - }) - .withDwPlugins({}, {}, false, editors, 'che-incubator/che-idea/next') - .build(); - - let errorText: string | undefined; - try { - await getCustomEditor(optionalFilesContent, store.dispatch, store.getState); - } catch (e) { - errorText = common.helpers.errors.getMessage(e); - } - - expect(errorText).toEqual( - 'Failed to fetch editor yaml by id: che-incubator/che-idea/next.', - ); - }); - }); - - describe('from the custom registry', () => { - it('should return an editor without changes', async () => { - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ - id: 'che-incubator/che-idea/next', - registryUrl: 'https://dummy/che-plugin-registry/main/v3', - }); - const store = new FakeStoreBuilder() - .withDevfileRegistries({ - devfiles: { - ['https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml']: - { - content: dump(editor), - }, - }, - }) - .build(); - - const customEditor = await getCustomEditor( - optionalFilesContent, - store.dispatch, - store.getState, - ); - - expect(customEditor).toEqual(dump(editor)); - }); - - it('should return an overridden devfile', async () => { - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ - id: 'che-incubator/che-idea/next', - registryUrl: 'https://dummy/che-plugin-registry/main/v3', - override: { - containers: [ - { - name: 'eclipse-ide', - memoryLimit: '1234Mi', - }, - ], - }, - }); - const store = new FakeStoreBuilder() - .withDevfileRegistries({ - devfiles: { - ['https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml']: - { - content: dump(editor), - }, - }, - }) - .build(); - - const customEditor = await getCustomEditor( - optionalFilesContent, - store.dispatch, - store.getState, - ); - - expect(customEditor).toEqual(expect.stringContaining('memoryLimit: 1234Mi')); - }); - - it('should throw the "missing metadata.name" error message', async () => { - // set an empty value as a name - editor.metadata.name = ''; - optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ - id: 'che-incubator/che-idea/next', - registryUrl: 'https://dummy/che-plugin-registry/main/v3', - }); - const store = new FakeStoreBuilder() - .withDevfileRegistries({ - devfiles: { - ['https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml']: - { - content: dump(editor), - }, - }, - }) - .build(); - - let errorText: string | undefined; - try { - await getCustomEditor(optionalFilesContent, store.dispatch, store.getState); - } catch (e) { - errorText = common.helpers.errors.getMessage(e); - } - - expect(errorText).toEqual( - 'Failed to analyze the editor devfile, reason: Missing metadata.name attribute in the editor yaml file.', - ); - }); - }); - }); - }); }); - -function buildEditor(): devfileApi.Devfile { - return { - schemaVersion: '2.1.0', - metadata: { - name: 'che-idea', - namespace: 'che', - attributes: { - publisher: 'che-incubator', - version: 'next', - }, - }, - components: [ - { - name: 'eclipse-ide', - container: { - image: 'docker.io/wsskeleton/eclipse-broadway', - mountSources: true, - memoryLimit: '2048M', - }, - }, - ], - }; -} diff --git a/packages/dashboard-frontend/src/services/workspace-client/helpers.ts b/packages/dashboard-frontend/src/services/workspace-client/helpers.ts index 2d76acb84..19a430214 100644 --- a/packages/dashboard-frontend/src/services/workspace-client/helpers.ts +++ b/packages/dashboard-frontend/src/services/workspace-client/helpers.ts @@ -12,14 +12,8 @@ import common from '@eclipse-che/common'; import { AxiosResponse } from 'axios'; -import { dump, load } from 'js-yaml'; -import { ThunkDispatch } from 'redux-thunk'; -import devfileApi, { isDevfileV2 } from '@/services/devfileApi'; -import { ICheEditorYaml } from '@/services/workspace-client/devworkspace/devWorkspaceClient'; -import { AppState } from '@/store'; -import { KnownAction } from '@/store/DevfileRegistries'; -import { getEditor } from '@/store/DevfileRegistries/getEditor'; +export const CHE_EDITOR_YAML_PATH = '.che/che-editor.yaml'; /** * Returns an error message for the sanity check service @@ -130,98 +124,3 @@ function hasStatus(error: unknown, _status: number): boolean { } return false; } - -export const CHE_EDITOR_YAML_PATH = '.che/che-editor.yaml'; - -/** - * Look for the custom editor in .che/che-editor.yaml - */ -export async function getCustomEditor( - optionalFilesContent: { [fileName: string]: string }, - dispatch: ThunkDispatch, - getState: () => AppState, -): Promise { - let editorsDevfile: devfileApi.Devfile | undefined; - - // do we have a custom editor specified in the repository ? - const cheEditorYaml = optionalFilesContent[CHE_EDITOR_YAML_PATH] - ? (load(optionalFilesContent[CHE_EDITOR_YAML_PATH]) as ICheEditorYaml) - : undefined; - - if (cheEditorYaml) { - // check the content of cheEditor file - console.debug('Using the repository .che/che-editor.yaml file', cheEditorYaml); - - let repositoryEditorYaml: devfileApi.Devfile | undefined; - let editorReference: string | undefined; - // it's an inlined editor, use the inline content - if (cheEditorYaml.inline) { - console.debug('Using the inline content of the repository editor'); - repositoryEditorYaml = cheEditorYaml.inline; - } else if (cheEditorYaml.id) { - // load the content of this editor - console.debug(`Loading editor from its id ${cheEditorYaml.id}`); - - // registryUrl ? - if (cheEditorYaml.registryUrl) { - editorReference = `${cheEditorYaml.registryUrl}/plugins/${cheEditorYaml.id}/devfile.yaml`; - } else { - editorReference = cheEditorYaml.id; - } - } else if (cheEditorYaml.reference) { - // load the content of this editor - console.debug(`Loading editor from reference ${cheEditorYaml.reference}`); - editorReference = cheEditorYaml.reference; - } - if (editorReference) { - const response = await getEditor(editorReference, dispatch, getState); - if (response.content) { - const yaml = load(response.content); - repositoryEditorYaml = isDevfileV2(yaml) ? yaml : undefined; - } else { - throw new Error(response.error); - } - } - - // if there are some overrides, apply them - if (cheEditorYaml.override) { - console.debug(`Applying overrides ${JSON.stringify(cheEditorYaml.override)}...`); - cheEditorYaml.override.containers?.forEach(container => { - // search matching component - const matchingComponent = repositoryEditorYaml?.components - ? repositoryEditorYaml.components.find(component => component.name === container.name) - : undefined; - if (matchingComponent?.container) { - // apply overrides except the name - Object.keys(container).forEach(property => { - if (matchingComponent.container?.[property] && property !== 'name') { - console.debug( - `Updating property from ${matchingComponent.container[property]} to ${container[property]}`, - ); - matchingComponent.container[property] = container[property]; - } - }); - } - }); - } - - if (!repositoryEditorYaml) { - throw new Error( - 'Failed to analyze the editor devfile inside the repository, reason: Missing id, reference or inline content.', - ); - } - // Use the repository defined editor - editorsDevfile = repositoryEditorYaml; - } - - if (editorsDevfile) { - if (!editorsDevfile.metadata || !editorsDevfile.metadata.name) { - throw new Error( - 'Failed to analyze the editor devfile, reason: Missing metadata.name attribute in the editor yaml file.', - ); - } - return dump(editorsDevfile); - } - - return undefined; -} diff --git a/packages/dashboard-frontend/src/store/BannerAlert/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/BannerAlert/__tests__/actions.spec.ts new file mode 100644 index 000000000..1a35d0ac5 --- /dev/null +++ b/packages/dashboard-frontend/src/store/BannerAlert/__tests__/actions.spec.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { actionCreators, bannerAddAction, bannerRemoveAction } from '@/store/BannerAlert/actions'; + +describe('BannerAlert, actionCreators', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + }); + + it('should dispatch bannerAddAction when addBanner is called', () => { + const message = 'Test banner message'; + const expectedActions = [bannerAddAction(message)]; + + store.dispatch(actionCreators.addBanner(message)); + + expect(store.getActions()).toEqual(expectedActions); + }); + + it('should dispatch bannerRemoveAction when removeBanner is called', () => { + const message = 'Test banner message'; + const expectedActions = [bannerRemoveAction(message)]; + + store.dispatch(actionCreators.removeBanner(message)); + + expect(store.getActions()).toEqual(expectedActions); + }); +}); diff --git a/packages/dashboard-frontend/src/store/BannerAlert/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/BannerAlert/__tests__/reducer.spec.ts new file mode 100644 index 000000000..4176da330 --- /dev/null +++ b/packages/dashboard-frontend/src/store/BannerAlert/__tests__/reducer.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { bannerAddAction, bannerRemoveAction } from '@/store/BannerAlert/actions'; +import { reducer, State, unloadedState } from '@/store/BannerAlert/reducer'; + +describe('BannerAlert, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle bannerAddAction', () => { + const message = 'Test banner message'; + const action = bannerAddAction(message); + const expectedState: State = { + messages: [message], + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle bannerRemoveAction', () => { + const message = 'Test banner message'; + const stateWithMessage: State = { + messages: [message], + }; + const action = bannerRemoveAction(message); + const expectedState: State = { + messages: [], + }; + + expect(reducer(stateWithMessage, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' }; + const currentState: State = { + messages: ['Existing message'], + }; + + expect(reducer(currentState, unknownAction)).toEqual(currentState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/BannerAlert/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/BannerAlert/__tests__/selectors.spec.ts new file mode 100644 index 000000000..a7c8a77fd --- /dev/null +++ b/packages/dashboard-frontend/src/store/BannerAlert/__tests__/selectors.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { selectBannerAlertMessages } from '@/store/BannerAlert/selectors'; + +describe('BannerAlert, selectors', () => { + it('should select banner alert messages', () => { + const mockState = { + bannerAlert: { + messages: ['Test message 1', 'Test message 2'], + }, + } as Partial as RootState; + const selectedMessages = selectBannerAlertMessages(mockState); + expect(selectedMessages).toEqual(['Test message 1', 'Test message 2']); + }); + + it('should return an empty array if there are no messages', () => { + const mockState: RootState = { + bannerAlert: { + messages: [], + }, + } as Partial as RootState; + + const selectedMessages = selectBannerAlertMessages(mockState); + expect(selectedMessages).toEqual([]); + }); +}); diff --git a/packages/dashboard-frontend/src/store/BannerAlert/actions.ts b/packages/dashboard-frontend/src/store/BannerAlert/actions.ts new file mode 100644 index 000000000..2831373de --- /dev/null +++ b/packages/dashboard-frontend/src/store/BannerAlert/actions.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createAction } from '@reduxjs/toolkit'; + +import { AppThunk } from '@/store'; + +export const bannerAddAction = createAction('banner/add'); +export const bannerRemoveAction = createAction('banner/remove'); + +export const actionCreators = { + addBanner: + (message: string): AppThunk => + dispatch => { + dispatch(bannerAddAction(message)); + }, + + removeBanner: + (message: string): AppThunk => + dispatch => { + dispatch(bannerRemoveAction(message)); + }, +}; diff --git a/packages/dashboard-frontend/src/store/BannerAlert/index.ts b/packages/dashboard-frontend/src/store/BannerAlert/index.ts index a0f575bed..e6ef39bec 100644 --- a/packages/dashboard-frontend/src/store/BannerAlert/index.ts +++ b/packages/dashboard-frontend/src/store/BannerAlert/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,81 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -import { Action, Reducer } from 'redux'; - -import { createObject } from '@/store/helpers'; - -import { AppThunk } from '..'; - -export interface State { - messages: string[]; -} - -export interface AddBannerAction { - type: 'ADD_BANNER'; - message: string; -} - -export interface RemoveBannerAction { - type: 'REMOVE_BANNER'; - message: string; -} - -type KnownAction = AddBannerAction | RemoveBannerAction; - -export type ActionCreators = { - addBanner: (message: string) => AppThunk; - removeBanner: (message: string) => AppThunk; -}; - -export const actionCreators: ActionCreators = { - addBanner: - (message: string): AppThunk => - dispatch => { - dispatch({ - type: 'ADD_BANNER', - message, - }); - }, - - removeBanner: - (message: string): AppThunk => - dispatch => { - dispatch({ - type: 'REMOVE_BANNER', - message, - }); - }, -}; - -const unloadedState: State = { - messages: [], -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - - switch (action.type) { - case 'ADD_BANNER': - return createObject(state, { - messages: state.messages.includes(action.message) - ? state.messages - : state.messages.concat([action.message]), - }); - case 'REMOVE_BANNER': - return createObject(state, { - messages: state.messages.includes(action.message) - ? state.messages.filter(message => message !== action.message) - : state.messages, - }); - default: - return state; - } -}; +export { actionCreators as bannerAlertActionCreators } from '@/store/BannerAlert/actions'; +export { + reducer as bannerAlertReducer, + State as BannerAlertState, +} from '@/store/BannerAlert/reducer'; +export * from '@/store/BannerAlert/selectors'; diff --git a/packages/dashboard-frontend/src/store/BannerAlert/reducer.ts b/packages/dashboard-frontend/src/store/BannerAlert/reducer.ts new file mode 100644 index 000000000..962e0e6ca --- /dev/null +++ b/packages/dashboard-frontend/src/store/BannerAlert/reducer.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { bannerAddAction, bannerRemoveAction } from '@/store/BannerAlert/actions'; + +export interface State { + messages: string[]; +} + +export const unloadedState: State = { + messages: [], +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(bannerAddAction, (state, action) => { + state.messages.push(action.payload); + }) + .addCase(bannerRemoveAction, (state, action) => { + state.messages = state.messages.filter(message => message !== action.payload); + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/BannerAlert/selectors.ts b/packages/dashboard-frontend/src/store/BannerAlert/selectors.ts index ecd177cc9..18581e555 100644 --- a/packages/dashboard-frontend/src/store/BannerAlert/selectors.ts +++ b/packages/dashboard-frontend/src/store/BannerAlert/selectors.ts @@ -10,10 +10,10 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.bannerAlert; +const selectState = (state: RootState) => state.bannerAlert; export const selectBannerAlertMessages = createSelector(selectState, state => state.messages); diff --git a/packages/dashboard-frontend/src/store/Branding/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Branding/__tests__/actions.spec.ts new file mode 100644 index 000000000..60d220a4d --- /dev/null +++ b/packages/dashboard-frontend/src/store/Branding/__tests__/actions.spec.ts @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import { fetchApiInfo, fetchBranding } from '@/services/assets/branding'; +import { BRANDING_DEFAULT, BrandingData } from '@/services/bootstrap/branding.constant'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + ASSET_PREFIX, + brandingErrorAction, + brandingReceiveAction, + brandingRequestAction, + getBrandingData, +} from '@/store/Branding/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('axios'); +jest.mock('@/services/assets/branding'); +jest.mock('@/store/SanityCheck'); + +describe('Branding', () => { + let store: ReturnType; + let brandingData: BrandingData; + let expectedBrandingData: BrandingData; + + beforeEach(() => { + store = createMockStore({}); + brandingData = { ...BRANDING_DEFAULT, productVersion: '1.0.0' }; + expectedBrandingData = { + ...brandingData, + logoFile: ASSET_PREFIX + brandingData.logoFile, + logoTextFile: ASSET_PREFIX + brandingData.logoTextFile, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('actionCreators', () => { + it('should dispatch brandingRequestAction and brandingReceiveAction on successful fetch', async () => { + (fetchBranding as jest.Mock).mockResolvedValue(brandingData); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + const expectedActions = [ + brandingRequestAction(), + brandingReceiveAction(expectedBrandingData), + ]; + + await store.dispatch(actionCreators.requestBranding()); + + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + + it('should dispatch brandingErrorAction on fetch failure', async () => { + const error = new Error('Fetch failed'); + (fetchBranding as jest.Mock).mockRejectedValue(error); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + jest.spyOn(common.helpers.errors, 'getMessage').mockReturnValue('Fetch failed'); + + const expectedActions = [ + brandingRequestAction(), + brandingErrorAction( + 'Failed to fetch branding data by URL: "./assets/branding/product.json", reason: Fetch failed', + ), + ]; + + await expect(store.dispatch(actionCreators.requestBranding())).rejects.toThrow( + 'Fetch failed', + ); + + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + }); + + it('should dispatch brandingReceiveAction with product version from API if not in branding data', async () => { + const apiInfo = { implementationVersion: '2.0.0' }; + (fetchApiInfo as jest.Mock).mockResolvedValue(apiInfo); + + // Mock the first fetch to return branding data without product version + const expectedBrandingDataNoVersion: BrandingData = { + ...expectedBrandingData, + productVersion: undefined, + }; + (fetchBranding as jest.Mock).mockResolvedValue(expectedBrandingDataNoVersion); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + const expectedBrandingDataWithVersion = { + ...expectedBrandingDataNoVersion, + productVersion: '2.0.0', + }; + + await store.dispatch(actionCreators.requestBranding()); + + const actions = store.getActions(); + expect(actions).toHaveLength(4); + expect(actions[0]).toEqual(brandingRequestAction()); + expect(actions[1]).toEqual(brandingReceiveAction(expectedBrandingDataNoVersion)); + expect(actions[2]).toEqual(brandingRequestAction()); + expect(actions[3]).toEqual(brandingReceiveAction(expectedBrandingDataWithVersion)); + }); + + it('should dispatch brandingErrorAction on product version fetch failure', async () => { + const error = new Error('Fetch failed'); + (fetchApiInfo as jest.Mock).mockRejectedValue(error); + + // Mock the first fetch to return branding data without product version + const expectedBrandingDataNoVersion: BrandingData = { + ...expectedBrandingData, + productVersion: undefined, + }; + (fetchBranding as jest.Mock).mockResolvedValue(expectedBrandingDataNoVersion); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + // const expectedBrandingDataWithVersion = { + // ...expectedBrandingDataNoVersion, + // productVersion: '2.0.0', + // }; + + await expect(store.dispatch(actionCreators.requestBranding())).rejects.toThrow( + 'Fetch failed', + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(4); + expect(actions[0]).toEqual(brandingRequestAction()); + expect(actions[1]).toEqual(brandingReceiveAction(expectedBrandingDataNoVersion)); + expect(actions[2]).toEqual(brandingRequestAction()); + // expect(actions[3]).toEqual(brandingReceiveAction(expectedBrandingDataWithVersion)); + }); + }); + + describe('getBrandingData', () => { + it('merges received branding data with default branding data', () => { + const receivedBranding = { logoFile: 'custom-logo.png' }; + const expectedBranding = { + ...expectedBrandingData, + logoFile: ASSET_PREFIX + 'custom-logo.png', + productVersion: undefined, + }; + + const result = getBrandingData(receivedBranding); + expect(result).toEqual(expectedBranding); + }); + + it('returns default branding data if no received branding data', () => { + const result = getBrandingData(); + const expectedBranding = { + ...expectedBrandingData, + productVersion: undefined, + }; + expect(result).toEqual(expectedBranding); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Branding/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/Branding/__tests__/reducer.spec.ts new file mode 100644 index 000000000..ca55817b9 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Branding/__tests__/reducer.spec.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import { BRANDING_DEFAULT, BrandingData } from '@/services/bootstrap/branding.constant'; +import { + brandingErrorAction, + brandingReceiveAction, + brandingRequestAction, +} from '@/store/Branding/actions'; +import { reducer, State, unloadedState } from '@/store/Branding/reducer'; + +describe('Branding, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle brandingRequestAction', () => { + const action = brandingRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle brandingReceiveAction', () => { + const brandingData: BrandingData = { ...BRANDING_DEFAULT, productVersion: '1.0.0' }; + const action = brandingReceiveAction(brandingData); + const expectedState: State = { + ...initialState, + isLoading: false, + data: brandingData, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle brandingErrorAction', () => { + const error = 'Failed to fetch branding data'; + const action = brandingErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + const currentState = { ...initialState }; + + expect(reducer(currentState, unknownAction)).toEqual(currentState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Branding/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Branding/__tests__/selectors.spec.ts new file mode 100644 index 000000000..9ea025b85 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Branding/__tests__/selectors.spec.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { BRANDING_DEFAULT } from '@/services/bootstrap/branding.constant'; +import { RootState } from '@/store'; +import { selectBranding, selectBrandingError } from '@/store/Branding/selectors'; + +describe('Branding Selectors', () => { + const mockState = { + branding: { + data: BRANDING_DEFAULT, + error: 'Error message', + }, + } as RootState; + + it('should select branding data', () => { + const result = selectBranding(mockState); + expect(result).toEqual(BRANDING_DEFAULT); + }); + + it('should select branding error', () => { + const result = selectBrandingError(mockState); + expect(result).toBe('Error message'); + }); + + it('should return empty string if branding error is not available', () => { + const stateWithoutError = { + ...mockState, + branding: { error: undefined }, + } as RootState; + const result = selectBrandingError(stateWithoutError); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Branding/actions.ts b/packages/dashboard-frontend/src/store/Branding/actions.ts new file mode 100644 index 000000000..ae576b318 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Branding/actions.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; +import { merge } from 'lodash'; + +import { fetchApiInfo, fetchBranding } from '@/services/assets/branding'; +import { BRANDING_DEFAULT, BrandingData } from '@/services/bootstrap/branding.constant'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const ASSET_PREFIX = './assets/branding/'; + +export const brandingRequestAction = createAction('branding/request'); +export const brandingReceiveAction = createAction('branding/receive'); +export const brandingErrorAction = createAction('branding/error'); + +export const actionCreators = { + requestBranding: + (): AppThunk> => + async (dispatch, getState): Promise => { + const url = `${ASSET_PREFIX}product.json`; + + let branding: BrandingData; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(brandingRequestAction()); + + const rawBranding = await fetchBranding(url); + branding = getBrandingData(rawBranding); + dispatch(brandingReceiveAction(branding)); + } catch (e) { + const errorMessage = + `Failed to fetch branding data by URL: "${url}", reason: ` + + common.helpers.errors.getMessage(e); + dispatch(brandingErrorAction(errorMessage)); + throw e; + } + + // Use the products version if specified in product.json, otherwise use the default version given by che server + if (!branding.productVersion) { + try { + dispatch(brandingRequestAction()); + + const apiInfo = await fetchApiInfo(); + const brandingWithVersion = { + ...branding, + productVersion: apiInfo.implementationVersion, + }; + + dispatch(brandingReceiveAction(brandingWithVersion)); + } catch (e) { + const errorMessage = + 'Failed to fetch the product version, reason: ' + common.helpers.errors.getMessage(e); + dispatch(brandingErrorAction(errorMessage)); + throw e; + } + } + }, +}; + +export function getBrandingData(receivedBranding?: { [key: string]: any }): BrandingData { + let branding: BrandingData = Object.assign({}, BRANDING_DEFAULT); + + if (receivedBranding && Object.keys(receivedBranding).length > 0) { + branding = merge(branding, receivedBranding); + } + // resolve asset paths + const assetTitles: Array = ['logoFile', 'logoTextFile']; + assetTitles.forEach((asset: string) => { + const path = branding[asset] as string; + if (path.startsWith(ASSET_PREFIX)) { + return; + } + branding[asset] = ASSET_PREFIX + branding[asset]; + }); + + return branding; +} diff --git a/packages/dashboard-frontend/src/store/Branding/index.ts b/packages/dashboard-frontend/src/store/Branding/index.ts index 00103b021..8f28db4e8 100644 --- a/packages/dashboard-frontend/src/store/Branding/index.ts +++ b/packages/dashboard-frontend/src/store/Branding/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,167 +12,6 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import axios from 'axios'; -import { merge } from 'lodash'; -import { Action, Reducer } from 'redux'; - -import { fetchBranding } from '@/services/assets/branding'; -import { BRANDING_DEFAULT, BrandingData } from '@/services/bootstrap/branding.constant'; -import { createObject } from '@/store/helpers'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export const ASSET_PREFIX = './assets/branding/'; - -export interface State { - isLoading: boolean; - data: BrandingData; - error?: string; -} - -export interface RequestBrandingAction extends Action, SanityCheckAction { - type: 'REQUEST_BRANDING'; -} - -export interface ReceivedBrandingAction { - type: 'RECEIVED_BRANDING'; - data: BrandingData; -} - -export interface ReceivedBrandingErrorAction { - type: 'RECEIVED_BRANDING_ERROR'; - error: string; -} - -type KnownAction = RequestBrandingAction | ReceivedBrandingAction | ReceivedBrandingErrorAction; - -export type ActionCreators = { - requestBranding: () => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestBranding: - (): AppThunk> => - async (dispatch): Promise => { - const url = `${ASSET_PREFIX}product.json`; - - await dispatch({ - type: 'REQUEST_BRANDING', - check: AUTHORIZED, - }); - - let branding: BrandingData; - try { - const receivedBranding = await fetchBranding(url); - branding = getBrandingData(receivedBranding); - dispatch({ - type: 'RECEIVED_BRANDING', - data: branding, - }); - } catch (e) { - const errorMessage = - `Failed to fetch branding data by URL: "${url}", reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: 'RECEIVED_BRANDING_ERROR', - error: errorMessage, - }); - throw errorMessage; - } - - // Use the products version if specified in product.json, otherwise use the default version given by che server - if (!branding.productVersion) { - try { - const apiInfo = await getApiInfo(); - branding.productVersion = apiInfo.implementationVersion; - - dispatch({ - type: 'RECEIVED_BRANDING', - data: branding, - }); - } catch (e) { - const errorMessage = - 'OPTIONS request to "/api/" failed, reason: ' + common.helpers.errors.getMessage(e); - dispatch({ - type: 'RECEIVED_BRANDING_ERROR', - error: errorMessage, - }); - throw errorMessage; - } - } - }, -}; - -const unloadedState: State = { - isLoading: false, - data: getBrandingData(), -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case 'REQUEST_BRANDING': - return createObject(state, { - isLoading: true, - error: undefined, - }); - case 'RECEIVED_BRANDING': - return createObject(state, { - isLoading: false, - data: action.data, - }); - case 'RECEIVED_BRANDING_ERROR': - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; - -async function getApiInfo(): Promise<{ - buildInfo: string; - implementationVendor: string; - implementationVersion: string; - scmRevision: string; - specificationTitle: string; - specificationVendor: string; - specificationVersion: string; -}> { - try { - const { data } = await axios.options('/api/'); - return data; - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - throw new Error(errorMessage); - } -} - -function getBrandingData(receivedBranding?: { [key: string]: any }): BrandingData { - let branding: BrandingData = Object.assign({}, BRANDING_DEFAULT); - - if (receivedBranding && Object.keys(receivedBranding).length > 0) { - branding = merge(branding, receivedBranding); - } - // resolve asset paths - const assetTitles: Array = ['logoFile', 'logoTextFile']; - assetTitles.forEach((asset: string) => { - const path = branding[asset] as string; - if (path.startsWith(ASSET_PREFIX)) { - return; - } - branding[asset] = ASSET_PREFIX + branding[asset]; - }); - - return branding; -} +export { actionCreators as brandingActionCreators } from '@/store/Branding/actions'; +export { reducer as brandingReducer, State as BrandingState } from '@/store/Branding/reducer'; +export * from '@/store/Branding/selectors'; diff --git a/packages/dashboard-frontend/src/store/Branding/reducer.ts b/packages/dashboard-frontend/src/store/Branding/reducer.ts new file mode 100644 index 000000000..1d4e1ffbc --- /dev/null +++ b/packages/dashboard-frontend/src/store/Branding/reducer.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { BrandingData } from '@/services/bootstrap/branding.constant'; +import { + brandingErrorAction, + brandingReceiveAction, + brandingRequestAction, + getBrandingData, +} from '@/store/Branding/actions'; + +export interface State { + isLoading: boolean; + data: BrandingData; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + data: getBrandingData(), +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(brandingRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(brandingReceiveAction, (state, action) => { + state.isLoading = false; + state.data = action.payload; + }) + .addCase(brandingErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Branding/selectors.ts b/packages/dashboard-frontend/src/store/Branding/selectors.ts index de365240d..2cf628ed4 100644 --- a/packages/dashboard-frontend/src/store/Branding/selectors.ts +++ b/packages/dashboard-frontend/src/store/Branding/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.branding; +const selectState = (state: RootState) => state.branding; export const selectBranding = createSelector(selectState, state => state.data); diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/actions.spec.ts new file mode 100644 index 000000000..2b2774257 --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/actions.spec.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { ClusterConfig } from '@eclipse-che/common'; + +import { fetchClusterConfig } from '@/services/backend-client/clusterConfigApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { bannerAddAction } from '@/store/BannerAlert/actions'; +import { + actionCreators, + clusterConfigErrorAction, + clusterConfigReceiveAction, + clusterConfigRequestAction, +} from '@/store/ClusterConfig/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/clusterConfigApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('requestClusterConfig action creator', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + it('should dispatch receive action and banner alert action on successful fetch', async () => { + const mockClusterConfig = { dashboardWarning: 'Warning message' }; + (fetchClusterConfig as jest.Mock).mockResolvedValue(mockClusterConfig); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + await store.dispatch(actionCreators.requestClusterConfig()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(clusterConfigRequestAction()); + expect(actions[1]).toEqual(clusterConfigReceiveAction(mockClusterConfig as ClusterConfig)); + expect(actions[2]).toEqual(bannerAddAction(mockClusterConfig.dashboardWarning)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + (fetchClusterConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestClusterConfig())).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(clusterConfigRequestAction()); + expect(actions[1]).toEqual( + clusterConfigErrorAction('Failed to fetch cluster configuration, reason: ' + errorMessage), + ); + }); +}); diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/index.spec.ts deleted file mode 100644 index 2762d47c3..000000000 --- a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/index.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { ClusterConfig } from '@eclipse-che/common'; -import mockAxios, { AxiosError } from 'axios'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; - -describe('clusterConfig store', () => { - const clusterConfig: ClusterConfig = { - dashboardWarning: 'Maintenance warning info', - allWorkspacesLimit: -1, - runningWorkspacesLimit: 1, - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('actions', () => { - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - beforeEach(() => { - appStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - }); - - it('should create REQUEST_CLUSTER_CONFIG and RECEIVE_CLUSTER_CONFIG when fetching cluster config', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: clusterConfig, - }); - - await appStore.dispatch(testStore.actionCreators.requestClusterConfig()); - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_CLUSTER_CONFIG, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_CLUSTER_CONFIG, - clusterConfig: clusterConfig, - }, - { - message: 'Maintenance warning info', - type: 'ADD_BANNER', - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_CLUSTER_CONFIG and RECEIVE_CLUSTER_CONFIG_ERROR when fails to fetch cluster config', async () => { - (mockAxios.get as jest.Mock).mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - try { - await appStore.dispatch(testStore.actionCreators.requestClusterConfig()); - } catch (e) { - // noop - } - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_CLUSTER_CONFIG, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_CLUSTER_CONFIG_ERROR, - error: expect.stringContaining('Something unexpected happened.'), - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('reducers', () => { - it('should return initial state', () => { - const incomingAction: testStore.RequestClusterConfigAction = { - type: testStore.Type.REQUEST_CLUSTER_CONFIG, - check: AUTHORIZED, - }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - }; - - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { - isLoading: true, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - }; - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_CLUSTER_CONFIG', () => { - const initialState: testStore.State = { - isLoading: false, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - error: 'unexpected error', - }; - const incomingAction: testStore.RequestClusterConfigAction = { - type: testStore.Type.REQUEST_CLUSTER_CONFIG, - check: AUTHORIZED, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_CLUSTER_INFO', () => { - const initialState: testStore.State = { - isLoading: true, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - }; - const incomingAction: testStore.ReceiveClusterConfigAction = { - type: testStore.Type.RECEIVE_CLUSTER_CONFIG, - clusterConfig, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - clusterConfig: { - dashboardWarning: 'Maintenance warning info', - runningWorkspacesLimit: 1, - allWorkspacesLimit: -1, - }, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_CLUSTER_CONFIG_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - }; - const incomingAction: testStore.ReceivedClusterConfigErrorAction = { - type: testStore.Type.RECEIVE_CLUSTER_CONFIG_ERROR, - error: 'unexpected error', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - clusterConfig: { runningWorkspacesLimit: 1, allWorkspacesLimit: -1 }, - error: 'unexpected error', - }; - - expect(newState).toEqual(expectedState); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/reducer.spec.ts new file mode 100644 index 000000000..e8f96b529 --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/reducer.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ClusterConfig } from '@eclipse-che/common'; + +import { + clusterConfigErrorAction, + clusterConfigReceiveAction, + clusterConfigRequestAction, +} from '@/store/ClusterConfig/actions'; +import { reducer, State, unloadedState } from '@/store/ClusterConfig/reducer'; + +describe('ClusterConfig, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle clusterConfigRequestAction', () => { + const action = clusterConfigRequestAction(); + const expectedState = { + clusterConfig: { + allWorkspacesLimit: -1, + runningWorkspacesLimit: 1, + }, + isLoading: true, + error: undefined, + } as State; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle clusterConfigReceiveAction', () => { + const clusterConfig: ClusterConfig = { + dashboardWarning: 'Maintenance warning info', + allWorkspacesLimit: -1, + runningWorkspacesLimit: 1, + }; + + const action = clusterConfigReceiveAction(clusterConfig); + + const expectedState = { + clusterConfig, + isLoading: false, + error: undefined, + } as State; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle clusterConfigErrorAction', () => { + const action = clusterConfigErrorAction('Error message'); + const expectedState = { + clusterConfig: { + allWorkspacesLimit: -1, + runningWorkspacesLimit: 1, + }, + isLoading: false, + error: 'Error message', + } as State; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' }; + const currentState = { + clusterConfig: { + allWorkspacesLimit: -1, + runningWorkspacesLimit: 1, + }, + isLoading: false, + error: undefined, + } as State; + + expect(reducer(currentState, unknownAction)).toEqual(currentState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/selectors.spec.ts index c9d8470e4..19f9e82b8 100644 --- a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/selectors.spec.ts @@ -10,69 +10,82 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; import { selectClusterConfigError, + selectDashboardFavicon, selectDashboardWarning, selectRunningWorkspacesLimit, } from '@/store/ClusterConfig/selectors'; -import * as store from '..'; - -describe('ClusterConfig', () => { - it('should return an error', () => { - const fakeStore = new FakeStoreBuilder() - .withClusterConfig(undefined, false, 'Something unexpected') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); +describe('ClusterConfig Selectors', () => { + const mockState = { + clusterConfig: { + clusterConfig: { + allWorkspacesLimit: -1, + dashboardWarning: 'A warning message', + runningWorkspacesLimit: 1, + dashboardFavicon: { + base64data: 'base64data', + mediatype: 'image/png', + }, + }, + isLoading: false, + error: 'Something unexpected', + }, + } as RootState; - const selectedError = selectClusterConfigError(state); - expect(selectedError).toEqual('Something unexpected'); + it('should select dashboard warning', () => { + const result = selectDashboardWarning(mockState); + expect(result).toEqual('A warning message'); }); - it('should return a dashboard warning', () => { - const fakeStore = new FakeStoreBuilder() - .withClusterConfig({ dashboardWarning: 'A warning message' }, false, 'Something unexpected') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should return empty string if dashboard warning is not available', () => { + const stateWithoutWarning = { + ...mockState, + clusterConfig: { + clusterConfig: { + dashboardWarning: undefined, + }, + }, + } as RootState; + const result = selectDashboardWarning(stateWithoutWarning); + expect(result).toEqual(''); + }); - const selectedInfo = selectDashboardWarning(state); - expect(selectedInfo).toEqual('A warning message'); + it('should select running workspaces limit', () => { + const result = selectRunningWorkspacesLimit(mockState); + expect(result).toEqual(1); }); - it('should return the default value for running workspaces limit', () => { - const fakeStore = new FakeStoreBuilder() - .withClusterConfig(undefined, false, 'Something unexpected') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should return default value for running workspaces limit if not set', () => { + const stateWithoutLimit = { + ...mockState, + clusterConfig: { ...mockState.clusterConfig, runningWorkspacesLimit: undefined }, + } as RootState; + const result = selectRunningWorkspacesLimit(stateWithoutLimit); + expect(result).toEqual(1); // Assuming 1 is the default value + }); - const runningLimit = selectRunningWorkspacesLimit(state); - expect(runningLimit).toEqual(1); + it('should select cluster config error', () => { + const result = selectClusterConfigError(mockState); + expect(result).toEqual('Something unexpected'); }); - it('should return a running workspaces limit', () => { - const fakeStore = new FakeStoreBuilder() - .withClusterConfig({ runningWorkspacesLimit: 2 }, false, 'Something unexpected') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should return undefined if cluster config error is not available', () => { + const stateWithoutError = { + ...mockState, + clusterConfig: { ...mockState.clusterConfig, error: undefined }, + } as RootState; + const result = selectClusterConfigError(stateWithoutError); + expect(result).toBeUndefined(); + }); - const runningLimit = selectRunningWorkspacesLimit(state); - expect(runningLimit).toEqual(2); + it('should select dashboard favicon', () => { + const result = selectDashboardFavicon(mockState); + expect(result).toEqual({ + base64data: 'base64data', + mediatype: 'image/png', + }); }); }); diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/actions.ts b/packages/dashboard-frontend/src/store/ClusterConfig/actions.ts new file mode 100644 index 000000000..6783cd898 --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterConfig/actions.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { ClusterConfig } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { fetchClusterConfig } from '@/services/backend-client/clusterConfigApi'; +import { AppThunk } from '@/store'; +import * as BannerAlertStore from '@/store/BannerAlert'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const clusterConfigRequestAction = createAction('clusterConfig/request'); +export const clusterConfigReceiveAction = createAction('clusterConfig/receive'); +export const clusterConfigErrorAction = createAction('clusterConfig/error'); + +export const actionCreators = { + requestClusterConfig: + (): AppThunk => + async (dispatch, getState): Promise => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(clusterConfigRequestAction()); + + const clusterConfig = await fetchClusterConfig(); + dispatch(clusterConfigReceiveAction(clusterConfig)); + + if (clusterConfig.dashboardWarning) { + dispatch( + BannerAlertStore.bannerAlertActionCreators.addBanner(clusterConfig.dashboardWarning), + ); + } + } catch (e) { + const errorMessage = + 'Failed to fetch cluster configuration, reason: ' + common.helpers.errors.getMessage(e); + dispatch(clusterConfigErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/index.ts b/packages/dashboard-frontend/src/store/ClusterConfig/index.ts index 81785a218..362624c0a 100644 --- a/packages/dashboard-frontend/src/store/ClusterConfig/index.ts +++ b/packages/dashboard-frontend/src/store/ClusterConfig/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,118 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -import common, { ClusterConfig } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { fetchClusterConfig } from '@/services/backend-client/clusterConfigApi'; -import * as BannerAlertStore from '@/store/BannerAlert'; -import { AddBannerAction } from '@/store/BannerAlert'; -import { createObject } from '@/store/helpers'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export interface State { - isLoading: boolean; - clusterConfig: ClusterConfig; - error?: string; -} - -export enum Type { - REQUEST_CLUSTER_CONFIG = 'REQUEST_CLUSTER_CONFIG', - RECEIVE_CLUSTER_CONFIG = 'RECEIVE_CLUSTER_CONFIG', - RECEIVE_CLUSTER_CONFIG_ERROR = 'RECEIVE_CLUSTER_CONFIG_ERROR', -} - -export interface RequestClusterConfigAction extends Action, SanityCheckAction { - type: Type.REQUEST_CLUSTER_CONFIG; -} - -export interface ReceiveClusterConfigAction { - type: Type.RECEIVE_CLUSTER_CONFIG; - clusterConfig: ClusterConfig; -} - -export interface ReceivedClusterConfigErrorAction { - type: Type.RECEIVE_CLUSTER_CONFIG_ERROR; - error: string; -} - -export type KnownAction = - | RequestClusterConfigAction - | ReceiveClusterConfigAction - | ReceivedClusterConfigErrorAction - | AddBannerAction; - -export type ActionCreators = { - requestClusterConfig: () => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestClusterConfig: - (): AppThunk> => - async (dispatch): Promise => { - await dispatch({ - type: Type.REQUEST_CLUSTER_CONFIG, - check: AUTHORIZED, - }); - - try { - const clusterConfig = await fetchClusterConfig(); - dispatch({ - type: Type.RECEIVE_CLUSTER_CONFIG, - clusterConfig, - }); - - if (clusterConfig.dashboardWarning) { - dispatch(BannerAlertStore.actionCreators.addBanner(clusterConfig.dashboardWarning)); - } - } catch (e) { - const errorMessage = - 'Failed to fetch cluster configuration, reason: ' + common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_CLUSTER_CONFIG_ERROR, - error: errorMessage, - }); - throw errorMessage; - } - }, -}; - -const unloadedState: State = { - isLoading: false, - clusterConfig: { - runningWorkspacesLimit: 1, - allWorkspacesLimit: -1, - }, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_CLUSTER_CONFIG: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_CLUSTER_CONFIG: - return createObject(state, { - isLoading: false, - clusterConfig: action.clusterConfig, - }); - case Type.RECEIVE_CLUSTER_CONFIG_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as clusterConfigActionCreators } from '@/store/ClusterConfig/actions'; +export { + reducer as clusterConfigReducer, + State as ClusterConfigState, +} from '@/store/ClusterConfig/reducer'; +export * from '@/store/ClusterConfig/selectors'; diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/reducer.ts b/packages/dashboard-frontend/src/store/ClusterConfig/reducer.ts new file mode 100644 index 000000000..5b91578e5 --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterConfig/reducer.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ClusterConfig } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + clusterConfigErrorAction, + clusterConfigReceiveAction, + clusterConfigRequestAction, +} from '@/store/ClusterConfig/actions'; + +export interface State { + isLoading: boolean; + clusterConfig: ClusterConfig; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + clusterConfig: { + runningWorkspacesLimit: 1, + allWorkspacesLimit: -1, + }, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(clusterConfigRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(clusterConfigReceiveAction, (state, action) => { + state.isLoading = false; + state.clusterConfig = action.payload; + }) + .addCase(clusterConfigErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts b/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts index 2e9beedd0..f3c6594a8 100644 --- a/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts +++ b/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts @@ -10,21 +10,20 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.clusterConfig; +const selectState = (state: RootState) => state.clusterConfig; export const selectDashboardWarning = createSelector( selectState, - state => state.clusterConfig.dashboardWarning || [], + state => state.clusterConfig.dashboardWarning || '', ); -export const selectRunningWorkspacesLimit = createSelector( - selectState, - state => state.clusterConfig.runningWorkspacesLimit, -); +export const selectRunningWorkspacesLimit = createSelector(selectState, state => { + return state.clusterConfig.runningWorkspacesLimit; +}); export const selectAllWorkspacesLimit = createSelector( selectState, diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/actions.spec.ts new file mode 100644 index 000000000..38be3a243 --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/actions.spec.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { ClusterInfo } from '@eclipse-che/common'; + +import { fetchClusterInfo } from '@/services/backend-client/clusterInfoApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + clusterInfoErrorAction, + clusterInfoReceiveAction, + clusterInfoRequestAction, +} from '@/store/ClusterInfo/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/clusterInfoApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('requestClusterInfo action creator', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + it('should dispatch receive action on successful fetch', async () => { + const mockClusterInfo = { + applications: [ + { title: 'app1', icon: 'icon1', url: 'url1' }, + { title: 'app2', icon: 'icon2', url: 'url2' }, + ], + } as ClusterInfo; + (fetchClusterInfo as jest.Mock).mockResolvedValue(mockClusterInfo); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + await store.dispatch(actionCreators.requestClusterInfo()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(clusterInfoRequestAction()); + expect(actions[1]).toEqual(clusterInfoReceiveAction(mockClusterInfo)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + (fetchClusterInfo as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestClusterInfo())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(clusterInfoRequestAction()); + expect(actions[1]).toEqual( + clusterInfoErrorAction('Failed to fetch cluster properties, reason: ' + errorMessage), + ); + }); +}); diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/index.spec.ts deleted file mode 100644 index 0ea4fabd2..000000000 --- a/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/index.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { ApplicationId, ClusterInfo } from '@eclipse-che/common'; -import mockAxios, { AxiosError } from 'axios'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; - -describe('clusterInfo store', () => { - const clusterInfo: ClusterInfo = { - applications: [ - { - id: ApplicationId.CLUSTER_CONSOLE, - url: 'web/console/url', - title: 'Web Console', - icon: 'web/console/icon.png', - }, - ], - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('actions', () => { - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - beforeEach(() => { - appStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - }); - - it('should create REQUEST_CLUSTER_INFO and RECEIVE_CLUSTER_INFO when fetching cluster info', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: clusterInfo, - }); - - await appStore.dispatch(testStore.actionCreators.requestClusterInfo()); - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_CLUSTER_INFO, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_CLUSTER_INFO, - clusterInfo: clusterInfo, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_CLUSTER_INFO and RECEIVE_CLUSTER_INFO_ERROR when fails to fetch cluster info', async () => { - (mockAxios.get as jest.Mock).mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - try { - await appStore.dispatch(testStore.actionCreators.requestClusterInfo()); - } catch (e) { - // noop - } - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_CLUSTER_INFO, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_CLUSTER_INFO_ERROR, - error: expect.stringContaining('Something unexpected happened.'), - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('reducers', () => { - it('should return initial state', () => { - const incomingAction: testStore.RequestClusterInfoAction = { - type: testStore.Type.REQUEST_CLUSTER_INFO, - check: AUTHORIZED, - }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - clusterInfo: { - applications: [], - }, - }; - - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { - isLoading: true, - clusterInfo: { - applications: [], - }, - }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - clusterInfo: { - applications: [], - }, - }; - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_CLUSTER_INFO', () => { - const initialState: testStore.State = { - isLoading: false, - clusterInfo: { - applications: [], - }, - error: 'unexpected error', - }; - const incomingAction: testStore.RequestClusterInfoAction = { - type: testStore.Type.REQUEST_CLUSTER_INFO, - check: AUTHORIZED, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - clusterInfo: { - applications: [], - }, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_CLUSTER_INFO', () => { - const initialState: testStore.State = { - isLoading: true, - clusterInfo: { - applications: [], - }, - }; - const incomingAction: testStore.ReceiveClusterInfoAction = { - type: testStore.Type.RECEIVE_CLUSTER_INFO, - clusterInfo, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - clusterInfo: { - applications: [ - { - id: ApplicationId.CLUSTER_CONSOLE, - url: 'web/console/url', - title: 'Web Console', - icon: 'web/console/icon.png', - }, - ], - }, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_CLUSTER_INFO_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - clusterInfo: { - applications: [], - }, - }; - const incomingAction: testStore.ReceivedClusterInfoErrorAction = { - type: testStore.Type.RECEIVE_CLUSTER_INFO_ERROR, - error: 'unexpected error', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - clusterInfo: { - applications: [], - }, - error: 'unexpected error', - }; - - expect(newState).toEqual(expectedState); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/reducer.spec.ts new file mode 100644 index 000000000..0f60b1f9b --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/reducer.spec.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ClusterInfo } from '@eclipse-che/common'; + +import { + clusterInfoErrorAction, + clusterInfoReceiveAction, + clusterInfoRequestAction, +} from '@/store/ClusterInfo/actions'; +import { reducer, State, unloadedState } from '@/store/ClusterInfo/reducer'; + +describe('ClusterInfo reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle clusterInfoRequestAction', () => { + const action = clusterInfoRequestAction(); + const expectedState = { + clusterInfo: { + applications: [], + }, + isLoading: true, + error: undefined, + } as State; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle clusterInfoReceiveAction', () => { + const clusterInfo: ClusterInfo = { + applications: [{ title: 'app1', icon: 'icon1', url: 'url1' }], + }; + + const action = clusterInfoReceiveAction(clusterInfo); + + const expectedState = { + clusterInfo, + isLoading: false, + error: undefined, + } as State; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle clusterInfoErrorAction', () => { + const action = clusterInfoErrorAction('Error message'); + const expectedState = { + clusterInfo: { + applications: [], + }, + isLoading: false, + error: 'Error message', + } as State; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' }; + const currentState = { + clusterInfo: { + applications: [], + }, + isLoading: false, + error: undefined, + } as State; + + expect(reducer(currentState, unknownAction)).toEqual(currentState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/selectors.spec.ts index f3dd50c38..c0c8a3067 100644 --- a/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/ClusterInfo/__tests__/selectors.spec.ts @@ -10,51 +10,47 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; import { selectApplications, selectClusterInfoError } from '@/store/ClusterInfo/selectors'; -import * as store from '..'; - -const applications = [ - { - title: 'App1', - url: 'my/app/1', - icon: 'my/app/1/logo', - }, - { - title: 'App2', - url: 'my/app/2', - icon: 'my/app/2/logo', - }, -]; describe('ClusterInfo', () => { - it('should return an error', () => { - const fakeStore = new FakeStoreBuilder() - .withClusterInfo({ applications }, false, 'Something unexpected') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + const applications = [ + { + title: 'App1', + url: 'my/app/1', + icon: 'my/app/1/logo', + }, + ]; + const mockState = { + clusterInfo: { + clusterInfo: { + applications, + }, + isLoading: false, + error: 'Something unexpected', + }, + } as Partial as RootState; - const selectedError = selectClusterInfoError(state); - expect(selectedError).toEqual('Something unexpected'); + it('should return all applications', () => { + const selectedApps = selectApplications(mockState); + expect(selectedApps).toEqual(applications); }); - it('should return all applications', () => { - const fakeStore = new FakeStoreBuilder() - .withClusterInfo({ applications }, false, 'Something unexpected') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should return an empty array if there are no applications', () => { + const stateWithoutApps = { + ...mockState, + clusterInfo: { + clusterInfo: { + applications: undefined, + } as unknown, + }, + } as RootState; + const selectedApps = selectApplications(stateWithoutApps); + expect(selectedApps).toEqual([]); + }); - const selectedApps = selectApplications(state); - expect(selectedApps).toEqual(applications); + it('should return an error', () => { + const selectedError = selectClusterInfoError(mockState); + expect(selectedError).toEqual('Something unexpected'); }); }); diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/actions.ts b/packages/dashboard-frontend/src/store/ClusterInfo/actions.ts new file mode 100644 index 000000000..1f07de9f3 --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterInfo/actions.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { ClusterInfo } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { fetchClusterInfo } from '@/services/backend-client/clusterInfoApi'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const clusterInfoRequestAction = createAction('clusterInfo/request'); +export const clusterInfoReceiveAction = createAction('clusterInfo/receive'); +export const clusterInfoErrorAction = createAction('clusterInfo/receiveError'); + +export const actionCreators = { + requestClusterInfo: + (): AppThunk => + async (dispatch, getState): Promise => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(clusterInfoRequestAction()); + + const clusterInfo = await fetchClusterInfo(); + dispatch(clusterInfoReceiveAction(clusterInfo)); + } catch (e) { + const errorMessage = + 'Failed to fetch cluster properties, reason: ' + common.helpers.errors.getMessage(e); + dispatch(clusterInfoErrorAction(errorMessage)); + throw new Error(errorMessage); + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/index.ts b/packages/dashboard-frontend/src/store/ClusterInfo/index.ts index d13aebc10..7cca93585 100644 --- a/packages/dashboard-frontend/src/store/ClusterInfo/index.ts +++ b/packages/dashboard-frontend/src/store/ClusterInfo/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,110 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -import common, { ClusterInfo } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { fetchClusterInfo } from '@/services/backend-client/clusterInfoApi'; -import { createObject } from '@/store/helpers'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export interface State { - isLoading: boolean; - clusterInfo: ClusterInfo; - error?: string; -} - -export enum Type { - REQUEST_CLUSTER_INFO = 'REQUEST_CLUSTER_INFO', - RECEIVE_CLUSTER_INFO = 'RECEIVE_CLUSTER_INFO', - RECEIVE_CLUSTER_INFO_ERROR = 'RECEIVE_CLUSTER_INFO_ERROR', -} - -export interface RequestClusterInfoAction extends Action, SanityCheckAction { - type: Type.REQUEST_CLUSTER_INFO; -} - -export interface ReceiveClusterInfoAction { - type: Type.RECEIVE_CLUSTER_INFO; - clusterInfo: ClusterInfo; -} - -export interface ReceivedClusterInfoErrorAction { - type: Type.RECEIVE_CLUSTER_INFO_ERROR; - error: string; -} - -export type KnownAction = - | RequestClusterInfoAction - | ReceiveClusterInfoAction - | ReceivedClusterInfoErrorAction; - -export type ActionCreators = { - requestClusterInfo: () => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestClusterInfo: - (): AppThunk> => - async (dispatch): Promise => { - await dispatch({ - type: Type.REQUEST_CLUSTER_INFO, - check: AUTHORIZED, - }); - - try { - const clusterInfo = await fetchClusterInfo(); - dispatch({ - type: Type.RECEIVE_CLUSTER_INFO, - clusterInfo, - }); - } catch (e) { - const errorMessage = - 'Failed to fetch cluster properties, reason: ' + common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_CLUSTER_INFO_ERROR, - error: errorMessage, - }); - throw errorMessage; - } - }, -}; - -const unloadedState: State = { - isLoading: false, - clusterInfo: { - applications: [], - }, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_CLUSTER_INFO: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_CLUSTER_INFO: - return createObject(state, { - isLoading: false, - clusterInfo: action.clusterInfo, - }); - case Type.RECEIVE_CLUSTER_INFO_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as clusterInfoActionCreators } from '@/store/ClusterInfo/actions'; +export { + reducer as clusterInfoReducer, + State as ClusterInfoState, +} from '@/store/ClusterInfo/reducer'; +export * from '@/store/ClusterInfo/selectors'; diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/reducer.ts b/packages/dashboard-frontend/src/store/ClusterInfo/reducer.ts new file mode 100644 index 000000000..098a8804c --- /dev/null +++ b/packages/dashboard-frontend/src/store/ClusterInfo/reducer.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ClusterInfo } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + clusterInfoErrorAction, + clusterInfoReceiveAction, + clusterInfoRequestAction, +} from '@/store/ClusterInfo/actions'; + +export interface State { + isLoading: boolean; + clusterInfo: ClusterInfo; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + clusterInfo: { + applications: [], + }, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(clusterInfoRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(clusterInfoReceiveAction, (state, action) => { + state.isLoading = false; + state.clusterInfo = action.payload; + }) + .addCase(clusterInfoErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/ClusterInfo/selectors.ts b/packages/dashboard-frontend/src/store/ClusterInfo/selectors.ts index d169eecf3..ecba861b8 100644 --- a/packages/dashboard-frontend/src/store/ClusterInfo/selectors.ts +++ b/packages/dashboard-frontend/src/store/ClusterInfo/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.clusterInfo; +const selectState = (state: RootState) => state.clusterInfo; export const selectApplications = createSelector( selectState, diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/actions.spec.ts new file mode 100644 index 000000000..3319dc2f1 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/actions.spec.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import { isRunningDevWorkspacesClusterLimitExceeded } from '@/services/backend-client/devWorkspaceClusterApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + devWorkspacesClusterErrorAction, + devWorkspacesClusterReceiveAction, + devWorkspacesClusterRequestAction, +} from '@/store/DevWorkspacesCluster/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/devWorkspaceClusterApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('DevfileRegistries Actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestRunningDevWorkspacesClusterLimitExceeded', () => { + it('should dispatch receive action on successful fetch', async () => { + (isRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockResolvedValue(true); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + await store.dispatch(actionCreators.requestRunningDevWorkspacesClusterLimitExceeded()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(devWorkspacesClusterRequestAction()); + expect(actions[1]).toEqual(devWorkspacesClusterReceiveAction(true)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + (isRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockRejectedValue( + new Error(errorMessage), + ); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.requestRunningDevWorkspacesClusterLimitExceeded()), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(devWorkspacesClusterRequestAction()); + expect(actions[1]).toEqual(devWorkspacesClusterErrorAction(errorMessage)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/devWorkspacesCluster.spec.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/devWorkspacesCluster.spec.ts deleted file mode 100644 index f07cb55fc..000000000 --- a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/devWorkspacesCluster.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { isRunningDevWorkspacesClusterLimitExceeded } from '@/services/backend-client/devWorkspaceClusterApi'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { - actionCreators, - checkRunningDevWorkspacesClusterLimitExceeded, - reducer, - RunningDevWorkspacesClusterLimitExceededError, - unloadedState, -} from '@/store/DevWorkspacesCluster'; -import * as testStore from '@/store/DevWorkspacesCluster'; -import { selectRunningDevWorkspacesClusterLimitExceeded } from '@/store/DevWorkspacesCluster/selectors'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -jest.mock('@/services/backend-client/devWorkspaceClusterApi'); - -describe('Test DevWorkspaceCluster selector', () => { - it('test selector', () => { - const store = new FakeStoreBuilder() - .withDevWorkspacesCluster({ - isRunningDevWorkspacesClusterLimitExceeded: true, - }) - .build(); - const state = store.getState(); - - const isRunningDevWorkspacesClusterLimitExceeded = - selectRunningDevWorkspacesClusterLimitExceeded.apply(null, [state]); - - expect(isRunningDevWorkspacesClusterLimitExceeded).toEqual(true); - }); -}); - -describe('Test checkRunningDevWorkspacesClusterLimitExceeded', () => { - it('checkRunningDevWorkspacesClusterLimitExceeded should throw error', () => { - const store = new FakeStoreBuilder() - .withDevWorkspacesCluster({ - isRunningDevWorkspacesClusterLimitExceeded: true, - }) - .build(); - const state = store.getState(); - - try { - checkRunningDevWorkspacesClusterLimitExceeded(state); - fail('Expected an error'); - } catch (error) { - expect(error).toBeInstanceOf(RunningDevWorkspacesClusterLimitExceededError); - } - }); - - it('checkRunningDevWorkspacesClusterLimitExceeded should not throw error', () => { - const store = new FakeStoreBuilder() - .withDevWorkspacesCluster({ - isRunningDevWorkspacesClusterLimitExceeded: false, - }) - .build(); - const state = store.getState(); - - try { - checkRunningDevWorkspacesClusterLimitExceeded(state); - } catch (error) { - fail('Unexpected error'); - } - }); -}); - -describe('Test DevWorkspaceCluster reducer', () => { - const state = { - isLoading: false, - error: undefined, - isRunningDevWorkspacesClusterLimitExceeded: false, - }; - - it('should return unloaded state', () => { - const state = reducer.apply(null, [undefined, { type: 'REQUEST_DEVWORKSPACES_CLUSTER' }]); - - expect(state).toEqual(unloadedState); - }); - - it('Test REQUEST_DEVWORKSPACES_CLUSTER action', () => { - const result = reducer.apply(null, [state, { type: 'REQUEST_DEVWORKSPACES_CLUSTER' }]); - - expect(result.isLoading).toEqual(true); - expect(result.error).toBeUndefined(); - expect(result.isRunningDevWorkspacesClusterLimitExceeded).toBeFalsy(); - }); - - it('Test RECEIVED_DEVWORKSPACES_CLUSTER action', () => { - const result = reducer.apply(null, [ - state, - { type: 'RECEIVED_DEVWORKSPACES_CLUSTER', isRunningDevWorkspacesClusterLimitExceeded: true }, - ]); - - expect(result.isLoading).toEqual(false); - expect(result.error).toBeUndefined(); - expect(result.isRunningDevWorkspacesClusterLimitExceeded).toBeTruthy(); - }); - - it('Test RECEIVED_DEVWORKSPACES_CLUSTER_ERROR action', () => { - const result = reducer.apply(null, [ - state, - { type: 'RECEIVED_DEVWORKSPACES_CLUSTER_ERROR', error: 'error' }, - ]); - - expect(result.isLoading).toEqual(false); - expect(result.error).toEqual('error'); - expect(result.isRunningDevWorkspacesClusterLimitExceeded).toBeFalsy(); - }); -}); - -describe('Test DevWorkspaceCluster ActionCreators', () => { - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - beforeEach(() => { - appStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('Test dispatch RECEIVED_DEVWORKSPACES_CLUSTER action', async () => { - const spyDispatch = jest.spyOn(appStore, 'dispatch'); - - (isRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockReturnValue( - Promise.resolve(true), - ); - - await actionCreators - .requestRunningDevWorkspacesClusterLimitExceeded() - .apply(null, [appStore.dispatch, appStore.getState, null]); - - expect(spyDispatch).toHaveBeenCalledTimes(2); - expect(spyDispatch).toHaveBeenCalledWith({ - type: 'REQUEST_DEVWORKSPACES_CLUSTER', - check: AUTHORIZED, - }); - expect(spyDispatch).toHaveBeenCalledWith({ - type: 'RECEIVED_DEVWORKSPACES_CLUSTER', - isRunningDevWorkspacesClusterLimitExceeded: true, - }); - }); - - it('Test dispatch RECEIVED_DEVWORKSPACES_CLUSTER_ERROR action', async () => { - const spyDispatch = jest.spyOn(appStore, 'dispatch'); - - (isRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockRejectedValueOnce( - new Error('error'), - ); - - try { - await actionCreators - .requestRunningDevWorkspacesClusterLimitExceeded() - .apply(null, [appStore.dispatch, appStore.getState, null]); - fail('Expected an error'); - } catch (e) { - expect(spyDispatch).toHaveBeenCalledTimes(2); - expect(spyDispatch).toHaveBeenCalledWith({ - type: 'REQUEST_DEVWORKSPACES_CLUSTER', - check: AUTHORIZED, - }); - expect(spyDispatch).toHaveBeenCalledWith({ - type: 'RECEIVED_DEVWORKSPACES_CLUSTER_ERROR', - error: 'error', - }); - } - }); -}); diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/index.spec.ts new file mode 100644 index 000000000..316ac8c50 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/index.spec.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { + checkRunningDevWorkspacesClusterLimitExceeded, + RunningDevWorkspacesClusterLimitExceededError, + throwRunningDevWorkspacesClusterLimitExceededError, +} from '@/store/DevWorkspacesCluster'; + +const mockSelectRunningDevWorkspacesClusterLimitExceeded = jest.fn(); +jest.mock('@/store/DevWorkspacesCluster/selectors', () => ({ + selectRunningDevWorkspacesClusterLimitExceeded: () => + mockSelectRunningDevWorkspacesClusterLimitExceeded(), +})); + +describe('DevWorkspacesCluster index', () => { + describe('RunningDevWorkspacesClusterLimitExceededError', () => { + it('should create an error with the correct message and name', () => { + const error = new RunningDevWorkspacesClusterLimitExceededError('Test message'); + expect(error.message).toBe('Test message'); + expect(error.name).toBe('RunningDevWorkspacesClusterLimitExceededError'); + }); + }); + + describe('checkRunningDevWorkspacesClusterLimitExceeded', () => { + it('should not throw an error if the limit is not exceeded', () => { + mockSelectRunningDevWorkspacesClusterLimitExceeded.mockReturnValue(false); + const mockState = {} as RootState; + + expect(() => checkRunningDevWorkspacesClusterLimitExceeded(mockState)).not.toThrow(); + }); + + it('should throw an error if the limit is exceeded', () => { + mockSelectRunningDevWorkspacesClusterLimitExceeded.mockReturnValue(true); + const mockState = {} as RootState; + + expect(() => checkRunningDevWorkspacesClusterLimitExceeded(mockState)).toThrow( + RunningDevWorkspacesClusterLimitExceededError, + ); + }); + }); + + describe('throwRunningDevWorkspacesClusterLimitExceededError', () => { + it('should throw RunningDevWorkspacesClusterLimitExceededError with the correct message', () => { + expect(() => throwRunningDevWorkspacesClusterLimitExceededError()).toThrow( + new RunningDevWorkspacesClusterLimitExceededError( + 'Exceeded the cluster limit for running DevWorkspaces', + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/reducer.spec.ts new file mode 100644 index 000000000..a6fa9c756 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/reducer.spec.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import { + devWorkspacesClusterErrorAction, + devWorkspacesClusterReceiveAction, + devWorkspacesClusterRequestAction, +} from '@/store/DevWorkspacesCluster/actions'; +import { reducer, State, unloadedState } from '@/store/DevWorkspacesCluster/reducer'; + +describe('DevWorkspacesCluster, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle devWorkspacesClusterRequestAction', () => { + const action = devWorkspacesClusterRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + error: undefined, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle devWorkspacesClusterReceiveAction', () => { + const action = devWorkspacesClusterReceiveAction(true); + const expectedState: State = { + ...initialState, + isLoading: false, + isRunningDevWorkspacesClusterLimitExceeded: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle devWorkspacesClusterErrorAction', () => { + const action = devWorkspacesClusterErrorAction('Error message'); + const expectedState: State = { + ...initialState, + isLoading: false, + error: 'Error message', + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(unloadedState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/selectors.spec.ts new file mode 100644 index 000000000..1394c402a --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/__tests__/selectors.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { selectRunningDevWorkspacesClusterLimitExceeded } from '@/store/DevWorkspacesCluster/selectors'; + +describe('DevWorkspacesCluster Selectors', () => { + const mockState = { + devWorkspacesCluster: { + isRunningDevWorkspacesClusterLimitExceeded: true, + }, + } as RootState; + + it('should select if running dev workspaces cluster limit is exceeded', () => { + const result = selectRunningDevWorkspacesClusterLimitExceeded(mockState); + expect(result).toBe(true); + }); + + it('should return false if running dev workspaces cluster limit is not exceeded', () => { + const stateWithLimitNotExceeded = { + devWorkspacesCluster: { + isRunningDevWorkspacesClusterLimitExceeded: false, + }, + } as RootState; + const result = selectRunningDevWorkspacesClusterLimitExceeded(stateWithLimitNotExceeded); + expect(result).toBe(false); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/actions.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/actions.ts new file mode 100644 index 000000000..f363de464 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/actions.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { isRunningDevWorkspacesClusterLimitExceeded } from '@/services/backend-client/devWorkspaceClusterApi'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const devWorkspacesClusterRequestAction = createAction('devWorkspaceCluster/request'); +export const devWorkspacesClusterReceiveAction = createAction( + 'devWorkspaceCluster/receive', +); +export const devWorkspacesClusterErrorAction = createAction('devWorkspaceCluster/error'); + +export const actionCreators = { + requestRunningDevWorkspacesClusterLimitExceeded: + (): AppThunk => + async (dispatch, getState): Promise => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesClusterRequestAction()); + + const isLimitExceeded = await isRunningDevWorkspacesClusterLimitExceeded(); + dispatch(devWorkspacesClusterReceiveAction(isLimitExceeded)); + } catch (e) { + dispatch(devWorkspacesClusterErrorAction(common.helpers.errors.getMessage(e))); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/index.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/index.ts index 3f8c9dc02..e71a70723 100644 --- a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/index.ts +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,21 +12,17 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { isRunningDevWorkspacesClusterLimitExceeded } from '@/services/backend-client/devWorkspaceClusterApi'; +import { RootState } from '@/store'; import { selectRunningDevWorkspacesClusterLimitExceeded } from '@/store/DevWorkspacesCluster/selectors'; -import { createObject } from '@/store/helpers'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; -import { AppState, AppThunk } from '..'; +export { actionCreators as devWorkspacesClusterActionCreators } from '@/store/DevWorkspacesCluster/actions'; +export { + State as DevWorkspaceClusterState, + reducer as devWorkspacesClusterReducer, +} from '@/store/DevWorkspacesCluster/reducer'; +export * from '@/store/DevWorkspacesCluster/selectors'; -export interface State { - isLoading: boolean; - isRunningDevWorkspacesClusterLimitExceeded: boolean; - error?: string; -} +/* c8 ignore stop */ export class RunningDevWorkspacesClusterLimitExceededError extends Error { constructor(message: string) { @@ -33,7 +31,7 @@ export class RunningDevWorkspacesClusterLimitExceededError extends Error { } } -export function checkRunningDevWorkspacesClusterLimitExceeded(state: AppState) { +export function checkRunningDevWorkspacesClusterLimitExceeded(state: RootState) { const runningLimitExceeded = selectRunningDevWorkspacesClusterLimitExceeded(state); if (runningLimitExceeded === false) { return; @@ -47,93 +45,3 @@ export function throwRunningDevWorkspacesClusterLimitExceededError() { 'Exceeded the cluster limit for running DevWorkspaces', ); } - -export enum Type { - REQUEST_DEVWORKSPACES_CLUSTER = 'REQUEST_DEVWORKSPACES_CLUSTER', - RECEIVED_DEVWORKSPACES_CLUSTER = 'RECEIVED_DEVWORKSPACES_CLUSTER', - RECEIVED_DEVWORKSPACES_CLUSTER_ERROR = 'RECEIVED_DEVWORKSPACES_CLUSTER_ERROR', -} - -export interface RequestDevWorkspacesClusterAction extends Action, SanityCheckAction { - type: 'REQUEST_DEVWORKSPACES_CLUSTER'; -} - -export interface ReceivedDevWorkspacesClusterAction { - type: 'RECEIVED_DEVWORKSPACES_CLUSTER'; - isRunningDevWorkspacesClusterLimitExceeded: boolean; -} - -export interface ReceivedDevWorkspacesClusterErrorAction { - type: 'RECEIVED_DEVWORKSPACES_CLUSTER_ERROR'; - error: string; -} - -export type KnownAction = - | RequestDevWorkspacesClusterAction - | ReceivedDevWorkspacesClusterAction - | ReceivedDevWorkspacesClusterErrorAction; - -export type ActionCreators = { - requestRunningDevWorkspacesClusterLimitExceeded: () => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestRunningDevWorkspacesClusterLimitExceeded: - (): AppThunk> => - async (dispatch): Promise => { - dispatch({ - type: 'REQUEST_DEVWORKSPACES_CLUSTER', - check: AUTHORIZED, - }); - - try { - const isLimitExceeded = await isRunningDevWorkspacesClusterLimitExceeded(); - dispatch({ - type: 'RECEIVED_DEVWORKSPACES_CLUSTER', - isRunningDevWorkspacesClusterLimitExceeded: isLimitExceeded, - }); - } catch (e) { - dispatch({ - type: 'RECEIVED_DEVWORKSPACES_CLUSTER_ERROR', - error: common.helpers.errors.getMessage(e), - }); - throw e; - } - }, -}; - -export const unloadedState: State = { - isLoading: false, - isRunningDevWorkspacesClusterLimitExceeded: false, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case 'REQUEST_DEVWORKSPACES_CLUSTER': - return createObject(state, { - isLoading: true, - error: undefined, - }); - case 'RECEIVED_DEVWORKSPACES_CLUSTER': - return createObject(state, { - isLoading: false, - isRunningDevWorkspacesClusterLimitExceeded: - action.isRunningDevWorkspacesClusterLimitExceeded, - }); - case 'RECEIVED_DEVWORKSPACES_CLUSTER_ERROR': - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/reducer.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/reducer.ts new file mode 100644 index 000000000..90d183f0f --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/reducer.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { + devWorkspacesClusterErrorAction, + devWorkspacesClusterReceiveAction, + devWorkspacesClusterRequestAction, +} from '@/store/DevWorkspacesCluster/actions'; + +export interface State { + isLoading: boolean; + isRunningDevWorkspacesClusterLimitExceeded: boolean; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + isRunningDevWorkspacesClusterLimitExceeded: false, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(devWorkspacesClusterRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(devWorkspacesClusterReceiveAction, (state, action) => { + state.isLoading = false; + state.isRunningDevWorkspacesClusterLimitExceeded = action.payload; + }) + .addCase(devWorkspacesClusterErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/selectors.ts b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/selectors.ts index 393514ae1..aa3c65758 100644 --- a/packages/dashboard-frontend/src/store/DevWorkspacesCluster/selectors.ts +++ b/packages/dashboard-frontend/src/store/DevWorkspacesCluster/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.devWorkspacesCluster; +const selectState = (state: RootState) => state.devWorkspacesCluster; export const selectRunningDevWorkspacesClusterLimitExceeded = createSelector( selectState, diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/actions.spec.ts new file mode 100644 index 000000000..111bc0a51 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/actions.spec.ts @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { che } from '@/services/models'; +import { fetchDevfile, fetchRegistryMetadata } from '@/services/registry/devfiles'; +import { fetchResources, loadResourcesContent } from '@/services/registry/resources'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + devfileReceiveAction, + devfileRequestAction, + filterClearAction, + filterSetAction, + registryMetadataErrorAction, + registryMetadataReceiveAction, + registryMetadataRequestAction, + resourcesErrorAction, + resourcesReceiveAction, + resourcesRequestAction, +} from '@/store/DevfileRegistries/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/registry/devfiles'); +jest.mock('@/services/registry/resources'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('DevfileRegistries, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestRegistriesMetadata', () => { + it('should dispatch receive action on successful fetch the internal registry', async () => { + const mockMetadata = [{ displayName: 'devfile1' }] as che.DevfileMetaData[]; + (fetchRegistryMetadata as jest.Mock).mockResolvedValue(mockMetadata); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + await store.dispatch(actionCreators.requestRegistriesMetadata('url1 url2', false)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(registryMetadataRequestAction()); + expect(actions[1]).toEqual(registryMetadataRequestAction()); + expect(actions[2]).toEqual( + registryMetadataReceiveAction({ url: 'url1', metadata: mockMetadata }), + ); + expect(actions[3]).toEqual( + registryMetadataReceiveAction({ url: 'url2', metadata: mockMetadata }), + ); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + (fetchRegistryMetadata as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.requestRegistriesMetadata('url1', false)), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(registryMetadataRequestAction()); + expect(actions[1]).toEqual( + registryMetadataErrorAction({ + url: 'url1', + error: errorMessage, + }), + ); + }); + }); + + describe('requestDevfile', () => { + it('dispatch receive action on successful fetch', async () => { + const mockDevfile = 'devfile content'; + (fetchDevfile as jest.Mock).mockResolvedValue(mockDevfile); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + const result = await store.dispatch(actionCreators.requestDevfile('url')); + + const actions = store.getActions(); + expect(actions[0]).toEqual(devfileRequestAction()); + expect(actions[1]).toEqual(devfileReceiveAction({ url: 'url', devfile: mockDevfile })); + expect(result).toEqual(mockDevfile); + }); + + it('should throw error on failed fetch', async () => { + const errorMessage = 'Network error'; + (fetchDevfile as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + await expect(store.dispatch(actionCreators.requestDevfile('url'))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(devfileRequestAction()); + }); + }); + + describe('requestResources', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockResourcesContent = 'resources content'; + const mockResources = [ + { kind: 'DevWorkspace', metadata: { name: 'workspace1' } } as devfileApi.DevWorkspace, + { + kind: 'DevWorkspaceTemplate', + metadata: { name: 'template1' }, + } as devfileApi.DevWorkspaceTemplate, + ]; + (fetchResources as jest.Mock).mockResolvedValue(mockResourcesContent); + (loadResourcesContent as jest.Mock).mockReturnValue(mockResources); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + await store.dispatch(actionCreators.requestResources('url')); + + const actions = store.getActions(); + expect(actions[0]).toEqual(resourcesRequestAction()); + expect(actions[1]).toEqual( + resourcesReceiveAction({ + url: 'url', + devWorkspace: mockResources[0] as devfileApi.DevWorkspace, + devWorkspaceTemplate: mockResources[1] as devfileApi.DevWorkspaceTemplate, + }), + ); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + (fetchResources as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestResources('url'))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(resourcesRequestAction()); + expect(actions[1]).toEqual( + resourcesErrorAction({ + url: 'url', + error: errorMessage, + }), + ); + }); + + it('should throw error if DevWorkspace is not found in the fetched resources', async () => { + const mockResourcesContent = 'resources content'; + (fetchResources as jest.Mock).mockResolvedValue(mockResourcesContent); + (loadResourcesContent as jest.Mock).mockReturnValue([]); + + await expect(store.dispatch(actionCreators.requestResources('url'))).rejects.toThrow( + 'Failed to find a DevWorkspace in the fetched resources.', + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(resourcesRequestAction()); + }); + + it('should throw error if DevWorkspaceTemplate is not found in the fetched resources', async () => { + const mockResourcesContent = 'resources content'; + (fetchResources as jest.Mock).mockResolvedValue(mockResourcesContent); + (loadResourcesContent as jest.Mock).mockReturnValue([{ kind: 'DevWorkspace' }]); + + await expect(store.dispatch(actionCreators.requestResources('url'))).rejects.toThrow( + 'Failed to find a DevWorkspaceTemplate in the fetched resources.', + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(resourcesRequestAction()); + }); + }); + + describe('filter actions', () => { + it('should dispatch setFilter action', () => { + store.dispatch(actionCreators.setFilter('filter value')); + + const actions = store.getActions(); + expect(actions[0]).toEqual(filterSetAction('filter value')); + }); + + it('should dispatch clearFilter action', () => { + store.dispatch(actionCreators.clearFilter()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(filterClearAction()); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/getEditor.spec.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/getEditor.spec.ts index c51317f5b..6b91f06eb 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/getEditor.spec.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/getEditor.spec.ts @@ -10,129 +10,154 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; +import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; import { dump } from 'js-yaml'; import devfileApi from '@/services/devfileApi'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; +import { actionCreators } from '@/store/DevfileRegistries/actions'; import { getEditor } from '@/store/DevfileRegistries/getEditor'; +import { State } from '@/store/DevfileRegistries/reducer'; -const mockFetchData = jest.fn(); -jest.mock('@/services/registry/fetchData', () => ({ - fetchData: (...args: unknown[]) => mockFetchData(...args), +jest.mock('js-yaml', () => ({ + dump: jest.fn(), })); -describe('Get Devfile by URL', () => { - let editor: devfileApi.Devfile; +jest.mock('@/store/DevfileRegistries/actions', () => ({ + actionCreators: { + requestDevfile: jest.fn(), + }, +})); - beforeEach(() => { - editor = buildEditor(); - mockFetchData.mockResolvedValueOnce(dump(editor)); - }); +describe('getEditor', () => { + let dispatch: ThunkDispatch; + let getState: () => RootState; - afterEach(() => { + beforeEach(() => { + dispatch = jest.fn(); + getState = jest.fn(); jest.clearAllMocks(); }); - it('Should throw the "failed to fetch editor yaml" error message', async () => { - const store = new FakeStoreBuilder().build(); + it('should return existing devfile content by URL', async () => { + const mockState = { + devfileRegistries: { + devfiles: { + 'https://registry.com/devfile.yaml': { + content: 'devfile content', + }, + }, + } as Partial as State, + } as RootState; - let errorText: string | undefined; - try { - await getEditor('che-incubator/che-idea/next', store.dispatch, store.getState); - } catch (e) { - errorText = common.helpers.errors.getMessage(e); - } + (getState as jest.Mock).mockReturnValue(mockState); - expect(errorText).toEqual('Failed to fetch editor yaml by id: che-incubator/che-idea/next.'); + const result = await getEditor('https://registry.com/devfile.yaml', dispatch, getState); + + expect(result).toEqual({ + content: 'devfile content', + editorYamlUrl: 'https://registry.com/devfile.yaml', + }); + expect(dispatch).not.toHaveBeenCalled(); }); - it('Should request a devfile content by editor id', async () => { - const editors = [ - { - schemaVersion: '2.2.2', - metadata: { - name: 'che-idea', - attributes: { - publisher: 'che-incubator', - version: 'next', + it('should fetch devfile content by URL if not in state', async () => { + const mockState = { + devfileRegistries: { + devfiles: {}, + }, + } as RootState; + + const mockNextState = { + devfileRegistries: { + devfiles: { + 'https://registry.com/devfile.yaml': { + content: 'fetched devfile content', }, }, - } as devfileApi.Devfile, - ]; - const store = new FakeStoreBuilder() - .withDwPlugins({}, {}, false, editors, 'che-incubator/che-idea/next') - .build(); - - try { - const result = await getEditor('che-incubator/che-idea/next', store.dispatch, store.getState); - expect(result).toEqual(dump(editors[0])); - } catch (e) { - // no-op - } - }); + } as Partial as State, + } as RootState; + + (getState as jest.Mock).mockReturnValueOnce(mockState).mockReturnValueOnce(mockNextState); + + const result = await getEditor('https://registry.com/devfile.yaml', dispatch, getState); - it('Should return an existing devfile content by editor id', async () => { - const store = new FakeStoreBuilder().build(); - - try { - await getEditor( - 'https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml', - store.dispatch, - store.getState, - ); - } catch (e) { - // no-op - } - - expect(mockFetchData).toHaveBeenCalledTimes(1); - expect(mockFetchData).toHaveBeenCalledWith( - 'https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml', + expect(dispatch).toHaveBeenCalledWith( + actionCreators.requestDevfile('https://registry.com/devfile.yaml'), ); + expect(result).toEqual({ + content: 'fetched devfile content', + editorYamlUrl: 'https://registry.com/devfile.yaml', + }); }); - it('Should return an existing devfile content by editor path', async () => { - const store = new FakeStoreBuilder() - .withDevfileRegistries({ - devfiles: { - ['https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml']: - { - content: dump(editor), - }, - }, - }) - .build(); + it('should throw error if devfile content cannot be fetched by URL', async () => { + const mockState = { + devfileRegistries: { + devfiles: {}, + }, + } as RootState; - const customEditor = await getEditor( - 'https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml', - store.dispatch, - store.getState, - ); + const mockNextState = { + devfileRegistries: { + devfiles: {}, + }, + } as RootState; + + (getState as jest.Mock).mockReturnValueOnce(mockState).mockReturnValueOnce(mockNextState); + + await expect( + getEditor('https://registry.com/devfile.yaml', dispatch, getState), + ).rejects.toThrow('Failed to fetch editor yaml by URL: https://registry.com/devfile.yaml.'); - expect(mockFetchData).toHaveBeenCalledTimes(0); - expect(customEditor.content).toEqual(dump(editor)); - expect(customEditor.editorYamlUrl).toEqual( - 'https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml', + expect(dispatch).toHaveBeenCalledWith( + actionCreators.requestDevfile('https://registry.com/devfile.yaml'), ); }); -}); -function buildEditor(): devfileApi.Devfile { - return { - schemaVersion: '2.1.0', - metadata: { - name: 'ws-skeleton/eclipseide/4.9.0', - namespace: 'che', - }, - components: [ - { - name: 'eclipse-ide', - container: { - image: 'docker.io/wsskeleton/eclipse-broadway', - mountSources: true, - memoryLimit: '2048M', + it('should return existing devfile content by editor ID', async () => { + const mockEditor = { + schemaVersion: '2.1.0', + metadata: { + name: 'che-idea', + attributes: { + publisher: 'che-incubator', + version: 'next', }, }, - ], - }; -} + } as devfileApi.Devfile; + + const mockState = { + dwPlugins: { + cmEditors: [mockEditor], + }, + } as RootState; + + (getState as jest.Mock).mockReturnValue(mockState); + (dump as jest.Mock).mockReturnValue('dumped devfile content'); + + const result = await getEditor('che-incubator/che-idea/next', dispatch, getState); + + expect(result).toEqual({ + content: 'dumped devfile content', + editorYamlUrl: 'che-incubator/che-idea/next', + }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('should throw error if devfile content cannot be fetched by editor ID', async () => { + const mockState = { + dwPlugins: { + cmEditors: [], + } as Partial, + } as RootState; + + (getState as jest.Mock).mockReturnValue(mockState); + + await expect(getEditor('che-incubator/che-idea/next', dispatch, getState)).rejects.toThrow( + 'Failed to fetch editor yaml by id: che-incubator/che-idea/next.', + ); + + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/index.spec.ts deleted file mode 100644 index ba1b6707e..000000000 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/index.spec.ts +++ /dev/null @@ -1,397 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { che } from '@/services/models'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as devfileRegistriesStore from '..'; - -const mockFetchDevfile = jest.fn(); -const mockFetchRegistryMetadata = jest.fn(); -jest.mock('@/services/registry/devfiles', () => ({ - fetchDevfile: (...args: unknown[]) => mockFetchDevfile(...args), - fetchRegistryMetadata: (...args: unknown[]) => mockFetchRegistryMetadata(...args), -})); - -// mute error outputs -console.error = jest.fn(); - -describe('Devfile registries', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('actions', () => { - it('should create REQUEST_REGISTRY_METADATA, RECEIVE_REGISTRY_METADATA when fetching registries', async () => { - const registryMetadataV1 = getMetadataV1(); - const registryMetadataV2 = getMetadataV2(); - - mockFetchRegistryMetadata.mockResolvedValueOnce(getMetadataV1()); - mockFetchRegistryMetadata.mockResolvedValueOnce(getMetadataV2()); - mockFetchRegistryMetadata.mockResolvedValueOnce([]); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - const location1 = 'http://example.com/location1'; - const location2 = 'http://example.com/location2'; - const location3 = 'http://example.com/location3'; - const locations = `${location1} ${location2} ${location3}`; - await store.dispatch( - devfileRegistriesStore.actionCreators.requestRegistriesMetadata(locations, false), - ); - - const actions = store.getActions(); - - const expectedActions: devfileRegistriesStore.KnownAction[] = [ - { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }, - { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }, - { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }, - { - type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, - url: location1, - metadata: expect.arrayContaining([ - expect.objectContaining({ displayName: registryMetadataV1[0].displayName }), - ]), - }, - { - type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, - url: location2, - metadata: expect.arrayContaining([ - expect.objectContaining({ displayName: registryMetadataV2[0].displayName }), - ]), - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_REGISTRY_METADATA and RECEIVE_REGISTRY_ERROR when failed to fetch a registry', async () => { - const location1 = 'http://example.com/location1'; - const location2 = 'http://example.com/location2'; - const location3 = 'http://example.com/location3'; - const locations = `${location1} ${location2} ${location3}`; - - const registryMetadataV1 = getMetadataV1(); - const registryMetadataV2 = getMetadataV2(); - - mockFetchRegistryMetadata.mockResolvedValueOnce(getMetadataV1()); - mockFetchRegistryMetadata.mockResolvedValueOnce(getMetadataV2()); - const registryError = new Error(`Failed to fetch registry metadata by URL ${location3}`); - mockFetchRegistryMetadata.mockRejectedValueOnce(registryError); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await expect( - store.dispatch( - devfileRegistriesStore.actionCreators.requestRegistriesMetadata(locations, false), - ), - ).rejects.toMatch(registryError.message); - - const actions = store.getActions(); - - const expectedActions: devfileRegistriesStore.KnownAction[] = [ - { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }, - { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }, - { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }, - { - type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, - url: location1, - metadata: expect.arrayContaining([ - expect.objectContaining({ displayName: registryMetadataV1[0].displayName }), - ]), - }, - { - type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, - url: location2, - metadata: expect.arrayContaining([ - expect.objectContaining({ displayName: registryMetadataV2[0].displayName }), - ]), - }, - { - type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_ERROR, - url: location3, - error: registryError.message, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_DEVFILE and RECEIVE_DEVFILE when fetching a devfile', async () => { - mockFetchDevfile.mockResolvedValueOnce('a devfile content'); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - const devfileUrl = 'http://example.com/devfile.yaml'; - await store.dispatch(devfileRegistriesStore.actionCreators.requestDevfile(devfileUrl)); - - const actions = store.getActions(); - - const expectedActions: devfileRegistriesStore.KnownAction[] = [ - { - type: devfileRegistriesStore.Type.REQUEST_DEVFILE, - check: AUTHORIZED, - }, - { - type: devfileRegistriesStore.Type.RECEIVE_DEVFILE, - url: devfileUrl, - devfile: 'a devfile content', - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('reducer', () => { - it('should return initial state', () => { - const incomingAction: devfileRegistriesStore.RequestRegistryMetadataAction = { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }; - const initialState = devfileRegistriesStore.reducer(undefined, incomingAction); - - const expectedState: devfileRegistriesStore.State = { - isLoading: false, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action type is not matched', () => { - const initialState: devfileRegistriesStore.State = { - isLoading: true, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = devfileRegistriesStore.reducer(initialState, incomingAction); - - const expectedState: devfileRegistriesStore.State = { - isLoading: true, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - expect(newState).toEqual(expectedState); - }); - - it('should should handle REQUEST_REGISTRY_METADATA', () => { - const initialState: devfileRegistriesStore.State = { - isLoading: false, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - const incomingAction: devfileRegistriesStore.RequestRegistryMetadataAction = { - type: devfileRegistriesStore.Type.REQUEST_REGISTRY_METADATA, - check: AUTHORIZED, - }; - const newState = devfileRegistriesStore.reducer(initialState, incomingAction); - - const expectedState: devfileRegistriesStore.State = { - isLoading: true, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - expect(newState).toEqual(expectedState); - }); - - it('should should handle RECEIVE_REGISTRY_METADATA', () => { - const initialState: devfileRegistriesStore.State = { - isLoading: true, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - const url = 'http://example.com/devfiles/registry'; - const metadata = [...getMetadataV1(), ...getMetadataV2()]; - const incomingAction: devfileRegistriesStore.ReceiveRegistryMetadataAction = { - type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_METADATA, - url, - metadata, - }; - const newState = devfileRegistriesStore.reducer(initialState, incomingAction); - - const expectedState: devfileRegistriesStore.State = { - isLoading: false, - registries: { - [url]: { metadata }, - }, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - expect(newState).toEqual(expectedState); - }); - - it('should should handle RECEIVE_REGISTRY_ERROR', () => { - const initialState: devfileRegistriesStore.State = { - isLoading: true, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - const url = 'http://example.com/devfiles/registry'; - const error = 'error message'; - const incomingAction: devfileRegistriesStore.ReceiveRegistryErrorAction = { - type: devfileRegistriesStore.Type.RECEIVE_REGISTRY_ERROR, - url, - error, - }; - const newState = devfileRegistriesStore.reducer(initialState, incomingAction); - - const expectedState: devfileRegistriesStore.State = { - isLoading: false, - registries: { - [url]: { error }, - }, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - expect(newState).toEqual(expectedState); - }); - - it('should should handle REQUEST_DEVFILE', () => { - const initialState: devfileRegistriesStore.State = { - isLoading: false, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - const incomingAction: devfileRegistriesStore.RequestDevfileAction = { - type: devfileRegistriesStore.Type.REQUEST_DEVFILE, - check: AUTHORIZED, - }; - const newState = devfileRegistriesStore.reducer(initialState, incomingAction); - - const expectedState: devfileRegistriesStore.State = { - isLoading: true, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - expect(newState).toEqual(expectedState); - }); - - it('should should handle RECEIVE_DEVFILE', () => { - const initialState: devfileRegistriesStore.State = { - isLoading: true, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - filter: '', - }; - const url = 'http://example.com/devfile.yaml'; - const content = 'a devfile content'; - const incomingAction: devfileRegistriesStore.ReceiveDevfileAction = { - type: devfileRegistriesStore.Type.RECEIVE_DEVFILE, - url, - devfile: content, - }; - const newState = devfileRegistriesStore.reducer(initialState, incomingAction); - - const expectedState: devfileRegistriesStore.State = { - isLoading: false, - registries: {}, - devfiles: { - [url]: { content }, - }, - devWorkspaceResources: {}, - filter: '', - }; - expect(newState).toEqual(expectedState); - }); - }); -}); - -function getMetadataV1(): che.DevfileMetaData[] { - return [ - { - displayName: 'Java with JBoss EAP XP 3.0 Bootable Jar', - description: 'Java stack with OpenJDK 11, Maven 3.6.3 and JBoss EAP XP 3.0 Bootable Jar', - tags: ['Java', 'OpenJDK', 'Maven', 'EAP', 'Microprofile', 'EAP XP', 'Bootable Jar', 'UBI8'], - icon: '/images/type-jboss.svg', - links: { - self: '/devfiles/00_java11-maven-microprofile-bootable/devfile.yaml', - }, - }, - ]; -} -function getMetadataV2(): che.DevfileMetaData[] { - return [ - { - displayName: 'Go', - description: 'Stack with Go 1.14', - tags: ['Debian', 'Go'], - icon: '/images/go.svg', - links: { - v2: 'https://github.com/che-samples/golang-echo-example/tree/devfile2', - devWorkspaces: { - 'eclipse/che-theia/latest': '/devfiles/go/devworkspace-che-theia-latest.yaml', - 'eclipse/che-theia/next': '/devfiles/go/devworkspace-che-theia-next.yaml', - }, - self: '/devfiles/go/devfile.yaml', - }, - }, - ]; -} diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/reducer.spec.ts new file mode 100644 index 000000000..80a2cc9f9 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/reducer.spec.ts @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import devfileApi from '@/services/devfileApi'; +import { che } from '@/services/models'; +import { + devfileReceiveAction, + devfileRequestAction, + filterClearAction, + filterSetAction, + registryMetadataErrorAction, + registryMetadataReceiveAction, + registryMetadataRequestAction, + resourcesErrorAction, + resourcesReceiveAction, + resourcesRequestAction, +} from '@/store/DevfileRegistries/actions'; +import { reducer, State, unloadedState } from '@/store/DevfileRegistries/reducer'; + +describe('DevfileRegistries, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle registryMetadataRequestAction', () => { + const action = registryMetadataRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle registryMetadataReceiveAction', () => { + const metadata = [{ displayName: 'devfile1' }] as che.DevfileMetaData[]; + const action = registryMetadataReceiveAction({ url: 'url1', metadata }); + const expectedState: State = { + ...initialState, + isLoading: false, + registries: { + url1: { metadata }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle registryMetadataErrorAction', () => { + const action = registryMetadataErrorAction({ url: 'url1', error: 'Error message' }); + const expectedState: State = { + ...initialState, + isLoading: false, + registries: { + url1: { error: 'Error message', metadata: [] }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle devfileRequestAction', () => { + const action = devfileRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle devfileReceiveAction', () => { + const action = devfileReceiveAction({ url: 'url1', devfile: 'devfile content' }); + const expectedState: State = { + ...initialState, + isLoading: false, + devfiles: { + url1: { content: 'devfile content' }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle resourcesRequestAction', () => { + const action = resourcesRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle resourcesReceiveAction', () => { + const devWorkspace = { kind: 'DevWorkspace', metadata: { name: 'workspace1' } }; + const devWorkspaceTemplate = { kind: 'DevWorkspaceTemplate', metadata: { name: 'template1' } }; + const action = resourcesReceiveAction({ + url: 'url1', + devWorkspace: devWorkspace as devfileApi.DevWorkspace, + devWorkspaceTemplate: devWorkspaceTemplate as devfileApi.DevWorkspaceTemplate, + }); + const expectedState: State = { + ...initialState, + isLoading: false, + devWorkspaceResources: { + url1: { + resources: [ + devWorkspace as devfileApi.DevWorkspace, + devWorkspaceTemplate as devfileApi.DevWorkspaceTemplate, + ], + }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle resourcesErrorAction', () => { + const action = resourcesErrorAction({ url: 'url1', error: 'Error message' }); + const expectedState: State = { + ...initialState, + isLoading: false, + devWorkspaceResources: { + url1: { error: 'Error message' }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle filterSetAction', () => { + const action = filterSetAction('new filter'); + const expectedState: State = { + ...initialState, + filter: 'new filter', + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle filterClearAction', () => { + const stateWithFilter: State = { + ...initialState, + filter: 'some filter', + }; + const action = filterClearAction(); + const expectedState: State = { + ...initialState, + filter: '', + }; + + expect(reducer(stateWithFilter, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/selectors.spec.ts index 78f307680..94b7dc923 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/__tests__/selectors.spec.ts @@ -10,142 +10,232 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; -import { dump } from 'js-yaml'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; +import { load } from 'js-yaml'; import devfileApi from '@/services/devfileApi'; import { che } from '@/services/models'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; import { selectDefaultDevfile, + selectDevWorkspaceResources, + selectEmptyWorkspaceUrl, + selectFilterValue, selectIsRegistryDevfile, + selectMetadataFiltered, selectRegistriesErrors, + selectRegistriesMetadata, } from '@/store/DevfileRegistries/selectors'; -describe('devfileRegistries selectors', () => { - const registryUrl = 'https://registry-url'; - const sampleResourceUrl = 'https://resources-url/devfile.yaml'; - const registryMetadata = { - displayName: 'Empty Workspace', - description: 'Start an empty remote development environment', - tags: ['Empty'], - icon: '/images/empty.svg', - links: { - v2: sampleResourceUrl, - }, - } as che.DevfileMetaData; - const sampleContent = dump({ - schemaVersion: '2.1.0', - metadata: { - generateName: 'empty', - }, - } as devfileApi.Devfile); - const defaultComponents = [ - { - name: 'universal-developer-image', - container: { - image: 'quay.io/devfile/universal-developer-image:ubi8-latest', - }, - }, - ]; +jest.mock('js-yaml', () => ({ + load: jest.fn(), +})); +jest.mock('@/store/ServerConfig/selectors', () => { + return { + selectDefaultComponents: jest.fn().mockReturnValue([]), + }; +}); - it('should return the default devfile', () => { - const fakeStore = new FakeStoreBuilder() - .withDevfileRegistries({ +describe('DevfileRegistries, selectors', () => { + let mockState: RootState; + + beforeEach(() => { + mockState = { + devfileRegistries: { registries: { - [registryUrl]: { - metadata: [registryMetadata], + 'https://registry1.com': { + metadata: [ + { + displayName: 'Devfile 1', + description: 'Description 1', + links: { v2: 'https://registry1.com/devfile1' }, + tags: ['tag1'], + } as che.DevfileMetaData, + ], + }, + 'https://registry2.com': { + metadata: [ + { + displayName: 'Devfile 2', + description: 'Description 2', + links: { v2: 'https://registry2.com/devfile2' }, + tags: ['Empty'], + } as che.DevfileMetaData, + ], + error: 'Error message', }, }, devfiles: { - [sampleResourceUrl]: { - content: sampleContent, + 'https://registry2.com/devfile2': { + content: 'devfile content', }, }, - }) - .withDwServerConfig({ - defaults: { - components: defaultComponents, + devWorkspaceResources: { + 'https://registry2.com/devfile2': { + resources: [ + { kind: 'DevWorkspace', metadata: { name: 'workspace1' } } as devfileApi.DevWorkspace, + { + kind: 'DevWorkspaceTemplate', + metadata: { name: 'template1' }, + } as devfileApi.DevWorkspaceTemplate, + ], + }, }, - } as api.IServerConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const defaultDevfile = selectDefaultDevfile(state); - expect(defaultDevfile).toEqual({ - schemaVersion: '2.1.0', - metadata: { - generateName: 'empty', + filter: 'Devfile 1', + isLoading: false, }, - components: [ + } as Partial as RootState; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should select registries metadata', () => { + const result = selectRegistriesMetadata(mockState); + expect(result).toEqual([ + { + displayName: 'Devfile 1', + description: 'Description 1', + links: { v2: 'https://registry1.com/devfile1' }, + tags: ['tag1'], + registry: 'https://registry1.com', + }, + { + displayName: 'Devfile 2', + description: 'Description 2', + links: { v2: 'https://registry2.com/devfile2' }, + tags: ['Empty'], + registry: 'https://registry2.com', + }, + ]); + }); + + it('should select if a URL is a registry devfile', () => { + const result = selectIsRegistryDevfile(mockState)('https://registry2.com/devfile2'); + expect(result).toBe(true); + }); + + it('should select registries errors', () => { + const result = selectRegistriesErrors(mockState); + expect(result).toEqual([ + { + url: 'https://registry2.com', + errorMessage: 'Error message', + }, + ]); + }); + + it('should select filter value', () => { + const result = selectFilterValue(mockState); + expect(result).toEqual('Devfile 1'); + }); + + describe('selectMetadataFiltered', () => { + it('should select metadata filtered by filter value', () => { + const result = selectMetadataFiltered(mockState); + expect(result).toEqual([ { - name: 'universal-developer-image', - container: { - image: 'quay.io/devfile/universal-developer-image:ubi8-latest', - }, + displayName: 'Devfile 1', + description: 'Description 1', + links: { v2: 'https://registry1.com/devfile1' }, + tags: ['tag1'], + registry: 'https://registry1.com', }, - ], + ]); }); - }); - test('if devfile is from registry or not', () => { - const fakeStore = new FakeStoreBuilder() - .withDevfileRegistries({ - registries: { - [registryUrl]: { - metadata: [registryMetadata], - }, + it('should select all metadata if filter value is empty', () => { + const mockStateWithoutFilterValue = { + ...mockState, + devfileRegistries: { + ...mockState.devfileRegistries, + filter: '', }, - devfiles: { - [sampleResourceUrl]: { - content: sampleContent, - }, + } as RootState; + + const result = selectMetadataFiltered(mockStateWithoutFilterValue); + expect(result).toEqual([ + { + displayName: 'Devfile 1', + description: 'Description 1', + links: { v2: 'https://registry1.com/devfile1' }, + tags: ['tag1'], + registry: 'https://registry1.com', }, - }) - .withDwServerConfig({ - defaults: { - components: defaultComponents, + { + displayName: 'Devfile 2', + description: 'Description 2', + links: { v2: 'https://registry2.com/devfile2' }, + tags: ['Empty'], + registry: 'https://registry2.com', }, - } as api.IServerConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const ifRegistryDevfileFn = selectIsRegistryDevfile(state); + ]); + }); + }); - const registryDevfileUrl = `${registryUrl}/devfile.yaml`; - expect(ifRegistryDevfileFn(registryDevfileUrl)).toBeTruthy(); + it('should select empty workspace URL', () => { + const result = selectEmptyWorkspaceUrl(mockState); + expect(result).toEqual('https://registry2.com/devfile2'); + }); - const registryDevfileUrl2 = sampleResourceUrl; - expect(ifRegistryDevfileFn(registryDevfileUrl2)).toBeTruthy(); + describe('selectDefaultDevfile', () => { + it('should select default devfile', () => { + const mockDevfile = { components: [] }; + (load as jest.Mock).mockReturnValue(mockDevfile); - const otherDevfileUrl = 'https://other-url/devfile.yaml'; - expect(ifRegistryDevfileFn(otherDevfileUrl)).toBeFalsy(); - }); + const result = selectDefaultDevfile(mockState); + expect(result).toEqual(mockDevfile); + }); - it('should return error', () => { - const error = `Failed to fetch registry metadata.`; - const fakeStore = new FakeStoreBuilder() - .withDevfileRegistries({ - registries: { - [registryUrl]: { - error, + it('should return undefined if the empty workspace URL is not found', () => { + const mockStateWithoutEmptyWorkspaceUrl = { + ...mockState, + devfileRegistries: { + ...mockState.devfileRegistries, + registries: { + 'https://registry1.com': + mockState.devfileRegistries.registries['https://registry1.com'], }, }, - }) - .withDwServerConfig({ - defaults: { - components: defaultComponents, + } as RootState; + + const result = selectDefaultDevfile(mockStateWithoutEmptyWorkspaceUrl); + expect(result).toBeUndefined(); + }); + + it('should return undefined if the devfile content is not found', () => { + const mockStateWithoutDevfileContent = { + ...mockState, + devfileRegistries: { + ...mockState.devfileRegistries, + devfiles: {}, }, - } as api.IServerConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); + } as RootState; - expect(selectRegistriesErrors(state)).toStrictEqual([ - { url: registryUrl, errorMessage: error }, - ]); + const result = selectDefaultDevfile(mockStateWithoutDevfileContent); + expect(result).toBeUndefined(); + }); + + it('should return undefined if the devfile content is not valid', () => { + console.error = jest.fn(); + (load as jest.Mock).mockImplementation(() => { + throw new Error('Invalid devfile content'); + }); + + const result = selectDefaultDevfile(mockState); + expect(result).toBeUndefined(); + }); + }); + + it('should select dev workspace resources', () => { + const result = selectDevWorkspaceResources(mockState); + expect(result).toEqual({ + 'https://registry2.com/devfile2': { + resources: [ + { kind: 'DevWorkspace', metadata: { name: 'workspace1' } }, + { kind: 'DevWorkspaceTemplate', metadata: { name: 'template1' } }, + ], + }, + }); }); }); diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/actions.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/actions.ts new file mode 100644 index 000000000..3fc359946 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/actions.ts @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import devfileApi from '@/services/devfileApi'; +import { che } from '@/services/models'; +import { fetchDevfile, fetchRegistryMetadata } from '@/services/registry/devfiles'; +import { fetchResources, loadResourcesContent } from '@/services/registry/resources'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +/** Registry Metadata Actions */ + +export const registryMetadataRequestAction = createAction('devfileRegistries/request'); + +type RegistryMetadataReceivePayload = { + url: string; + metadata: che.DevfileMetaData[]; +}; +export const registryMetadataReceiveAction = createAction( + 'devfileRegistries/receive', +); + +type RegistryMetadataErrorPayload = { + url: string; + error: string; +}; +export const registryMetadataErrorAction = + createAction('devfileRegistries/error'); + +/** Devfile Actions */ + +export const devfileRequestAction = createAction('devfile/request'); + +type DevfileReceivePayload = { + url: string; + devfile: string; +}; +export const devfileReceiveAction = createAction('devfile/receive'); + +/** Resources Actions */ + +export const resourcesRequestAction = createAction('resources/request'); + +type ResourcesReceiveActionPayload = { + url: string; + devWorkspace: devfileApi.DevWorkspace; + devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate; +}; +export const resourcesReceiveAction = + createAction('resources/receive'); + +type resourcesErrorAction = { + url: string; + error: string; +}; +export const resourcesErrorAction = createAction('resources/error'); + +/** Filter Actions */ + +export const filterSetAction = createAction('devfileRegistries/setFilter'); +export const filterClearAction = createAction('devfileRegistries/clearFilter'); + +export const actionCreators = { + /** + * Request devfile metadata from available registries. `registryUrls` is space-separated list of urls. + */ + requestRegistriesMetadata: + (registryUrls: string, isExternal: boolean): AppThunk => + async (dispatch, getState): Promise => { + const registries: string[] = registryUrls.split(' '); + const promises = registries.map(async url => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(registryMetadataRequestAction()); + + const metadata: che.DevfileMetaData[] = await fetchRegistryMetadata(url, isExternal); + + dispatch(registryMetadataReceiveAction({ url, metadata })); + } catch (e) { + const error = common.helpers.errors.getMessage(e); + dispatch(registryMetadataErrorAction({ url, error })); + throw e; + } + }); + const results = await Promise.allSettled(promises); + results.forEach(result => { + if (result.status === 'rejected') { + throw new Error(result.reason); + } + }); + }, + + requestDevfile: + (url: string): AppThunk> => + async (dispatch, getState): Promise => { + await verifyAuthorized(dispatch, getState); + + dispatch(devfileRequestAction()); + + const devfile = await fetchDevfile(url); + + dispatch(devfileReceiveAction({ url, devfile })); + return devfile; + }, + + requestResources: + (resourcesUrl: string): AppThunk => + async (dispatch, getState): Promise => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(resourcesRequestAction()); + + const resourcesContent = await fetchResources(resourcesUrl); + const resources = loadResourcesContent(resourcesContent); + + const devWorkspace = resources.find( + resource => resource.kind === 'DevWorkspace', + ) as devfileApi.DevWorkspace; + if (!devWorkspace) { + throw new Error('Failed to find a DevWorkspace in the fetched resources.'); + } + + const devWorkspaceTemplate = resources.find( + resource => resource.kind === 'DevWorkspaceTemplate', + ) as devfileApi.DevWorkspaceTemplate; + if (!devWorkspaceTemplate) { + throw new Error('Failed to find a DevWorkspaceTemplate in the fetched resources.'); + } + + dispatch( + resourcesReceiveAction({ + url: resourcesUrl, + devWorkspace, + devWorkspaceTemplate, + }), + ); + } catch (e) { + dispatch( + resourcesErrorAction({ + url: resourcesUrl, + error: common.helpers.errors.getMessage(e), + }), + ); + throw e; + } + }, + + setFilter: + (value: string): AppThunk => + dispatch => + dispatch(filterSetAction(value)), + clearFilter: (): AppThunk => dispatch => dispatch(filterClearAction()), +}; diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/getEditor.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/getEditor.ts index 289096265..afcd7b14a 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/getEditor.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/getEditor.ts @@ -10,17 +10,17 @@ * Red Hat, Inc. - initial API and implementation */ +import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; import { dump } from 'js-yaml'; -import { ThunkDispatch } from 'redux-thunk'; import devfileApi from '@/services/devfileApi'; -import { actionCreators, KnownAction } from '@/store/DevfileRegistries/index'; -import { AppState } from '@/store/index'; +import { RootState } from '@/store'; +import { actionCreators } from '@/store/DevfileRegistries/actions'; export async function getEditor( editorIdOrPath: string, - dispatch: ThunkDispatch, - getState: () => AppState, + dispatch: ThunkDispatch, + getState: () => RootState, ): Promise<{ content?: string; editorYamlUrl: string; error?: string }> { let editorYamlUrl: string; diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts index d4f9b1570..a4e463378 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,343 +12,12 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import devfileApi from '@/services/devfileApi'; -import { che } from '@/services/models'; -import { fetchDevfile, fetchRegistryMetadata } from '@/services/registry/devfiles'; -import { fetchResources, loadResourcesContent } from '@/services/registry/resources'; -import { createObject } from '@/store/helpers'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; +export { actionCreators as devfileRegistriesActionCreators } from '@/store/DevfileRegistries/actions'; +export { + reducer as devfileRegistriesReducer, + State as DevfileRegistriesState, + DevWorkspaceResources, +} from '@/store/DevfileRegistries/reducer'; +export * from '@/store/DevfileRegistries/selectors'; export const DEFAULT_REGISTRY = '/dashboard/devfile-registry/'; - -export type DevWorkspaceResources = [devfileApi.DevWorkspace, devfileApi.DevWorkspaceTemplate]; - -// This state defines the type of data maintained in the Redux store. -export interface State { - isLoading: boolean; - registries: { - [location: string]: { - metadata?: che.DevfileMetaData[]; - error?: string; - }; - }; - devfiles: { - [location: string]: { - content?: string; - error?: string; - }; - }; - devWorkspaceResources: { - [location: string]: { - resources?: DevWorkspaceResources; - error?: string; - }; - }; - - // current filter value - filter: string; -} - -export enum Type { - REQUEST_REGISTRY_METADATA = 'REQUEST_REGISTRY_METADATA', - RECEIVE_REGISTRY_METADATA = 'RECEIVE_REGISTRY_METADATA', - RECEIVE_REGISTRY_ERROR = 'RECEIVE_REGISTRY_ERROR', - REQUEST_DEVFILE = 'REQUEST_DEVFILE', - RECEIVE_DEVFILE = 'RECEIVE_DEVFILE', - REQUEST_RESOURCES = 'REQUEST_RESOURCES', - RECEIVE_RESOURCES = 'RECEIVE_RESOURCES', - RECEIVE_RESOURCES_ERROR = 'RECEIVE_RESOURCES_ERROR', - SET_FILTER = 'SET_FILTER', - CLEAR_FILTER = 'CLEAR_FILTER', -} - -export interface RequestRegistryMetadataAction extends Action, SanityCheckAction { - type: Type.REQUEST_REGISTRY_METADATA; -} - -export interface ReceiveRegistryMetadataAction { - type: Type.RECEIVE_REGISTRY_METADATA; - url: string; - metadata: che.DevfileMetaData[]; -} - -export interface ReceiveRegistryErrorAction { - type: Type.RECEIVE_REGISTRY_ERROR; - url: string; - error: string; -} - -export interface RequestDevfileAction extends Action, SanityCheckAction { - type: Type.REQUEST_DEVFILE; -} - -export interface ReceiveDevfileAction { - type: Type.RECEIVE_DEVFILE; - url: string; - devfile: string; -} - -export interface RequestResourcesAction extends Action, SanityCheckAction { - type: Type.REQUEST_RESOURCES; -} - -export interface ReceiveResourcesAction { - type: Type.RECEIVE_RESOURCES; - url: string; - devWorkspace: devfileApi.DevWorkspace; - devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate; -} - -export interface ReceiveResourcesErrorAction { - type: Type.RECEIVE_RESOURCES_ERROR; - url: string; - error: string; -} - -export interface SetFilterValue extends Action { - type: Type.SET_FILTER; - value: string; -} - -export interface ClearFilterValue extends Action { - type: Type.CLEAR_FILTER; -} - -export type KnownAction = - | RequestRegistryMetadataAction - | ReceiveRegistryMetadataAction - | ReceiveRegistryErrorAction - | RequestDevfileAction - | ReceiveDevfileAction - | RequestResourcesAction - | ReceiveResourcesAction - | ReceiveResourcesErrorAction - | SetFilterValue - | ClearFilterValue; - -export type ActionCreators = { - requestRegistriesMetadata: ( - location: string, - isExternal: boolean, - ) => AppThunk>; - requestDevfile: (location: string) => AppThunk>; - requestResources: (resourceUrl: string) => AppThunk>; - - setFilter: (value: string) => AppThunk; - clearFilter: () => AppThunk; -}; - -export const actionCreators: ActionCreators = { - /** - * Request devfile metadata from available registries. `registryUrls` is space-separated list of urls. - */ - requestRegistriesMetadata: - (registryUrls: string, isExternal: boolean): AppThunk> => - async (dispatch, getState): Promise => { - const registries: string[] = registryUrls.split(' '); - const promises = registries.map(async url => { - try { - await dispatch({ type: Type.REQUEST_REGISTRY_METADATA, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - - const metadata: che.DevfileMetaData[] = await fetchRegistryMetadata(url, isExternal); - if (!Array.isArray(metadata) || metadata.length === 0) { - return; - } - - dispatch({ - type: Type.RECEIVE_REGISTRY_METADATA, - url, - metadata, - }); - } catch (e) { - const error = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_REGISTRY_ERROR, - url, - error, - }); - throw error; - } - }); - const results = await Promise.allSettled(promises); - results.forEach(result => { - if (result.status === 'rejected') { - throw result.reason; - } - }); - }, - - requestDevfile: - (url: string): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_DEVFILE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - - const devfile = await fetchDevfile(url); - - dispatch({ type: Type.RECEIVE_DEVFILE, devfile, url }); - return devfile; - } catch (e) { - throw new Error(`Failed to request a devfile from URL: ${url}, \n` + e); - } - }, - - requestResources: - (resourcesUrl: string): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_RESOURCES, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const resourcesContent = await fetchResources(resourcesUrl); - const resources = loadResourcesContent(resourcesContent); - - const devWorkspace = resources.find( - resource => resource.kind === 'DevWorkspace', - ) as devfileApi.DevWorkspace; - if (!devWorkspace) { - throw new Error('Failed to find a DevWorkspace in the fetched resources.'); - } - - const devWorkspaceTemplate = resources.find( - resource => resource.kind === 'DevWorkspaceTemplate', - ) as devfileApi.DevWorkspaceTemplate; - if (!devWorkspaceTemplate) { - throw new Error('Failed to find a DevWorkspaceTemplate in the fetched resources.'); - } - - dispatch({ - type: Type.RECEIVE_RESOURCES, - url: resourcesUrl, - devWorkspace, - devWorkspaceTemplate, - }); - } catch (e) { - const message = - 'Failed to fetch devworkspace resources. ' + common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_RESOURCES_ERROR, - url: resourcesUrl, - error: message, - }); - throw new Error(message); - } - }, - - setFilter: - (value: string): AppThunk => - dispatch => { - dispatch({ type: Type.SET_FILTER, value }); - }, - - clearFilter: (): AppThunk => dispatch => { - dispatch({ type: Type.CLEAR_FILTER }); - }, -}; - -const unloadedState: State = { - isLoading: false, - registries: {}, - devfiles: {}, - devWorkspaceResources: {}, - - filter: '', -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_REGISTRY_METADATA: - return createObject(state, { - isLoading: true, - }); - case Type.REQUEST_DEVFILE: - return createObject(state, { - isLoading: true, - }); - case Type.REQUEST_RESOURCES: - return createObject(state, { - isLoading: true, - }); - case Type.RECEIVE_REGISTRY_METADATA: - return createObject(state, { - isLoading: false, - registries: createObject(state.registries, { - [action.url]: { - metadata: action.metadata, - }, - }), - }); - case Type.RECEIVE_REGISTRY_ERROR: - return createObject(state, { - isLoading: false, - registries: { - [action.url]: { - error: action.error, - }, - }, - }); - case Type.RECEIVE_DEVFILE: - return createObject(state, { - isLoading: false, - devfiles: createObject(state.devfiles, { - [action.url]: { - content: action.devfile, - }, - }), - }); - case Type.RECEIVE_RESOURCES: - return createObject(state, { - isLoading: false, - devWorkspaceResources: createObject(state.devWorkspaceResources, { - [action.url]: { - resources: [action.devWorkspace, action.devWorkspaceTemplate], - }, - }), - }); - case Type.RECEIVE_RESOURCES_ERROR: - return createObject(state, { - isLoading: false, - devWorkspaceResources: { - [action.url]: { - error: action.error, - }, - }, - }); - case Type.SET_FILTER: { - return createObject(state, { - filter: action.value, - }); - } - case Type.CLEAR_FILTER: { - return createObject(state, { - filter: '', - }); - } - default: - return state; - } -}; diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/reducer.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/reducer.ts new file mode 100644 index 000000000..0d9e3d115 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/reducer.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import devfileApi from '@/services/devfileApi'; +import { che } from '@/services/models'; +import { + devfileReceiveAction, + devfileRequestAction, + filterClearAction, + filterSetAction, + registryMetadataErrorAction, + registryMetadataReceiveAction, + registryMetadataRequestAction, + resourcesErrorAction, + resourcesReceiveAction, + resourcesRequestAction, +} from '@/store/DevfileRegistries/actions'; + +export type DevWorkspaceResources = [devfileApi.DevWorkspace, devfileApi.DevWorkspaceTemplate]; + +export interface State { + isLoading: boolean; + registries: { + [location: string]: { + metadata?: che.DevfileMetaData[]; + error?: string; + }; + }; + devfiles: { + [location: string]: { + content?: string; + error?: string; + }; + }; + devWorkspaceResources: { + [location: string]: { + resources?: DevWorkspaceResources; + error?: string; + }; + }; + + // current filter value + filter: string; +} + +export const unloadedState: State = { + isLoading: false, + registries: {}, + devfiles: {}, + devWorkspaceResources: {}, + + filter: '', +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(registryMetadataRequestAction, state => { + state.isLoading = true; + }) + .addCase(registryMetadataReceiveAction, (state, action) => { + state.isLoading = false; + state.registries[action.payload.url] = { + metadata: action.payload.metadata, + }; + }) + .addCase(registryMetadataErrorAction, (state, action) => { + state.isLoading = false; + state.registries[action.payload.url] = { + error: action.payload.error, + metadata: state.registries[action.payload.url]?.metadata || [], + }; + }) + .addCase(devfileRequestAction, state => { + state.isLoading = true; + }) + .addCase(devfileReceiveAction, (state, action) => { + state.isLoading = false; + state.devfiles[action.payload.url] = { + content: action.payload.devfile, + }; + }) + .addCase(resourcesRequestAction, state => { + state.isLoading = true; + }) + .addCase(resourcesReceiveAction, (state, action) => { + state.isLoading = false; + state.devWorkspaceResources[action.payload.url] = { + resources: [action.payload.devWorkspace, action.payload.devWorkspaceTemplate], + }; + }) + .addCase(resourcesErrorAction, (state, action) => { + state.isLoading = false; + state.devWorkspaceResources[action.payload.url] = { + error: action.payload.error, + }; + }) + .addCase(filterSetAction, (state, action) => { + state.filter = action.payload; + }) + .addCase(filterClearAction, state => { + state.filter = ''; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts index 6ab562d15..7509e9fee 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts @@ -10,19 +10,18 @@ * Red Hat, Inc. - initial API and implementation */ +import { createSelector } from '@reduxjs/toolkit'; import { load } from 'js-yaml'; -import { createSelector } from 'reselect'; import devfileApi from '@/services/devfileApi'; import match from '@/services/helpers/filter'; import { che } from '@/services/models'; +import { RootState } from '@/store'; import { selectDefaultComponents } from '@/store/ServerConfig/selectors'; -import { AppState } from '..'; - export const EMPTY_WORKSPACE_TAG = 'Empty'; -const selectState = (state: AppState) => state.devfileRegistries; +const selectState = (state: RootState) => state.devfileRegistries; export const selectRegistriesMetadata = createSelector(selectState, devfileRegistriesState => { const registriesMetadata = Object.keys(devfileRegistriesState.registries).map(registry => { @@ -78,15 +77,11 @@ export const selectMetadataFiltered = createSelector( }, ); -export const selectEmptyWorkspaceUrl = createSelector( - selectState, - selectRegistriesMetadata, - (state, metadata) => { - const v2Metadata = filterDevfileV2Metadata(metadata); - const emptyWorkspaceMetadata = v2Metadata.find(meta => meta.tags.includes(EMPTY_WORKSPACE_TAG)); - return emptyWorkspaceMetadata?.links?.v2; - }, -); +export const selectEmptyWorkspaceUrl = createSelector(selectRegistriesMetadata, metadata => { + const v2Metadata = filterDevfileV2Metadata(metadata); + const emptyWorkspaceMetadata = v2Metadata.find(meta => meta.tags.includes(EMPTY_WORKSPACE_TAG)); + return emptyWorkspaceMetadata?.links?.v2; +}); export const selectDefaultDevfile = createSelector( selectState, diff --git a/packages/dashboard-frontend/src/store/DockerConfig/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/DockerConfig/__tests__/actions.spec.ts new file mode 100644 index 000000000..14c01684d --- /dev/null +++ b/packages/dashboard-frontend/src/store/DockerConfig/__tests__/actions.spec.ts @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { helpers } from '@eclipse-che/common'; + +import * as DwApi from '@/services/backend-client/devWorkspaceApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + dockerConfigErrorAction, + dockerConfigReceiveAction, + dockerConfigRequestAction, + getDockerConfig, + putDockerConfig, +} from '@/store/DockerConfig/actions'; +import * as namespaceSelectors from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/devWorkspaceApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('DockerConfig', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('actions', () => { + const mockNamespace = 'test-namespace'; + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + }); + + describe('requestCredentials', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockRegistries = [ + { url: 'https://registry.com', username: 'user', password: 'pass' }, + ]; + const mockResourceVersion = '12345'; + + jest + .spyOn(namespaceSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (DwApi.getDockerConfig as jest.Mock).mockResolvedValue({ + dockerconfig: window.btoa( + JSON.stringify({ + auths: { + 'https://registry.com': { + auth: window.btoa('user:pass'), + }, + }, + }), + ), + resourceVersion: mockResourceVersion, + }); + + await store.dispatch(actionCreators.requestCredentials()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(dockerConfigRequestAction()); + expect(actions[1]).toEqual( + dockerConfigReceiveAction({ + registries: mockRegistries, + resourceVersion: mockResourceVersion, + }), + ); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + jest + .spyOn(namespaceSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (DwApi.getDockerConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestCredentials())).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(dockerConfigRequestAction()); + expect(actions[1]).toEqual(dockerConfigErrorAction(errorMessage)); + }); + }); + + describe('updateCredentials', () => { + it('should dispatch receive action on successful update', async () => { + const mockRegistries = [ + { url: 'https://registry.com', username: 'user', password: 'pass' }, + ]; + const mockResourceVersion = '12345'; + + jest + .spyOn(namespaceSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (DwApi.putDockerConfig as jest.Mock).mockResolvedValue({ + resourceVersion: mockResourceVersion, + }); + + await store.dispatch(actionCreators.updateCredentials(mockRegistries)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(dockerConfigRequestAction()); + expect(actions[1]).toEqual( + dockerConfigReceiveAction({ + registries: mockRegistries, + resourceVersion: mockResourceVersion, + }), + ); + }); + + it('should dispatch error action on failed update', async () => { + const errorMessage = 'Network error'; + const mockRegistries = [ + { url: 'https://registry.com', username: 'user', password: 'pass' }, + ]; + + jest + .spyOn(namespaceSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (DwApi.putDockerConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.updateCredentials(mockRegistries)), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(dockerConfigRequestAction()); + expect(actions[1]).toEqual(dockerConfigErrorAction(errorMessage)); + }); + }); + }); + + describe('getDockerConfig', () => { + it('should return registries and resourceVersion on successful fetch', async () => { + const mockNamespace = 'test-namespace'; + const mockDockerConfig = window.btoa( + JSON.stringify({ + auths: { + 'https://registry.com': { + auth: window.btoa('user:pass'), + }, + }, + }), + ); + const mockResourceVersion = '12345'; + + (DwApi.getDockerConfig as jest.Mock).mockResolvedValue({ + dockerconfig: mockDockerConfig, + resourceVersion: mockResourceVersion, + }); + + const result = await getDockerConfig(mockNamespace); + + expect(result).toEqual({ + registries: [{ url: 'https://registry.com', username: 'user', password: 'pass' }], + resourceVersion: mockResourceVersion, + }); + }); + + it('should throw an error if fetching docker config fails', async () => { + const mockNamespace = 'test-namespace'; + const errorMessage = 'Network error'; + + (DwApi.getDockerConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(getDockerConfig(mockNamespace)).rejects.toThrow( + `Failed to request the docker config. Reason: ${errorMessage}`, + ); + }); + + it('should throw an error if decoding and parsing docker config fails', async () => { + const mockNamespace = 'test-namespace'; + const mockDockerConfig = 'invalid-base64'; + const errorMessage = 'Invalid base64 string'; + + (DwApi.getDockerConfig as jest.Mock).mockResolvedValue({ + dockerconfig: mockDockerConfig, + resourceVersion: '12345', + }); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(getDockerConfig(mockNamespace)).rejects.toThrow( + `Unable to decode and parse data. Reason: ${errorMessage}`, + ); + }); + + it('should return empty registries if dockerconfig is not provided', async () => { + const mockNamespace = 'test-namespace'; + + (DwApi.getDockerConfig as jest.Mock).mockResolvedValue({ + dockerconfig: undefined, + resourceVersion: '12345', + }); + + const result = await getDockerConfig(mockNamespace); + + expect(result).toEqual({ + registries: [], + resourceVersion: '12345', + }); + }); + }); + + describe('putDockerConfig', () => { + it('should update docker config successfully', async () => { + const mockNamespace = 'test-namespace'; + const mockRegistries = [{ url: 'https://registry.com', username: 'user', password: 'pass' }]; + const mockResourceVersion = '12345'; + const mockResponse = { + dockerconfig: 'mockDockerConfig', + resourceVersion: mockResourceVersion, + }; + + (DwApi.putDockerConfig as jest.Mock).mockResolvedValue(mockResponse); + + const result = await putDockerConfig(mockNamespace, mockRegistries, mockResourceVersion); + + expect(result).toEqual(mockResponse); + expect(DwApi.putDockerConfig).toHaveBeenCalledWith(mockNamespace, { + dockerconfig: window.btoa( + JSON.stringify({ + auths: { + 'https://registry.com': { + username: 'user', + password: 'pass', + auth: window.btoa('user:pass'), + }, + }, + }), + ), + resourceVersion: mockResourceVersion, + }); + }); + + it('should throw an error if updating docker config fails', async () => { + const mockNamespace = 'test-namespace'; + const mockRegistries = [{ url: 'https://registry.com', username: 'user', password: 'pass' }]; + const errorMessage = 'Network error'; + + (DwApi.putDockerConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(putDockerConfig(mockNamespace, mockRegistries)).rejects.toThrow( + `Failed to update the docker config. Reason: ${errorMessage}`, + ); + }); + + it('should throw an error if encoding and parsing data fails', async () => { + const mockNamespace = 'test-namespace'; + const mockRegistries = [{ url: 'https://registry.com', username: 'user', password: 'pass' }]; + const errorMessage = 'Encoding error'; + + jest.spyOn(window, 'btoa').mockImplementation(() => { + throw new Error(errorMessage); + }); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(putDockerConfig(mockNamespace, mockRegistries)).rejects.toThrow( + `Unable to parse and code data. Reason: ${errorMessage}`, + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DockerConfig/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/DockerConfig/__tests__/index.spec.ts deleted file mode 100644 index c234803bd..000000000 --- a/packages/dashboard-frontend/src/store/DockerConfig/__tests__/index.spec.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import mockAxios from 'axios'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as dwDockerConfigStore from '..'; -import { Type } from '..'; - -// mute the outputs -console.error = jest.fn(); - -describe('dwDockerConfig store', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('actions', () => { - it('should create REQUEST_DEVWORKSPACE_CREDENTIALS and SET_DEVWORKSPACE_CREDENTIALS when requestCredentials', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: { - resourceVersion: '45654', - dockerconfig: - 'eyJhdXRocyI6eyJkdW1teS5pbyI6eyJhdXRoIjoiZEdWemRHNWhiV1U2V0ZoWVdGaFlXRmhZV0ZoWVdGaFkifX19', - }, - }); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwDockerConfigStore.actionCreators.requestCredentials()); - - const actions = store.getActions(); - - const expectedActions: dwDockerConfigStore.KnownAction[] = [ - { - type: Type.REQUEST_DEVWORKSPACE_CREDENTIALS, - check: AUTHORIZED, - }, - { - type: Type.SET_DEVWORKSPACE_CREDENTIALS, - registries: [ - { - password: 'XXXXXXXXXXXXXXX', - url: 'dummy.io', - username: 'testname', - }, - ], - resourceVersion: '45654', - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE_CREDENTIALS and SET_DEVWORKSPACE_CREDENTIALS when updateCredentials', async () => { - (mockAxios.put as jest.Mock).mockResolvedValueOnce({ - data: { - resourceVersion: '12345', - }, - }); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch( - dwDockerConfigStore.actionCreators.updateCredentials([ - { - password: 'YYYYYYYYYYYY', - url: 'dummy.io', - username: 'testname2', - }, - ]), - ); - - const actions = store.getActions(); - - const expectedActions: dwDockerConfigStore.KnownAction[] = [ - { - type: Type.REQUEST_DEVWORKSPACE_CREDENTIALS, - check: AUTHORIZED, - }, - { - type: Type.SET_DEVWORKSPACE_CREDENTIALS, - registries: [ - { - password: 'YYYYYYYYYYYY', - url: 'dummy.io', - username: 'testname2', - }, - ], - resourceVersion: '12345', - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - describe('reducers', () => { - it('should return initial state', () => { - const incomingAction: dwDockerConfigStore.RequestCredentialsAction = { - type: Type.REQUEST_DEVWORKSPACE_CREDENTIALS, - check: AUTHORIZED, - }; - const initialState = dwDockerConfigStore.reducer(undefined, incomingAction); - - const expectedState: dwDockerConfigStore.State = { - isLoading: false, - registries: [], - resourceVersion: undefined, - error: undefined, - }; - - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action type is not matched', () => { - const initialState: dwDockerConfigStore.State = { - isLoading: false, - registries: [ - { - password: 'YYYYYYYYYYYY', - url: 'dummy.io', - username: 'testname3', - }, - ], - resourceVersion: '123', - error: undefined, - } as dwDockerConfigStore.State; - const incomingAction = { - type: 'OTHER_ACTION', - isLoading: true, - registries: [], - resourceVersion: undefined, - } as AnyAction; - const newState = dwDockerConfigStore.reducer(initialState, incomingAction); - - const expectedState: dwDockerConfigStore.State = { - isLoading: false, - registries: [ - { - password: 'YYYYYYYYYYYY', - url: 'dummy.io', - username: 'testname3', - }, - ], - resourceVersion: '123', - error: undefined, - }; - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_DEVWORKSPACE_CREDENTIALS', () => { - const initialState: dwDockerConfigStore.State = { - isLoading: false, - registries: [ - { - password: '********', - url: 'dummy.io', - username: 'testname4', - }, - ], - resourceVersion: '123', - error: undefined, - }; - const incomingAction: dwDockerConfigStore.RequestCredentialsAction = { - type: Type.REQUEST_DEVWORKSPACE_CREDENTIALS, - check: AUTHORIZED, - }; - - const newState = dwDockerConfigStore.reducer(initialState, incomingAction); - - const expectedState: dwDockerConfigStore.State = { - isLoading: true, - registries: [ - { - password: '********', - url: 'dummy.io', - username: 'testname4', - }, - ], - resourceVersion: '123', - error: undefined, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle SET_DEVWORKSPACE_CREDENTIALS', () => { - const initialState: dwDockerConfigStore.State = { - isLoading: true, - registries: [ - { - password: '********', - url: 'dummy.io', - username: 'testname4', - }, - ], - resourceVersion: '123', - error: undefined, - }; - const incomingAction: dwDockerConfigStore.SetCredentialsAction = { - type: Type.SET_DEVWORKSPACE_CREDENTIALS, - registries: [], - resourceVersion: '345', - }; - - const newState = dwDockerConfigStore.reducer(initialState, incomingAction); - - const expectedState: dwDockerConfigStore.State = { - isLoading: false, - registries: [], - resourceVersion: '345', - error: undefined, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR', () => { - const initialState: dwDockerConfigStore.State = { - isLoading: true, - registries: [], - resourceVersion: undefined, - error: undefined, - }; - const incomingAction: dwDockerConfigStore.ReceiveErrorAction = { - type: Type.RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR, - error: 'unexpected error', - }; - - const newState = dwDockerConfigStore.reducer(initialState, incomingAction); - - const expectedState: dwDockerConfigStore.State = { - isLoading: false, - registries: [], - resourceVersion: undefined, - error: 'unexpected error', - }; - - expect(newState).toEqual(expectedState); - }); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/DockerConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/DockerConfig/__tests__/reducer.spec.ts new file mode 100644 index 000000000..02149a16d --- /dev/null +++ b/packages/dashboard-frontend/src/store/DockerConfig/__tests__/reducer.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import { + dockerConfigErrorAction, + dockerConfigReceiveAction, + dockerConfigRequestAction, +} from '@/store/DockerConfig/actions'; +import { reducer, RegistryEntry, State, unloadedState } from '@/store/DockerConfig/reducer'; + +describe('DockerConfig, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle dockerConfigRequestAction', () => { + const action = dockerConfigRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + error: undefined, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dockerConfigReceiveAction', () => { + const registries = [ + { url: 'https://registry.com', username: 'user', password: 'pass' }, + ] as RegistryEntry[]; + const resourceVersion = '12345'; + const action = dockerConfigReceiveAction({ registries, resourceVersion }); + const expectedState: State = { + ...initialState, + isLoading: false, + registries, + resourceVersion, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dockerConfigErrorAction', () => { + const error = 'Error message'; + const action = dockerConfigErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DockerConfig/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/DockerConfig/__tests__/selectors.spec.ts index 8715de78b..6678b8f3e 100644 --- a/packages/dashboard-frontend/src/store/DockerConfig/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/DockerConfig/__tests__/selectors.spec.ts @@ -10,93 +10,45 @@ * Red Hat, Inc. - initial API and implementation */ -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; import { selectError, selectIsLoading, selectRegistries } from '@/store/DockerConfig/selectors'; -describe('dockerConfig selectors', () => { - const registries = [ - { - url: 'dummy.io', - username: 'testname1', - password: 'XXXXXXXXXXXXXXX', - }, - { - url: 'dummy.io', - username: 'testname2', - password: 'YYYYYYYYYYYYYYY', +describe('DockerConfig Selectors', () => { + const mockState = { + dockerConfig: { + isLoading: true, + registries: [ + { url: 'https://registry1.com', username: 'user1', password: 'pass1' }, + { url: 'https://registry2.com', username: 'user2', password: 'pass2' }, + ], + error: 'Something went wrong', }, - ]; - - describe('devworkspaces enabled', () => { - it('should return all registries', () => { - const fakeStore = new FakeStoreBuilder() - .withDockerConfig(registries, false) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const expectedRegistries = registries; - const selectedRegistries = selectRegistries(state); - expect(selectedRegistries).toEqual(expectedRegistries); - }); - - it('should return isLoading status', () => { - const fakeStore = new FakeStoreBuilder() - .withDockerConfig([], true) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const isLoading = selectIsLoading(state); - - expect(isLoading).toBeTruthy(); - }); + } as RootState; - it('should return an error related to default editor fetching', () => { - const fakeStore = new FakeStoreBuilder() - .withDockerConfig(registries, false, 'default editor fetching error') - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedError = selectError(state); - expect(selectedError).toEqual('default editor fetching error'); - }); + it('should select isLoading', () => { + const result = selectIsLoading(mockState); + expect(result).toBe(true); }); - describe('devworkspaces disabled', () => { - it('should return all registries', () => { - const fakeStore = new FakeStoreBuilder() - .withDockerConfig(registries, false) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const expectedRegistries = registries; - const selectedRegistries = selectRegistries(state); - expect(selectedRegistries).toEqual(expectedRegistries); - }); - - it('should return isLoading status', () => { - const fakeStore = new FakeStoreBuilder() - .withDockerConfig([], true) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const isLoading = selectIsLoading(state); - - expect(isLoading).toBeTruthy(); - }); + it('should select registries', () => { + const result = selectRegistries(mockState); + expect(result).toEqual([ + { url: 'https://registry1.com', username: 'user1', password: 'pass1' }, + { url: 'https://registry2.com', username: 'user2', password: 'pass2' }, + ]); + }); - it('should return an error related to default editor fetching', () => { - const fakeStore = new FakeStoreBuilder() - .withDockerConfig(registries, false, 'default editor fetching error') - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); + it('should select error', () => { + const result = selectError(mockState); + expect(result).toEqual('Something went wrong'); + }); - const selectedError = selectError(state); - expect(selectedError).toEqual('default editor fetching error'); - }); + it('should return undefined if error is not set', () => { + const stateWithoutError = { + ...mockState, + dockerConfig: { ...mockState.dockerConfig, error: undefined }, + } as RootState; + const result = selectError(stateWithoutError); + expect(result).toBeUndefined(); }); }); diff --git a/packages/dashboard-frontend/src/store/DockerConfig/actions.ts b/packages/dashboard-frontend/src/store/DockerConfig/actions.ts new file mode 100644 index 000000000..efa99269b --- /dev/null +++ b/packages/dashboard-frontend/src/store/DockerConfig/actions.ts @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import * as DwApi from '@/services/backend-client/devWorkspaceApi'; +import { AppThunk } from '@/store'; +import { RegistryEntry } from '@/store/DockerConfig'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const dockerConfigRequestAction = createAction('dockerConfig/request'); +type DockerConfigReceivePayload = { + registries: RegistryEntry[]; + resourceVersion: string | undefined; +}; +export const dockerConfigReceiveAction = + createAction('dockerConfig/receive'); +export const dockerConfigErrorAction = createAction('dockerConfig/error'); + +export const actionCreators = { + requestCredentials: + (): AppThunk => + async (dispatch, getState): Promise => { + const namespace = selectDefaultNamespace(getState()).name; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(dockerConfigRequestAction()); + + const { registries, resourceVersion } = await getDockerConfig(namespace); + dispatch(dockerConfigReceiveAction({ registries, resourceVersion })); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(dockerConfigErrorAction(errorMessage)); + throw e; + } + }, + + updateCredentials: + (registries: RegistryEntry[]): AppThunk => + async (dispatch, getState): Promise => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(dockerConfigRequestAction()); + + const { resourceVersion } = await putDockerConfig( + namespace, + registries, + state.dockerConfig?.resourceVersion, + ); + dispatch(dockerConfigReceiveAction({ registries, resourceVersion })); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(dockerConfigErrorAction(errorMessage)); + throw e; + } + }, +}; + +export async function getDockerConfig( + namespace: string, +): Promise<{ registries: RegistryEntry[]; resourceVersion?: string }> { + let dockerconfig, resourceVersion: string | undefined; + try { + const resp = await DwApi.getDockerConfig(namespace); + dockerconfig = resp.dockerconfig; + resourceVersion = resp.resourceVersion; + } catch (e) { + throw new Error('Failed to request the docker config. Reason: ' + helpers.errors.getMessage(e)); + } + + const registries: RegistryEntry[] = []; + if (dockerconfig) { + try { + const auths = JSON.parse(window.atob(dockerconfig))['auths']; + Object.keys(auths).forEach(key => { + const [username, password] = window.atob(auths[key]['auth']).split(':'); + registries.push({ url: key, username, password }); + }); + } catch (e) { + throw new Error('Unable to decode and parse data. Reason: ' + helpers.errors.getMessage(e)); + } + } + return { registries, resourceVersion }; +} + +export async function putDockerConfig( + namespace: string, + registries: RegistryEntry[], + resourceVersion?: string, +): Promise { + const config: api.IDockerConfig = { dockerconfig: '' }; + + try { + const authInfo = { auths: {} }; + registries.forEach(item => { + const { url, username, password } = item; + authInfo.auths[url] = { username, password }; + authInfo.auths[url].auth = window.btoa(username + ':' + password); + }); + config.dockerconfig = window.btoa(JSON.stringify(authInfo)); + if (resourceVersion) { + config.resourceVersion = resourceVersion; + } + } catch (e) { + throw new Error('Unable to parse and code data. Reason: ' + helpers.errors.getMessage(e)); + } + + try { + const dockerConfig = await DwApi.putDockerConfig(namespace, config); + return dockerConfig; + } catch (err) { + throw new Error( + 'Failed to update the docker config. Reason: ' + helpers.errors.getMessage(err), + ); + } +} diff --git a/packages/dashboard-frontend/src/store/DockerConfig/index.ts b/packages/dashboard-frontend/src/store/DockerConfig/index.ts index 5a9a4a709..9b1283642 100644 --- a/packages/dashboard-frontend/src/store/DockerConfig/index.ts +++ b/packages/dashboard-frontend/src/store/DockerConfig/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,199 +12,10 @@ * Red Hat, Inc. - initial API and implementation */ -import { api, helpers } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import * as DwApi from '@/services/backend-client/devWorkspaceApi'; -import { RegistryEntry } from '@/store/DockerConfig/types'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export interface State { - isLoading: boolean; - registries: RegistryEntry[]; - resourceVersion?: string; - error: string | undefined; -} - -export enum Type { - REQUEST_DEVWORKSPACE_CREDENTIALS = 'REQUEST_DEVWORKSPACE_CREDENTIALS', - SET_DEVWORKSPACE_CREDENTIALS = 'SET_DEVWORKSPACE_CREDENTIALS', - RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR = 'RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR', -} - -export interface RequestCredentialsAction extends Action, SanityCheckAction { - type: Type.REQUEST_DEVWORKSPACE_CREDENTIALS; -} - -export interface SetCredentialsAction extends Action { - type: Type.SET_DEVWORKSPACE_CREDENTIALS; - registries: RegistryEntry[]; - resourceVersion: string | undefined; -} - -export interface ReceiveErrorAction extends Action { - type: Type.RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR; - error: string; -} - -export type KnownAction = RequestCredentialsAction | SetCredentialsAction | ReceiveErrorAction; - -export type ActionCreators = { - requestCredentials: () => AppThunk>; - updateCredentials: (registries: RegistryEntry[]) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestCredentials: - (): AppThunk> => - async (dispatch, getState): Promise => { - const namespace = selectDefaultNamespace(getState()).name; - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE_CREDENTIALS, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const { registries, resourceVersion } = await getDockerConfig(namespace); - dispatch({ - type: Type.SET_DEVWORKSPACE_CREDENTIALS, - registries, - resourceVersion, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - updateCredentials: - (registries: RegistryEntry[]): AppThunk> => - async (dispatch, getState): Promise => { - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE_CREDENTIALS, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const { resourceVersion } = await putDockerConfig( - namespace, - registries, - state.dockerConfig?.resourceVersion, - ); - dispatch({ - type: Type.SET_DEVWORKSPACE_CREDENTIALS, - registries, - resourceVersion, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR, - error: errorMessage, - }); - throw e; - } - }, -}; - -async function getDockerConfig( - namespace: string, -): Promise<{ registries: RegistryEntry[]; resourceVersion?: string }> { - let dockerconfig, resourceVersion: string | undefined; - try { - const resp = await DwApi.getDockerConfig(namespace); - dockerconfig = resp.dockerconfig; - resourceVersion = resp.resourceVersion; - } catch (e) { - throw 'Failed to request the docker config. Reason: ' + helpers.errors.getMessage(e); - } - const registries: RegistryEntry[] = []; - if (dockerconfig) { - try { - const auths = JSON.parse(window.atob(dockerconfig))['auths']; - Object.keys(auths).forEach(key => { - const [username, password] = window.atob(auths[key]['auth']).split(':'); - registries.push({ url: key, username, password }); - }); - } catch (e) { - throw 'Unable to decode and parse data. Reason: ' + helpers.errors.getMessage(e); - } - } - return { registries, resourceVersion }; -} - -function putDockerConfig( - namespace: string, - registries: RegistryEntry[], - resourceVersion?: string, -): Promise { - const config: api.IDockerConfig = { dockerconfig: '' }; - try { - const authInfo = { auths: {} }; - registries.forEach(item => { - const { url, username, password } = item; - authInfo.auths[url] = { username, password }; - authInfo.auths[url].auth = window.btoa(username + ':' + password); - }); - config.dockerconfig = window.btoa(JSON.stringify(authInfo)); - if (resourceVersion) { - config.resourceVersion = resourceVersion; - } - try { - return DwApi.putDockerConfig(namespace, config); - } catch (err) { - throw 'Failed to update the docker cofig. Reason: ' + helpers.errors.getMessage(err); - } - } catch (e) { - throw 'Unable to parse and code data. Reason: ' + helpers.errors.getMessage(e); - } -} - -const unloadedState: State = { - isLoading: false, - registries: [], - resourceVersion: undefined, - error: undefined, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_DEVWORKSPACE_CREDENTIALS: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.SET_DEVWORKSPACE_CREDENTIALS: - return createObject(state, { - isLoading: false, - registries: action.registries, - resourceVersion: action.resourceVersion, - }); - case Type.RECEIVE_DEVWORKSPACE_CREDENTIALS_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as dockerConfigActionCreators } from '@/store/DockerConfig/actions'; +export { + reducer as dockerConfigReducer, + State as DockerConfigState, + RegistryEntry, +} from '@/store/DockerConfig/reducer'; +export * from '@/store/DockerConfig/selectors'; diff --git a/packages/dashboard-frontend/src/store/DockerConfig/reducer.ts b/packages/dashboard-frontend/src/store/DockerConfig/reducer.ts new file mode 100644 index 000000000..80abc2e14 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DockerConfig/reducer.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { + dockerConfigErrorAction, + dockerConfigReceiveAction, + dockerConfigRequestAction, +} from '@/store/DockerConfig/actions'; + +export type RegistryEntry = { + url: string; + username?: string; + password?: string; +}; + +export interface State { + isLoading: boolean; + registries: RegistryEntry[]; + resourceVersion?: string; + error: string | undefined; +} + +export const unloadedState: State = { + isLoading: false, + registries: [], + resourceVersion: undefined, + error: undefined, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(dockerConfigRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(dockerConfigReceiveAction, (state, action) => { + state.isLoading = false; + state.registries = action.payload.registries; + state.resourceVersion = action.payload.resourceVersion; + }) + .addCase(dockerConfigErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/DockerConfig/selectors.ts b/packages/dashboard-frontend/src/store/DockerConfig/selectors.ts index 1d7d6c85a..e8a5417e7 100644 --- a/packages/dashboard-frontend/src/store/DockerConfig/selectors.ts +++ b/packages/dashboard-frontend/src/store/DockerConfig/selectors.ts @@ -10,19 +10,17 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { State } from '@/store/DockerConfig/index'; +import { RootState } from '@/store'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.dockerConfig; +const selectState = (state: RootState) => state.dockerConfig; export const selectIsLoading = createSelector(selectState, state => { return state.isLoading; }); -export const selectRegistries = createSelector(selectState, (state: State) => { +export const selectRegistries = createSelector(selectState, state => { return state.registries; }); diff --git a/packages/dashboard-frontend/src/store/Events/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Events/__tests__/actions.spec.ts index a39601655..a72c05aa4 100644 --- a/packages/dashboard-frontend/src/store/Events/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/Events/__tests__/actions.spec.ts @@ -10,208 +10,212 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; -import { V1Status } from '@kubernetes/client-node'; -import mockAxios, { AxiosError } from 'axios'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; +import { api, helpers } from '@eclipse-che/common'; +import { CoreV1Event } from '@kubernetes/client-node'; import { container } from '@/inversify.config'; +import { fetchEvents } from '@/services/backend-client/eventsApi'; import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { event1, event2 } from '@/store/Events/__tests__/stubs'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + eventDeleteAction, + eventErrorAction, + eventModifyAction, + eventsReceiveAction, + eventsRequestAction, +} from '@/store/Events/actions'; +import * as namespaceSelectors from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/eventsApi'); +jest.mock('@/store/Events/selectors'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common', () => { + const original = jest.requireActual('@eclipse-che/common'); + return { + ...original, + helpers: { + ...original.helpers, + errors: { + getMessage: jest.fn(), + }, + }, + }; +}); -describe('Events store, actions', () => { - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; +describe('Events Actions', () => { + const mockNamespace = 'test-namespace'; + let store: ReturnType; beforeEach(() => { - appStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - container.snapshot(); - }); - - afterEach(() => { + store = createMockStore({}); jest.clearAllMocks(); - container.restore(); }); - it('should create REQUEST_EVENTS and RECEIVE_EVENTS when fetching events', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: { items: [event1, event2], metadata: { resourceVersion: '123' } }, - }); - - await appStore.dispatch(testStore.actionCreators.requestEvents()); + describe('requestEvents', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockEvents = [{ metadata: { name: 'event1' } }] as CoreV1Event[]; + const mockResourceVersion = '12345'; + + jest + .spyOn(namespaceSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchEvents as jest.Mock).mockResolvedValue({ + items: mockEvents, + metadata: { resourceVersion: mockResourceVersion }, + }); - const actions = appStore.getActions(); + await store.dispatch(actionCreators.requestEvents()); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_EVENTS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_EVENTS, - events: [event1, event2], - resourceVersion: '123', - }, - ]; + const actions = store.getActions(); + expect(actions[0]).toEqual(eventsRequestAction()); + expect(actions[1]).toEqual( + eventsReceiveAction({ + events: mockEvents, + resourceVersion: mockResourceVersion, + }), + ); + }); - expect(actions).toEqual(expectedActions); + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + jest + .spyOn(namespaceSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchEvents as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestEvents())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(eventsRequestAction()); + expect(actions[1]).toEqual( + eventErrorAction({ error: `Failed to fetch events, reason: ${errorMessage}` }), + ); + }); }); - it('should create REQUEST_EVENTS and RECEIVE_ERROR when fails to fetch events', async () => { - (mockAxios.get as jest.Mock).mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - try { - await appStore.dispatch(testStore.actionCreators.requestEvents()); - } catch (e) { - // noop - } - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_EVENTS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: expect.stringContaining('Something unexpected happened.'), - }, - ]; - - expect(actions).toEqual(expectedActions); - }); + describe('handleWebSocketMessage', () => { + let mockUnsubscribeFromChannel: jest.Mock; + let mockSubscribeToChannel: jest.Mock; + + beforeEach(() => { + mockUnsubscribeFromChannel = jest.fn(); + mockSubscribeToChannel = jest.fn(); + + class MockWebsocketClient extends WebsocketClient { + public unsubscribeFromChannel( + ...args: Parameters + ) { + mockUnsubscribeFromChannel(...args); + } + public subscribeToChannel(...args: Parameters) { + mockSubscribeToChannel(...args); + } + } + container.snapshot(); + container.rebind(WebsocketClient).to(MockWebsocketClient).inSingletonScope(); + }); - describe('handle WebSocket events', () => { - it('should create RECEIVE_EVENTS action when receiving a new event', async () => { - await appStore.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - event: event1, - eventPhase: api.webSocket.EventPhase.ADDED, - }), - ); + afterEach(() => { + container.restore(); + }); - const actions = appStore.getActions(); + it('should handle status message and re-subscribe on error status', async () => { + const mockMessage = { + status: { code: 500, message: 'Internal Server Error' }, + eventPhase: api.webSocket.EventPhase.ERROR, + } as api.webSocket.StatusMessage; + const mockEvents = [{ metadata: { name: 'event1' } }] as CoreV1Event[]; + const mockResourceVersion = '12345'; + + jest + .spyOn(namespaceSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchEvents as jest.Mock).mockResolvedValue({ + items: mockEvents, + metadata: { resourceVersion: mockResourceVersion }, + }); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.RECEIVE_EVENTS, - events: [event1], - resourceVersion: '1', - }, - ]; + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - expect(actions).toEqual(expectedActions); + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(eventsRequestAction()); + expect(actions[1]).toEqual( + eventsReceiveAction({ + events: mockEvents, + resourceVersion: mockResourceVersion, + }), + ); + expect(mockUnsubscribeFromChannel).toHaveBeenCalledWith(api.webSocket.Channel.EVENT); + expect(mockSubscribeToChannel).toHaveBeenCalledWith( + api.webSocket.Channel.EVENT, + mockNamespace, + expect.any(Object), + ); }); - it('should create MODIFY_EVENT action when receiving a modified event', async () => { - await appStore.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - event: event1, - eventPhase: api.webSocket.EventPhase.MODIFIED, + it('should handle event message with ADDED phase', async () => { + const mockEvent = { metadata: { name: 'event1', resourceVersion: '12345' } } as CoreV1Event; + const mockMessage = { + event: mockEvent, + eventPhase: api.webSocket.EventPhase.ADDED, + } as api.webSocket.NotificationMessage; + + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + eventsReceiveAction({ + events: [mockEvent], + resourceVersion: mockEvent.metadata?.resourceVersion, }), ); + }); - const actions = appStore.getActions(); + it('should handle event message with MODIFIED phase', async () => { + const mockEvent = { metadata: { name: 'event1' } } as CoreV1Event; + const mockMessage = { + event: mockEvent, + eventPhase: api.webSocket.EventPhase.MODIFIED, + } as api.webSocket.NotificationMessage; - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.MODIFY_EVENT, - event: event1, - }, - ]; + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - expect(actions).toEqual(expectedActions); + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(eventModifyAction({ event: mockEvent })); }); - it('should create DELETE_EVENTS action when receiving a deleted event', async () => { - await appStore.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - event: event1, - eventPhase: api.webSocket.EventPhase.DELETED, - }), - ); + it('should handle event message with DELETED phase', async () => { + const mockEvent = { metadata: { name: 'event1' } } as CoreV1Event; + const mockMessage = { + event: mockEvent, + eventPhase: api.webSocket.EventPhase.DELETED, + } as api.webSocket.NotificationMessage; - const actions = appStore.getActions(); + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.DELETE_EVENT, - event: event1, - }, - ]; - - expect(actions).toEqual(expectedActions); + const actions = store.getActions(); + expect(actions[0]).toEqual(eventDeleteAction({ event: mockEvent })); }); - it('should create REQUEST_EVENTS and RECEIVE_EVENTS and resubscribe to channel', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: { items: [event1, event2], metadata: { resourceVersion: '123' } }, - }); + it('should log unexpected message', async () => { + const mockMessage = { + unexpectedField: 'unexpectedValue', + } as unknown as api.webSocket.NotificationMessage; - const websocketClient = container.get(WebsocketClient); - const unsubscribeFromChannelSpy = jest - .spyOn(websocketClient, 'unsubscribeFromChannel') - .mockReturnValue(undefined); - const subscribeToChannelSpy = jest - .spyOn(websocketClient, 'subscribeToChannel') - .mockReturnValue(undefined); - - const namespace = 'user-che'; - const appStoreWithNamespace = new FakeStoreBuilder() - .withInfrastructureNamespace([{ name: namespace, attributes: { phase: 'Active' } }]) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - await appStoreWithNamespace.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - status: { - code: 410, - message: 'The resourceVersion for the provided watch is too old.', - } as V1Status, - eventPhase: api.webSocket.EventPhase.ERROR, - params: { - namespace, - resourceVersion: '123', - }, - }), - ); + console.warn = jest.fn(); - const actions = appStoreWithNamespace.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - check: AUTHORIZED, - type: testStore.Type.REQUEST_EVENTS, - }, - { - type: testStore.Type.RECEIVE_EVENTS, - events: [event1, event2], - resourceVersion: '123', - }, - ]; - - expect(actions).toEqual(expectedActions); - expect(unsubscribeFromChannelSpy).toHaveBeenCalledWith(api.webSocket.Channel.EVENT); - expect(subscribeToChannelSpy).toHaveBeenCalledWith(api.webSocket.Channel.EVENT, namespace, { - getResourceVersion: expect.any(Function), - }); + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); + + expect(console.warn).toHaveBeenCalledWith('WebSocket: unexpected message:', mockMessage); }); }); }); diff --git a/packages/dashboard-frontend/src/store/Events/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/Events/__tests__/reducers.spec.ts index a16d10a54..d7091c614 100644 --- a/packages/dashboard-frontend/src/store/Events/__tests__/reducers.spec.ts +++ b/packages/dashboard-frontend/src/store/Events/__tests__/reducers.spec.ts @@ -11,176 +11,104 @@ */ import { CoreV1Event } from '@kubernetes/client-node'; -import { cloneDeep } from 'lodash'; -import { AnyAction } from 'redux'; +import { UnknownAction } from 'redux'; -import * as stub from '@/store/Events/__tests__/stubs'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; +import { + eventDeleteAction, + eventErrorAction, + eventModifyAction, + eventsReceiveAction, + eventsRequestAction, +} from '@/store/Events/actions'; +import { reducer, State, unloadedState } from '@/store/Events/reducer'; -import * as testStore from '..'; - -describe('Events store, reducers', () => { - let event1: CoreV1Event; - let event2: CoreV1Event; +describe('Events, reducer', () => { + let initialState: State; beforeEach(() => { - event1 = cloneDeep(stub.event1); - event2 = cloneDeep(stub.event2); - }); - - it('should return initial state', () => { - const incomingAction: testStore.RequestEventsAction = { - type: testStore.Type.REQUEST_EVENTS, - check: AUTHORIZED, - }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - events: [], - resourceVersion: '0', - }; - - expect(initialState).toEqual(expectedState); + initialState = { ...unloadedState }; }); - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { + it('should handle eventsRequestAction', () => { + const action = eventsRequestAction(); + const expectedState: State = { + ...initialState, isLoading: true, - events: [event1, event2], - resourceVersion: '0', + error: undefined, }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - const expectedState: testStore.State = { - isLoading: true, - events: [event1, event2], - resourceVersion: '0', - }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle REQUEST_EVENTS', () => { - const initialState: testStore.State = { + it('should handle eventsReceiveAction', () => { + const events = [{ metadata: { name: 'event1' } }] as CoreV1Event[]; + const resourceVersion = '12345'; + const action = eventsReceiveAction({ events, resourceVersion }); + const expectedState: State = { + ...initialState, isLoading: false, - events: [], - error: 'unexpected error', - resourceVersion: '0', - }; - const incomingAction: testStore.RequestEventsAction = { - type: testStore.Type.REQUEST_EVENTS, - check: AUTHORIZED, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - events: [], - resourceVersion: '0', + events, + resourceVersion, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_EVENTS', () => { - const initialState: testStore.State = { - isLoading: true, - events: [event1], - resourceVersion: '1', - }; - const incomingAction: testStore.ReceiveEventsAction = { - type: testStore.Type.RECEIVE_EVENTS, - events: [event2], - resourceVersion: '2', + it('should handle eventModifyAction', () => { + const initialStateWithEvents: State = { + ...initialState, + events: [ + { metadata: { name: 'event1', uid: 'uid1', resourceVersion: '123' } }, + { metadata: { name: 'event2', uid: 'uid2', resourceVersion: '124' } }, + ] as CoreV1Event[], }; + const modifiedEvent = { + metadata: { name: 'event1', uid: 'uid1', resourceVersion: '125' }, + } as CoreV1Event; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - events: [event1, event2], - resourceVersion: '2', + const action = eventModifyAction({ event: modifiedEvent }); + const expectedState: State = { + ...initialStateWithEvents, + events: [modifiedEvent, initialStateWithEvents.events[1]], + resourceVersion: '125', }; - expect(newState).toEqual(expectedState); + expect(reducer(initialStateWithEvents, action)).toEqual(expectedState); }); - it('should handle MODIFY_EVENT', () => { - const initialState: testStore.State = { - isLoading: false, - events: [event1, event2], - resourceVersion: '1', + it('should handle eventDeleteAction', () => { + const initialStateWithEvents: State = { + ...initialState, + events: [ + { metadata: { name: 'event1', uid: 'uid1' } }, + { metadata: { name: 'event2', uid: 'uid2' } }, + ] as CoreV1Event[], }; - - const modifiedEvent = cloneDeep(event1); - modifiedEvent.message = 'modified message'; - const incomingAction: testStore.ModifyEventAction = { - type: testStore.Type.MODIFY_EVENT, - event: modifiedEvent, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - events: [modifiedEvent, event2], - resourceVersion: '1', + const action = eventDeleteAction({ + event: { metadata: { name: 'event1', uid: 'uid1' } } as CoreV1Event, + }); + const expectedState: State = { + ...initialStateWithEvents, + events: [initialStateWithEvents.events[1]], }; - expect(newState).toEqual(expectedState); + expect(reducer(initialStateWithEvents, action)).toEqual(expectedState); }); - it('should handle RECEIVE_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - events: [], - resourceVersion: '0', - }; - const incomingAction: testStore.ReceiveErrorAction = { - type: testStore.Type.RECEIVE_ERROR, - error: 'unexpected error', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { + it('should handle eventErrorAction', () => { + const error = 'Error message'; + const action = eventErrorAction({ error }); + const expectedState: State = { + ...initialState, isLoading: false, - events: [], - error: 'unexpected error', - resourceVersion: '0', + error, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle DELETE_EVENTS', () => { - event1.metadata.resourceVersion = '1'; - event2.metadata.resourceVersion = '2'; - const initialState: testStore.State = { - isLoading: false, - events: [event1, event2], - resourceVersion: '2', - }; - - const nextEvent1 = cloneDeep(event1); - nextEvent1.metadata.resourceVersion = '3'; - const incomingAction: testStore.DeleteEventAction = { - type: testStore.Type.DELETE_EVENT, - event: nextEvent1, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - events: [event2], - resourceVersion: '3', - }; - - expect(newState).toEqual(expectedState); + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/Events/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Events/__tests__/selectors.spec.ts index afe35fb63..b27f7a3cf 100644 --- a/packages/dashboard-frontend/src/store/Events/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/Events/__tests__/selectors.spec.ts @@ -10,14 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -import { CoreV1Event } from '@kubernetes/client-node'; -import { cloneDeep } from 'lodash'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as stub from '@/store/Events/__tests__/stubs'; +import { RootState } from '@/store'; import { selectAllEvents, selectEventsError, @@ -25,71 +18,47 @@ import { selectEventsResourceVersion, } from '@/store/Events/selectors'; -import * as store from '..'; - -describe('Events store, selectors', () => { - let event1: CoreV1Event; - let event2: CoreV1Event; +describe('Events Selectors', () => { + const mockState = { + events: { + events: [ + { metadata: { name: 'event0' } }, + { metadata: { name: 'event1', resourceVersion: '123' } }, + { metadata: { name: 'event2', resourceVersion: '124' } }, + { metadata: { name: 'event3', resourceVersion: '125' } }, + ], + error: 'Something went wrong', + resourceVersion: '125', + }, + } as RootState; - beforeEach(() => { - event1 = cloneDeep(stub.event1); - event2 = cloneDeep(stub.event2); + it('should select all events', () => { + const result = selectAllEvents(mockState); + expect(result).toEqual(mockState.events.events); }); - it('should return the error', () => { - const fakeStore = new FakeStoreBuilder() - .withEvents({ events: [], error: 'Something unexpected' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedError = selectEventsError(state); - expect(selectedError).toEqual('Something unexpected'); + it('should select events from a specific resource version', () => { + const selectFromResourceVersion = selectEventsFromResourceVersion(mockState); + const result = selectFromResourceVersion('124'); + expect(result).toEqual([ + { metadata: { name: 'event2', resourceVersion: '124' } }, + { metadata: { name: 'event3', resourceVersion: '125' } }, + ]); }); - it('should return all events', () => { - const fakeStore = new FakeStoreBuilder() - .withEvents({ events: [event1, event2] }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const allEvents = selectAllEvents(state); - expect(allEvents).toEqual([event1, event2]); + it('should return an empty array if resource version is invalid', () => { + const selectFromResourceVersion = selectEventsFromResourceVersion(mockState); + const result = selectFromResourceVersion('invalid'); + expect(result).toEqual([]); }); - it('should return the resource version', () => { - const fakeStore = new FakeStoreBuilder() - .withEvents({ events: [event1, event2], resourceVersion: '1234' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const resourceVersion = selectEventsResourceVersion(state); - expect(resourceVersion).toEqual('1234'); + it('should select events error', () => { + const result = selectEventsError(mockState); + expect(result).toEqual(mockState.events.error); }); - it('should return events starting from a resource version', () => { - event1.metadata.resourceVersion = '1'; - event2.metadata.resourceVersion = '5'; - const fakeStore = new FakeStoreBuilder() - .withEvents({ events: [event1, event2], resourceVersion: '1' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectEventsFn = selectEventsFromResourceVersion(state); - expect(typeof selectEventsFn).toEqual('function'); - - const events = selectEventsFn('3'); - expect(events).toEqual([event2]); + it('should select events resource version', () => { + const result = selectEventsResourceVersion(mockState); + expect(result).toEqual(mockState.events.resourceVersion); }); }); diff --git a/packages/dashboard-frontend/src/store/Events/actions.ts b/packages/dashboard-frontend/src/store/Events/actions.ts new file mode 100644 index 000000000..7650cfcc0 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Events/actions.ts @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { CoreV1Event } from '@kubernetes/client-node'; +import { createAction } from '@reduxjs/toolkit'; + +import { container } from '@/inversify.config'; +import { fetchEvents } from '@/services/backend-client/eventsApi'; +import { WebsocketClient } from '@/services/backend-client/websocketClient'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const eventsRequestAction = createAction('events/request'); + +type EventsReceivePayload = { + events: CoreV1Event[]; + resourceVersion: string | undefined; +}; +export const eventsReceiveAction = createAction('events/receive'); + +type EventModifyAction = { + event: CoreV1Event; +}; +export const eventModifyAction = createAction('events/modify'); + +type EventDeleteAction = { + event: CoreV1Event; +}; +export const eventDeleteAction = createAction('events/delete'); + +type EventErrorAction = { + error: string; +}; +export const eventErrorAction = createAction('events/error'); + +export const actionCreators = { + requestEvents: + (): AppThunk => + async (dispatch, getState): Promise => { + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const defaultNamespace = defaultKubernetesNamespace.name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(eventsRequestAction()); + + const eventsList = await fetchEvents(defaultNamespace); + dispatch( + eventsReceiveAction({ + events: eventsList.items, + resourceVersion: eventsList.metadata?.resourceVersion, + }), + ); + } catch (e) { + const errorMessage = 'Failed to fetch events, reason: ' + helpers.errors.getMessage(e); + dispatch(eventErrorAction({ error: errorMessage })); + throw e; + } + }, + + handleWebSocketMessage: + (message: api.webSocket.NotificationMessage): AppThunk => + async (dispatch, getState): Promise => { + if (api.webSocket.isStatusMessage(message)) { + const { status } = message; + + const errorMessage = `WebSocket(EVENT): status code ${status.code}, reason: ${status.message}`; + console.debug(errorMessage); + + if (status.code !== 200) { + /* in case of error status trying to fetch all events and re-subscribe to websocket channel */ + + const websocketClient = container.get(WebsocketClient); + + websocketClient.unsubscribeFromChannel(api.webSocket.Channel.EVENT); + + await dispatch(actionCreators.requestEvents()); + + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const namespace = defaultKubernetesNamespace.name; + const getResourceVersion = () => { + const state = getState(); + return state.events.resourceVersion; + }; + websocketClient.subscribeToChannel(api.webSocket.Channel.EVENT, namespace, { + getResourceVersion, + }); + } + return; + } + + if (api.webSocket.isEventMessage(message)) { + const { event, eventPhase } = message; + switch (eventPhase) { + case api.webSocket.EventPhase.ADDED: + dispatch( + eventsReceiveAction({ + events: [event], + resourceVersion: event.metadata?.resourceVersion, + }), + ); + return; + case api.webSocket.EventPhase.MODIFIED: + dispatch(eventModifyAction({ event })); + return; + case api.webSocket.EventPhase.DELETED: + dispatch(eventDeleteAction({ event })); + return; + } + } + + console.warn('WebSocket: unexpected message:', message); + }, +}; diff --git a/packages/dashboard-frontend/src/store/Events/index.ts b/packages/dashboard-frontend/src/store/Events/index.ts index 1f8d29b5d..fb81dc204 100644 --- a/packages/dashboard-frontend/src/store/Events/index.ts +++ b/packages/dashboard-frontend/src/store/Events/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,223 +12,6 @@ * Red Hat, Inc. - initial API and implementation */ -import { api, helpers } from '@eclipse-che/common'; -import { CoreV1Event } from '@kubernetes/client-node'; -import { Action, Reducer } from 'redux'; - -import { container } from '@/inversify.config'; -import { fetchEvents } from '@/services/backend-client/eventsApi'; -import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import { getNewerResourceVersion } from '@/services/helpers/resourceVersion'; -import { selectEventsResourceVersion } from '@/store/Events/selectors'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export interface State { - isLoading: boolean; - events: CoreV1Event[]; - resourceVersion: string; - error?: string; -} - -export enum Type { - REQUEST_EVENTS = 'REQUEST_EVENTS', - RECEIVE_EVENTS = 'RECEIVE_EVENTS', - MODIFY_EVENT = 'MODIFY_EVENT', - DELETE_EVENT = 'DELETE_EVENT', - RECEIVE_ERROR = 'RECEIVE_ERROR', -} - -export interface RequestEventsAction extends Action, SanityCheckAction { - type: Type.REQUEST_EVENTS; -} - -export interface ReceiveEventsAction { - type: Type.RECEIVE_EVENTS; - events: CoreV1Event[]; - resourceVersion: string | undefined; -} - -export interface ModifyEventAction { - type: Type.MODIFY_EVENT; - event: CoreV1Event; -} - -export interface DeleteEventAction { - type: Type.DELETE_EVENT; - event: CoreV1Event; -} - -export interface ReceiveErrorAction { - type: Type.RECEIVE_ERROR; - error: string; -} - -export type KnownAction = - | RequestEventsAction - | ReceiveEventsAction - | ModifyEventAction - | DeleteEventAction - | ReceiveErrorAction; - -export type ActionCreators = { - requestEvents: () => AppThunk>; - - handleWebSocketMessage: ( - message: api.webSocket.NotificationMessage, - ) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestEvents: - (): AppThunk> => - async (dispatch, getState): Promise => { - await dispatch({ - type: Type.REQUEST_EVENTS, - check: AUTHORIZED, - }); - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const defaultNamespace = defaultKubernetesNamespace.name; - - try { - const eventsList = await fetchEvents(defaultNamespace); - - dispatch({ - type: Type.RECEIVE_EVENTS, - events: eventsList.items, - resourceVersion: eventsList.metadata?.resourceVersion, - }); - } catch (e) { - const errorMessage = 'Failed to fetch events, reason: ' + helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw errorMessage; - } - }, - - handleWebSocketMessage: - (message: api.webSocket.NotificationMessage): AppThunk> => - async (dispatch, getState): Promise => { - if (api.webSocket.isStatusMessage(message)) { - const { status } = message; - - const errorMessage = `WebSocket(EVENT): status code ${status.code}, reason: ${status.message}`; - console.debug(errorMessage); - - if (status.code !== 200) { - /* in case of error status trying to fetch all events and re-subscribe to websocket channel */ - - const websocketClient = container.get(WebsocketClient); - - websocketClient.unsubscribeFromChannel(api.webSocket.Channel.EVENT); - - await dispatch(actionCreators.requestEvents()); - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const namespace = defaultKubernetesNamespace.name; - const getResourceVersion = () => { - const state = getState(); - return selectEventsResourceVersion(state); - }; - websocketClient.subscribeToChannel(api.webSocket.Channel.EVENT, namespace, { - getResourceVersion, - }); - } - return; - } - - if (api.webSocket.isEventMessage(message)) { - const { event, eventPhase } = message; - switch (eventPhase) { - case api.webSocket.EventPhase.ADDED: - dispatch({ - type: Type.RECEIVE_EVENTS, - events: [event], - resourceVersion: event.metadata?.resourceVersion, - }); - break; - case api.webSocket.EventPhase.MODIFIED: - dispatch({ - type: Type.MODIFY_EVENT, - event, - }); - break; - case api.webSocket.EventPhase.DELETED: - dispatch({ - type: Type.DELETE_EVENT, - event, - }); - break; - default: - console.warn('WebSocket: unexpected eventPhase:', message); - } - return; - } - - console.warn('WebSocket: unexpected message:', message); - }, -}; - -const unloadedState: State = { - isLoading: false, - events: [], - resourceVersion: '0', -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_EVENTS: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_EVENTS: - return createObject(state, { - isLoading: false, - events: state.events.concat(action.events), - resourceVersion: getNewerResourceVersion(action.resourceVersion, state.resourceVersion), - }); - case Type.MODIFY_EVENT: - return createObject(state, { - events: state.events.map(event => { - if (event.metadata.uid === action.event.metadata.uid) { - return action.event; - } - return event; - }), - resourceVersion: getNewerResourceVersion( - action.event.metadata.resourceVersion, - state.resourceVersion, - ), - }); - case Type.DELETE_EVENT: - return createObject(state, { - events: state.events.filter(event => event.metadata.uid !== action.event.metadata.uid), - resourceVersion: getNewerResourceVersion( - action.event.metadata.resourceVersion, - state.resourceVersion, - ), - }); - case Type.RECEIVE_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as eventsActionCreators } from '@/store/Events/actions'; +export { reducer as eventsReducer, State as EventsState } from '@/store/Events/reducer'; +export * from '@/store/Events/selectors'; diff --git a/packages/dashboard-frontend/src/store/Events/reducer.ts b/packages/dashboard-frontend/src/store/Events/reducer.ts new file mode 100644 index 000000000..69b50901d --- /dev/null +++ b/packages/dashboard-frontend/src/store/Events/reducer.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { CoreV1Event } from '@kubernetes/client-node'; +import { createReducer } from '@reduxjs/toolkit'; + +import { getNewerResourceVersion } from '@/services/helpers/resourceVersion'; +import { + eventDeleteAction, + eventErrorAction, + eventModifyAction, + eventsReceiveAction, + eventsRequestAction, +} from '@/store/Events/actions'; + +export interface State { + isLoading: boolean; + events: CoreV1Event[]; + resourceVersion: string; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + events: [], + resourceVersion: '0', +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(eventsRequestAction, state => { + state.isLoading = true; + }) + .addCase(eventsReceiveAction, (state, action) => { + state.isLoading = false; + state.events = state.events.concat(action.payload.events); + state.resourceVersion = getNewerResourceVersion( + action.payload.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(eventModifyAction, (state, action) => { + state.events = state.events.map(event => { + if (event.metadata.uid === action.payload.event.metadata.uid) { + return action.payload.event; + } + return event; + }); + state.resourceVersion = getNewerResourceVersion( + action.payload.event.metadata.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(eventDeleteAction, (state, action) => { + state.events = state.events.filter( + event => event.metadata.uid !== action.payload.event.metadata.uid, + ); + state.resourceVersion = getNewerResourceVersion( + action.payload.event.metadata.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(eventErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload.error; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Events/selectors.ts b/packages/dashboard-frontend/src/store/Events/selectors.ts index 165426996..6e73b0861 100644 --- a/packages/dashboard-frontend/src/store/Events/selectors.ts +++ b/packages/dashboard-frontend/src/store/Events/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.events; +const selectState = (state: RootState) => state.events; export const selectAllEvents = createSelector(selectState, state => state.events); diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/actions.spec.ts index 6da383eda..61f8cbb92 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/actions.spec.ts @@ -10,224 +10,234 @@ * Red Hat, Inc. - initial API and implementation */ -import { AxiosError } from 'axios'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; +import common from '@eclipse-che/common'; -import * as factoryResolver from '@/services/backend-client/factoryApi'; -import * as yamlResolver from '@/services/backend-client/yamlResolverApi'; +import { getFactoryResolver } from '@/services/backend-client/factoryApi'; +import { getYamlResolver } from '@/services/backend-client/yamlResolverApi'; import devfileApi from '@/services/devfileApi'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { actionCreators } from '@/store/FactoryResolver/actions'; -import { normalizeDevfile } from '@/store/FactoryResolver/helpers'; -import { KnownAction, Resolver, Type } from '@/store/FactoryResolver/types'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -const mockGrabLink = jest.fn().mockResolvedValue(undefined); -const mockIsDevfileRegistryLocation = jest.fn().mockReturnValue(false); -const mockNormalizeDevfile = jest - .fn() - .mockImplementation((...args: Parameters) => args[0].devfile); -jest.mock('@/store/FactoryResolver/helpers.ts', () => { - return { - grabLink: (...args: unknown[]) => mockGrabLink(...args), - isDevfileRegistryLocation: (...args: unknown[]) => mockIsDevfileRegistryLocation(...args), - normalizeDevfile: (...args: unknown[]) => mockNormalizeDevfile(...args), - }; -}); - -jest.mock('@/services/devfileApi'); -jest.mock('@/services/devfileApi/typeguards', () => { - return { - ...jest.requireActual('@/services/devfileApi/typeguards'), - isDevfileV2: (devfile: unknown): boolean => { - return (devfile as devfileApi.Devfile).schemaVersion !== undefined; - }, - }; -}); - -const getFactoryResolverSpy = jest.spyOn(factoryResolver, 'getFactoryResolver'); -const getYamlResolverSpy = jest.spyOn(yamlResolver, 'getYamlResolver'); - -describe('FactoryResolver store, actions', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - let store: MockStoreEnhanced>; +import { isOAuthResponse } from '@/services/oauth'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { FactoryResolverStateResolver } from '@/store/FactoryResolver'; +import { + actionCreators, + factoryResolverErrorAction, + factoryResolverReceiveAction, + factoryResolverRequestAction, +} from '@/store/FactoryResolver/actions'; +import { + grabLink, + isDevfileRegistryLocation, + normalizeDevfile, +} from '@/store/FactoryResolver/helpers'; +import * as infrastructureNamespaces from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { ServerConfigState } from '@/store/ServerConfig'; +import * as serverConfig from '@/store/ServerConfig/selectors'; + +jest.mock('@/services/backend-client/factoryApi'); +jest.mock('@/services/backend-client/yamlResolverApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/FactoryResolver/helpers'); +jest.mock('@eclipse-che/common'); +jest.mock('@/services/oauth'); + +describe('FactoryResolver, actions', () => { + let store: ReturnType; beforeEach(() => { - store = new FakeStoreBuilder().build(); - }); - - it('should create REQUEST_FACTORY_RESOLVER and RECEIVE_FACTORY_RESOLVER_ERROR if factory resolver fails', async () => { - getFactoryResolverSpy.mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - response: { - data: { - message: 'Something unexpected happened.', - }, - }, - } as AxiosError); - - const location = 'http://factory-link'; - await expect( - store.dispatch(actionCreators.requestFactoryResolver(location, {})), - ).rejects.toEqual( - 'Unexpected error. Check DevTools console and network tabs for more information.', - ); - - const actions = store.getActions(); - const expectedActions: KnownAction[] = [ - { - type: Type.REQUEST_FACTORY_RESOLVER, - check: AUTHORIZED, - }, - { - type: Type.RECEIVE_FACTORY_RESOLVER_ERROR, - error: expect.stringContaining('Unexpected error'), - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_FACTORY_RESOLVER and RECEIVE_FACTORY_RESOLVER_ERROR if resolver returns no devfile', async () => { - getFactoryResolverSpy.mockResolvedValueOnce({ - devfile: undefined, - }); - - const location = 'http://factory-link'; - await expect( - store.dispatch(actionCreators.requestFactoryResolver(location, {})), - ).rejects.toEqual('The specified link does not contain any Devfile.'); - - const actions = store.getActions(); - const expectedActions: KnownAction[] = [ - { - type: Type.REQUEST_FACTORY_RESOLVER, - check: AUTHORIZED, - }, - { - error: 'The specified link does not contain any Devfile.', - type: Type.RECEIVE_FACTORY_RESOLVER_ERROR, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_FACTORY_RESOLVER if authentication is needed', async () => { - getFactoryResolverSpy.mockRejectedValueOnce({ - isAxiosError: true, - code: '401', - response: { - headers: {}, - status: 401, - statusText: 'Unauthorized', + store = createMockStore({ + dwServerConfig: { config: {}, - data: { - attributes: { - oauth_provider: 'oauth_provider', - oauth_authentication_url: 'oauth_authentication_url', - }, - }, - }, - } as AxiosError); - - const location = 'http://factory-link'; - await expect( - store.dispatch(actionCreators.requestFactoryResolver(location, {})), - ).rejects.toStrictEqual({ - attributes: { - oauth_provider: 'oauth_provider', - oauth_authentication_url: 'oauth_authentication_url', - }, + isLoading: false, + } as ServerConfigState, }); - - const actions = store.getActions(); - const expectedActions: KnownAction[] = [ - { - type: Type.REQUEST_FACTORY_RESOLVER, - check: AUTHORIZED, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_FACTORY_RESOLVER and RECEIVE_FACTORY_RESOLVER', async () => { - const devfile = { - schemaVersion: '2.0.0', - } as devfileApi.Devfile; - getFactoryResolverSpy.mockResolvedValueOnce({ - devfile, - }); - - const location = 'http://factory-link'; - await expect( - store.dispatch(actionCreators.requestFactoryResolver(location, {})), - ).resolves.toBeUndefined(); - - const actions = store.getActions(); - const expectedActions: KnownAction[] = [ - { - type: Type.REQUEST_FACTORY_RESOLVER, - check: AUTHORIZED, - }, - { - resolver: { - devfile, - location, - optionalFilesContent: {}, - }, - type: Type.RECEIVE_FACTORY_RESOLVER, - }, - ]; - - expect(actions).toEqual(expectedActions); + jest.clearAllMocks(); }); - describe('check resolver types', () => { - const resolver = { - devfile: { - schemaVersion: '2.0.0', - } as devfileApi.Devfile, - } as Resolver; + describe('requestFactoryResolver', () => { + describe('from a devfile registry', () => { + it('should dispatch receive action on successful fetch from a devfile registry', async () => { + const mockNamespace = 'test-namespace'; + const mockLocation = 'https://example.com/devfile.yaml'; + const mockFactoryParams = {}; + const mockYamlResolver = { devfile: {} }; + const mockDefaultComponents = []; + const mockNormalizedDevfile = {}; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (isDevfileRegistryLocation as jest.Mock).mockReturnValue(true); + (getYamlResolver as jest.Mock).mockResolvedValue(mockYamlResolver); + jest.spyOn(serverConfig, 'selectDefaultComponents').mockReturnValue(mockDefaultComponents); + (normalizeDevfile as jest.Mock).mockReturnValue(mockNormalizedDevfile); + + await store.dispatch( + actionCreators.requestFactoryResolver(mockLocation, mockFactoryParams), + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(factoryResolverRequestAction()); + expect(actions[1]).toEqual( + factoryResolverReceiveAction({ + ...mockYamlResolver, + devfile: mockNormalizedDevfile as devfileApi.Devfile, + location: mockLocation, + optionalFilesContent: {}, + }), + ); + }); - beforeEach(() => { - getFactoryResolverSpy.mockResolvedValueOnce(resolver); - getYamlResolverSpy.mockResolvedValueOnce(resolver); + it('should throw an error if the specified link does not contain any Devfile', async () => { + const mockNamespace = 'test-namespace'; + const mockLocation = 'https://example.com/devfile.yaml'; + const mockFactoryParams = {}; + const mockYamlResolver = { devfile: undefined }; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (isDevfileRegistryLocation as jest.Mock).mockReturnValue(true); + (getYamlResolver as jest.Mock).mockResolvedValue(mockYamlResolver); + + await expect( + store.dispatch(actionCreators.requestFactoryResolver(mockLocation, mockFactoryParams)), + ).rejects.toThrow('The specified link does not contain any Devfile'); + + const actions = store.getActions(); + expect(actions[0]).toEqual(factoryResolverRequestAction()); + expect(actions).not.toContainEqual( + factoryResolverReceiveAction(mockYamlResolver as unknown as FactoryResolverStateResolver), + ); + }); }); - it('should call the yaml resolver for a devfile registry', async () => { - const location = 'http://registry/devfile.yaml'; - - mockIsDevfileRegistryLocation.mockReturnValueOnce(true); + describe('from another location', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockNamespace = 'test-namespace'; + const mockLocation = 'https://example.com/devfile.yaml'; + const mockFactoryParams = {}; + const mockFactoryResolver = { devfile: {} }; + const mockDefaultComponents = []; + const mockNormalizedDevfile = {}; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (isDevfileRegistryLocation as jest.Mock).mockReturnValue(false); + (getFactoryResolver as jest.Mock).mockResolvedValue(mockFactoryResolver); + (grabLink as jest.Mock).mockResolvedValue(undefined); + jest.spyOn(serverConfig, 'selectDefaultComponents').mockReturnValue(mockDefaultComponents); + (normalizeDevfile as jest.Mock).mockReturnValue(mockNormalizedDevfile); + + await store.dispatch( + actionCreators.requestFactoryResolver(mockLocation, mockFactoryParams), + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(factoryResolverRequestAction()); + expect(actions[1]).toEqual( + factoryResolverReceiveAction({ + ...mockFactoryResolver, + devfile: mockNormalizedDevfile as devfileApi.Devfile, + location: mockLocation, + optionalFilesContent: {}, + }), + ); + }); - await expect( - store.dispatch(actionCreators.requestFactoryResolver(location, {})), - ).resolves.toBeUndefined(); + it('should dispatch receive action on successful fetch with optional files content', async () => { + const mockNamespace = 'test-namespace'; + const mockLocation = 'https://example.com/devfile.yaml'; + const mockFactoryParams = {}; + const mockFactoryResolver = { devfile: {}, links: [] }; + const mockDefaultComponents = []; + const mockNormalizedDevfile = {}; + const mockOptionalFilesContent = { '.che/che-editor.yaml': 'content' }; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (isDevfileRegistryLocation as jest.Mock).mockReturnValue(false); + (getFactoryResolver as jest.Mock).mockResolvedValue(mockFactoryResolver); + (grabLink as jest.Mock).mockResolvedValue('content'); + jest.spyOn(serverConfig, 'selectDefaultComponents').mockReturnValue(mockDefaultComponents); + (normalizeDevfile as jest.Mock).mockReturnValue(mockNormalizedDevfile); + + await store.dispatch( + actionCreators.requestFactoryResolver(mockLocation, mockFactoryParams), + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(factoryResolverRequestAction()); + expect(actions[1]).toEqual( + factoryResolverReceiveAction({ + ...mockFactoryResolver, + devfile: mockNormalizedDevfile as devfileApi.Devfile, + location: mockLocation, + optionalFilesContent: mockOptionalFilesContent, + }), + ); + }); - expect(getYamlResolverSpy).toHaveBeenCalledWith(location); - expect(getFactoryResolverSpy).not.toHaveBeenCalled(); + it('should dispatch error action on failed fetch', async () => { + const mockNamespace = 'test-namespace'; + const mockLocation = 'https://example.com/devfile.yaml'; + const mockFactoryParams = {}; + const errorMessage = 'Network error'; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (isDevfileRegistryLocation as jest.Mock).mockReturnValue(false); + (getFactoryResolver as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.requestFactoryResolver(mockLocation, mockFactoryParams)), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(factoryResolverRequestAction()); + expect(actions[1]).toEqual(factoryResolverErrorAction(errorMessage)); + }); }); - it('should call the factory resolver for non-registry devfiles', async () => { - const location = 'https://github.com/eclipse-che/che-dashboard.git'; + it('should handle OAuth response error', async () => { + const mockNamespace = 'test-namespace'; + const mockLocation = 'https://example.com/devfile.yaml'; + const mockFactoryParams = {}; + const mockOAuthResponse = { error: 'unauthorized' }; + const mockError = { + response: { + status: 401, + data: mockOAuthResponse, + }, + }; - mockIsDevfileRegistryLocation.mockReturnValueOnce(false); + jest.spyOn(infrastructureNamespaces, 'selectDefaultNamespace').mockReturnValue({ + name: mockNamespace, + attributes: { phase: 'Active' }, + }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (isDevfileRegistryLocation as jest.Mock).mockReturnValue(false); + (getFactoryResolver as jest.Mock).mockRejectedValue(mockError); + (common.helpers.errors.includesAxiosResponse as unknown as jest.Mock).mockReturnValue(true); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue('Unauthorized'); + (isOAuthResponse as unknown as jest.Mock).mockImplementation(() => true); await expect( - store.dispatch(actionCreators.requestFactoryResolver(location, {})), - ).resolves.toBeUndefined(); + store.dispatch(actionCreators.requestFactoryResolver(mockLocation, mockFactoryParams)), + ).rejects.toEqual(mockOAuthResponse); - expect(getYamlResolverSpy).not.toHaveBeenCalledWith(); - expect(getFactoryResolverSpy).toHaveBeenCalledWith(location, { - error_code: undefined, - }); + const actions = store.getActions(); + expect(actions[0]).toEqual(factoryResolverRequestAction()); + expect(actions).not.toContainEqual(factoryResolverErrorAction('Unauthorized')); }); }); }); diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/reducer.spec.ts index 1d59d6d61..dccc4eba7 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/reducer.spec.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/reducer.spec.ts @@ -10,101 +10,63 @@ * Red Hat, Inc. - initial API and implementation */ -import devfileApi from '@/services/devfileApi'; -import { reducer } from '@/store/FactoryResolver/reducer'; -import { KnownAction, Resolver, State, Type } from '@/store/FactoryResolver/types'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -describe('FactoryResolver store, reducers', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return initial state', () => { - const incomingAction: KnownAction = { - type: Type.REQUEST_FACTORY_RESOLVER, - check: AUTHORIZED, - }; - - const initialState = reducer(undefined, incomingAction); - const expectedState: State = { - isLoading: false, - }; - - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action is not matched', () => { - const initialState: State = { - isLoading: true, - }; - const incomingAction = { - type: 'OTHER_ACTION', - }; - - const newState = reducer(initialState, incomingAction); - const expectedState: State = { - isLoading: true, - }; +import { UnknownAction } from 'redux'; - expect(newState).toEqual(expectedState); +import devfileApi from '@/services/devfileApi'; +import { + factoryResolverErrorAction, + factoryResolverReceiveAction, + factoryResolverRequestAction, +} from '@/store/FactoryResolver/actions'; +import { reducer, State, unloadedState } from '@/store/FactoryResolver/reducer'; + +describe('FactoryResolver reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; }); - it('should handle REQUEST_FACTORY_RESOLVER', () => { - const initialState: State = { - isLoading: false, - }; - const incomingAction: KnownAction = { - type: Type.REQUEST_FACTORY_RESOLVER, - check: AUTHORIZED, - }; - - const newState = reducer(initialState, incomingAction); + it('should handle factoryResolverRequestAction', () => { + const action = factoryResolverRequestAction(); const expectedState: State = { + ...initialState, isLoading: true, + error: undefined, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_FACTORY_RESOLVER', () => { - const initialState: State = { - isLoading: true, - }; + it('should handle factoryResolverReceiveAction', () => { const resolver = { - devfile: { - schemaVersion: '2.0.0', - } as devfileApi.Devfile, - } as Resolver; - const incomingAction: KnownAction = { - type: Type.RECEIVE_FACTORY_RESOLVER, - resolver, + devfile: {} as devfileApi.Devfile, + optionalFilesContent: { 'README.md': 'Content' }, }; - - const newState = reducer(initialState, incomingAction); + const action = factoryResolverReceiveAction(resolver); const expectedState: State = { + ...initialState, isLoading: false, resolver, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_FACTORY_RESOLVER_ERROR', () => { - const initialState: State = { - isLoading: true, - }; - const incomingAction: KnownAction = { - type: Type.RECEIVE_FACTORY_RESOLVER_ERROR, - error: 'Unexpected error', - }; - - const newState = reducer(initialState, incomingAction); + it('should handle factoryResolverErrorAction', () => { + const error = 'Error message'; + const action = factoryResolverErrorAction(error); const expectedState: State = { + ...initialState, isLoading: false, - error: 'Unexpected error', + error, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/selectors.spec.ts index cfbd68aba..0bc57b23c 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/__tests__/selectors.spec.ts @@ -10,38 +10,27 @@ * Red Hat, Inc. - initial API and implementation */ -import devfileApi from '@/services/devfileApi'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectFactoryResolver, selectFactoryResolverError, } from '@/store/FactoryResolver/selectors'; -import { State } from '@/store/FactoryResolver/types'; -describe('FactoryResolver, selectors', () => { - test('selectFactoryResolver', () => { - const state = { - resolver: { - devfile: { - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - }, - isLoading: false, - } as State; +describe('FactoryResolver Selectors', () => { + const mockState = { + factoryResolver: { + resolver: { devfile: { metadata: { name: 'test-devfile' } } }, + error: 'Something went wrong', + }, + } as RootState; - const resolver = selectFactoryResolver({ factoryResolver: state } as AppState); - - expect(resolver).toEqual(state.resolver); + it('should select the factory resolver', () => { + const result = selectFactoryResolver(mockState); + expect(result).toEqual(mockState.factoryResolver.resolver); }); - test('selectFactoryResolverError', () => { - const state = { - error: 'error', - isLoading: false, - } as State; - - const error = selectFactoryResolverError({ factoryResolver: state } as AppState); - - expect(error).toEqual(state.error); + it('should select the factory resolver error', () => { + const result = selectFactoryResolverError(mockState); + expect(result).toEqual(mockState.factoryResolver.error); }); }); diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/actions.ts b/packages/dashboard-frontend/src/store/FactoryResolver/actions.ts index 17236e9aa..b8170f245 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/actions.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/actions.ts @@ -11,6 +11,7 @@ */ import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; import { getFactoryResolver } from '@/services/backend-client/factoryApi'; import { getYamlResolver } from '@/services/backend-client/yamlResolverApi'; @@ -18,24 +19,25 @@ import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams import { FactoryResolver } from '@/services/helpers/types'; import { isOAuthResponse } from '@/services/oauth'; import { CHE_EDITOR_YAML_PATH } from '@/services/workspace-client/helpers'; +import { AppThunk } from '@/store'; +import { FactoryResolverStateResolver } from '@/store/FactoryResolver'; import { grabLink, isDevfileRegistryLocation, normalizeDevfile, } from '@/store/FactoryResolver/helpers'; -import { ActionCreators, KnownAction, Type } from '@/store/FactoryResolver/types'; -import { AppThunk } from '@/store/index'; import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; +import { verifyAuthorized } from '@/store/SanityCheck'; import { selectDefaultComponents } from '@/store/ServerConfig/selectors'; -export const actionCreators: ActionCreators = { +export const factoryResolverRequestAction = createAction('factoryResolver/request'); +export const factoryResolverReceiveAction = + createAction('factoryResolver/receive'); +export const factoryResolverErrorAction = createAction('factoryResolver/error'); + +export const actionCreators = { requestFactoryResolver: - ( - location: string, - factoryParams: Partial = {}, - ): AppThunk> => + (location: string, factoryParams: Partial = {}): AppThunk => async (dispatch, getState): Promise => { const state = getState(); const namespace = selectDefaultNamespace(state).name; @@ -48,16 +50,11 @@ export const actionCreators: ActionCreators = { : undefined; try { - await dispatch({ - type: Type.REQUEST_FACTORY_RESOLVER, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - let data: FactoryResolver; + await verifyAuthorized(dispatch, getState); + dispatch(factoryResolverRequestAction()); + + let data: FactoryResolver; if (isDevfileRegistryLocation(location, state.dwServerConfig.config)) { data = await getYamlResolver(location); } else { @@ -87,11 +84,7 @@ export const actionCreators: ActionCreators = { location, optionalFilesContent, }; - - dispatch({ - type: Type.RECEIVE_FACTORY_RESOLVER, - resolver, - }); + dispatch(factoryResolverReceiveAction(resolver)); return; } catch (e) { if (common.helpers.errors.includesAxiosResponse(e)) { @@ -101,11 +94,8 @@ export const actionCreators: ActionCreators = { } } const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_FACTORY_RESOLVER_ERROR, - error: errorMessage, - }); - throw errorMessage; + dispatch(factoryResolverErrorAction(errorMessage)); + throw e; } }, }; diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/index.ts b/packages/dashboard-frontend/src/store/FactoryResolver/index.ts index 170db81cf..b4a31ad72 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/index.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -11,11 +13,10 @@ */ export { actionCreators as factoryResolverActionCreators } from '@/store/FactoryResolver/actions'; -export { reducer as factoryResolverReducer } from '@/store/FactoryResolver/reducer'; -export * from '@/store/FactoryResolver/selectors'; export { - ActionCreators as FactoryResolverActionCreators, + reducer as factoryResolverReducer, State as FactoryResolverState, Resolver as FactoryResolverStateResolver, OAuthResponse, -} from '@/store/FactoryResolver/types'; +} from '@/store/FactoryResolver/reducer'; +export * from '@/store/FactoryResolver/selectors'; diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/reducer.ts b/packages/dashboard-frontend/src/store/FactoryResolver/reducer.ts index 3e52c0d4c..8527989dd 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/reducer.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/reducer.ts @@ -10,41 +10,55 @@ * Red Hat, Inc. - initial API and implementation */ -import { Action, Reducer } from 'redux'; +import { createReducer } from '@reduxjs/toolkit'; -import { KnownAction, State } from '@/store/FactoryResolver/types'; -import { createObject } from '@/store/helpers'; +import devfileApi from '@/services/devfileApi'; +import { FactoryResolver } from '@/services/helpers/types'; +import { + factoryResolverErrorAction, + factoryResolverReceiveAction, + factoryResolverRequestAction, +} from '@/store/FactoryResolver/actions'; -const unloadedState: State = { - isLoading: false, +export type OAuthResponse = { + attributes: { + oauth_provider: string; + oauth_version: string; + oauth_authentication_url: string; + }; + errorCode: number; + message: string | undefined; }; -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } +export interface Resolver extends FactoryResolver { + devfile: devfileApi.Devfile; + optionalFilesContent?: { + [fileName: string]: string; + }; +} + +export interface State { + isLoading: boolean; + resolver?: Resolver; + error?: string; +} - const action = incomingAction as KnownAction; - switch (action.type) { - case 'REQUEST_FACTORY_RESOLVER': - return createObject(state, { - isLoading: true, - error: undefined, - }); - case 'RECEIVE_FACTORY_RESOLVER': - return createObject(state, { - isLoading: false, - resolver: action.resolver, - }); - case 'RECEIVE_FACTORY_RESOLVER_ERROR': - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } +export const unloadedState: State = { + isLoading: false, }; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(factoryResolverRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(factoryResolverReceiveAction, (state, action) => { + state.isLoading = false; + state.resolver = action.payload; + }) + .addCase(factoryResolverErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }), +); diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/selectors.ts b/packages/dashboard-frontend/src/store/FactoryResolver/selectors.ts index 3b590936c..0998ddf06 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/selectors.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.factoryResolver; +const selectState = (state: RootState) => state.factoryResolver; export const selectFactoryResolver = createSelector(selectState, state => state.resolver); diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/types.ts b/packages/dashboard-frontend/src/store/FactoryResolver/types.ts deleted file mode 100644 index dbc62f0a2..000000000 --- a/packages/dashboard-frontend/src/store/FactoryResolver/types.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { Action } from 'redux'; - -import devfileApi from '@/services/devfileApi'; -import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import { FactoryResolver } from '@/services/helpers/types'; -import { AppThunk } from '@/store'; -import { SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -export interface Resolver extends FactoryResolver { - devfile: devfileApi.Devfile; - optionalFilesContent?: { - [fileName: string]: string; - }; -} - -export interface State { - isLoading: boolean; - resolver?: Resolver; - error?: string; -} - -export type OAuthResponse = { - attributes: { - oauth_provider: string; - oauth_version: string; - oauth_authentication_url: string; - }; - errorCode: number; - message: string | undefined; -}; - -export enum Type { - REQUEST_FACTORY_RESOLVER = 'REQUEST_FACTORY_RESOLVER', - RECEIVE_FACTORY_RESOLVER = 'RECEIVE_FACTORY_RESOLVER', - RECEIVE_FACTORY_RESOLVER_ERROR = 'RECEIVE_FACTORY_RESOLVER_ERROR', -} - -interface RequestFactoryResolverAction extends Action, SanityCheckAction { - type: Type.REQUEST_FACTORY_RESOLVER; -} -interface ReceiveFactoryResolverAction { - type: Type.RECEIVE_FACTORY_RESOLVER; - resolver: Resolver; -} -interface ReceiveFactoryResolverErrorAction { - type: Type.RECEIVE_FACTORY_RESOLVER_ERROR; - error: string | undefined; -} - -export type KnownAction = - | RequestFactoryResolverAction - | ReceiveFactoryResolverAction - | ReceiveFactoryResolverErrorAction; - -export type ActionCreators = { - requestFactoryResolver: ( - location: string, - factoryParams: Partial, - ) => AppThunk>; -}; diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/actions.spec.ts new file mode 100644 index 000000000..1ac4615a1 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/__tests__/actions.spec.ts @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; + +import { fetchGitConfig, patchGitConfig } from '@/services/backend-client/gitConfigApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + gitConfigErrorAction, + gitConfigReceiveAction, + gitConfigRequestAction, +} from '@/store/GitConfig/actions'; +import * as infrastructureNamespaces from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/gitConfigApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('GitConfig, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({ + gitConfig: { isLoading: false, config: undefined, error: undefined }, + }); + jest.clearAllMocks(); + }); + + describe('requestGitConfig', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockNamespace = 'test-namespace'; + const mockGitConfig = { gitconfig: {} } as api.IGitConfig; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchGitConfig as jest.Mock).mockResolvedValue(mockGitConfig); + + await store.dispatch(actionCreators.requestGitConfig()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(gitConfigRequestAction()); + expect(actions[1]).toEqual(gitConfigReceiveAction(mockGitConfig)); + }); + + it('should dispatch receive action with undefined on 404 error', async () => { + const mockNamespace = 'test-namespace'; + const errorMessage = 'Not Found'; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchGitConfig as jest.Mock).mockRejectedValue({ + response: { status: 404 }, + }); + jest.spyOn(helpers.errors, 'includesAxiosResponse').mockReturnValueOnce(true); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await store.dispatch(actionCreators.requestGitConfig()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(gitConfigRequestAction()); + expect(actions[1]).toEqual(gitConfigReceiveAction(undefined)); + }); + + it('should dispatch error action on failed fetch', async () => { + const mockNamespace = 'test-namespace'; + const errorMessage = 'Network error'; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchGitConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestGitConfig())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(gitConfigRequestAction()); + expect(actions[1]).toEqual(gitConfigErrorAction(errorMessage)); + }); + }); + + describe('updateGitConfig', () => { + it('should dispatch receive action on successful update', async () => { + const mockNamespace = 'test-namespace'; + const mockGitConfig = { + gitconfig: { + user: { + name: 'test1', + email: 'test1@che', + }, + }, + } as api.IGitConfig; + const mockUpdatedGitConfig = { + gitconfig: { + user: { + name: 'test2', + email: 'test2@che', + }, + }, + } as api.IGitConfig; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (patchGitConfig as jest.Mock).mockResolvedValue(mockUpdatedGitConfig); + + await store.dispatch(actionCreators.updateGitConfig(mockGitConfig.gitconfig)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(gitConfigRequestAction()); + expect(actions[1]).toEqual(gitConfigReceiveAction(mockUpdatedGitConfig)); + }); + + it('should dispatch error action on failed update', async () => { + const mockNamespace = 'test-namespace'; + const mockGitConfig = { gitconfig: {} } as api.IGitConfig; + const errorMessage = 'Network error'; + + jest.spyOn(infrastructureNamespaces, 'selectDefaultNamespace').mockReturnValue({ + name: mockNamespace, + attributes: { phase: 'Active' }, + }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (patchGitConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.updateGitConfig(mockGitConfig.gitconfig)), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(gitConfigRequestAction()); + expect(actions[1]).toEqual(gitConfigErrorAction(errorMessage)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts deleted file mode 100644 index 51dcee00a..000000000 --- a/packages/dashboard-frontend/src/store/GitConfig/__tests__/index.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as TestStore from '..'; - -const mockFetchGitConfig = jest.fn().mockResolvedValue({ gitconfig: {} } as api.IGitConfig); -const mockPatchGitConfig = jest.fn().mockResolvedValue({ gitconfig: {} } as api.IGitConfig); -jest.mock('@/services/backend-client/gitConfigApi', () => { - return { - fetchGitConfig: (...args: unknown[]) => mockFetchGitConfig(...args), - patchGitConfig: (...args: unknown[]) => mockPatchGitConfig(...args), - }; -}); - -// mute the outputs -console.error = jest.fn(); - -describe('GitConfig store, actions', () => { - let store: MockStoreEnhanced>; - - beforeEach(() => { - store = new FakeStoreBuilder() - .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) - .build(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG when fetch the gitconfig', async () => { - await store.dispatch(TestStore.actionCreators.requestGitConfig()); - - const actions = store.getActions(); - - const expectedActions: TestStore.KnownAction[] = [ - { - type: TestStore.Type.REQUEST_GITCONFIG, - check: AUTHORIZED, - }, - { - type: TestStore.Type.RECEIVE_GITCONFIG, - config: { gitconfig: {} } as api.IGitConfig, - }, - ]; - - expect(actions).toEqual(expectedActions); - - expect(mockFetchGitConfig).toHaveBeenCalledTimes(1); - expect(mockPatchGitConfig).toHaveBeenCalledTimes(0); - }); - - it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG when got 404', async () => { - mockFetchGitConfig.mockRejectedValueOnce({ - response: { - status: 404, - statusText: 'Not Found', - headers: {}, - config: {}, - data: {}, - }, - }); - - try { - await store.dispatch(TestStore.actionCreators.requestGitConfig()); - } catch (e) { - // ignore - } - - const actions = store.getActions(); - - const expectedActions: TestStore.KnownAction[] = [ - { - type: TestStore.Type.REQUEST_GITCONFIG, - check: AUTHORIZED, - }, - { - type: TestStore.Type.RECEIVE_GITCONFIG, - config: undefined, - }, - ]; - - expect(actions).toEqual(expectedActions); - - expect(mockFetchGitConfig).toHaveBeenCalledTimes(1); - expect(mockPatchGitConfig).toHaveBeenCalledTimes(0); - }); - - it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG_ERROR when fetch the gitconfig with error other than 404', async () => { - mockFetchGitConfig.mockRejectedValueOnce(new Error('unexpected error')); - - try { - await store.dispatch(TestStore.actionCreators.requestGitConfig()); - } catch (e) { - // ignore - } - - const actions = store.getActions(); - - const expectedActions: TestStore.KnownAction[] = [ - { - type: TestStore.Type.REQUEST_GITCONFIG, - check: AUTHORIZED, - }, - { - type: TestStore.Type.RECEIVE_GITCONFIG_ERROR, - error: 'unexpected error', - }, - ]; - - expect(actions).toEqual(expectedActions); - - expect(mockFetchGitConfig).toHaveBeenCalledTimes(1); - expect(mockPatchGitConfig).toHaveBeenCalledTimes(0); - }); - - it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG when path the gitconfig', async () => { - await store.dispatch( - TestStore.actionCreators.updateGitConfig({ - user: { - name: 'testname', - email: 'test@email', - }, - }), - ); - - const actions = store.getActions(); - - const expectedActions: TestStore.KnownAction[] = [ - { - type: TestStore.Type.REQUEST_GITCONFIG, - check: AUTHORIZED, - }, - { - type: TestStore.Type.RECEIVE_GITCONFIG, - config: { gitconfig: {} } as api.IGitConfig, - }, - ]; - - expect(actions).toEqual(expectedActions); - - expect(mockFetchGitConfig).toHaveBeenCalledTimes(0); - expect(mockPatchGitConfig).toHaveBeenCalledTimes(1); - expect(mockPatchGitConfig).toHaveBeenCalledWith('user-che', { - gitconfig: { user: { email: 'test@email', name: 'testname' } }, - }); - }); - - it('should create REQUEST_GITCONFIG and RECEIVE_GITCONFIG_ERROR when path the gitconfig with error', async () => { - mockPatchGitConfig.mockRejectedValueOnce(new Error('unexpected error')); - - try { - await store.dispatch( - TestStore.actionCreators.updateGitConfig({ - user: { - name: 'testname', - email: 'testemail', - }, - }), - ); - } catch (e) { - // ignore - } - - const actions = store.getActions(); - - const expectedActions: TestStore.KnownAction[] = [ - { - type: TestStore.Type.REQUEST_GITCONFIG, - check: AUTHORIZED, - }, - { - type: TestStore.Type.RECEIVE_GITCONFIG_ERROR, - error: 'unexpected error', - }, - ]; - - expect(actions).toEqual(expectedActions); - - expect(mockFetchGitConfig).toHaveBeenCalledTimes(0); - expect(mockPatchGitConfig).toHaveBeenCalledTimes(1); - expect(mockPatchGitConfig).toHaveBeenCalledWith('user-che', { - gitconfig: { user: { name: 'testname', email: 'testemail' } }, - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts index b9d94ebb9..9959dd62c 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/__tests__/reducer.spec.ts @@ -10,131 +10,59 @@ * Red Hat, Inc. - initial API and implementation */ -import { AnyAction } from 'redux'; +import { api } from '@eclipse-che/common'; +import { UnknownAction } from 'redux'; -import * as unloadedState from '@/store/GitConfig/reducer'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; +import { + gitConfigErrorAction, + gitConfigReceiveAction, + gitConfigRequestAction, +} from '@/store/GitConfig/actions'; +import { reducer, State, unloadedState } from '@/store/GitConfig/reducer'; -import * as TestStore from '..'; +describe('GitConfig, reducer', () => { + let initialState: State; -describe('GitConfig store, reducer', () => { - it('should return initial state', () => { - const incomingAction: TestStore.RequestGitConfigAction = { - type: TestStore.Type.REQUEST_GITCONFIG, - check: AUTHORIZED, - }; - const initialState = unloadedState.reducer(undefined, incomingAction); - - const expectedState: TestStore.State = { - isLoading: false, - config: undefined, - error: undefined, - }; - - expect(initialState).toEqual(expectedState); + beforeEach(() => { + initialState = { ...unloadedState }; }); - it('should return state if action type is not matched', () => { - const initialState: TestStore.State = { - isLoading: false, - config: undefined, - error: undefined, - }; - const incomingAction = { - type: 'OTHER_ACTION', + it('should handle gitConfigRequestAction', () => { + const action = gitConfigRequestAction(); + const expectedState: State = { + ...initialState, isLoading: true, - registries: [], - resourceVersion: undefined, - } as AnyAction; - const newState = unloadedState.reducer(initialState, incomingAction); - - const expectedState: TestStore.State = { - isLoading: false, - config: undefined, - error: undefined, }; - expect(newState).toEqual(expectedState); + + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle REQUEST_GITCONFIG', () => { - const initialState: TestStore.State = { + it('should handle gitConfigReceiveAction', () => { + const config = { gitconfig: {} } as api.IGitConfig; + const action = gitConfigReceiveAction(config); + const expectedState: State = { + ...initialState, isLoading: false, - config: undefined, - error: undefined, - }; - const incomingAction: TestStore.RequestGitConfigAction = { - type: TestStore.Type.REQUEST_GITCONFIG, - check: AUTHORIZED, - }; - - const newState = unloadedState.reducer(initialState, incomingAction); - - const expectedState: TestStore.State = { - isLoading: true, - config: undefined, - error: undefined, + config, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_GITCONFIG', () => { - const initialState: TestStore.State = { - isLoading: true, - config: undefined, - error: undefined, - }; - const incomingAction: TestStore.ReceiveGitConfigAction = { - type: TestStore.Type.RECEIVE_GITCONFIG, - config: { - gitconfig: { - user: { - email: 'user@che', - name: 'user-che', - }, - }, - resourceVersion: '345', - }, - }; - - const newState = unloadedState.reducer(initialState, incomingAction); - - const expectedState: TestStore.State = { + it('should handle gitConfigErrorAction', () => { + const error = 'Error message'; + const action = gitConfigErrorAction(error); + const expectedState: State = { + ...initialState, isLoading: false, - config: { - gitconfig: { - user: { - email: 'user@che', - name: 'user-che', - }, - }, - resourceVersion: '345', - }, - error: undefined, + error, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_GITCONFIG_ERROR', () => { - const initialState: TestStore.State = { - isLoading: true, - config: undefined, - error: undefined, - }; - const incomingAction: TestStore.ReceiveGitConfigErrorAction = { - type: TestStore.Type.RECEIVE_GITCONFIG_ERROR, - error: 'unexpected error', - }; - - const newState = unloadedState.reducer(initialState, incomingAction); - - const expectedState: TestStore.State = { - isLoading: false, - config: undefined, - error: 'unexpected error', - }; - - expect(newState).toEqual(expectedState); + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts index 785f89a67..5aed7a7e6 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/__tests__/selectors.spec.ts @@ -10,86 +10,34 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as TestStore from '@/store/GitConfig'; +import { RootState } from '@/store'; import { selectGitConfig, selectGitConfigError, selectGitConfigIsLoading, } from '@/store/GitConfig/selectors'; -describe('GitConfig store, selectors', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return the error', () => { - const fakeStore = new FakeStoreBuilder() - .withGitConfig({ config: {} as api.IGitConfig, error: 'Something unexpected' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedError = selectGitConfigError(state); - expect(selectedError).toEqual('Something unexpected'); +describe('GitConfig Selectors', () => { + const mockState = { + gitConfig: { + isLoading: true, + config: { gitconfig: 'mockConfig' }, + error: 'Something went wrong', + }, + } as unknown as RootState; + + it('should select isLoading', () => { + const result = selectGitConfigIsLoading(mockState); + expect(result).toBe(true); }); - it('should return the gitconfig', () => { - const fakeStore = new FakeStoreBuilder() - .withGitConfig({ - config: { - gitconfig: { - user: { - name: 'user-che', - email: 'user@che', - }, - }, - }, - }) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const gitconfig = selectGitConfig(state); - expect(gitconfig).toEqual({ - user: { - name: 'user-che', - email: 'user@che', - }, - }); + it('should select gitConfig', () => { + const result = selectGitConfig(mockState); + expect(result).toEqual('mockConfig'); }); - it('should return isLoading state', () => { - const fakeStore = new FakeStoreBuilder() - .withGitConfig( - { - config: { - gitconfig: { - user: { - name: 'user-che', - email: 'user@che', - }, - }, - }, - }, - true, - ) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const isLoading = selectGitConfigIsLoading(state); - expect(isLoading).toEqual(true); + it('should select error', () => { + const result = selectGitConfigError(mockState); + expect(result).toEqual('Something went wrong'); }); }); diff --git a/packages/dashboard-frontend/src/store/GitConfig/actions.ts b/packages/dashboard-frontend/src/store/GitConfig/actions.ts new file mode 100644 index 000000000..f1e47b1a0 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitConfig/actions.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api, helpers } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { fetchGitConfig, patchGitConfig } from '@/services/backend-client/gitConfigApi'; +import { AppThunk } from '@/store'; +import { GitConfig } from '@/store/GitConfig'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const gitConfigRequestAction = createAction('gitConfig/request'); +export const gitConfigReceiveAction = createAction('gitConfig/receive'); +export const gitConfigErrorAction = createAction('gitConfig/error'); + +export const actionCreators = { + requestGitConfig: + (): AppThunk => + async (dispatch, getState): Promise => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(gitConfigRequestAction()); + + const config = await fetchGitConfig(namespace); + dispatch(gitConfigReceiveAction(config)); + } catch (e) { + if (common.helpers.errors.includesAxiosResponse(e) && e.response.status === 404) { + dispatch(gitConfigReceiveAction(undefined)); + return; + } + + const errorMessage = helpers.errors.getMessage(e); + dispatch(gitConfigErrorAction(errorMessage)); + throw e; + } + }, + + updateGitConfig: + (changedGitConfig: GitConfig): AppThunk => + async (dispatch, getState): Promise => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + const { gitConfig } = state; + const gitconfig = Object.assign(gitConfig.config || {}, { + gitconfig: changedGitConfig, + } as api.IGitConfig); + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(gitConfigRequestAction()); + + const updated = await patchGitConfig(namespace, gitconfig); + dispatch(gitConfigReceiveAction(updated)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(gitConfigErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/GitConfig/index.ts b/packages/dashboard-frontend/src/store/GitConfig/index.ts index f13fcc2bd..55c48ea89 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/index.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,86 +12,10 @@ * Red Hat, Inc. - initial API and implementation */ -import common, { api, helpers } from '@eclipse-che/common'; - -import { fetchGitConfig, patchGitConfig } from '@/services/backend-client/gitConfigApi'; -import { GitConfig, KnownAction, Type } from '@/store/GitConfig/types'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export * from './reducer'; -export * from './types'; - -export type ActionCreators = { - requestGitConfig: () => AppThunk>; - updateGitConfig: (gitconfig: GitConfig) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestGitConfig: - (): AppThunk> => - async (dispatch, getState): Promise => { - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - await dispatch({ type: Type.REQUEST_GITCONFIG, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const config = await fetchGitConfig(namespace); - dispatch({ - type: Type.RECEIVE_GITCONFIG, - config, - }); - } catch (e) { - if (common.helpers.errors.includesAxiosResponse(e) && e.response.status === 404) { - dispatch({ - type: Type.RECEIVE_GITCONFIG, - config: undefined, - }); - return; - } - - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_GITCONFIG_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - updateGitConfig: - (changedGitConfig: GitConfig): AppThunk> => - async (dispatch, getState): Promise => { - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - const { gitConfig } = state; - const gitconfig = Object.assign(gitConfig.config || {}, { - gitconfig: changedGitConfig, - } as api.IGitConfig); - try { - await dispatch({ type: Type.REQUEST_GITCONFIG, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const updated = await patchGitConfig(namespace, gitconfig); - dispatch({ - type: Type.RECEIVE_GITCONFIG, - config: updated, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_GITCONFIG_ERROR, - error: errorMessage, - }); - throw e; - } - }, -}; +export { actionCreators as gitConfigActionCreators } from '@/store/GitConfig/actions'; +export { + GitConfig, + reducer as gitConfigReducer, + State as GitConfigState, +} from '@/store/GitConfig/reducer'; +export * from '@/store/GitConfig/selectors'; diff --git a/packages/dashboard-frontend/src/store/GitConfig/reducer.ts b/packages/dashboard-frontend/src/store/GitConfig/reducer.ts index 831cef016..9493c3479 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/reducer.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/reducer.ts @@ -10,44 +10,41 @@ * Red Hat, Inc. - initial API and implementation */ -import { Action, Reducer } from 'redux'; +import { api } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; -import { KnownAction, State, Type } from '@/store/GitConfig/types'; -import { createObject } from '@/store/helpers'; +import { + gitConfigErrorAction, + gitConfigReceiveAction, + gitConfigRequestAction, +} from '@/store/GitConfig/actions'; -const unloadedState: State = { +export type GitConfig = api.IGitConfig['gitconfig']; + +export interface State { + isLoading: boolean; + config?: api.IGitConfig; + error: string | undefined; +} + +export const unloadedState: State = { isLoading: false, config: undefined, error: undefined, }; -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_GITCONFIG: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_GITCONFIG: - return createObject(state, { - isLoading: false, - error: undefined, - config: action.config, - }); - case Type.RECEIVE_GITCONFIG_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export const reducer = createReducer(unloadedState, builder => { + builder + .addCase(gitConfigRequestAction, state => { + state.isLoading = true; + }) + .addCase(gitConfigReceiveAction, (state, action) => { + state.isLoading = false; + state.config = action.payload; + }) + .addCase(gitConfigErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state); +}); diff --git a/packages/dashboard-frontend/src/store/GitConfig/selectors.ts b/packages/dashboard-frontend/src/store/GitConfig/selectors.ts index 3af0bfc65..9e3bc5d9f 100644 --- a/packages/dashboard-frontend/src/store/GitConfig/selectors.ts +++ b/packages/dashboard-frontend/src/store/GitConfig/selectors.ts @@ -10,19 +10,14 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { State } from '@/store/GitConfig/types'; +import { RootState } from '@/store'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.gitConfig; +const selectState = (state: RootState) => state.gitConfig; export const selectGitConfigIsLoading = createSelector(selectState, state => state.isLoading); -export const selectGitConfig = createSelector( - selectState, - (state: State) => state.config?.gitconfig, -); +export const selectGitConfig = createSelector(selectState, state => state.config?.gitconfig); export const selectGitConfigError = createSelector(selectState, state => state.error); diff --git a/packages/dashboard-frontend/src/store/GitConfig/types.ts b/packages/dashboard-frontend/src/store/GitConfig/types.ts deleted file mode 100644 index 157c6e990..000000000 --- a/packages/dashboard-frontend/src/store/GitConfig/types.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; -import { Action } from 'redux'; - -import { SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -export type GitConfig = api.IGitConfig['gitconfig']; - -export interface State { - isLoading: boolean; - config?: api.IGitConfig; - error: string | undefined; -} - -export enum Type { - REQUEST_GITCONFIG = 'REQUEST_GITCONFIG', - RECEIVE_GITCONFIG = 'RECEIVE_GITCONFIG', - RECEIVE_GITCONFIG_ERROR = 'RECEIVE_GITCONFIG_ERROR', -} - -export interface RequestGitConfigAction extends Action, SanityCheckAction { - type: Type.REQUEST_GITCONFIG; -} - -export interface ReceiveGitConfigAction extends Action { - type: Type.RECEIVE_GITCONFIG; - config: api.IGitConfig | undefined; -} - -export interface ReceiveGitConfigErrorAction extends Action { - type: Type.RECEIVE_GITCONFIG_ERROR; - error: string; -} - -export type KnownAction = - | RequestGitConfigAction - | ReceiveGitConfigAction - | ReceiveGitConfigErrorAction; diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/actions.spec.ts new file mode 100644 index 000000000..ede4d4cda --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/actions.spec.ts @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api } from '@eclipse-che/common'; + +import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { + deleteOAuthToken, + getOAuthProviders, + getOAuthToken, +} from '@/services/backend-client/oAuthApi'; +import { fetchTokens } from '@/services/backend-client/personalAccessTokenApi'; +import { + deleteSkipOauthProvider, + getWorkspacePreferences, +} from '@/services/backend-client/workspacePreferencesApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + findUserToken, + gitOauthDeleteAction, + gitOauthErrorAction, + gitOauthReceiveAction, + gitOauthRequestAction, + isTokenGitProvider, + skipOauthReceiveAction, +} from '@/store/GitOauthConfig/actions'; +import { IGitOauth } from '@/store/GitOauthConfig/reducer'; +import * as infrastructureNamespaces from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/oAuthApi'); +jest.mock('@/services/backend-client/personalAccessTokenApi'); +jest.mock('@/services/backend-client/workspacePreferencesApi'); +jest.mock('@/services/backend-client/kubernetesNamespaceApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('GitOauthConfig', () => { + describe('actions', () => { + const mockNamespace = 'test-namespace'; + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestSkipAuthorizationProviders', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockSkipOauthProviders = ['github', 'bitbucket'] as api.GitOauthProvider[]; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getWorkspacePreferences as jest.Mock).mockResolvedValue({ + 'skip-authorisation': mockSkipOauthProviders, + }); + + await store.dispatch(actionCreators.requestSkipAuthorizationProviders()); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(skipOauthReceiveAction(mockSkipOauthProviders)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getWorkspacePreferences as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.requestSkipAuthorizationProviders()), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthErrorAction(errorMessage)); + }); + }); + + describe('requestGitOauthConfig', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockOAuthProviders = [ + { + endpointUrl: 'https://github.com', + name: 'github', + }, + ] as IGitOauth[]; + const mockTokens = [ + { + cheUserId: 'test-user', + gitProvider: 'github', + gitProviderEndpoint: 'https://github.com', + tokenData: 'test-token-data', + tokenName: 'test-token', + }, + ] as api.PersonalAccessToken[]; + const mockOauthProviders = ['github'] as api.GitOauthProvider[]; + const mockOAuthToken = 'oauth-token'; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getOAuthProviders as jest.Mock).mockResolvedValue(mockOAuthProviders); + (fetchTokens as jest.Mock).mockResolvedValue(mockTokens); + (getOAuthToken as jest.Mock).mockResolvedValue(mockOAuthToken); + (getWorkspacePreferences as jest.Mock).mockResolvedValue({ + 'skip-authorisation': mockOauthProviders, + }); + + await store.dispatch(actionCreators.requestGitOauthConfig()); + + const actions = store.getActions(); + expect(actions).toHaveLength(4); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthRequestAction()); + expect(actions[2]).toEqual(skipOauthReceiveAction(mockOauthProviders)); + expect(actions[3]).toEqual( + gitOauthReceiveAction({ + providersWithToken: mockOauthProviders, + supportedGitOauth: mockOAuthProviders, + }), + ); + }); + + it('should dispatch receive action on successful fetch with no tokens', async () => { + const mockOAuthProviders = [ + { + endpointUrl: 'https://github.com', + name: 'github', + }, + ] as IGitOauth[]; + const mockTokens = [] as api.PersonalAccessToken[]; + const mockOauthProviders = ['github'] as api.GitOauthProvider[]; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getOAuthProviders as jest.Mock).mockResolvedValue(mockOAuthProviders); + (fetchTokens as jest.Mock).mockResolvedValue(mockTokens); + (getOAuthToken as jest.Mock).mockRejectedValue(undefined); + (getWorkspacePreferences as jest.Mock).mockResolvedValue({ + 'skip-authorisation': mockOauthProviders, + }); + + await store.dispatch(actionCreators.requestGitOauthConfig()); + + const actions = store.getActions(); + expect(actions).toHaveLength(4); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthRequestAction()); + expect(actions[2]).toEqual(skipOauthReceiveAction(mockOauthProviders)); + expect(actions[3]).toEqual( + gitOauthReceiveAction({ + providersWithToken: [], + supportedGitOauth: mockOAuthProviders, + }), + ); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getOAuthProviders as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestGitOauthConfig())).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthErrorAction(errorMessage)); + }); + }); + + describe('revokeOauth', () => { + it('should dispatch delete action on successful revoke', async () => { + const mockProvider = 'github' as api.GitOauthProvider; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteOAuthToken as jest.Mock).mockResolvedValue(undefined); + (provisionKubernetesNamespace as jest.Mock).mockResolvedValue(undefined); + + await store.dispatch(actionCreators.revokeOauth(mockProvider)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthDeleteAction(mockProvider)); + }); + + it('should dispatch delete action on successful revoke with no tokens', async () => { + const mockProvider = 'github' as api.GitOauthProvider; + const errorMessage = 'OAuth token for user test-user was not found'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteOAuthToken as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (provisionKubernetesNamespace as jest.Mock).mockResolvedValue(undefined); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await store.dispatch(actionCreators.revokeOauth(mockProvider)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthDeleteAction(mockProvider)); + }); + + it('should dispatch error action on failed revoke', async () => { + const mockProvider = 'github' as api.GitOauthProvider; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteOAuthToken as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.revokeOauth(mockProvider))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthErrorAction(errorMessage)); + }); + }); + + describe('deleteSkipOauth', () => { + it('should dispatch receive action on successful delete', async () => { + const mockProvider = 'github' as api.GitOauthProvider; + const mockSkipOauthProviders = [mockProvider]; + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { phase: 'Active' } }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteSkipOauthProvider as jest.Mock).mockResolvedValue(undefined); + (getWorkspacePreferences as jest.Mock).mockResolvedValue({ + 'skip-authorisation': mockSkipOauthProviders, + }); + + await store.dispatch(actionCreators.deleteSkipOauth(mockProvider)); + + const actions = store.getActions(); + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthRequestAction()); + expect(actions[2]).toEqual(skipOauthReceiveAction(mockSkipOauthProviders)); + }); + + it('should dispatch error action on failed delete', async () => { + const mockProvider = 'github' as api.GitOauthProvider; + const errorMessage = 'Network error'; + + jest.spyOn(infrastructureNamespaces, 'selectDefaultNamespace').mockReturnValue({ + name: mockNamespace, + attributes: { phase: 'Active' }, + }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteSkipOauthProvider as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.deleteSkipOauth(mockProvider))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(gitOauthRequestAction()); + expect(actions[1]).toEqual(gitOauthErrorAction(errorMessage)); + }); + }); + }); + + describe('isTokenGitProvider', () => { + it('should return true for oauth2 git provider', () => { + const gitProvider = 'oauth2-provider'; + const result = isTokenGitProvider(gitProvider); + expect(result).toBe(true); + }); + + it('should return true for bitbucket-server token format', () => { + const gitProvider = `che-token--<${window.location.hostname}>`; + const result = isTokenGitProvider(gitProvider); + expect(result).toBe(true); + }); + + it('should return false for non-oauth2 and non-bitbucket-server token format', () => { + const gitProvider = 'github'; + const result = isTokenGitProvider(gitProvider); + expect(result).toBe(false); + }); + + it('should return false for invalid bitbucket-server token format', () => { + const gitProvider = `che-token--`; + const result = isTokenGitProvider(gitProvider); + expect(result).toBe(false); + }); + }); + + describe('findUserToken', () => { + const mockGitOauth = { + name: 'github', + endpointUrl: 'https://github.com/', + } as IGitOauth; + + // test compatibility with the old format of the git provider value + const mockTokens = [ + { + gitProviderEndpoint: 'https://github.com/', + gitProvider: 'oauth2-provider', + }, + { + gitProviderEndpoint: 'https://bitbucket.org/', + gitProvider: 'oauth2-provider', + }, + { + gitProviderEndpoint: 'https://github.com/', + gitProvider: `che-token--<${window.location.hostname}>`, + }, + ] as unknown as api.PersonalAccessToken[]; + + it('should return providers with token when matching token is found', () => { + const result = findUserToken(mockGitOauth, mockTokens); + expect(result).toEqual(['github']); + }); + + it('should return an empty array when no matching token is found', () => { + const mockGitOauthNoMatch = { + name: 'gitlab', + endpointUrl: 'https://gitlab.com/', + } as IGitOauth; + + const result = findUserToken(mockGitOauthNoMatch, mockTokens); + expect(result).toEqual([]); + }); + + it('should normalize endpoint URLs before comparison', () => { + const mockGitOauthWithTrailingSlash = { + name: 'github', + endpointUrl: 'https://github.com/', + } as IGitOauth; + + const mockTokensWithTrailingSlash = [ + { + gitProviderEndpoint: 'https://github.com/', + gitProvider: 'oauth2-provider', + cheUserId: 'test-user', + tokenData: 'test-token-data', + tokenName: 'test-token', + } as unknown as api.PersonalAccessToken, + ]; + + const result = findUserToken(mockGitOauthWithTrailingSlash, mockTokensWithTrailingSlash); + expect(result).toEqual(['github']); + }); + + it('should handle bitbucket-server token format', () => { + const mockGitOauthBitbucket = { + name: 'bitbucket', + endpointUrl: 'https://bitbucket.org/', + } as IGitOauth; + + const mockTokensBitbucket = [ + { + gitProviderEndpoint: 'https://bitbucket.org/', + gitProvider: `che-token--<${window.location.hostname}>`, + } as unknown as api.PersonalAccessToken, + ]; + + const result = findUserToken(mockGitOauthBitbucket, mockTokensBitbucket); + expect(result).toEqual(['bitbucket']); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/index.spec.ts deleted file mode 100644 index 1cca2867e..000000000 --- a/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/index.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; - -import * as TestStore from '..'; - -const gitOauth = [ - { - name: 'github', - endpointUrl: 'https://github.com', - }, - { - name: 'gitlab', - endpointUrl: 'https://gitlab.com', - }, - { - name: 'azure-devops', - endpointUrl: 'https://dev.azure.com', - }, - { - name: 'bitbucket-server', - endpointUrl: 'https://bitbucket-server.com', - }, -] as IGitOauth[]; - -const mockGetOAuthProviders = jest.fn().mockResolvedValue(gitOauth); -const mockGetOAuthToken = jest.fn().mockImplementation(provider => { - if (provider === 'github') { - return new Promise(resolve => resolve('github-token')); - } - return new Promise((_resolve, reject) => reject(new Error('Token not found'))); -}); -const mockFetchTokens = jest.fn().mockResolvedValue([ - { - tokenName: 'github-personal-access-token', - gitProvider: 'oauth2-token', - gitProviderEndpoint: 'https://github.com/', - }, - { - tokenName: 'azure-devops-personal-access-token', - gitProvider: 'oauth2-token', - gitProviderEndpoint: 'https://dev.azure.com/', - }, - { - tokenName: 'bitbucket-server-personal-access-token', - gitProvider: 'che-token--', - gitProviderEndpoint: 'https://bitbucket-server.com/', - }, -] as any[]); - -jest.mock('@/services/backend-client/oAuthApi', () => { - return { - getOAuthProviders: (...args: unknown[]) => mockGetOAuthProviders(...args), - getOAuthToken: (...args: unknown[]) => mockGetOAuthToken(...args), - }; -}); -jest.mock('@/services/backend-client/personalAccessTokenApi', () => { - return { - fetchTokens: (...args: unknown[]) => mockFetchTokens(...args), - }; -}); - -// mute the outputs -console.error = jest.fn(); - -window = Object.create(window); -Object.defineProperty(window, 'location', { - value: { - hostname: 'bitbucket-server', - }, -}); - -describe('GitOauthConfig store, actions', () => { - let store: MockStoreEnhanced>; - - beforeEach(() => { - store = new FakeStoreBuilder() - .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) - .build(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should request GitOauthConfig', async () => { - await store.dispatch(TestStore.actionCreators.requestGitOauthConfig()); - - const actions = store.getActions(); - - const expectedAction: TestStore.KnownAction = { - supportedGitOauth: gitOauth, - providersWithToken: ['github', 'azure-devops', 'bitbucket-server'], - type: TestStore.Type.RECEIVE_GIT_OAUTH_PROVIDERS, - }; - - expect(actions).toContainEqual(expectedAction); - }); -}); diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/reducer.spec.ts new file mode 100644 index 000000000..015cad5b4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/reducer.spec.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; + +import { + gitOauthDeleteAction, + gitOauthErrorAction, + gitOauthReceiveAction, + gitOauthRequestAction, + skipOauthReceiveAction, +} from '@/store/GitOauthConfig/actions'; +import { IGitOauth, reducer, State, unloadedState } from '@/store/GitOauthConfig/reducer'; + +describe('GitOauthConfig, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle gitOauthRequestAction', () => { + const action = gitOauthRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + error: undefined, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle gitOauthReceiveAction', () => { + const payload = { + supportedGitOauth: [ + { + name: 'github', + endpointUrl: 'https://github.com', + }, + ] as IGitOauth[], + providersWithToken: ['github'] as api.GitOauthProvider[], + }; + const action = gitOauthReceiveAction(payload); + const expectedState: State = { + ...initialState, + isLoading: false, + gitOauth: payload.supportedGitOauth, + providersWithToken: payload.providersWithToken, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle skipOauthReceiveAction', () => { + const payload = ['github'] as api.GitOauthProvider[]; + const action = skipOauthReceiveAction(payload); + const expectedState: State = { + ...initialState, + isLoading: false, + skipOauthProviders: payload, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle gitOauthDeleteAction', () => { + const initialStateWithProviders: State = { + ...initialState, + providersWithToken: ['github', 'gitlab'], + }; + const action = gitOauthDeleteAction('github'); + const expectedState: State = { + ...initialStateWithProviders, + isLoading: false, + providersWithToken: ['gitlab'], + }; + + expect(reducer(initialStateWithProviders, action)).toEqual(expectedState); + }); + + it('should handle gitOauthErrorAction', () => { + const error = 'Error message'; + const action = gitOauthErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as any; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/selectors.spec.ts new file mode 100644 index 000000000..b21d2a870 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/selectors.spec.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { + selectError, + selectGitOauth, + selectIsLoading, + selectProvidersWithToken, + selectSkipOauthProviders, +} from '@/store/GitOauthConfig/selectors'; + +describe('GitOauthConfig, selectors', () => { + const mockState = { + gitOauthConfig: { + isLoading: true, + gitOauth: [{ name: 'github', endpointUrl: 'https://github.com' }], + providersWithToken: ['github'], + skipOauthProviders: ['gitlab'], + error: 'Something went wrong', + }, + } as RootState; + + it('should select isLoading', () => { + const result = selectIsLoading(mockState); + expect(result).toBe(true); + }); + + it('should select gitOauth', () => { + const result = selectGitOauth(mockState); + expect(result).toEqual([{ name: 'github', endpointUrl: 'https://github.com' }]); + }); + + it('should select providersWithToken', () => { + const result = selectProvidersWithToken(mockState); + expect(result).toEqual(['github']); + }); + + it('should select skipOauthProviders', () => { + const result = selectSkipOauthProviders(mockState); + expect(result).toEqual(['gitlab']); + }); + + it('should select error', () => { + const result = selectError(mockState); + expect(result).toEqual('Something went wrong'); + }); +}); diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/types.spec.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/types.spec.ts deleted file mode 100644 index 703acf8d6..000000000 --- a/packages/dashboard-frontend/src/store/GitOauthConfig/__tests__/types.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import * as types from '@/store/GitOauthConfig/types'; - -describe('types', () => { - // this makes the coverage tool happy - test('should export types', () => { - expect(types).toBeDefined(); - }); -}); diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/actions.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/actions.ts new file mode 100644 index 000000000..b4f023959 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitOauthConfig/actions.ts @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { + deleteOAuthToken, + getOAuthProviders, + getOAuthToken, +} from '@/services/backend-client/oAuthApi'; +import { fetchTokens } from '@/services/backend-client/personalAccessTokenApi'; +import { + deleteSkipOauthProvider, + getWorkspacePreferences, +} from '@/services/backend-client/workspacePreferencesApi'; +import { AppThunk } from '@/store'; +import { IGitOauth } from '@/store/GitOauthConfig'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const gitOauthRequestAction = createAction('gitOauth/request'); + +interface GitOAuthReceivePayload { + supportedGitOauth: IGitOauth[]; + providersWithToken: api.GitOauthProvider[]; +} +export const gitOauthReceiveAction = createAction('gitOauth/receive'); + +export const gitOauthDeleteAction = createAction('gitOauth/delete'); + +export const gitOauthErrorAction = createAction('gitOauth/error'); + +export const skipOauthReceiveAction = createAction('skipOauth/receive'); + +export const actionCreators = { + requestSkipAuthorizationProviders: + (): AppThunk => + async (dispatch, getState): Promise => { + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + try { + await verifyAuthorized(dispatch, getState); + + dispatch(gitOauthRequestAction()); + + const devWorkspacePreferences = await getWorkspacePreferences( + defaultKubernetesNamespace.name, + ); + + const skipOauthProviders = devWorkspacePreferences['skip-authorisation'] || []; + dispatch(skipOauthReceiveAction(skipOauthProviders)); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(gitOauthErrorAction(errorMessage)); + throw e; + } + }, + + requestGitOauthConfig: + (): AppThunk => + async (dispatch, getState): Promise => { + const providersWithToken: api.GitOauthProvider[] = []; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(gitOauthRequestAction()); + + const supportedGitOauth = await getOAuthProviders(); + + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const tokens = await fetchTokens(defaultKubernetesNamespace.name); + + const promises: Promise[] = []; + for (const gitOauth of supportedGitOauth) { + promises.push( + getOAuthToken(gitOauth.name) + .then(() => { + providersWithToken.push(gitOauth.name); + }) + .catch(() => { + // if `api/oauth/token` doesn't return a user's token, + // then check if there is the user's token in a Kubernetes Secret + providersWithToken.push(...findUserToken(gitOauth, tokens)); + }), + ); + } + promises.push(dispatch(actionCreators.requestSkipAuthorizationProviders())); + await Promise.allSettled(promises); + + dispatch( + gitOauthReceiveAction({ + providersWithToken, + supportedGitOauth, + }), + ); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(gitOauthErrorAction(errorMessage)); + throw e; + } + }, + + revokeOauth: + (oauthProvider: api.GitOauthProvider): AppThunk => + async (dispatch, getState): Promise => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(gitOauthRequestAction()); + + await deleteOAuthToken(oauthProvider); + + // request namespace provision as it triggers tokens validation + try { + await provisionKubernetesNamespace(); + /* c8 ignore next 3 */ + } catch (e) { + // no-op + } + + dispatch(gitOauthDeleteAction(oauthProvider)); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + if (/^OAuth token for user .* was not found$/.test(errorMessage)) { + dispatch(gitOauthDeleteAction(oauthProvider)); + } else { + dispatch(gitOauthErrorAction(errorMessage)); + throw e; + } + } + }, + + deleteSkipOauth: + (oauthProvider: api.GitOauthProvider): AppThunk => + async (dispatch, getState): Promise => { + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + try { + await verifyAuthorized(dispatch, getState); + + dispatch(gitOauthRequestAction()); + + await deleteSkipOauthProvider(defaultKubernetesNamespace.name, oauthProvider); + await dispatch(actionCreators.requestSkipAuthorizationProviders()); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(gitOauthErrorAction(errorMessage)); + throw e; + } + }, +}; + +/** + * Check the user's token in a Kubernetes Secret + */ +export function findUserToken(gitOauth: IGitOauth, tokens: api.PersonalAccessToken[]) { + const providersWithToken: api.GitOauthProvider[] = []; + + const normalizedGitOauthEndpoint = gitOauth.endpointUrl.endsWith('/') + ? gitOauth.endpointUrl.slice(0, -1) + : gitOauth.endpointUrl; + + for (const token of tokens) { + const normalizedTokenGitProviderEndpoint = token.gitProviderEndpoint.endsWith('/') + ? token.gitProviderEndpoint.slice(0, -1) + : token.gitProviderEndpoint; + + // compare Git OAuth Endpoint url ONLY with OAuth tokens + const gitProvider = token.gitProvider; + if ( + isTokenGitProvider(gitProvider) && + normalizedGitOauthEndpoint === normalizedTokenGitProviderEndpoint + ) { + providersWithToken.push(gitOauth.name); + break; + } + } + return providersWithToken; +} + +/** + * For compatibility with the old format of the git provider value + */ +export function isTokenGitProvider(gitProvider: string): boolean { + return ( + gitProvider.startsWith('oauth2') || + // The git provider value format of a bitbucket-server token is 'che-token--' + new RegExp(`^che-token-<.*>-<${window.location.hostname}>$`).test(gitProvider) + ); +} diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/index.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/index.ts index 558fd09b6..701c29747 100644 --- a/packages/dashboard-frontend/src/store/GitOauthConfig/index.ts +++ b/packages/dashboard-frontend/src/store/GitOauthConfig/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,326 +12,10 @@ * Red Hat, Inc. - initial API and implementation */ -import common, { api } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; -import { - deleteOAuthToken, - getOAuthProviders, - getOAuthToken, -} from '@/services/backend-client/oAuthApi'; -import { fetchTokens } from '@/services/backend-client/personalAccessTokenApi'; -import { - deleteSkipOauthProvider, - getWorkspacePreferences, -} from '@/services/backend-client/workspacePreferencesApi'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export interface State { - isLoading: boolean; - gitOauth: IGitOauth[]; - providersWithToken: api.GitOauthProvider[]; // authentication succeeded - skipOauthProviders: api.GitOauthProvider[]; // authentication declined - error: string | undefined; -} - -export enum Type { - REQUEST_GIT_OAUTH = 'REQUEST_GIT_OAUTH', - DELETE_GIT_OAUTH_TOKEN = 'DELETE_GIT_OAUTH_TOKEN', - RECEIVE_GIT_OAUTH_PROVIDERS = 'RECEIVE_GIT_OAUTH_PROVIDERS', - RECEIVE_SKIP_OAUTH_PROVIDERS = 'RECEIVE_SKIP_OAUTH_PROVIDERS', - DELETE_SKIP_OAUTH = 'DELETE_SKIP_OAUTH', - RECEIVE_GIT_OAUTH_ERROR = 'RECEIVE_GIT_OAUTH_ERROR', -} - -export interface RequestGitOAuthAction extends Action { - type: Type.REQUEST_GIT_OAUTH; -} - -export interface DeleteOauthAction extends Action { - type: Type.DELETE_GIT_OAUTH_TOKEN; - provider: api.GitOauthProvider; -} - -export interface ReceiveGitOAuthConfigAction extends Action { - type: Type.RECEIVE_GIT_OAUTH_PROVIDERS; - supportedGitOauth: IGitOauth[]; - providersWithToken: api.GitOauthProvider[]; -} - -export interface ReceivedGitOauthErrorAction extends Action { - type: Type.RECEIVE_GIT_OAUTH_ERROR; - error: string; -} - -export interface ReceiveSkipOauthProvidersAction extends Action { - type: Type.RECEIVE_SKIP_OAUTH_PROVIDERS; - skipOauthProviders: api.GitOauthProvider[]; -} - -export type KnownAction = - | RequestGitOAuthAction - | ReceiveGitOAuthConfigAction - | ReceiveSkipOauthProvidersAction - | DeleteOauthAction - | ReceivedGitOauthErrorAction; - -export type ActionCreators = { - requestSkipAuthorizationProviders: () => AppThunk>; - requestGitOauthConfig: () => AppThunk>; - revokeOauth: (oauthProvider: api.GitOauthProvider) => AppThunk>; - deleteSkipOauth: (oauthProvider: api.GitOauthProvider) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestSkipAuthorizationProviders: - (): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_GIT_OAUTH, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error, - }); - throw new Error(error); - } - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - try { - const devWorkspacePreferences = await getWorkspacePreferences( - defaultKubernetesNamespace.name, - ); - - const skipOauthProviders = devWorkspacePreferences['skip-authorisation'] || []; - dispatch({ - type: Type.RECEIVE_SKIP_OAUTH_PROVIDERS, - skipOauthProviders, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - requestGitOauthConfig: - (): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_GIT_OAUTH, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error, - }); - throw new Error(error); - } - - const providersWithToken: api.GitOauthProvider[] = []; - try { - const supportedGitOauth = await getOAuthProviders(); - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const tokens = await fetchTokens(defaultKubernetesNamespace.name); - - const promises: Promise[] = []; - for (const gitOauth of supportedGitOauth) { - promises.push( - getOAuthToken(gitOauth.name) - .then(() => { - providersWithToken.push(gitOauth.name); - }) - - // if `api/oauth/token` doesn't return a user's token, - // then check if there is the user's token in a Kubernetes Secret - .catch(() => { - const normalizedGitOauthEndpoint = gitOauth.endpointUrl.endsWith('/') - ? gitOauth.endpointUrl.slice(0, -1) - : gitOauth.endpointUrl; - - for (const token of tokens) { - const normalizedTokenGitProviderEndpoint = token.gitProviderEndpoint.endsWith('/') - ? token.gitProviderEndpoint.slice(0, -1) - : token.gitProviderEndpoint; - - // compare Git OAuth Endpoint url ONLY with OAuth tokens - const gitProvider = token.gitProvider; - if ( - (gitProvider.startsWith('oauth2') || - // The git provider value format of a bitbucket-server token is 'che-token--' - new RegExp(`^che-token-<.*>-<${window.location.hostname}>$`).test( - gitProvider, - )) && - normalizedGitOauthEndpoint === normalizedTokenGitProviderEndpoint - ) { - providersWithToken.push(gitOauth.name); - break; - } - } - }), - ); - } - promises.push(dispatch(actionCreators.requestSkipAuthorizationProviders())); - await Promise.allSettled(promises); - - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_PROVIDERS, - supportedGitOauth, - providersWithToken, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - revokeOauth: - (oauthProvider: api.GitOauthProvider): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_GIT_OAUTH, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error, - }); - throw new Error(error); - } - - try { - await deleteOAuthToken(oauthProvider); - - // request namespace provision as it triggers tokens validation - try { - await provisionKubernetesNamespace(); - /* c8 ignore next 3 */ - } catch (e) { - // no-op - } - - dispatch({ - type: Type.DELETE_GIT_OAUTH_TOKEN, - provider: oauthProvider, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - if (new RegExp('^OAuth token for user .* was not found$').test(errorMessage)) { - dispatch({ - type: Type.DELETE_GIT_OAUTH_TOKEN, - provider: oauthProvider, - }); - } else { - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error: errorMessage, - }); - throw e; - } - } - }, - - deleteSkipOauth: - (oauthProvider: api.GitOauthProvider): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_GIT_OAUTH, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error, - }); - throw new Error(error); - } - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - try { - await deleteSkipOauthProvider(defaultKubernetesNamespace.name, oauthProvider); - await dispatch(actionCreators.requestSkipAuthorizationProviders()); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_GIT_OAUTH_ERROR, - error: errorMessage, - }); - throw e; - } - }, -}; - -const unloadedState: State = { - isLoading: false, - gitOauth: [], - providersWithToken: [], - skipOauthProviders: [], - error: undefined, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_GIT_OAUTH: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_GIT_OAUTH_PROVIDERS: - return createObject(state, { - isLoading: false, - gitOauth: action.supportedGitOauth, - providersWithToken: action.providersWithToken, - }); - case Type.RECEIVE_SKIP_OAUTH_PROVIDERS: - return createObject(state, { - isLoading: false, - skipOauthProviders: action.skipOauthProviders, - }); - case Type.DELETE_GIT_OAUTH_TOKEN: - return createObject(state, { - isLoading: false, - providersWithToken: state.providersWithToken.filter( - provider => provider !== action.provider, - ), - }); - case Type.RECEIVE_GIT_OAUTH_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as gitOauthConfigActionCreators } from '@/store/GitOauthConfig/actions'; +export { + reducer as gitOauthConfigReducer, + State as GitOauthConfigState, + IGitOauth, +} from '@/store/GitOauthConfig/reducer'; +export * from '@/store/GitOauthConfig/selectors'; diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/reducer.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/reducer.ts new file mode 100644 index 000000000..074114c80 --- /dev/null +++ b/packages/dashboard-frontend/src/store/GitOauthConfig/reducer.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { che } from '@/services/models'; +import { + gitOauthDeleteAction, + gitOauthErrorAction, + gitOauthReceiveAction, + gitOauthRequestAction, + skipOauthReceiveAction, +} from '@/store/GitOauthConfig/actions'; + +export interface IGitOauth { + name: api.GitOauthProvider; + endpointUrl: string; + links?: che.api.core.rest.Link[]; +} + +export interface State { + isLoading: boolean; + gitOauth: IGitOauth[]; + providersWithToken: api.GitOauthProvider[]; // authentication succeeded + skipOauthProviders: api.GitOauthProvider[]; // authentication declined + error: string | undefined; +} + +export const unloadedState: State = { + isLoading: false, + gitOauth: [], + providersWithToken: [], + skipOauthProviders: [], + error: undefined, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(gitOauthRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(gitOauthReceiveAction, (state, action) => { + state.isLoading = false; + state.gitOauth = action.payload.supportedGitOauth; + state.providersWithToken = action.payload.providersWithToken; + }) + .addCase(skipOauthReceiveAction, (state, action) => { + state.isLoading = false; + state.skipOauthProviders = action.payload; + }) + .addCase(gitOauthDeleteAction, (state, action) => { + state.isLoading = false; + state.providersWithToken = state.providersWithToken.filter( + provider => provider !== action.payload, + ); + }) + .addCase(gitOauthErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/selectors.ts b/packages/dashboard-frontend/src/store/GitOauthConfig/selectors.ts index e75f4448d..703a817dd 100644 --- a/packages/dashboard-frontend/src/store/GitOauthConfig/selectors.ts +++ b/packages/dashboard-frontend/src/store/GitOauthConfig/selectors.ts @@ -10,27 +10,25 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { State } from '@/store/GitOauthConfig/index'; +import { RootState } from '@/store'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.gitOauthConfig; +const selectState = (state: RootState) => state.gitOauthConfig; export const selectIsLoading = createSelector(selectState, state => { return state.isLoading; }); -export const selectGitOauth = createSelector(selectState, (state: State) => { +export const selectGitOauth = createSelector(selectState, state => { return state.gitOauth; }); -export const selectProvidersWithToken = createSelector(selectState, (state: State) => { +export const selectProvidersWithToken = createSelector(selectState, state => { return state.providersWithToken; }); -export const selectSkipOauthProviders = createSelector(selectState, (state: State) => { +export const selectSkipOauthProviders = createSelector(selectState, state => { return state.skipOauthProviders; }); diff --git a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/actions.spec.ts new file mode 100644 index 000000000..492b5c66f --- /dev/null +++ b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/actions.spec.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import { getKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { che } from '@/services/models'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + namespaceErrorAction, + namespaceReceiveAction, + namespaceRequestAction, +} from '@/store/InfrastructureNamespaces/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/kubernetesNamespaceApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +describe('InfrastructureNamespace, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestNamespaces', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockNamespaces = [ + { name: 'namespace1' }, + { name: 'namespace2' }, + ] as che.KubernetesNamespace[]; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getKubernetesNamespace as jest.Mock).mockResolvedValue(mockNamespaces); + + await store.dispatch(actionCreators.requestNamespaces()); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(namespaceRequestAction()); + expect(actions[1]).toEqual(namespaceReceiveAction(mockNamespaces)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getKubernetesNamespace as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestNamespaces())).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(namespaceRequestAction()); + expect(actions[1]).toEqual( + namespaceErrorAction( + `Failed to fetch list of available kubernetes namespaces, reason: ${errorMessage}`, + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/reducer.spec.ts new file mode 100644 index 000000000..a4235ce9c --- /dev/null +++ b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/reducer.spec.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from '@reduxjs/toolkit'; + +import { che } from '@/services/models'; +import { + namespaceErrorAction, + namespaceReceiveAction, + namespaceRequestAction, +} from '@/store/InfrastructureNamespaces/actions'; +import { reducer, State, unloadedState } from '@/store/InfrastructureNamespaces/reducer'; + +describe('InfrastructureNamespaces, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle namespaceRequestAction', () => { + const action = namespaceRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle namespaceReceiveAction', () => { + const namespaces = [ + { name: 'namespace1' }, + { name: 'namespace2' }, + ] as che.KubernetesNamespace[]; + const action = namespaceReceiveAction(namespaces); + const expectedState: State = { + ...initialState, + isLoading: false, + namespaces, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle namespaceErrorAction', () => { + const error = 'Error message'; + const action = namespaceErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/selectors.spec.ts new file mode 100644 index 000000000..3b408d1f7 --- /dev/null +++ b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/__tests__/selectors.spec.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { che } from '@/services/models'; +import { RootState } from '@/store'; +import { + selectDefaultNamespace, + selectInfrastructureNamespaces, + selectInfrastructureNamespacesError, +} from '@/store/InfrastructureNamespaces/selectors'; + +describe('InfrastructureNamespaces, selectors', () => { + const mockState = { + infrastructureNamespaces: { + namespaces: [ + { name: 'namespace1', attributes: { default: 'false' } }, + { name: 'namespace2', attributes: { phase: 'Active', default: 'true' } }, + ], + error: 'Something went wrong', + }, + } as RootState; + + it('should select the default namespace', () => { + const result = selectDefaultNamespace(mockState); + expect(result).toEqual({ + name: 'namespace2', + attributes: { phase: 'Active', default: 'true' }, + } as che.KubernetesNamespace); + }); + + it('should select the first namespace if no default is found', () => { + const stateWithoutDefault = { + ...mockState, + infrastructureNamespaces: { + ...mockState.infrastructureNamespaces, + namespaces: [{ name: 'namespace1', attributes: { phase: 'Active' } }], + }, + } as RootState; + const result = selectDefaultNamespace(stateWithoutDefault); + expect(result).toEqual({ + name: 'namespace1', + attributes: { phase: 'Active' }, + } as che.KubernetesNamespace); + }); + + it('should return an empty object if no namespaces are available', () => { + const stateWithoutNamespaces = { + ...mockState, + infrastructureNamespaces: { + ...mockState.infrastructureNamespaces, + namespaces: [], + }, + } as RootState; + const result = selectDefaultNamespace(stateWithoutNamespaces); + expect(result).toEqual({}); + }); + + it('should select all infrastructure namespaces', () => { + const result = selectInfrastructureNamespaces(mockState); + expect(result).toEqual(mockState.infrastructureNamespaces.namespaces); + }); + + it('should select the infrastructure namespaces error', () => { + const result = selectInfrastructureNamespacesError(mockState); + expect(result).toEqual(mockState.infrastructureNamespaces.error); + }); +}); diff --git a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/actions.ts b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/actions.ts new file mode 100644 index 000000000..906dd73ba --- /dev/null +++ b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/actions.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { getKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { che } from '@/services/models'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const namespaceRequestAction = createAction('namespace/request'); +export const namespaceReceiveAction = + createAction>('namespace/receive'); +export const namespaceErrorAction = createAction('namespace/receiveError'); + +export const actionCreators = { + requestNamespaces: (): AppThunk => async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(namespaceRequestAction()); + + const namespaces = await getKubernetesNamespace(); + dispatch(namespaceReceiveAction(namespaces)); + } catch (e) { + const errorMessage = + 'Failed to fetch list of available kubernetes namespaces, reason: ' + + common.helpers.errors.getMessage(e); + dispatch(namespaceErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/index.ts b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/index.ts index 398f960b4..342b6671f 100644 --- a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/index.ts +++ b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,103 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { getKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; -import { che } from '@/services/models'; -import { createObject } from '@/store/helpers'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export interface State { - isLoading: boolean; - namespaces: che.KubernetesNamespace[]; - error?: string; -} - -interface RequestNamespacesAction extends Action, SanityCheckAction { - type: 'REQUEST_NAMESPACES'; -} - -interface ReceiveNamespacesAction { - type: 'RECEIVE_NAMESPACES'; - namespaces: che.KubernetesNamespace[]; -} - -interface ReceiveNamespacesErrorAction { - type: 'RECEIVE_NAMESPACES_ERROR'; - error: string; -} - -type KnownAction = RequestNamespacesAction | ReceiveNamespacesAction | ReceiveNamespacesErrorAction; - -export type ActionCreators = { - requestNamespaces: () => AppThunk>>; -}; - -export const actionCreators: ActionCreators = { - requestNamespaces: - (): AppThunk>> => - async (dispatch, getState): Promise> => { - try { - await dispatch({ type: 'REQUEST_NAMESPACES', check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const namespaces = await getKubernetesNamespace(); - dispatch({ - type: 'RECEIVE_NAMESPACES', - namespaces, - }); - return namespaces; - } catch (e) { - const errorMessage = - 'Failed to fetch list of available kubernetes namespaces, reason: ' + - common.helpers.errors.getMessage(e); - dispatch({ - type: 'RECEIVE_NAMESPACES_ERROR', - error: errorMessage, - }); - throw errorMessage; - } - }, -}; - -const unloadedState: State = { - isLoading: false, - namespaces: [], -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case 'REQUEST_NAMESPACES': - return createObject(state, { - isLoading: true, - error: undefined, - }); - case 'RECEIVE_NAMESPACES': - return createObject(state, { - isLoading: false, - namespaces: action.namespaces, - }); - case 'RECEIVE_NAMESPACES_ERROR': - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as infrastructureNamespacesActionCreators } from '@/store/InfrastructureNamespaces/actions'; +export { + reducer as infrastructureNamespacesReducer, + State as InfrastructureNamespacesState, +} from '@/store/InfrastructureNamespaces/reducer'; +export * from '@/store/InfrastructureNamespaces/selectors'; diff --git a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/reducer.ts b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/reducer.ts new file mode 100644 index 000000000..d1127ccb4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/reducer.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { che } from '@/services/models'; +import { + namespaceErrorAction, + namespaceReceiveAction, + namespaceRequestAction, +} from '@/store/InfrastructureNamespaces/actions'; + +export interface State { + isLoading: boolean; + namespaces: che.KubernetesNamespace[]; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + namespaces: [], +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(namespaceRequestAction, state => { + state.isLoading = true; + }) + .addCase(namespaceReceiveAction, (state, action) => { + state.isLoading = false; + state.namespaces = action.payload; + }) + .addCase(namespaceErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/selectors.ts b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/selectors.ts index 1269315f3..64e1e4cbc 100644 --- a/packages/dashboard-frontend/src/store/InfrastructureNamespaces/selectors.ts +++ b/packages/dashboard-frontend/src/store/InfrastructureNamespaces/selectors.ts @@ -10,13 +10,12 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; import { che } from '@/services/models'; +import { RootState } from '@/store'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.infrastructureNamespaces; +const selectState = (state: RootState) => state.infrastructureNamespaces; export const selectDefaultNamespace = createSelector( selectState, diff --git a/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/actions.spec.ts deleted file mode 100644 index f2375e103..000000000 --- a/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/actions.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import * as KubernetesNamespaceApi from '@/services/backend-client/kubernetesNamespaceApi'; -import * as PersonalAccessTokenApi from '@/services/backend-client/personalAccessTokenApi'; -import { che } from '@/services/models'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { token1, token2 } from '@/store/PersonalAccessToken/__tests__/stub'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; - -jest.mock( - '@/services/backend-client/kubernetesNamespaceApi', - () => - ({ - provisionKubernetesNamespace: () => Promise.resolve({} as che.KubernetesNamespace), - }) as typeof KubernetesNamespaceApi, -); - -const mockFetchTokens = jest.fn(); -const mockAddToken = jest.fn(); -const mockUpdateToken = jest.fn(); -const mockRemoveToken = jest.fn(); -jest.mock( - '@/services/backend-client/personalAccessTokenApi', - () => - ({ - fetchTokens: (...args) => mockFetchTokens(...args), - addToken: (...args) => mockAddToken(...args), - updateToken: (...args) => mockUpdateToken(...args), - removeToken: (...args) => mockRemoveToken(...args), - }) as typeof PersonalAccessTokenApi, -); - -// mute the outputs -console.error = jest.fn(); - -describe('Personal Access Token store, actions', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create REQUEST_TOKENS and RECEIVE_TOKENS when requesting tokens', async () => { - mockFetchTokens.mockResolvedValueOnce([token1, token2]); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(testStore.actionCreators.requestTokens()); - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_TOKENS, - tokens: [token1, token2], - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and RECEIVE_ERROR when requesting tokens', async () => { - const errorMessage = 'Something bad happened'; - mockFetchTokens.mockRejectedValueOnce(new Error(errorMessage)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.requestTokens()); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: errorMessage, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and ADD_TOKEN when adding token', async () => { - mockAddToken.mockResolvedValueOnce(token1); - mockFetchTokens.mockResolvedValueOnce([token1]); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(testStore.actionCreators.addToken(token1)); - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.ADD_TOKEN, - token: token1, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and RECEIVE_ERROR when adding token', async () => { - const errorMessage = 'Something bad happened'; - mockAddToken.mockRejectedValueOnce(new Error(errorMessage)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.addToken(token1)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: errorMessage, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and RECEIVE_ERROR when adding a non valid token', async () => { - // the non valid token was added successfully - mockAddToken.mockResolvedValueOnce(token1); - // but it was missing among the tokens returned by the backend - mockFetchTokens.mockResolvedValueOnce([]); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.addToken(token1)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: `Token "${token1.tokenName}" was not added because it is not valid.`, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and UPDATE_TOKEN when updating token', async () => { - mockUpdateToken.mockResolvedValueOnce(token1); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(testStore.actionCreators.updateToken(token1)); - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_TOKEN, - token: token1, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and RECEIVE_ERROR when updating token', async () => { - const errorMessage = 'Something bad happened'; - mockUpdateToken.mockRejectedValueOnce(new Error(errorMessage)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.updateToken(token1)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: errorMessage, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and REMOVE_TOKEN when deleting token', async () => { - mockRemoveToken.mockResolvedValueOnce(token1); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(testStore.actionCreators.removeToken(token1)); - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.REMOVE_TOKEN, - token: token1, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_TOKENS and RECEIVE_ERROR when deleting token', async () => { - const errorMessage = 'Something bad happened'; - mockRemoveToken.mockRejectedValueOnce(new Error(errorMessage)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.removeToken(token1)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: errorMessage, - }, - ]; - expect(actions).toEqual(expectedActions); - }); -}); diff --git a/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/reducers.spec.ts deleted file mode 100644 index c576047c9..000000000 --- a/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/reducers.spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; -import { AnyAction } from 'redux'; - -import { token1, token2 } from '@/store/PersonalAccessToken/__tests__/stub'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; - -describe('Personal Access Token store', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return initial state', () => { - const incomingAction: testStore.RequestTokensAction = { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }; - - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - tokens: [], - error: undefined, - }; - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { - isLoading: false, - tokens: [token1, token2], - error: undefined, - }; - - const incomingAction = { - type: 'NOT_MATCHED', - check: AUTHORIZED, - } as AnyAction; - const state = testStore.reducer(initialState, incomingAction); - - expect(state).toEqual(initialState); - }); - - it('should handle REQUEST_TOKENS', () => { - const initialState: testStore.State = { - isLoading: false, - tokens: [token1, token2], - error: 'an error', - }; - - const incomingAction: testStore.RequestTokensAction = { - type: testStore.Type.REQUEST_TOKENS, - check: AUTHORIZED, - }; - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - tokens: [token1, token2], - error: undefined, - }; - expect(state).toEqual(expectedState); - }); - - it('should handle RECEIVE_TOKENS', () => { - const initialState: testStore.State = { - isLoading: true, - tokens: [token1], - error: undefined, - }; - - const newToken1 = { - ...token1, - tokenData: 'new-token-data', - } as api.PersonalAccessToken; - const incomingAction: testStore.ReceiveTokensAction = { - type: testStore.Type.RECEIVE_TOKENS, - tokens: [newToken1, token2], - }; - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - tokens: [newToken1, token2], - error: undefined, - }; - expect(state).toEqual(expectedState); - }); - - it('should handle ADD_TOKEN', () => { - const incomingAction: testStore.AddTokenAction = { - type: testStore.Type.ADD_TOKEN, - token: token2, - }; - const initialState: testStore.State = { - isLoading: false, - tokens: [token1], - error: undefined, - }; - const state = testStore.reducer(initialState, incomingAction); - const expectedState: testStore.State = { - isLoading: false, - tokens: [token1, token2], - error: undefined, - }; - expect(state).toEqual(expectedState); - }); - - it('should handle UPDATE_TOKEN', () => { - const initialState: testStore.State = { - isLoading: false, - tokens: [token1, token2], - error: undefined, - }; - - const newToken1 = { - ...token1, - tokenData: 'newTokenData', - } as api.PersonalAccessToken; - const incomingAction: testStore.UpdateTokenAction = { - type: testStore.Type.UPDATE_TOKEN, - token: newToken1, - }; - - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - tokens: [newToken1, token2], - error: undefined, - }; - expect(state).toEqual(expectedState); - }); - - it('should handle REMOVE_TOKEN', () => { - const initialState: testStore.State = { - isLoading: false, - tokens: [token1, token2], - error: undefined, - }; - - const incomingAction: testStore.RemoveTokenAction = { - type: testStore.Type.REMOVE_TOKEN, - token: token1, - }; - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - tokens: [token2], - error: undefined, - }; - expect(state).toEqual(expectedState); - }); - - it('should handle RECEIVE_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - tokens: [token1], - error: undefined, - }; - - const incomingAction: testStore.ReceiveErrorAction = { - type: testStore.Type.RECEIVE_ERROR, - error: 'error', - }; - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - tokens: [token1], - error: 'error', - }; - expect(state).toEqual(expectedState); - }); -}); diff --git a/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/selectors.spec.ts deleted file mode 100644 index ee8ee602b..000000000 --- a/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/selectors.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { token1, token2 } from '@/store/PersonalAccessToken/__tests__/stub'; -import { - selectPersonalAccessTokens, - selectPersonalAccessTokensError, - selectPersonalAccessTokensIsLoading, -} from '@/store/PersonalAccessToken/selectors'; - -import * as store from '..'; - -describe('Personal Access Token store, selectors', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return the error', () => { - const fakeStore = new FakeStoreBuilder() - .withPersonalAccessTokens({ tokens: [], error: 'Something unexpected' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedError = selectPersonalAccessTokensError(state); - expect(selectedError).toEqual('Something unexpected'); - }); - - it('should return all tokens', () => { - const fakeStore = new FakeStoreBuilder() - .withPersonalAccessTokens({ tokens: [token1, token2] }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const allTokens = selectPersonalAccessTokens(state); - expect(allTokens).toEqual([token1, token2]); - }); - - it('should return isLoading state', () => { - const fakeStore = new FakeStoreBuilder() - .withPersonalAccessTokens({ tokens: [token1, token2] }, true) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const isLoading = selectPersonalAccessTokensIsLoading(state); - expect(isLoading).toEqual(true); - }); -}); diff --git a/packages/dashboard-frontend/src/store/PersonalAccessToken/index.ts b/packages/dashboard-frontend/src/store/PersonalAccessToken/index.ts deleted file mode 100644 index 8c0c4a815..000000000 --- a/packages/dashboard-frontend/src/store/PersonalAccessToken/index.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api, helpers } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; -import { - addToken, - fetchTokens, - removeToken, - updateToken, -} from '@/services/backend-client/personalAccessTokenApi'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { State } from '@/store/PersonalAccessToken/state'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export * from './state'; - -export enum Type { - RECEIVE_ERROR = 'RECEIVE_ERROR', - RECEIVE_TOKENS = 'RECEIVE_TOKENS', - REQUEST_TOKENS = 'REQUEST_TOKENS', - ADD_TOKEN = 'ADD_TOKEN', - UPDATE_TOKEN = 'UPDATE_TOKEN', - REMOVE_TOKEN = 'REMOVE_TOKEN', -} - -export interface RequestTokensAction extends Action, SanityCheckAction { - type: Type.REQUEST_TOKENS; -} - -export interface ReceiveTokensAction extends Action { - type: Type.RECEIVE_TOKENS; - tokens: api.PersonalAccessToken[]; -} - -export interface AddTokenAction extends Action { - type: Type.ADD_TOKEN; - token: api.PersonalAccessToken; -} - -export interface UpdateTokenAction extends Action { - type: Type.UPDATE_TOKEN; - token: api.PersonalAccessToken; -} - -export interface RemoveTokenAction extends Action { - type: Type.REMOVE_TOKEN; - token: api.PersonalAccessToken; -} - -export interface ReceiveErrorAction extends Action { - type: Type.RECEIVE_ERROR; - error: string; -} - -export type KnownAction = - | AddTokenAction - | ReceiveErrorAction - | ReceiveTokensAction - | RequestTokensAction - | UpdateTokenAction - | RemoveTokenAction; - -export type ActionCreators = { - requestTokens: () => AppThunk>; - addToken: (token: api.PersonalAccessToken) => AppThunk>; - updateToken: (token: api.PersonalAccessToken) => AppThunk>; - removeToken: (token: api.PersonalAccessToken) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestTokens: - (): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_TOKENS, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_ERROR, - error, - }); - throw new Error(error); - } - - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - const tokens = await fetchTokens(namespace); - dispatch({ - type: Type.RECEIVE_TOKENS, - tokens, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - addToken: - (token: api.PersonalAccessToken): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_TOKENS, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_ERROR, - error, - }); - throw new Error(error); - } - - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - let newToken: api.PersonalAccessToken; - try { - newToken = await addToken(namespace, token); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - - /* request namespace provision as it triggers tokens validation */ - await provisionKubernetesNamespace(); - - /* check if the new token is available */ - - const allTokens = await fetchTokens(namespace); - const tokenExists = allTokens.some(t => t.tokenName === newToken.tokenName); - - if (tokenExists) { - dispatch({ - type: Type.ADD_TOKEN, - token: newToken, - }); - } else { - const errorMessage = `Token "${newToken.tokenName}" was not added because it is not valid.`; - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw new Error(errorMessage); - } - }, - - updateToken: - (token: api.PersonalAccessToken): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_TOKENS, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_ERROR, - error, - }); - throw new Error(error); - } - - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - const newToken = await updateToken(namespace, token); - dispatch({ - type: Type.UPDATE_TOKEN, - token: newToken, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - removeToken: - (token: api.PersonalAccessToken): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_TOKENS, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_ERROR, - error, - }); - throw new Error(error); - } - - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - await removeToken(namespace, token); - dispatch({ - type: Type.REMOVE_TOKEN, - token, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - }, -}; - -const unloadedState: State = { - isLoading: false, - tokens: [], -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_TOKENS: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_TOKENS: - return createObject(state, { - isLoading: false, - tokens: action.tokens, - }); - case Type.ADD_TOKEN: - return createObject(state, { - isLoading: false, - tokens: [...state.tokens, action.token], - }); - case Type.UPDATE_TOKEN: - return createObject(state, { - isLoading: false, - tokens: state.tokens.map(token => - token.tokenName === action.token.tokenName ? action.token : token, - ), - }); - case Type.REMOVE_TOKEN: - return createObject(state, { - isLoading: false, - tokens: state.tokens.filter(token => token.tokenName !== action.token.tokenName), - }); - case Type.RECEIVE_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; diff --git a/packages/dashboard-frontend/src/store/PersonalAccessToken/state.ts b/packages/dashboard-frontend/src/store/PersonalAccessToken/state.ts deleted file mode 100644 index 2335c30c7..000000000 --- a/packages/dashboard-frontend/src/store/PersonalAccessToken/state.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; - -export interface State { - isLoading: boolean; - tokens: api.PersonalAccessToken[]; - error?: string; -} diff --git a/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/actions.spec.ts new file mode 100644 index 000000000..c553b9756 --- /dev/null +++ b/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/actions.spec.ts @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; + +import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { + addToken, + fetchTokens, + removeToken, + updateToken, +} from '@/services/backend-client/personalAccessTokenApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as infrastructureNamespacesSelector from '@/store/InfrastructureNamespaces/selectors'; +import { + actionCreators, + tokenAddAction, + tokenErrorAction, + tokenReceiveAction, + tokenRemoveAction, + tokenRequestAction, + tokenUpdateAction, +} from '@/store/PersonalAccessTokens/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/personalAccessTokenApi'); +jest.mock('@/services/backend-client/kubernetesNamespaceApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@eclipse-che/common'); + +const mockNamespace = 'test-namespace'; +jest.spyOn(infrastructureNamespacesSelector, 'selectDefaultNamespace').mockReturnValue({ + name: mockNamespace, + attributes: { + phase: 'Active', + }, +}); + +describe('PersonalAccessTokens, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestTokens', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockTokens = [{ tokenName: 'token1' }] as api.PersonalAccessToken[]; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchTokens as jest.Mock).mockResolvedValue(mockTokens); + + await store.dispatch(actionCreators.requestTokens()); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenReceiveAction(mockTokens)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchTokens as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestTokens())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenErrorAction(errorMessage)); + }); + }); + + describe('addToken', () => { + it('should dispatch add action on successful add', async () => { + const mockNewToken = { tokenName: 'token1' } as api.PersonalAccessToken; + const mockTokens = [ + mockNewToken, + { tokenName: 'token2' } as api.PersonalAccessToken, + ] as api.PersonalAccessToken[]; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (addToken as jest.Mock).mockResolvedValue(mockNewToken); + (fetchTokens as jest.Mock).mockResolvedValue(mockTokens); + + await store.dispatch(actionCreators.addToken(mockNewToken)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenAddAction(mockNewToken)); + }); + + it('should dispatch error action on failed add', async () => { + const mockToken = { tokenName: 'token1' } as api.PersonalAccessToken; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (addToken as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.addToken(mockToken))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenErrorAction(errorMessage)); + }); + + it('should dispatch error action on successful add but token is not available', async () => { + const mockNewToken = { tokenName: 'token1' } as api.PersonalAccessToken; + const mockTokens = [{ tokenName: 'token2' }]; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (addToken as jest.Mock).mockResolvedValue(mockNewToken); + (fetchTokens as jest.Mock).mockResolvedValue(mockTokens); + (provisionKubernetesNamespace as jest.Mock).mockResolvedValue(undefined); + (helpers.errors.getMessage as jest.Mock).mockReturnValue('Token is not valid'); + + await expect(store.dispatch(actionCreators.addToken(mockNewToken))).rejects.toThrow( + `Token "${mockNewToken.tokenName}" was not added because it is not valid.`, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenErrorAction('Token is not valid')); + }); + }); + + describe('updateToken', () => { + it('should dispatch update action on successful update', async () => { + const mockToken = { tokenName: 'token1' } as api.PersonalAccessToken; + const mockUpdatedToken = { tokenName: 'token1' } as api.PersonalAccessToken; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (updateToken as jest.Mock).mockResolvedValue(mockUpdatedToken); + + await store.dispatch(actionCreators.updateToken(mockToken)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenUpdateAction(mockUpdatedToken)); + }); + + it('should dispatch error action on failed update', async () => { + const mockToken = { tokenName: 'token1' } as api.PersonalAccessToken; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (updateToken as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.updateToken(mockToken))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenErrorAction(errorMessage)); + }); + }); + + describe('removeToken', () => { + it('should dispatch remove action on successful remove', async () => { + const mockToken = { tokenName: 'token1' } as api.PersonalAccessToken; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (removeToken as jest.Mock).mockResolvedValue(undefined); + + await store.dispatch(actionCreators.removeToken(mockToken)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenRemoveAction(mockToken)); + }); + + it('should dispatch error action on failed remove', async () => { + const mockToken = { tokenName: 'token1' } as api.PersonalAccessToken; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (removeToken as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.removeToken(mockToken))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(tokenRequestAction()); + expect(actions[1]).toEqual(tokenErrorAction(errorMessage)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/reducers.spec.ts new file mode 100644 index 000000000..761a54c81 --- /dev/null +++ b/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/reducers.spec.ts @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; + +import { + tokenAddAction, + tokenErrorAction, + tokenReceiveAction, + tokenRemoveAction, + tokenRequestAction, + tokenUpdateAction, +} from '@/store/PersonalAccessTokens/actions'; +import { reducer, State, unloadedState } from '@/store/PersonalAccessTokens/reducer'; + +describe('PersonalAccessTokens, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle tokenRequestAction', () => { + const action = tokenRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle tokenReceiveAction', () => { + const tokens = [{ tokenName: 'token1' }] as api.PersonalAccessToken[]; + const action = tokenReceiveAction(tokens); + const expectedState: State = { + ...initialState, + isLoading: false, + tokens, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle tokenAddAction', () => { + const token = { tokenName: 'token1' } as api.PersonalAccessToken; + const action = tokenAddAction(token); + const expectedState: State = { + ...initialState, + isLoading: false, + tokens: [token], + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle tokenUpdateAction', () => { + const initialStateWithTokens: State = { + ...initialState, + tokens: [ + { tokenName: 'token1', tokenData: 'oldValue' }, + { tokenName: 'token2' }, + ] as api.PersonalAccessToken[], + }; + const updatedToken = { tokenName: 'token1', tokenData: 'newValue' } as api.PersonalAccessToken; + + const action = tokenUpdateAction(updatedToken); + + const expectedState: State = { + ...initialStateWithTokens, + isLoading: false, + tokens: [updatedToken, initialStateWithTokens.tokens[1]], + }; + expect(reducer(initialStateWithTokens, action)).toEqual(expectedState); + }); + + it('should handle tokenRemoveAction', () => { + const initialStateWithTokens: State = { + ...initialState, + tokens: [{ tokenName: 'token1' }] as api.PersonalAccessToken[], + }; + + const action = tokenRemoveAction({ tokenName: 'token1' } as api.PersonalAccessToken); + + const expectedState: State = { + ...initialStateWithTokens, + isLoading: false, + tokens: [], + }; + expect(reducer(initialStateWithTokens, action)).toEqual(expectedState); + }); + + it('should handle tokenErrorAction', () => { + const error = 'Error message'; + const action = tokenErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as any; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/selectors.spec.ts new file mode 100644 index 000000000..d7939ed3b --- /dev/null +++ b/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/selectors.spec.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { + selectPersonalAccessTokens, + selectPersonalAccessTokensError, + selectPersonalAccessTokensIsLoading, +} from '@/store/PersonalAccessTokens/selectors'; + +describe('PersonalAccessTokens, selectors', () => { + const mockState = { + personalAccessToken: { + isLoading: true, + tokens: [{ tokenName: 'token1' }, { tokenName: 'token2' }], + error: 'Something went wrong', + }, + } as RootState; + + it('should select isLoading', () => { + const result = selectPersonalAccessTokensIsLoading(mockState); + expect(result).toBe(true); + }); + + it('should select personal access tokens', () => { + const result = selectPersonalAccessTokens(mockState); + expect(result).toEqual([{ tokenName: 'token1' }, { tokenName: 'token2' }]); + }); + + it('should select error', () => { + const result = selectPersonalAccessTokensError(mockState); + expect(result).toEqual('Something went wrong'); + }); +}); diff --git a/packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/stub.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/stub.ts similarity index 100% rename from packages/dashboard-frontend/src/store/PersonalAccessToken/__tests__/stub.ts rename to packages/dashboard-frontend/src/store/PersonalAccessTokens/__tests__/stub.ts diff --git a/packages/dashboard-frontend/src/store/PersonalAccessTokens/actions.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/actions.ts new file mode 100644 index 000000000..3c05ea4e5 --- /dev/null +++ b/packages/dashboard-frontend/src/store/PersonalAccessTokens/actions.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { + addToken, + fetchTokens, + removeToken, + updateToken, +} from '@/services/backend-client/personalAccessTokenApi'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const tokenRequestAction = createAction('token/request'); +export const tokenReceiveAction = createAction('token/receive'); +export const tokenAddAction = createAction('token/add'); +export const tokenUpdateAction = createAction('token/update'); +export const tokenRemoveAction = createAction('token/remove'); +export const tokenErrorAction = createAction('token/error'); + +export const actionCreators = { + requestTokens: + (): AppThunk => + async (dispatch, getState): Promise => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(tokenRequestAction()); + + const tokens = await fetchTokens(namespace); + dispatch(tokenReceiveAction(tokens)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(tokenErrorAction(errorMessage)); + throw e; + } + }, + + addToken: + (token: api.PersonalAccessToken): AppThunk => + async (dispatch, getState): Promise => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(tokenRequestAction()); + + const newToken = await addToken(namespace, token); + + /* request namespace provision as it triggers tokens validation */ + await provisionKubernetesNamespace(); + + /* check if the new token is available */ + + const allTokens = await fetchTokens(namespace); + const tokenExists = allTokens.some(t => t.tokenName === newToken.tokenName); + + if (tokenExists) { + dispatch(tokenAddAction(newToken)); + } else { + const errorMessage = `Token "${newToken.tokenName}" was not added because it is not valid.`; + throw new Error(errorMessage); + } + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(tokenErrorAction(errorMessage)); + throw e; + } + }, + + updateToken: + (token: api.PersonalAccessToken): AppThunk => + async (dispatch, getState): Promise => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(tokenRequestAction()); + + const newToken = await updateToken(namespace, token); + dispatch(tokenUpdateAction(newToken)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(tokenErrorAction(errorMessage)); + throw e; + } + }, + + removeToken: + (token: api.PersonalAccessToken): AppThunk => + async (dispatch, getState): Promise => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(tokenRequestAction()); + + await removeToken(namespace, token); + dispatch(tokenRemoveAction(token)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(tokenErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/DockerConfig/types.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/index.ts similarity index 51% rename from packages/dashboard-frontend/src/store/DockerConfig/types.ts rename to packages/dashboard-frontend/src/store/PersonalAccessTokens/index.ts index d05793aeb..c36f26260 100644 --- a/packages/dashboard-frontend/src/store/DockerConfig/types.ts +++ b/packages/dashboard-frontend/src/store/PersonalAccessTokens/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,15 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -export type RegistryEntry = { - url: string; - username?: string; - password?: string; -}; - -export type ContainerCredentials = { - [key: string]: { - username?: string; - password?: string; - }; -}; +export { actionCreators as personalAccessTokenActionCreators } from '@/store/PersonalAccessTokens/actions'; +export { + reducer as personalAccessTokenReducer, + State as PersonalAccessTokenState, +} from '@/store/PersonalAccessTokens/reducer'; +export * from '@/store/PersonalAccessTokens/selectors'; diff --git a/packages/dashboard-frontend/src/store/PersonalAccessTokens/reducer.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/reducer.ts new file mode 100644 index 000000000..3ce7c9b28 --- /dev/null +++ b/packages/dashboard-frontend/src/store/PersonalAccessTokens/reducer.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + tokenAddAction, + tokenErrorAction, + tokenReceiveAction, + tokenRemoveAction, + tokenRequestAction, + tokenUpdateAction, +} from '@/store/PersonalAccessTokens/actions'; + +export interface State { + isLoading: boolean; + tokens: api.PersonalAccessToken[]; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + tokens: [], +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(tokenRequestAction, state => { + state.isLoading = true; + }) + .addCase(tokenReceiveAction, (state, action) => { + state.isLoading = false; + state.tokens = action.payload; + }) + .addCase(tokenAddAction, (state, action) => { + state.isLoading = false; + state.tokens.push(action.payload); + }) + .addCase(tokenUpdateAction, (state, action) => { + state.isLoading = false; + state.tokens = state.tokens.map(token => + token.tokenName === action.payload.tokenName ? action.payload : token, + ); + }) + .addCase(tokenRemoveAction, (state, action) => { + state.isLoading = false; + state.tokens = state.tokens.filter(token => token.tokenName !== action.payload.tokenName); + }) + .addCase(tokenErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/PersonalAccessToken/selectors.ts b/packages/dashboard-frontend/src/store/PersonalAccessTokens/selectors.ts similarity index 64% rename from packages/dashboard-frontend/src/store/PersonalAccessToken/selectors.ts rename to packages/dashboard-frontend/src/store/PersonalAccessTokens/selectors.ts index df825a980..6c7f94d6e 100644 --- a/packages/dashboard-frontend/src/store/PersonalAccessToken/selectors.ts +++ b/packages/dashboard-frontend/src/store/PersonalAccessTokens/selectors.ts @@ -10,22 +10,17 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { State } from '@/store/PersonalAccessToken/state'; +import { RootState } from '@/store'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.personalAccessToken; +const selectState = (state: RootState) => state.personalAccessToken; export const selectPersonalAccessTokensIsLoading = createSelector( selectState, state => state.isLoading, ); -export const selectPersonalAccessTokens = createSelector( - selectState, - (state: State) => state.tokens, -); +export const selectPersonalAccessTokens = createSelector(selectState, state => state.tokens); export const selectPersonalAccessTokensError = createSelector(selectState, state => state.error); diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/actions.spec.ts new file mode 100644 index 000000000..f1ce0e849 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/actions.spec.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import { che } from '@/services/models'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + pluginsErrorAction, + pluginsReceiveAction, + pluginsRequestAction, +} from '@/store/Plugins/chePlugins/actions'; +import { convertToEditorPlugin } from '@/store/Plugins/chePlugins/helpers'; +import { devWorkspacePluginsActionCreators } from '@/store/Plugins/devWorkspacePlugins'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/store/Plugins/chePlugins/helpers'); +jest.mock('@/store/SanityCheck'); + +describe('ChePlugins, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestPlugins', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockEditors = [{ id: 'editor1' }, { id: 'editor2' }]; + const mockPlugins = [{ id: 'plugin-editor1' }, { id: 'plugin-editor2' }] as che.Plugin[]; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + jest + .spyOn(devWorkspacePluginsActionCreators, 'requestEditors') + .mockImplementation(() => async () => {}); + (convertToEditorPlugin as jest.Mock).mockImplementation(editor => ({ + id: `plugin-${editor.id}`, + })); + + const storeWithDwPlugins = createMockStore({ + dwPlugins: { + cmEditors: mockEditors as any, + plugins: {}, + defaultPlugins: {}, + editors: {}, + isLoading: false, + }, + }); + + await storeWithDwPlugins.dispatch(actionCreators.requestPlugins()); + + const actions = storeWithDwPlugins.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(pluginsRequestAction()); + expect(actions[1]).toEqual(pluginsReceiveAction(mockPlugins)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + jest + .spyOn(devWorkspacePluginsActionCreators, 'requestEditors') + .mockImplementation(() => async () => { + throw new Error(errorMessage); + }); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestPlugins())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(pluginsRequestAction()); + expect(actions[1]).toEqual(pluginsErrorAction(errorMessage)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/reducer.spec.ts new file mode 100644 index 000000000..c54b6e5a3 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/reducer.spec.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import { che } from '@/services/models'; +import { + pluginsErrorAction, + pluginsReceiveAction, + pluginsRequestAction, +} from '@/store/Plugins/chePlugins/actions'; +import { reducer, State, unloadedState } from '@/store/Plugins/chePlugins/reducer'; + +describe('Plugins reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = unloadedState; + }); + + it('should handle pluginsRequestAction', () => { + const action = pluginsRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle pluginsReceiveAction', () => { + const plugins = [{ id: 'plugin1' }, { id: 'plugin2' }] as che.Plugin[]; + const action = pluginsReceiveAction(plugins); + const expectedState: State = { + ...initialState, + isLoading: false, + plugins, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle pluginsErrorAction', () => { + const error = 'Error message'; + const action = pluginsErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/selectors.spec.ts new file mode 100644 index 000000000..bb9a9cf0d --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/__tests__/selectors.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { + EXCLUDED_TARGET_EDITOR_NAMES, + selectEditors, + selectPlugins, + selectPluginsError, + selectPluginsState, +} from '@/store/Plugins/chePlugins/selectors'; + +describe('Plugins Selectors', () => { + const mockState = { + plugins: { + plugins: [ + { id: 'plugin1', type: 'Che Plugin', name: 'Plugin 1' }, + { id: 'plugin2', type: 'Che Editor', name: 'Editor 1' }, + { id: 'plugin3', type: 'Che Editor', name: 'Editor 2' }, + ], + error: 'Something went wrong', + }, + } as RootState; + + it('should select the plugins state', () => { + const result = selectPluginsState(mockState); + expect(result).toEqual(mockState.plugins); + }); + + it('should select plugins excluding Che Editor', () => { + const result = selectPlugins(mockState); + expect(result).toEqual([{ id: 'plugin1', type: 'Che Plugin', name: 'Plugin 1' }]); + }); + + it('should select editors excluding those in EXCLUDED_TARGET_EDITOR_NAMES', () => { + EXCLUDED_TARGET_EDITOR_NAMES.push('Editor 2'); + const result = selectEditors(mockState); + expect(result).toEqual([{ id: 'plugin2', type: 'Che Editor', name: 'Editor 1' }]); + }); + + it('should select plugins error', () => { + const result = selectPluginsError(mockState); + expect(result).toEqual('Something went wrong'); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/actions.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/actions.ts new file mode 100644 index 000000000..d692c1f30 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/actions.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { che } from '@/services/models'; +import { AppThunk } from '@/store'; +import { convertToEditorPlugin } from '@/store/Plugins/chePlugins/helpers'; +import { devWorkspacePluginsActionCreators } from '@/store/Plugins/devWorkspacePlugins'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const pluginsRequestAction = createAction('plugins/request'); +export const pluginsReceiveAction = createAction('plugins/receive'); +export const pluginsErrorAction = createAction('plugins/error'); + +export const actionCreators = { + requestPlugins: + (): AppThunk => + async (dispatch, getState): Promise => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(pluginsRequestAction()); + + // request editors from config map + await dispatch(devWorkspacePluginsActionCreators.requestEditors()); + + const state = getState(); + const editors = state.dwPlugins.cmEditors || []; + const editorsPlugins = editors.map(editor => convertToEditorPlugin(editor)); + + dispatch(pluginsReceiveAction(editorsPlugins)); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(pluginsErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts index 55f9bfe32..4ef3438fb 100644 --- a/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,118 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { che } from '@/services/models'; -import { AppThunk } from '@/store'; -import { createObject } from '@/store/helpers'; -import { convertToEditorPlugin } from '@/store/Plugins/chePlugins/helpers'; -import * as devWorkspacePlugins from '@/store/Plugins/devWorkspacePlugins'; -import { SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -export const EXCLUDED_TARGET_EDITOR_NAMES = ['']; - -export interface State { - isLoading: boolean; - plugins: che.Plugin[]; - error?: string; -} - -interface RequestPluginsAction extends Action, SanityCheckAction { - type: 'REQUEST_PLUGINS'; -} - -interface ReceivePluginsAction { - type: 'RECEIVE_PLUGINS'; - plugins: che.Plugin[]; -} - -interface ReceivePluginsErrorAction { - type: 'RECEIVE_PLUGINS_ERROR'; - error: string; -} - -type KnownAction = RequestPluginsAction | ReceivePluginsAction | ReceivePluginsErrorAction; - -export type ActionCreators = { - requestPlugins: () => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestPlugins: - (): AppThunk> => - async (dispatch, getState): Promise => { - try { - // request editors from config map - await dispatch(devWorkspacePlugins.actionCreators.requestEditors()); - - const state = getState(); - const editors = state.dwPlugins.cmEditors || []; - const editorsPlugins = editors.map(editor => convertToEditorPlugin(editor)); - dispatch({ - type: 'RECEIVE_PLUGINS', - plugins: editorsPlugins, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: 'RECEIVE_PLUGINS_ERROR', - error: errorMessage, - }); - throw errorMessage; - } - }, -}; - -const unloadedState: State = { - isLoading: false, - plugins: [], -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case 'REQUEST_PLUGINS': - return createObject(state, { - isLoading: true, - error: undefined, - }); - case 'RECEIVE_PLUGINS': - return createObject(state, { - isLoading: false, - plugins: action.plugins, - }); - case 'RECEIVE_PLUGINS_ERROR': - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; - -/** - * Because the `baseUrl` ends with '/v3/' and the `link` starts with '/v3/', - * the `resolveLink` function will remove the duplicate '/v3/' from the `resolved` URL. - */ -export function resolveLink(baseUrl: string, link: string): string { - const resolved = baseUrl + link; - - const regexSingle = /(\/v\d)/i; - const regexDuplicate = new RegExp(regexSingle.source + '\\1', regexSingle.flags); - - if (regexDuplicate.test(resolved)) { - return resolved.replace(regexSingle, ''); - } - - return resolved; -} +export { actionCreators as chePluginsActionCreators } from '@/store/Plugins/chePlugins/actions'; +export { + reducer as chePluginsReducer, + State as ChePluginsState, +} from '@/store/Plugins/chePlugins/reducer'; +export * from '@/store/Plugins/chePlugins/selectors'; diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/reducer.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/reducer.ts new file mode 100644 index 000000000..978c8cabd --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/reducer.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { che } from '@/services/models'; +import { + pluginsErrorAction, + pluginsReceiveAction, + pluginsRequestAction, +} from '@/store/Plugins/chePlugins/actions'; + +export interface State { + isLoading: boolean; + plugins: che.Plugin[]; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + plugins: [], +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(pluginsRequestAction, state => { + state.isLoading = true; + }) + .addCase(pluginsReceiveAction, (state, action) => { + state.isLoading = false; + state.plugins = action.payload; + }) + .addCase(pluginsErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts index 8900baf8b..29f1c59bc 100644 --- a/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts @@ -10,14 +10,14 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '@/store'; -import { EXCLUDED_TARGET_EDITOR_NAMES } from '@/store/Plugins/chePlugins'; +import { RootState } from '@/store'; +export const EXCLUDED_TARGET_EDITOR_NAMES = ['']; const CHE_EDITOR = 'Che Editor'; -const selectState = (state: AppState) => state.plugins; +const selectState = (state: RootState) => state.plugins; export const selectPluginsState = selectState; export const selectPlugins = createSelector(selectState, state => diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/actions.spec.ts new file mode 100644 index 000000000..a810989c4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/actions.spec.ts @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { load } from 'js-yaml'; + +import { fetchEditors } from '@/services/backend-client/editorsApi'; +import devfileApi from '@/services/devfileApi'; +import { fetchDevfile } from '@/services/registry/devfiles'; +import { fetchData } from '@/services/registry/fetchData'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + dwDefaultEditorErrorAction, + dwDefaultEditorReceiveAction, + dwDefaultEditorRequestAction, + dwDefaultPluginsReceiveAction, + dwDefaultPluginsRequestAction, + dwEditorErrorAction, + dwEditorReceiveAction, + dwEditorRequestAction, + dwEditorsErrorAction, + dwEditorsReceiveAction, + dwEditorsRequestAction, + dwPluginErrorAction, + dwPluginReceiveAction, + dwPluginRequestAction, +} from '@/store/Plugins/devWorkspacePlugins/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { ServerConfigState } from '@/store/ServerConfig'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/editorsApi'); +jest.mock('@/services/devfileApi'); +jest.mock('@/services/registry/devfiles'); +jest.mock('@/services/registry/fetchData'); +jest.mock('@/store/SanityCheck'); +jest.mock('js-yaml'); + +describe('Plugins Actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestDwDevfile', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockUrl = 'https://example.com/devfile.yaml'; + const mockDevfileContent = 'mock devfile content'; + const mockDevfile = { metadata: { name: 'mock-devfile' } } as devfileApi.Devfile; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchDevfile as jest.Mock).mockResolvedValue(mockDevfileContent); + (load as jest.Mock).mockReturnValue(mockDevfile); + + await store.dispatch(actionCreators.requestDwDevfile(mockUrl)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(dwPluginRequestAction(mockUrl)); + expect(actions[1]).toEqual(dwPluginReceiveAction({ url: mockUrl, plugin: mockDevfile })); + }); + + it('should dispatch error action on failed fetch', async () => { + const mockUrl = 'https://example.com/devfile.yaml'; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchDevfile as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestDwDevfile(mockUrl))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(dwPluginRequestAction(mockUrl)); + expect(actions[1]).toEqual(dwPluginErrorAction({ url: mockUrl, error: errorMessage })); + }); + }); + + describe('requestEditors', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockEditors = [ + { metadata: { name: 'editor1', attributes: { publisher: 'publisher1', version: '1.0' } } }, + { metadata: { name: 'editor2', attributes: { publisher: 'publisher2' } } }, + ] as devfileApi.Devfile[]; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchEditors as jest.Mock).mockResolvedValue(mockEditors); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await store.dispatch(actionCreators.requestEditors()); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(dwEditorsRequestAction()); + expect(actions[1]).toEqual(dwEditorsReceiveAction([mockEditors[0]])); + expect(console.warn).toHaveBeenCalledWith( + `Missing metadata attributes in the editor yaml file: ${mockEditors[1].metadata.name}. metadata.name, metadata.attributes.publisher and metadata.attributes.version should be set. Skipping this editor.`, + ); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchEditors as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestEditors())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(dwEditorsRequestAction()); + expect(actions[1]).toEqual(dwEditorsErrorAction(errorMessage)); + }); + }); + + describe('requestDwEditor', () => { + it('should dispatch receive action on successful fetch by URL', async () => { + const mockEditorName = 'https://example.com/editor.yaml'; + const mockEditorContent = 'mock editor content'; + const mockEditor = { metadata: { name: 'mock-editor' } } as devfileApi.Devfile; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchData as jest.Mock).mockResolvedValue(mockEditorContent); + (load as jest.Mock).mockReturnValue(mockEditor); + + await store.dispatch(actionCreators.requestDwEditor(mockEditorName)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual( + dwEditorRequestAction({ editorName: mockEditorName, url: mockEditorName }), + ); + expect(actions[1]).toEqual( + dwEditorReceiveAction({ + editorName: mockEditorName, + url: mockEditorName, + plugin: mockEditor, + }), + ); + }); + + it('should dispatch error action on failed fetch by URL', async () => { + const mockEditorName = 'https://example.com/editor.yaml'; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchData as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestDwEditor(mockEditorName))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual( + dwEditorRequestAction({ editorName: mockEditorName, url: mockEditorName }), + ); + expect(actions[1]).toEqual( + dwEditorErrorAction({ + editorName: mockEditorName, + url: mockEditorName, + error: `Failed to load the editor ${mockEditorName}. Invalid devfile. Check 'che-editor' param.`, + }), + ); + }); + + it('should dispatch receive action on successful fetch by editor name', async () => { + const mockEditorName = 'publisher1/editor1/1.0'; + const mockEditor = { + metadata: { name: 'editor1', attributes: { publisher: 'publisher1', version: '1.0' } }, + } as devfileApi.Devfile; + + const storeWithDwPlugins = createMockStore({ + dwPlugins: { + cmEditors: [mockEditor], + plugins: {}, + defaultPlugins: {}, + editors: {}, + isLoading: false, + }, + }); + + await storeWithDwPlugins.dispatch(actionCreators.requestDwEditor(mockEditorName)); + + const actions = storeWithDwPlugins.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + dwEditorReceiveAction({ editorName: mockEditorName, url: '', plugin: mockEditor }), + ); + }); + + it('should dispatch error action on failed fetch by editor name', async () => { + const mockEditorName = 'publisher1/editor1/1.0'; + + const storeWithDwPlugins = createMockStore({ + dwPlugins: { + cmEditors: [], + plugins: {}, + defaultPlugins: {}, + editors: {}, + isLoading: false, + }, + }); + + await expect( + storeWithDwPlugins.dispatch(actionCreators.requestDwEditor(mockEditorName)), + ).rejects.toThrow( + `Failed to load editor ${mockEditorName}. The editor does not exist in the editors configuration map.`, + ); + + const actions = storeWithDwPlugins.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + dwEditorErrorAction({ + editorName: mockEditorName, + url: '', + error: `Failed to load editor ${mockEditorName}. The editor does not exist in the editors configuration map.`, + }), + ); + }); + }); + + describe('requestDwDefaultEditor', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockDefaultEditor = 'publisher1/editor1/1.0'; + const mockEditor = { + metadata: { name: 'editor1', attributes: { publisher: 'publisher1', version: '1.0' } }, + } as devfileApi.Devfile; + + const storeWithDwPlugins = createMockStore({ + dwServerConfig: { + config: { + defaults: { + editor: mockDefaultEditor, + }, + }, + }, + dwPlugins: { + cmEditors: [mockEditor], + }, + } as unknown as RootState); + + await storeWithDwPlugins.dispatch(actionCreators.requestDwDefaultEditor()); + + const actions = storeWithDwPlugins.getActions(); + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual(dwDefaultEditorRequestAction()); + expect(actions[1]).toEqual( + dwEditorReceiveAction({ editorName: mockDefaultEditor, url: '', plugin: mockEditor }), + ); + expect(actions[2]).toEqual( + dwDefaultEditorReceiveAction({ + url: '', + defaultEditorName: mockDefaultEditor, + }), + ); + }); + + it('should dispatch error action if default editor is not provided', async () => { + const storeWithServerConfig = createMockStore({ + dwServerConfig: { + config: { + defaults: {}, + }, + } as ServerConfigState, + }); + + await expect( + storeWithServerConfig.dispatch(actionCreators.requestDwDefaultEditor()), + ).rejects.toThrow( + 'Failed to load the default editor, reason: default editor ID is not provided by Che server.', + ); + + const actions = storeWithServerConfig.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(dwDefaultEditorRequestAction()); + expect(actions[1]).toEqual( + dwDefaultEditorErrorAction( + 'Failed to load the default editor, reason: default editor ID is not provided by Che server.', + ), + ); + }); + }); + + describe('requestDwDefaultPlugins', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockDefaultPlugins = { + editor1: ['plugin1', 'plugin2'], + editor2: ['plugin3'], + }; + + const storeWithServerConfig = createMockStore({ + dwServerConfig: { + config: { + defaults: { + plugins: [ + { editor: 'editor1', plugins: ['plugin1', 'plugin2'] }, + { editor: 'editor2', plugins: ['plugin3'] }, + ], + }, + }, + }, + } as unknown as RootState); + + await storeWithServerConfig.dispatch(actionCreators.requestDwDefaultPlugins()); + + const actions = storeWithServerConfig.getActions(); + expect(actions[0]).toEqual(dwDefaultPluginsRequestAction()); + expect(actions[1]).toEqual(dwDefaultPluginsReceiveAction(mockDefaultPlugins)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/index.spec.ts deleted file mode 100644 index da2cfe65c..000000000 --- a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/index.spec.ts +++ /dev/null @@ -1,1045 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; -import mockAxios, { AxiosError } from 'axios'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import devfileApi from '@/services/devfileApi'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as dwPluginsStore from '..'; - -// mute the outputs -console.error = jest.fn(); - -const mockFetchDevfile = jest.fn(); -jest.mock('@/services/registry/devfiles', () => ({ - fetchDevfile: (...args: unknown[]) => mockFetchDevfile(...args), -})); - -const mockFetchData = jest.fn(); -jest.mock('@/services/registry/fetchData', () => ({ - fetchData: (...args: unknown[]) => mockFetchData(...args), -})); - -const mockFetchEditors = jest.fn(); -jest.mock('@/services/backend-client/editorsApi', () => ({ - fetchEditors: (...args: unknown[]) => mockFetchEditors(...args), -})); - -const plugin = { - schemaVersion: '2.1.0', - metadata: { - name: 'void-sample', - }, -} as devfileApi.Devfile; - -const editors = [ - { - metadata: { - name: 'default-editor', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'insiders', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, -]; - -describe('dwPlugins store', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('actions', () => { - it('should create REQUEST_EDITORS and RECEIVE_EDITORS when fetching editors from the ConfigMap', async () => { - mockFetchEditors.mockResolvedValueOnce(editors); - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'che-incubator/default-editor/latest', - }, - } as api.IServerConfig) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwPluginsStore.actionCreators.requestEditors()); - - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_EDITORS', - }, - { - type: 'RECEIVE_EDITORS', - editors: editors, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should filter editors from ConfigMap and exclude one without metadata.name', async () => { - const editors = [ - { - metadata: { - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'insiders', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - mockFetchEditors.mockResolvedValueOnce(editors); - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'che-incubator/default-editor/latest', - }, - } as api.IServerConfig) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwPluginsStore.actionCreators.requestEditors()); - - const actions = store.getActions(); - - const expectedEditors = [ - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'insiders', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_EDITORS', - }, - { - type: 'RECEIVE_EDITORS', - editors: expectedEditors, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should filter editors from ConfigMap and exclude one without metadata.attributes.publisher', async () => { - const editors = [ - { - metadata: { - name: 'che-idea', - attributes: { - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'insiders', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - mockFetchEditors.mockResolvedValueOnce(editors); - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'che-incubator/default-editor/latest', - }, - } as api.IServerConfig) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwPluginsStore.actionCreators.requestEditors()); - - const actions = store.getActions(); - - const expectedEditors = [ - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'insiders', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_EDITORS', - }, - { - type: 'RECEIVE_EDITORS', - editors: expectedEditors, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should filter editors from ConfigMap and exclude one without metadata.attributes.version', async () => { - const editors = [ - { - metadata: { - name: 'che-idea', - attributes: { - publisher: 'che-incubator', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'insiders', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - mockFetchEditors.mockResolvedValueOnce(editors); - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'che-incubator/default-editor/latest', - }, - } as api.IServerConfig) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwPluginsStore.actionCreators.requestEditors()); - - const actions = store.getActions(); - - const expectedEditors = [ - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'insiders', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_EDITORS', - }, - { - type: 'RECEIVE_EDITORS', - editors: expectedEditors, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_EDITORS and RECEIVE_EDITORS_ERROR when failed to fetch editors', async () => { - mockFetchEditors.mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(dwPluginsStore.actionCreators.requestEditors()); - } catch (e) { - // noop - } - - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_EDITORS', - }, - { - type: 'RECEIVE_EDITORS_ERROR', - error: expect.stringContaining('Something unexpected happened.'), - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_DW_PLUGIN and RECEIVE_DW_PLUGIN when fetching a plugin', async () => { - mockFetchDevfile.mockResolvedValueOnce(JSON.stringify(plugin)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - const url = 'devworkspace-devfile-location'; - await store.dispatch(dwPluginsStore.actionCreators.requestDwDevfile(url)); - - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_PLUGIN', - url, - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_PLUGIN', - plugin, - url, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_DW_PLUGIN and RECEIVE_DW_PLUGIN_ERROR when failed to fetch a plugin', async () => { - mockFetchDevfile.mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - const url = 'devworkspace-devfile-location'; - try { - await store.dispatch(dwPluginsStore.actionCreators.requestDwDevfile(url)); - } catch (e) { - // noop - } - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_PLUGIN', - url, - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_PLUGIN_ERROR', - error: expect.stringContaining('Something unexpected happened.'), - url, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_DW_EDITOR and RECEIVE_DW_EDITOR when fetching the default editor', async () => { - mockFetchData.mockResolvedValueOnce(JSON.stringify(plugin)); - - const cmEditors = [ - { - metadata: { - name: 'default-editor', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'che-incubator/default-editor/latest', - }, - pluginRegistryURL: 'plugin-registry-location', - } as api.IServerConfig) - .withDwPlugins({}, {}, false, cmEditors) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwPluginsStore.actionCreators.requestDwDefaultEditor()); - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_DEFAULT_EDITOR', - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_EDITOR', - editorName: 'che-incubator/default-editor/latest', - url: '', - plugin: { - metadata: expect.objectContaining({ name: 'default-editor' }), - schemaVersion: '2.2.2', - }, - }, - { - type: 'RECEIVE_DW_DEFAULT_EDITOR', - defaultEditorName: 'che-incubator/default-editor/latest', - url: '', - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_DW_EDITOR and RECEIVE_DW_EDITOR when fetching http editor', async () => { - mockFetchData.mockResolvedValueOnce(JSON.stringify(plugin)); - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'default-editor', - }, - } as api.IServerConfig) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - // no plugin url as it should fetch from the editor directly - const editorLink = 'https://my-fake-editor.yaml'; - await store.dispatch(dwPluginsStore.actionCreators.requestDwEditor(editorLink)); - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_EDITOR', - url: 'https://my-fake-editor.yaml', - editorName: editorLink, - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_EDITOR', - url: 'https://my-fake-editor.yaml', - editorName: editorLink, - plugin, - }, - ]; - expect(actions).toEqual(expectedActions); - - expect(mockFetchData).toHaveBeenCalledWith(editorLink); - }); - - it('should create REQUEST_DW_EDITOR and RECEIVE_DW_EDITOR_ERROR when failed to fetch an editor', async () => { - mockFetchData.mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - // no plugin url as it should fetch from the editor directly - const editorLink = 'https://my-fake-editor.yaml'; - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'default-editor', - }, - } as api.IServerConfig) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwPluginsStore.actionCreators.requestDwEditor(editorLink)).catch(() => { - // noop - }); - - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_EDITOR', - url: 'https://my-fake-editor.yaml', - editorName: editorLink, - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_EDITOR_ERROR', - url: 'https://my-fake-editor.yaml', - error: expect.stringContaining( - 'Failed to load the editor https://my-fake-editor.yaml. Invalid devfile.', - ), - editorName: editorLink, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create only RECEIVE_DW_DEFAULT_EDITOR_ERROR if workspace settings do not have necessary fields', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: {}, - }); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(dwPluginsStore.actionCreators.requestDwDefaultEditor()); - } catch (e) { - // noop - } - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_DEFAULT_EDITOR', - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_DEFAULT_EDITOR_ERROR', - error: - 'Failed to load the default editor, reason: default editor ID is not provided by Che server.', - }, - ]; - - expect(actions).toEqual(expectedActions); - expect(mockAxios.get).not.toHaveBeenCalled(); - }); - - it('should create REQUEST_DW_EDITOR and RECEIVE_DW_EDITOR_ERROR when it does not exist in the config map', async () => { - (mockAxios.get as jest.Mock).mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'unexpected error', - } as AxiosError); - - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - editor: 'default-editor', - }, - } as api.IServerConfig) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(dwPluginsStore.actionCreators.requestDwDefaultEditor()); - } catch (e) { - // noop - } - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_DEFAULT_EDITOR', - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_EDITOR_ERROR', - url: '', - editorName: 'default-editor', - error: expect.stringContaining( - 'The editor does not exist in the editors configuration map', - ), - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_DW_DEFAULT_EDITOR and RECEIVE_DW_DEFAULT_EDITOR when fetching default plugins', async () => { - const store = new FakeStoreBuilder() - .withDwServerConfig({ - containerBuild: {}, - defaults: { - editor: 'eclipse/theia/next', - components: [ - { - name: 'universal-developer-image', - container: { - image: 'quay.io/devfile/universal-developer-image:ubi8-latest', - }, - }, - ], - plugins: [ - { - editor: 'eclipse/theia/next', - plugins: ['https://test.com/devfile.yaml'], - }, - ], - pvcStrategy: 'per-workspace', - }, - pluginRegistry: { - openVSXURL: '', - }, - timeouts: { - inactivityTimeout: -1, - runTimeout: -1, - startTimeout: 300, - }, - cheNamespace: 'eclipse-che', - devfileRegistry: { - disableInternalRegistry: false, - externalDevfileRegistries: [], - }, - pluginRegistryURL: '', - pluginRegistryInternalURL: '', - }) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwPluginsStore.actionCreators.requestDwDefaultPlugins()); - - const actions = store.getActions(); - - const expectedActions: dwPluginsStore.KnownAction[] = [ - { - type: 'REQUEST_DW_DEFAULT_PLUGINS', - check: AUTHORIZED, - }, - { - type: 'RECEIVE_DW_DEFAULT_PLUGINS', - defaultPlugins: { - 'eclipse/theia/next': ['https://test.com/devfile.yaml'], - }, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - }); - - describe('reducers', () => { - it('should return initial state', () => { - const incomingAction: dwPluginsStore.RequestDwPluginAction = { - type: 'REQUEST_DW_PLUGIN', - url: 'devfile-location', - check: AUTHORIZED, - }; - const initialState = dwPluginsStore.reducer(undefined, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - plugins: {}, - editors: {}, - cmEditors: [], - defaultEditorName: undefined, - defaultPlugins: {}, - }; - - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action type is not matched', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - } as dwPluginsStore.State; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: true, - plugins: {}, - editors: {}, - defaultPlugins: {}, - }; - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_DW_PLUGIN', () => { - const initialState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: { - 'devfile-location': { - error: 'unexpected error', - url: 'devfile-location', - }, - }, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.RequestDwPluginAction = { - type: 'REQUEST_DW_PLUGIN', - url: 'devfile-location', - check: AUTHORIZED, - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: { - 'devfile-location': { - url: 'devfile-location', - }, - }, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_DW_EDITOR', () => { - const initialState: dwPluginsStore.State = { - isLoading: false, - editors: { - foo: { - url: 'editor-location', - error: 'unexpected error', - }, - }, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.RequestDwEditorAction = { - type: 'REQUEST_DW_EDITOR', - editorName: 'foo', - url: 'editor-location', - check: AUTHORIZED, - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: true, - editors: { - foo: { - plugin: undefined, - url: 'editor-location', - }, - }, - plugins: {}, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_DW_DEFAULT_EDITOR', () => { - const initialState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: {}, - defaultPlugins: {}, - defaultEditorError: 'unexpected error', - }; - const incomingAction: dwPluginsStore.RequestDwDefaultEditorAction = { - type: 'REQUEST_DW_DEFAULT_EDITOR', - check: AUTHORIZED, - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DW_PLUGIN', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.ReceiveDwPluginAction = { - type: 'RECEIVE_DW_PLUGIN', - url: 'devfile-location', - plugin, - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: { - 'devfile-location': { - url: 'devfile-location', - plugin, - }, - }, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DW_EDITOR', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.ReceiveDwEditorAction = { - type: 'RECEIVE_DW_EDITOR', - url: 'devfile-location', - editorName: 'my-editor', - plugin, - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - editors: { - 'my-editor': { - url: 'devfile-location', - plugin, - }, - }, - plugins: {}, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DW_PLUGIN_ERROR', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.ReceiveDwPluginErrorAction = { - type: 'RECEIVE_DW_PLUGIN_ERROR', - url: 'devfile-location', - error: 'unexpected error', - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: { - 'devfile-location': { - url: 'devfile-location', - error: 'unexpected error', - }, - }, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DW_EDITOR_ERROR', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.RequestDwEditorErrorAction = { - type: 'RECEIVE_DW_EDITOR_ERROR', - url: 'editor-location', - editorName: 'foo', - error: 'unexpected error', - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - editors: { - foo: { - error: 'unexpected error', - url: 'editor-location', - }, - }, - plugins: {}, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DW_DEFAULT_EDITOR_ERROR', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.ReceiveDwDefaultEditorErrorAction = { - type: 'RECEIVE_DW_DEFAULT_EDITOR_ERROR', - error: 'unexpected error', - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: {}, - defaultPlugins: {}, - defaultEditorError: 'unexpected error', - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DW_DEFAULT_EDITOR', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.ReceiveDwDefaultEditorAction = { - type: 'RECEIVE_DW_DEFAULT_EDITOR', - url: 'default-editor-location', - defaultEditorName: 'hello', - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: {}, - defaultPlugins: {}, - defaultEditorName: 'hello', - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_DW_DEFAULT_PLUGINS', () => { - const initialState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - const incomingAction: dwPluginsStore.RequestDwDefaultPluginsAction = { - type: 'REQUEST_DW_DEFAULT_PLUGINS', - check: AUTHORIZED, - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_DW_DEFAULT_PLUGINS', () => { - const initialState: dwPluginsStore.State = { - isLoading: true, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }; - - const defaultPlugins = { 'eclipse/theia/next': ['https://test.com/devfile.yaml'] }; - - const incomingAction: dwPluginsStore.ReceiveDwDefaultPluginsAction = { - type: 'RECEIVE_DW_DEFAULT_PLUGINS', - defaultPlugins, - }; - - const newState = dwPluginsStore.reducer(initialState, incomingAction); - - const expectedState: dwPluginsStore.State = { - isLoading: false, - editors: {}, - plugins: {}, - defaultPlugins, - }; - expect(newState).toEqual(expectedState); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/reducer.spec.ts new file mode 100644 index 000000000..b1f3c08d7 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/reducer.spec.ts @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import devfileApi from '@/services/devfileApi'; +import { + dwDefaultEditorErrorAction, + dwDefaultEditorReceiveAction, + dwDefaultEditorRequestAction, + dwDefaultPluginsReceiveAction, + dwDefaultPluginsRequestAction, + dwEditorErrorAction, + dwEditorReceiveAction, + dwEditorRequestAction, + dwEditorsErrorAction, + dwEditorsReceiveAction, + dwEditorsRequestAction, + dwPluginErrorAction, + dwPluginReceiveAction, + dwPluginRequestAction, + WorkspacesDefaultPlugins, +} from '@/store/Plugins/devWorkspacePlugins/actions'; +import { reducer, State, unloadedState } from '@/store/Plugins/devWorkspacePlugins/reducer'; + +describe('Plugins reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle dwEditorsRequestAction', () => { + const action = dwEditorsRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwEditorsReceiveAction', () => { + const editors = [{ metadata: { name: 'editor1' } }] as devfileApi.Devfile[]; + const action = dwEditorsReceiveAction(editors); + const expectedState: State = { + ...initialState, + isLoading: false, + cmEditors: editors, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwEditorsErrorAction', () => { + const action = dwEditorsErrorAction('Error message'); + const expectedState: State = { + ...initialState, + isLoading: false, + cmEditors: [], + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwPluginRequestAction', () => { + const pluginUrl = 'https://example.com/plugin.yaml'; + const action = dwPluginRequestAction(pluginUrl); + const state: State = { + ...initialState, + plugins: { + [pluginUrl]: { + error: 'Error message', + url: pluginUrl, + }, + }, + }; + const expectedState: State = { + ...initialState, + isLoading: true, + plugins: { + [pluginUrl]: { + url: pluginUrl, + }, + }, + }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + + it('should handle dwPluginReceiveAction', () => { + const plugin = { metadata: { name: 'plugin1' } } as devfileApi.Devfile; + const action = dwPluginReceiveAction({ url: 'https://example.com/plugin.yaml', plugin }); + const expectedState: State = { + ...initialState, + isLoading: false, + plugins: { + 'https://example.com/plugin.yaml': { + plugin, + url: 'https://example.com/plugin.yaml', + }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwPluginErrorAction', () => { + const action = dwPluginErrorAction({ + url: 'https://example.com/plugin.yaml', + error: 'Error message', + }); + const expectedState: State = { + ...initialState, + isLoading: false, + plugins: { + 'https://example.com/plugin.yaml': { + error: 'Error message', + url: 'https://example.com/plugin.yaml', + }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwEditorRequestAction', () => { + const action = dwEditorRequestAction({ + editorName: 'editor1', + url: 'https://example.com/editor.yaml', + }); + const expectedState: State = { + ...initialState, + isLoading: true, + editors: { + editor1: { + url: 'https://example.com/editor.yaml', + }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwEditorReceiveAction', () => { + const editor = { metadata: { name: 'editor1' } } as devfileApi.Devfile; + const action = dwEditorReceiveAction({ + editorName: 'editor1', + url: 'https://example.com/editor.yaml', + plugin: editor, + }); + const expectedState: State = { + ...initialState, + isLoading: false, + editors: { + editor1: { + plugin: editor, + url: 'https://example.com/editor.yaml', + }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwEditorErrorAction', () => { + const action = dwEditorErrorAction({ + editorName: 'editor1', + url: 'https://example.com/editor.yaml', + error: 'Error message', + }); + const expectedState: State = { + ...initialState, + isLoading: false, + editors: { + editor1: { + error: 'Error message', + url: 'https://example.com/editor.yaml', + }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwDefaultEditorRequestAction', () => { + const action = dwDefaultEditorRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + defaultEditorName: undefined, + defaultEditorError: undefined, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwDefaultEditorReceiveAction', () => { + const action = dwDefaultEditorReceiveAction({ + defaultEditorName: 'editor1', + url: 'https://example.com/editor.yaml', + }); + const expectedState: State = { + ...initialState, + isLoading: false, + defaultEditorName: 'editor1', + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwDefaultEditorErrorAction', () => { + const action = dwDefaultEditorErrorAction('Error message'); + const expectedState: State = { + ...initialState, + isLoading: false, + defaultEditorError: 'Error message', + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwDefaultPluginsRequestAction', () => { + const action = dwDefaultPluginsRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle dwDefaultPluginsReceiveAction', () => { + const defaultPlugins = { editor1: ['plugin1', 'plugin2'] } as WorkspacesDefaultPlugins; + const action = dwDefaultPluginsReceiveAction(defaultPlugins); + const expectedState: State = { + ...initialState, + isLoading: false, + defaultPlugins, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/selectors.spec.ts index 1186f1f83..f56465e49 100644 --- a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/__tests__/selectors.spec.ts @@ -10,99 +10,77 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - import devfileApi from '@/services/devfileApi'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; import { + selectDefaultEditor, selectDwDefaultEditorError, + selectDwEditorsPluginsList, selectDwPlugins, selectDwPluginsList, } from '@/store/Plugins/devWorkspacePlugins/selectors'; -import * as store from '..'; - -const url = 'devworkspace-devfile-location'; - -describe('dwPlugins selectors', () => { - const plugins = { - 'plugin-location-1': { - url, - plugin: { - schemaVersion: '2.1.0', - metadata: { - name: 'void-sample-1', +describe('Plugins Selectors', () => { + const mockState = { + dwPlugins: { + plugins: { + 'https://example.com/plugin1.yaml': { + plugin: { metadata: { name: 'plugin1' } } as devfileApi.Devfile, + url: 'https://example.com/plugin1.yaml', }, - } as devfileApi.Devfile, - }, - 'plugin-location-2': { - url, - plugin: { - schemaVersion: '2.1.0', - metadata: { - name: 'void-sample-2', + 'https://example.com/plugin2.yaml': { + plugin: { metadata: { name: 'plugin2' } } as devfileApi.Devfile, + url: 'https://example.com/plugin2.yaml', }, - } as devfileApi.Devfile, - }, - 'plugin-location-3': { - url, - error: 'unexpected error', + }, + editors: { + editor1: { + plugin: { metadata: { name: 'editor1' } } as devfileApi.Devfile, + url: 'https://example.com/editor1.yaml', + }, + editor2: { + plugin: { metadata: { name: 'editor2' } } as devfileApi.Devfile, + url: 'https://example.com/editor2.yaml', + }, + }, + defaultEditorName: 'editor1', + defaultEditorError: 'Error message', + defaultPlugins: {}, + isLoading: false, }, - }; + } as Partial as RootState; - it('should return all plugins and errors', () => { - const fakeStore = new FakeStoreBuilder() - .withDwPlugins(plugins, {}, false, [], 'default editor fetching error') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select dwPlugins', () => { + const result = selectDwPlugins(mockState); + expect(result).toEqual(mockState.dwPlugins.plugins); + }); - const expectedPlugins = plugins; - const selectedPlugins = selectDwPlugins(state); - expect(selectedPlugins).toEqual(expectedPlugins); + it('should select dwPluginsList', () => { + const result = selectDwPluginsList(mockState); + expect(result).toEqual([{ metadata: { name: 'plugin1' } }, { metadata: { name: 'plugin2' } }]); }); - it('should return array of plugins', () => { - const fakeStore = new FakeStoreBuilder() - .withDwPlugins(plugins, {}, false, [], 'default editor fetching error') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select dwEditorsPluginsList for a specific editor', () => { + const selectEditorPlugins = selectDwEditorsPluginsList('editor1'); + const result = selectEditorPlugins(mockState); + expect(result).toEqual([ + { devfile: { metadata: { name: 'editor1' } }, url: 'https://example.com/editor1.yaml' }, + ]); + }); - const expectedPlugins = [ - { - schemaVersion: '2.1.0', - metadata: { - name: 'void-sample-1', - }, - } as devfileApi.Devfile, - { - schemaVersion: '2.1.0', - metadata: { - name: 'void-sample-2', - }, - } as devfileApi.Devfile, - ]; - const selectedPlugins = selectDwPluginsList(state); - expect(selectedPlugins).toEqual(expectedPlugins); + it('should select dwEditorsPluginsList for an editor that does not exist', () => { + const selectEditorPlugins = selectDwEditorsPluginsList('nonexistent-editor'); + const result = selectEditorPlugins(mockState); + expect(result).toEqual([]); }); - it('should return an error related to default editor fetching', () => { - const fakeStore = new FakeStoreBuilder() - .withDwPlugins(plugins, {}, false, [], 'default editor fetching error') - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select the default editor', () => { + const result = selectDefaultEditor(mockState); + expect(result).toEqual('editor1'); + }); - const selectedError = selectDwDefaultEditorError(state); - expect(selectedError).toEqual('default editor fetching error'); + it('should select the default editor error', () => { + const result = selectDwDefaultEditorError(mockState); + expect(result).toEqual('Error message'); }); }); diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/actions.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/actions.ts new file mode 100644 index 000000000..5faf50626 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/actions.ts @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; +import { load } from 'js-yaml'; + +import { fetchEditors } from '@/services/backend-client/editorsApi'; +import devfileApi from '@/services/devfileApi'; +import { fetchDevfile } from '@/services/registry/devfiles'; +import { fetchData } from '@/services/registry/fetchData'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export interface PluginDefinition { + plugin?: devfileApi.Devfile; + url: string; + error?: string; +} + +export interface WorkspacesDefaultPlugins { + [editorName: string]: string[]; +} + +/** Editors */ + +export const dwEditorsRequestAction = createAction('dwEditors/request'); +export const dwEditorsReceiveAction = createAction('dwEditors/receive'); +export const dwEditorsErrorAction = createAction('dwEditors/error'); + +/** Default Editor */ + +export const dwDefaultEditorRequestAction = createAction('dwDefaultEditor/request'); + +type DwDefaultEditorReceivePayload = { + url: string; + defaultEditorName: string; +}; +export const dwDefaultEditorReceiveAction = + createAction('dwDefaultEditor/receive'); + +export const dwDefaultEditorErrorAction = createAction('dwDefaultEditor/error'); + +/** Plugin */ + +export const dwPluginRequestAction = createAction('dwPlugin/request'); + +type DwPluginReceivePayload = { + url: string; + plugin: devfileApi.Devfile; +}; +export const dwPluginReceiveAction = createAction('dwPlugin/receive'); + +type DwPluginErrorPayload = { + url: string; + error: string; +}; +export const dwPluginErrorAction = createAction('dwPlugin/error'); + +/** Editor */ + +export interface DwEditorReceivePayload { + url: string; + editorName: string; + plugin: devfileApi.Devfile; +} +export const dwEditorReceiveAction = createAction('dwEditor/receive'); + +type DwEditorRequestPayload = { + url: string; + editorName: string; +}; +export const dwEditorRequestAction = createAction('dwEditor/request'); + +type DwEditorErrorPayload = { + editorName: string; + url: string; + error: string; +}; +export const dwEditorErrorAction = createAction('dwEditor/error'); + +/** Default Plugins */ + +export const dwDefaultPluginsRequestAction = createAction('dwDefaultPlugins/request'); + +export const dwDefaultPluginsReceiveAction = createAction( + 'dwDefaultPlugins/receive', +); + +export const actionCreators = { + requestDwDevfile: + (url: string): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(dwPluginRequestAction(url)); + + const pluginContent = await fetchDevfile(url); + const plugin = load(pluginContent) as devfileApi.Devfile; + dispatch(dwPluginReceiveAction({ url, plugin })); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(dwPluginErrorAction({ url, error: errorMessage })); + throw e; + } + }, + + requestEditors: (): AppThunk => async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(dwEditorsRequestAction()); + + const editors = (await fetchEditors()) as devfileApi.Devfile[]; + const filteredEditors: devfileApi.Devfile[] = []; + editors.forEach(editor => { + if ( + !editor.metadata.attributes.publisher || + !editor.metadata.attributes.version || + !editor.metadata.name + ) { + console.warn( + `Missing metadata attributes in the editor yaml file: ${editor.metadata.name}. metadata.name, metadata.attributes.publisher and metadata.attributes.version should be set. Skipping this editor.`, + ); + } else { + filteredEditors.push(editor); + } + }); + + dispatch(dwEditorsReceiveAction(filteredEditors)); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(dwEditorsErrorAction(errorMessage)); + throw e; + } + }, + + requestDwEditor: + (editorName: string): AppThunk => + async (dispatch, getState) => { + let editorUrl: string; + + // check if the editor is an id or URL to a given editor + if (editorName.startsWith('https://')) { + editorUrl = editorName; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(dwEditorRequestAction({ editorName, url: editorUrl })); + + const pluginContent = await fetchData(editorUrl); + const plugin = load(pluginContent) as devfileApi.Devfile; + + dispatch(dwEditorReceiveAction({ editorName, url: editorUrl, plugin })); + } catch (error) { + const errorMessage = `Failed to load the editor ${editorName}. Invalid devfile. Check 'che-editor' param.`; + dispatch(dwEditorErrorAction({ editorName, url: editorUrl, error: errorMessage })); + throw error; + } + } else { + const editors = getState().dwPlugins.cmEditors || []; + const editor = editors.find( + editor => + editor.metadata.attributes.publisher + + '/' + + editor.metadata.name + + '/' + + editor.metadata.attributes.version === + editorName, + ); + if (editor) { + dispatch(dwEditorReceiveAction({ editorName, url: '', plugin: editor })); + } else { + const errorMessage = `Failed to load editor ${editorName}. The editor does not exist in the editors configuration map.`; + dispatch(dwEditorErrorAction({ editorName, url: '', error: errorMessage })); + throw new Error(errorMessage); + } + } + }, + + requestDwDefaultEditor: (): AppThunk => async (dispatch, getState) => { + dispatch(dwDefaultEditorRequestAction()); + + const config = getState().dwServerConfig.config; + const defaultEditor = config.defaults?.editor; + + if (!defaultEditor) { + const errorMessage = + 'Failed to load the default editor, reason: default editor ID is not provided by Che server.'; + dispatch(dwDefaultEditorErrorAction(errorMessage)); + throw new Error(errorMessage); + } + + const defaultEditorUrl = (defaultEditor as string).startsWith('https://') ? defaultEditor : ''; + + // request default editor + await dispatch(actionCreators.requestDwEditor(defaultEditor)); + + dispatch( + dwDefaultEditorReceiveAction({ + url: defaultEditorUrl, + defaultEditorName: defaultEditor, + }), + ); + }, + + requestDwDefaultPlugins: (): AppThunk => async (dispatch, getState) => { + dispatch(dwDefaultPluginsRequestAction()); + + const defaultPlugins = {}; + const defaults = getState().dwServerConfig.config.defaults; + (defaults.plugins || []).forEach(item => { + if (!defaultPlugins[item.editor]) { + defaultPlugins[item.editor] = []; + } + defaultPlugins[item.editor].push(...item.plugins); + }); + + dispatch(dwDefaultPluginsReceiveAction(defaultPlugins)); + }, +}; diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/index.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/index.ts index 69de29f3b..dd19bc6f0 100644 --- a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/index.ts +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,450 +12,13 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { load } from 'js-yaml'; -import { Action, Reducer } from 'redux'; - -import { fetchEditors } from '@/services/backend-client/editorsApi'; -import devfileApi from '@/services/devfileApi'; -import { fetchDevfile } from '@/services/registry/devfiles'; -import { fetchData } from '@/services/registry/fetchData'; -import { AppThunk } from '@/store'; -import { createObject } from '@/store/helpers'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -export interface PluginDefinition { - plugin?: devfileApi.Devfile; - url: string; - error?: string; -} - -export interface WorkspacesDefaultPlugins { - [editorName: string]: string[]; -} - -export interface State { - isLoading: boolean; - plugins: { - [url: string]: PluginDefinition; - }; - editors: { - [editorName: string]: PluginDefinition; - }; - defaultPlugins: WorkspacesDefaultPlugins; - defaultEditorName?: string; - defaultEditorError?: string; - cmEditors?: devfileApi.Devfile[]; -} - -export interface RequestEditorsAction extends Action { - type: 'REQUEST_EDITORS'; -} - -export interface ReceiveEditorsAction extends Action { - type: 'RECEIVE_EDITORS'; - editors: devfileApi.Devfile[]; -} - -export interface ReceiveEditorsErrorAction { - type: 'RECEIVE_EDITORS_ERROR'; - error: string; -} - -export interface RequestDwDefaultEditorAction extends Action, SanityCheckAction { - type: 'REQUEST_DW_DEFAULT_EDITOR'; -} - -export interface RequestDwPluginAction extends Action, SanityCheckAction { - type: 'REQUEST_DW_PLUGIN'; - url: string; -} - -export interface ReceiveDwPluginAction { - type: 'RECEIVE_DW_PLUGIN'; - url: string; - plugin: devfileApi.Devfile; -} - -export interface ReceiveDwPluginErrorAction { - type: 'RECEIVE_DW_PLUGIN_ERROR'; - url: string; - error: string; -} - -export interface ReceiveDwEditorAction { - type: 'RECEIVE_DW_EDITOR'; - url: string; - editorName: string; - plugin: devfileApi.Devfile; -} - -export interface RequestDwEditorAction extends Action, SanityCheckAction { - type: 'REQUEST_DW_EDITOR'; - url: string; - editorName: string; -} - -export interface ReceiveDwDefaultEditorAction { - type: 'RECEIVE_DW_DEFAULT_EDITOR'; - url: string; - defaultEditorName: string; -} - -export interface RequestDwEditorErrorAction { - type: 'RECEIVE_DW_EDITOR_ERROR'; - editorName: string; - url: string; - error: string; -} - -export interface ReceiveDwDefaultEditorErrorAction { - type: 'RECEIVE_DW_DEFAULT_EDITOR_ERROR'; - error: string; -} - -export interface RequestDwDefaultPluginsAction extends Action, SanityCheckAction { - type: 'REQUEST_DW_DEFAULT_PLUGINS'; -} - -export interface ReceiveDwDefaultPluginsAction { - type: 'RECEIVE_DW_DEFAULT_PLUGINS'; - defaultPlugins: WorkspacesDefaultPlugins; -} - -export type KnownAction = - | RequestDwPluginAction - | ReceiveDwPluginAction - | ReceiveDwPluginErrorAction - | RequestDwDefaultEditorAction - | ReceiveDwDefaultEditorAction - | ReceiveDwDefaultEditorErrorAction - | RequestDwEditorAction - | ReceiveDwEditorAction - | RequestEditorsAction - | ReceiveEditorsAction - | ReceiveEditorsErrorAction - | RequestDwEditorErrorAction - | RequestDwDefaultPluginsAction - | ReceiveDwDefaultPluginsAction; - -export type ActionCreators = { - requestEditors: () => AppThunk>; - requestDwDevfile: (url: string) => AppThunk>; - requestDwDefaultEditor: () => AppThunk>; - requestDwDefaultPlugins: () => AppThunk>; - requestDwEditor: (editorName: string) => AppThunk>; -}; -export const actionCreators: ActionCreators = { - requestDwDevfile: - (url: string): AppThunk> => - async (dispatch): Promise => { - dispatch({ - type: 'REQUEST_DW_PLUGIN', - check: AUTHORIZED, - url, - }); - - try { - const pluginContent = await fetchDevfile(url); - const plugin = load(pluginContent) as devfileApi.Devfile; - dispatch({ - type: 'RECEIVE_DW_PLUGIN', - url, - plugin, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: 'RECEIVE_DW_PLUGIN_ERROR', - url, - error: errorMessage, - }); - throw errorMessage; - } - }, - - requestEditors: - (): AppThunk> => - async (dispatch): Promise => { - dispatch({ - type: 'REQUEST_EDITORS', - }); - - try { - const editors = (await fetchEditors()) as devfileApi.Devfile[]; - const filteredEditors: devfileApi.Devfile[] = []; - editors.forEach(editor => { - if ( - !editor.metadata.attributes.publisher || - !editor.metadata.attributes.version || - !editor.metadata.name - ) { - console.error( - `Missing metadata attributes in the editor yaml file: ${editor.metadata.name}. metadata.name, metadata.attributes.publisher and metadata.attributes.version should be set. Skipping this editor.`, - ); - } else { - filteredEditors.push(editor); - } - }); - dispatch({ - type: 'RECEIVE_EDITORS', - editors: filteredEditors, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: 'RECEIVE_EDITORS_ERROR', - error: errorMessage, - }); - throw errorMessage; - } - }, - - requestDwEditor: - (editorName: string): AppThunk> => - async (dispatch, getState): Promise => { - let editorUrl: string; - // check if the editor is an id or URL to a given editor - if (editorName.startsWith('https://')) { - editorUrl = editorName; - try { - dispatch({ - type: 'REQUEST_DW_EDITOR', - check: AUTHORIZED, - url: editorUrl, - editorName, - }); - const pluginContent = await fetchData(editorUrl); - const plugin = load(pluginContent) as devfileApi.Devfile; - dispatch({ - type: 'RECEIVE_DW_EDITOR', - editorName, - url: editorUrl, - plugin, - }); - } catch (error) { - const errorMessage = `Failed to load the editor ${editorName}. Invalid devfile. Check 'che-editor' param.`; - dispatch({ - type: 'RECEIVE_DW_EDITOR_ERROR', - url: editorUrl, - editorName, - error: errorMessage, - }); - throw common.helpers.errors.getMessage(error); - } - } else { - const editors = getState().dwPlugins.cmEditors || []; - const editor = editors.find( - editor => - editor.metadata.attributes.publisher + - '/' + - editor.metadata.name + - '/' + - editor.metadata.attributes.version === - editorName, - ); - if (!editor) { - const errorMessage = `Failed to load editor ${editorName}. The editor does not exist in the editors configuration map.`; - dispatch({ - type: 'RECEIVE_DW_EDITOR_ERROR', - url: '', - editorName, - error: errorMessage, - }); - throw errorMessage; - } else { - dispatch({ - type: 'RECEIVE_DW_EDITOR', - editorName, - url: '', - plugin: editor, - }); - } - } - }, - - requestDwDefaultEditor: - (): AppThunk> => - async (dispatch, getState): Promise => { - const config = getState().dwServerConfig.config; - const defaultEditor = config.defaults.editor; - dispatch({ - type: 'REQUEST_DW_DEFAULT_EDITOR', - check: AUTHORIZED, - }); - - if (!defaultEditor) { - const errorMessage = - 'Failed to load the default editor, reason: default editor ID is not provided by Che server.'; - dispatch({ - type: 'RECEIVE_DW_DEFAULT_EDITOR_ERROR', - error: errorMessage, - }); - throw errorMessage; - } - - const defaultEditorUrl = (defaultEditor as string).startsWith('https://') - ? defaultEditor - : ''; - - // request default editor - await dispatch(actionCreators.requestDwEditor(defaultEditor)); - - dispatch({ - type: 'RECEIVE_DW_DEFAULT_EDITOR', - defaultEditorName: defaultEditor, - url: defaultEditorUrl, - }); - }, - - requestDwDefaultPlugins: - (): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: 'REQUEST_DW_DEFAULT_PLUGINS', - check: AUTHORIZED, - }); - - const defaultPlugins = {}; - const defaults = getState().dwServerConfig.config.defaults; - (defaults.plugins || []).forEach(item => { - if (!defaultPlugins[item.editor]) { - defaultPlugins[item.editor] = []; - } - defaultPlugins[item.editor].push(...item.plugins); - }); - - dispatch({ - type: 'RECEIVE_DW_DEFAULT_PLUGINS', - defaultPlugins, - }); - }, -}; - -const unloadedState: State = { - isLoading: false, - plugins: {}, - editors: {}, - defaultPlugins: {}, - defaultEditorName: undefined, - cmEditors: [], -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case 'REQUEST_EDITORS': - return createObject(state, { - isLoading: true, - }); - case 'RECEIVE_EDITORS': - return createObject(state, { - isLoading: false, - cmEditors: action.editors, - }); - case 'RECEIVE_EDITORS_ERROR': - return createObject(state, { - isLoading: false, - cmEditors: [], - }); - case 'REQUEST_DW_PLUGIN': - return createObject(state, { - isLoading: true, - plugins: { - [action.url]: { - // only keep the plugin and get rid of an error - plugin: state.plugins[action.url]?.plugin, - url: action.url, - }, - }, - }); - case 'REQUEST_DW_EDITOR': - return createObject(state, { - isLoading: true, - editors: createObject(state.editors, { - [action.editorName]: { - plugin: undefined, - url: action.url, - }, - }), - }); - case 'REQUEST_DW_DEFAULT_EDITOR': - return createObject(state, { - isLoading: true, - defaultEditorName: undefined, - defaultEditorError: undefined, - }); - case 'RECEIVE_DW_PLUGIN': - return createObject(state, { - isLoading: false, - plugins: { - [action.url]: { - plugin: action.plugin, - url: action.url, - }, - }, - }); - case 'RECEIVE_DW_EDITOR': - return createObject(state, { - isLoading: false, - editors: createObject(state.editors, { - [action.editorName]: { - plugin: action.plugin, - url: action.url, - }, - }), - }); - case 'RECEIVE_DW_EDITOR_ERROR': - return createObject(state, { - isLoading: false, - editors: { - [action.editorName]: { - error: action.error, - url: action.url, - }, - }, - }); - - case 'RECEIVE_DW_PLUGIN_ERROR': - return createObject(state, { - isLoading: false, - plugins: { - [action.url]: { - // save the error and keep the plugin - url: action.url, - error: action.error, - plugin: state.plugins[action.url]?.plugin, - }, - }, - }); - case 'RECEIVE_DW_DEFAULT_EDITOR_ERROR': - return createObject(state, { - isLoading: false, - defaultEditorError: action.error, - }); - case 'RECEIVE_DW_DEFAULT_EDITOR': - return createObject(state, { - isLoading: false, - defaultEditorName: action.defaultEditorName, - }); - case 'REQUEST_DW_DEFAULT_PLUGINS': - return createObject(state, { - isLoading: true, - }); - case 'RECEIVE_DW_DEFAULT_PLUGINS': - return createObject(state, { - isLoading: false, - defaultPlugins: action.defaultPlugins, - }); - default: - return state; - } -}; +export { + actionCreators as devWorkspacePluginsActionCreators, + PluginDefinition, + WorkspacesDefaultPlugins, +} from '@/store/Plugins/devWorkspacePlugins/actions'; +export { + reducer as devWorkspacePluginsReducer, + State as DevWorkspacePluginsState, +} from '@/store/Plugins/devWorkspacePlugins/reducer'; +export * from '@/store/Plugins/devWorkspacePlugins/selectors'; diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/reducer.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/reducer.ts new file mode 100644 index 000000000..102816de2 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/reducer.ts @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import devfileApi from '@/services/devfileApi'; +import { + dwDefaultEditorErrorAction, + dwDefaultEditorReceiveAction, + dwDefaultEditorRequestAction, + dwDefaultPluginsReceiveAction, + dwDefaultPluginsRequestAction, + dwEditorErrorAction, + dwEditorReceiveAction, + dwEditorRequestAction, + dwEditorsErrorAction, + dwEditorsReceiveAction, + dwEditorsRequestAction, + dwPluginErrorAction, + dwPluginReceiveAction, + dwPluginRequestAction, + PluginDefinition, + WorkspacesDefaultPlugins, +} from '@/store/Plugins/devWorkspacePlugins/actions'; + +export interface State { + isLoading: boolean; + plugins: { + [url: string]: PluginDefinition; + }; + editors: { + [editorName: string]: PluginDefinition; + }; + defaultPlugins: WorkspacesDefaultPlugins; + defaultEditorName?: string; + defaultEditorError?: string; + cmEditors?: devfileApi.Devfile[]; +} + +export const unloadedState: State = { + isLoading: false, + plugins: {}, + editors: {}, + defaultPlugins: {}, + defaultEditorName: undefined, + cmEditors: [], +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(dwEditorsRequestAction, state => { + state.isLoading = true; + }) + .addCase(dwEditorsReceiveAction, (state, action) => { + state.isLoading = false; + state.cmEditors = action.payload; + }) + .addCase(dwEditorsErrorAction, state => { + state.isLoading = false; + state.cmEditors = []; + }) + .addCase(dwPluginRequestAction, (state, action) => { + state.isLoading = true; + // only remove the error + delete state.plugins?.[action.payload]?.error; + }) + .addCase(dwPluginReceiveAction, (state, action) => { + state.isLoading = false; + state.plugins[action.payload.url] = { + plugin: action.payload.plugin, + url: action.payload.url, + }; + }) + .addCase(dwPluginErrorAction, (state, action) => { + state.isLoading = false; + // save the error and keep the plugin + state.plugins[action.payload.url] = { + error: action.payload.error, + url: action.payload.url, + plugin: state.plugins[action.payload.url]?.plugin, + }; + }) + .addCase(dwEditorRequestAction, (state, action) => { + state.isLoading = true; + // remove both the plugin and the error + state.editors[action.payload.editorName] = { + url: action.payload.url, + }; + }) + .addCase(dwEditorReceiveAction, (state, action) => { + state.isLoading = false; + state.editors[action.payload.editorName] = { + plugin: action.payload.plugin, + url: action.payload.url, + }; + }) + .addCase(dwEditorErrorAction, (state, action) => { + state.isLoading = false; + state.editors[action.payload.editorName] = { + error: action.payload.error, + url: action.payload.url, + }; + }) + .addCase(dwDefaultEditorRequestAction, state => { + state.isLoading = true; + state.defaultEditorName = undefined; + state.defaultEditorError = undefined; + }) + .addCase(dwDefaultEditorReceiveAction, (state, action) => { + state.isLoading = false; + state.defaultEditorName = action.payload.defaultEditorName; + }) + .addCase(dwDefaultEditorErrorAction, (state, action) => { + state.isLoading = false; + state.defaultEditorError = action.payload; + }) + .addCase(dwDefaultPluginsRequestAction, state => { + state.isLoading = true; + }) + .addCase(dwDefaultPluginsReceiveAction, (state, action) => { + state.isLoading = false; + state.defaultPlugins = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts index ac48709a4..4ef38d705 100644 --- a/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts +++ b/packages/dashboard-frontend/src/store/Plugins/devWorkspacePlugins/selectors.ts @@ -10,14 +10,13 @@ * Red Hat, Inc. - initial API and implementation */ +import { createSelector } from '@reduxjs/toolkit'; import { cloneDeep } from 'lodash'; -import { createSelector } from 'reselect'; import devfileApi from '@/services/devfileApi'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.dwPlugins; -export const selectPluginsState = selectState; +const selectState = (state: RootState) => state.dwPlugins; export const selectDwPlugins = createSelector(selectState, state => state.plugins); diff --git a/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/actions.spec.ts index b9e41e3cd..67d8fc36b 100644 --- a/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/actions.spec.ts @@ -10,283 +10,247 @@ * Red Hat, Inc. - initial API and implementation */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - import { api } from '@eclipse-che/common'; -import { CoreV1Event, V1Pod, V1Status } from '@kubernetes/client-node'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; +import { V1Pod } from '@kubernetes/client-node'; import { container } from '@/inversify.config'; import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as infrastructureNamespacesSelectors from '@/store/InfrastructureNamespaces/selectors'; +import { + actionCreators, + podLogsDeleteAction, + podLogsReceiveAction, +} from '@/store/Pods/Logs/actions'; + +jest + .spyOn(infrastructureNamespacesSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: 'test-namespace', attributes: { phase: 'Active' } }); -import * as testStore from '..'; +describe('Pods, actions', () => { + const mockAddChannelMessageListener = jest.fn(); + const mockUnsubscribeFromChannel = jest.fn(); + const mockSubscribeToChannel = jest.fn(); -describe('Pod logs store, actions', () => { - const podName = 'pod1'; - const containerName = 'container1'; - const initContainerName = 'initContainer1'; - const namespace = 'user-che'; - let pod: V1Pod; + const mockPod = { metadata: { name: 'pod1' } } as V1Pod; - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; + let store: ReturnType; beforeEach(() => { - pod = { - kind: 'Pod', - metadata: { - name: podName, - namespace: 'user-che', - }, - spec: { - containers: [ - { - name: containerName, - }, - ], - initContainers: [ - { - name: initContainerName, - }, - ], + store = createMockStore({ + pods: { + pods: [mockPod], + isLoading: false, + resourceVersion: '1234', }, - } as V1Pod; - - appStore = new FakeStoreBuilder() - .withInfrastructureNamespace([{ attributes: { phase: 'Active' }, name: namespace }]) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; + }); + + class MockWebsocketClient extends WebsocketClient { + async connect() { + return; + } + hasChannelMessageListener() { + return false; + } + addChannelMessageListener(...args: unknown[]): void { + mockAddChannelMessageListener(...args); + } + public unsubscribeFromChannel(...args: unknown[]): void { + mockUnsubscribeFromChannel(...args); + } + public subscribeToChannel(...args: unknown[]): void { + mockSubscribeToChannel(...args); + } + } + + container.snapshot(); + container.rebind(WebsocketClient).to(MockWebsocketClient).inSingletonScope(); }); afterEach(() => { jest.clearAllMocks(); + container.restore(); }); - describe('start/stop watching logs', () => { - beforeEach(() => { - container.snapshot(); + describe('watchPodLogs', () => { + it('should dispatch podLogsReceiveAction on receiving logs message', async () => { + const mockPod = { metadata: { name: 'pod1' } } as V1Pod; + + await store.dispatch(actionCreators.watchPodLogs(mockPod)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockAddChannelMessageListener).toHaveBeenCalledWith( + api.webSocket.Channel.LOGS, + expect.any(Function), + ); + expect(mockUnsubscribeFromChannel).toHaveBeenCalledWith(api.webSocket.Channel.LOGS); + expect(mockSubscribeToChannel).toHaveBeenCalledWith( + api.webSocket.Channel.LOGS, + 'test-namespace', + { + podName: 'pod1', + }, + ); }); - afterEach(() => { - container.restore(); + it('should throw an error if pod name is undefined', async () => { + const mockPod = { metadata: {} } as V1Pod; + + await expect(store.dispatch(actionCreators.watchPodLogs(mockPod))).rejects.toThrow( + `Can't watch pod logs: pod name is undefined`, + ); }); + }); - it('should throw an error when start watching logs', async () => { - delete pod.metadata; + describe('stopWatchingPodLogs', () => { + it('should dispatch podLogsDeleteAction on stop watching logs', async () => { + const mockPod = { metadata: { name: 'pod1' } } as V1Pod; - console.warn = jest.fn(); + await store.dispatch(actionCreators.stopWatchingPodLogs(mockPod)); - const watch = () => appStore.dispatch(testStore.actionCreators.watchPodLogs(pod)); + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(podLogsDeleteAction('pod1')); + }); - expect(watch).rejects.toThrowError(`Can't watch pod logs: pod name is undefined`); + it('should throw an error if pod name is undefined', async () => { + const mockPod = { metadata: {} } as V1Pod; + + await expect(store.dispatch(actionCreators.stopWatchingPodLogs(mockPod))).rejects.toThrow( + `Can't stop watching pod logs: pod name is undefined`, + ); }); + }); - test('start watching pod logs', async () => { - const websocketClient = container.get(WebsocketClient); + describe('handleWebSocketMessage', () => { + it('should handle WebSocket status message and resubscribe if pod exists', async () => { + const mockMessage = { + status: { code: 500, message: 'Internal Server Error' }, + eventPhase: api.webSocket.EventPhase.ERROR, + // no `containerName` field + params: { podName: 'pod1', namespace: 'test-namespace' }, + } as api.webSocket.StatusMessage; - jest.spyOn(websocketClient, 'connect').mockImplementation(() => Promise.resolve()); - jest.spyOn(websocketClient, 'addChannelMessageListener').mockImplementation(() => undefined); - jest.spyOn(websocketClient, 'subscribeToChannel').mockImplementation(() => undefined); - jest.spyOn(websocketClient, 'unsubscribeFromChannel').mockImplementation(() => undefined); + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - await appStore.dispatch(testStore.actionCreators.watchPodLogs(pod)); + const actions = store.getActions(); + expect(actions).toHaveLength(0); - expect(websocketClient.connect).toHaveBeenCalled(); - expect(websocketClient.addChannelMessageListener).toHaveBeenCalledWith( - api.webSocket.Channel.LOGS, - expect.any(Function), - ); - expect(websocketClient.unsubscribeFromChannel).toHaveBeenCalledWith( - api.webSocket.Channel.LOGS, - ); - expect(websocketClient.subscribeToChannel).toHaveBeenCalledWith( + expect(mockUnsubscribeFromChannel).toHaveBeenCalledWith(api.webSocket.Channel.LOGS); + expect(mockSubscribeToChannel).toHaveBeenCalledWith( api.webSocket.Channel.LOGS, - namespace, - { podName }, + 'test-namespace', + { podName: 'pod1' }, ); }); - it('should throw an error when stop watching logs', async () => { - delete pod.metadata; + it('should handle WebSocket status message and not resubscribe if pod does not exist', async () => { + const mockMessage = { + status: { code: 500, message: 'Internal Server Error' }, + eventPhase: api.webSocket.EventPhase.ERROR, + // no `containerName` field + params: { podName: 'pod1', namespace: 'test-namespace' }, + } as api.webSocket.NotificationMessage; + + const storeNoPods = createMockStore({ + pods: { + pods: [], + isLoading: false, + resourceVersion: '1234', + }, + }); + + await storeNoPods.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - const fetch = () => appStore.dispatch(testStore.actionCreators.stopWatchingPodLogs(pod)); + const actions = store.getActions(); + expect(actions).toHaveLength(0); - expect(fetch).rejects.toThrowError(`Can't stop watching pod logs: pod name is undefined`); + expect(mockUnsubscribeFromChannel).toHaveBeenCalledWith(api.webSocket.Channel.LOGS); + expect(mockSubscribeToChannel).not.toHaveBeenCalled(); }); - test('stop watching logs', async () => { - const websocketClient = container.get(WebsocketClient); + it('should handle WebSocket message with unexpected params', async () => { + const mockMessage = { + status: { code: 500, message: 'Internal Server Error' }, + eventPhase: api.webSocket.EventPhase.ERROR, + params: { unexpectedField: 'unexpectedValue' } as unknown, + } as api.webSocket.NotificationMessage; - jest.spyOn(websocketClient, 'unsubscribeFromChannel').mockImplementation(() => undefined); + console.debug = jest.fn(); - await appStore.dispatch(testStore.actionCreators.stopWatchingPodLogs(pod)); + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - expect(websocketClient.unsubscribeFromChannel).toHaveBeenCalledWith( - api.webSocket.Channel.LOGS, + expect(console.debug).toHaveBeenCalledWith( + 'WebSocket(LOGS): unexpected message:', + mockMessage, ); - }); - }); - describe('handle WebSocket messages', () => { - describe('ADDED event phase', () => { - it('should create RECEIVE_LOGS', () => { - const logsMessage = { - eventPhase: api.webSocket.EventPhase.ADDED, - containerName, - podName, - logs: 'a few logs', - } as api.webSocket.LogsMessage; - - appStore.dispatch(testStore.actionCreators.handleWebSocketMessage(logsMessage)); - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.RECEIVE_LOGS, - containerName, - podName, - logs: logsMessage.logs, - failure: false, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); + expect(mockUnsubscribeFromChannel).not.toHaveBeenCalled(); + expect(mockSubscribeToChannel).not.toHaveBeenCalled(); }); - describe('ERROR event phase', () => { - it('should create RECEIVE_LOGS action if `containerName` is defined', () => { - const statusMessage = { - eventPhase: api.webSocket.EventPhase.ERROR, - params: { - containerName, - podName, - namespace, - }, - status: { - kind: 'Status', - message: 'an error message', - } as V1Status, - } as api.webSocket.StatusMessage; - - appStore.dispatch(testStore.actionCreators.handleWebSocketMessage(statusMessage)); - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.RECEIVE_LOGS, - containerName, - podName, - logs: statusMessage.status.message!, - failure: true, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); + it('should handle WebSocket status message and dispatch podLogsReceiveAction with failure=`true`', async () => { + const mockMessage = { + status: { + code: 500, + // no `message` field to test default message as well + // message: 'Internal Server Error', + }, + eventPhase: api.webSocket.EventPhase.ERROR, + params: { podName: 'pod1', namespace: 'test-namespace', containerName: 'container1' }, + } as api.webSocket.StatusMessage; + + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + podLogsReceiveAction({ + podName: 'pod1', + containerName: 'container1', + logs: 'Unknown error while watching logs', + failure: true, + }), + ); - describe('resubscribe if failure', () => { - it('should not resubscribe if pod not found', () => { - const statusMessage = { - eventPhase: api.webSocket.EventPhase.ERROR, - params: { - podName, - namespace, - }, - status: { - kind: 'Status', - message: 'an error message', - } as V1Status, - } as api.webSocket.StatusMessage; - - const websocketClient = container.get(WebsocketClient); - const unsubscribeFromChannelSpy = jest - .spyOn(websocketClient, 'unsubscribeFromChannel') - .mockReturnValue(undefined); - const subscribeToChannelSpy = jest - .spyOn(websocketClient, 'subscribeToChannel') - .mockReturnValue(undefined); - - /* no such pod in the store */ - appStore.dispatch(testStore.actionCreators.handleWebSocketMessage(statusMessage)); - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = []; - expect(actions).toStrictEqual(expectedActions); - - expect(unsubscribeFromChannelSpy).toHaveBeenCalledWith(api.webSocket.Channel.LOGS); - expect(subscribeToChannelSpy).not.toHaveBeenCalledWith(); - }); - - it('should not resubscribe if pod not found', () => { - const statusMessage = { - eventPhase: api.webSocket.EventPhase.ERROR, - params: { - podName, - namespace, - }, - status: { - kind: 'Status', - message: 'an error message', - } as V1Status, - } as api.webSocket.StatusMessage; - - const websocketClient = container.get(WebsocketClient); - const unsubscribeFromChannelSpy = jest - .spyOn(websocketClient, 'unsubscribeFromChannel') - .mockReturnValue(undefined); - const subscribeToChannelSpy = jest - .spyOn(websocketClient, 'subscribeToChannel') - .mockReturnValue(undefined); - - /* adding the pod in the store */ - const _appStore = new FakeStoreBuilder(appStore).withPods({ pods: [pod] }).build(); - - _appStore.dispatch(testStore.actionCreators.handleWebSocketMessage(statusMessage)); - - const actions = _appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = []; - expect(actions).toStrictEqual(expectedActions); - - expect(unsubscribeFromChannelSpy).toHaveBeenCalledWith(api.webSocket.Channel.LOGS); - expect(subscribeToChannelSpy).toHaveBeenCalledWith( - api.webSocket.Channel.LOGS, - namespace, - { - podName, - }, - ); - }); - }); + expect(mockUnsubscribeFromChannel).not.toHaveBeenCalled(); + expect(mockSubscribeToChannel).not.toHaveBeenCalled(); }); - it('should skip messages that are neither `LogsMessage` nor `StatusMessage`', () => { - const message = { - eventPhase: api.webSocket.EventPhase.DELETED, - event: {} as CoreV1Event, - } as api.webSocket.EventMessage; - - console.warn = jest.fn(); + it('should handle WebSocket logs message', async () => { + const mockMessage = { + eventPhase: api.webSocket.EventPhase.ADDED, + containerName: 'container1', + podName: 'pod1', + logs: 'log message', + } as api.webSocket.LogsMessage; + + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + podLogsReceiveAction({ + podName: 'pod1', + containerName: 'container1', + logs: 'log message', + failure: false, + }), + ); + }); - appStore.dispatch(testStore.actionCreators.handleWebSocketMessage(message)); + it('should log unexpected WebSocket message', async () => { + const mockMessage = { + unexpectedField: 'unexpectedValue', + } as unknown as api.webSocket.NotificationMessage; - const actions = appStore.getActions(); + console.warn = jest.fn(); - const expectedActions: testStore.KnownAction[] = []; + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - expect(actions).toStrictEqual(expectedActions); - expect(console.warn).toHaveBeenCalledWith('WebSocket: unexpected message:', message); + expect(console.warn).toHaveBeenCalledWith('WebSocket: unexpected message:', mockMessage); }); }); }); diff --git a/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/reducers.spec.ts index 4cd468ee8..65f3d4e07 100644 --- a/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/reducers.spec.ts +++ b/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/reducers.spec.ts @@ -10,278 +10,140 @@ * Red Hat, Inc. - initial API and implementation */ -import { AnyAction } from 'redux'; +import { UnknownAction } from 'redux'; -import * as testStore from '..'; +import { podLogsDeleteAction, podLogsReceiveAction } from '@/store/Pods/Logs/actions'; +import { reducer, State, unloadedState } from '@/store/Pods/Logs/reducer'; -describe('Logs store, reducers', () => { - const podName = 'pod1'; - const containerName = 'container1'; +describe('Pods Logs, reducer', () => { + let initialState: State; - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { - logs: {}, - }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - logs: {}, - }; - expect(newState).toEqual(expectedState); + beforeEach(() => { + initialState = { ...unloadedState }; }); - it('should handle RECEIVE_LOGS when no state', () => { - const incomingAction: testStore.ReceiveLogsAction = { - type: testStore.Type.RECEIVE_LOGS, - podName, - containerName, - logs: 'new logs', + it('should handle podLogsReceiveAction', () => { + const action = podLogsReceiveAction({ + podName: 'pod1', + containerName: 'container1', + logs: 'log message', failure: false, - }; - - const newState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { + }); + const expectedState: State = { logs: { - [podName]: { + pod1: { containers: { - [containerName]: { - logs: 'new logs', + container1: { + logs: 'log message', failure: false, }, }, + error: undefined, }, }, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - describe('should handle RECEIVE_LOGS', () => { - it('when no other logs', () => { - const initialState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: '', - failure: false, - }, - }, - }, - }, - }; - const incomingAction: testStore.ReceiveLogsAction = { - type: testStore.Type.RECEIVE_LOGS, - podName, - containerName, - logs: 'new logs', - failure: false, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'new logs', - failure: false, - }, - }, - }, - }, - }; - - expect(newState).toEqual(expectedState); - }); - - it('when have some logs', () => { - const initialState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'prev logs\n', - failure: false, - }, - }, - }, - }, - }; - const incomingAction: testStore.ReceiveLogsAction = { - type: testStore.Type.RECEIVE_LOGS, - podName, - containerName, - logs: 'new logs', - failure: false, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'prev logs\nnew logs', - failure: false, - }, - }, - }, - }, - }; - - expect(newState).toEqual(expectedState); - }); - - it('when have failure and received logs', () => { - const initialState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'something went wrong', - failure: true, - }, - }, - }, - }, - }; - const incomingAction: testStore.ReceiveLogsAction = { - type: testStore.Type.RECEIVE_LOGS, - podName, - containerName, - logs: 'new logs', - failure: false, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'new logs', - failure: false, - }, + it('should append logs if failure status is the same', () => { + const initialStateWithLogs: State = { + logs: { + pod1: { + containers: { + container1: { + logs: 'previous log message', + failure: false, }, }, + error: undefined, }, - }; - - expect(newState).toEqual(expectedState); + }, + }; + const action = podLogsReceiveAction({ + podName: 'pod1', + containerName: 'container1', + logs: ' new log message', + failure: false, }); - - it('when no other logs and receive failure', () => { - const initialState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: '', - failure: false, - }, + const expectedState: State = { + logs: { + pod1: { + containers: { + container1: { + logs: 'previous log message new log message', + failure: false, }, }, + error: undefined, }, - }; - const incomingAction: testStore.ReceiveLogsAction = { - type: testStore.Type.RECEIVE_LOGS, - podName, - containerName, - logs: 'something went wrong', - failure: true, - }; + }, + }; - const newState = testStore.reducer(initialState, incomingAction); + expect(reducer(initialStateWithLogs, action)).toEqual(expectedState); + }); - const expectedState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'something went wrong', - failure: true, - }, + it('should replace logs if failure status is different', () => { + const initialStateWithLogs: State = { + logs: { + pod1: { + containers: { + container1: { + logs: 'previous log message', + failure: false, }, }, + error: undefined, }, - }; - - expect(newState).toEqual(expectedState); + }, + }; + const action = podLogsReceiveAction({ + podName: 'pod1', + containerName: 'container1', + logs: ' new log message', + failure: true, }); - - it('when no have some logs and receive failure', () => { - const initialState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'prev logs\n', - failure: false, - }, - }, - }, - }, - }; - const incomingAction: testStore.ReceiveLogsAction = { - type: testStore.Type.RECEIVE_LOGS, - podName, - containerName, - logs: 'something went wrong', - failure: true, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - logs: { - [podName]: { - containers: { - [containerName]: { - logs: 'something went wrong', - failure: true, - }, + const expectedState: State = { + logs: { + pod1: { + containers: { + container1: { + logs: ' new log message', + failure: true, }, }, + error: undefined, }, - }; + }, + }; - expect(newState).toEqual(expectedState); - }); + expect(reducer(initialStateWithLogs, action)).toEqual(expectedState); }); - it('should handle DELETE_LOGS', () => { - const initialState: testStore.State = { + it('should handle podLogsDeleteAction', () => { + const initialStateWithLogs: State = { logs: { - [podName]: { + pod1: { containers: { - [containerName]: { - logs: 'some logs\n', + container1: { + logs: 'log message', failure: false, }, }, + error: undefined, }, }, }; - - const incomingAction: testStore.DeleteLogsAction = { - type: testStore.Type.DELETE_LOGS, - podName, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { + const action = podLogsDeleteAction('pod1'); + const expectedState: State = { logs: {}, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialStateWithLogs, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/selectors.spec.ts index e96ddc4d8..b354f56a5 100644 --- a/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/Pods/Logs/__tests__/selectors.spec.ts @@ -10,63 +10,57 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; import { selectAllLogs, selectPodLogs } from '@/store/Pods/Logs/selectors'; -import * as store from '..'; - -describe('Logs store, selectors', () => { - let logs: store.State['logs']; - - beforeEach(() => { - logs = { - pod1: { - containers: { - container1: { - logs: 'container1 logs', - failure: false, - }, - initContainer1: { - logs: 'initContainer1 logs', - failure: false, +describe('Pods Logs, selectors', () => { + const mockState = { + logs: { + logs: { + pod1: { + containers: { + container1: { + logs: 'log message 1', + failure: false, + }, + container2: { + logs: 'log message 2', + failure: true, + }, }, }, - }, - pod2: { - containers: { - container2: { - logs: 'something went wrong', - failure: true, + pod2: { + containers: { + container1: { + logs: 'log message 3', + failure: false, + }, }, }, }, - }; - }); + }, + } as Partial as RootState; - it('should return all logs', () => { - const fakeStore = new FakeStoreBuilder().withLogs(logs).build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select all logs', () => { + const result = selectAllLogs(mockState); + expect(result).toEqual(mockState.logs.logs); + }); - const allPods = selectAllLogs(state); - expect(allPods).toStrictEqual(logs); + it('should select pod logs for a specific pod', () => { + const selectLogsForPod = selectPodLogs(mockState); + const result = selectLogsForPod('pod1'); + expect(result).toEqual(mockState.logs.logs.pod1?.containers); }); - it('should return logs for a specified pod', () => { - const fakeStore = new FakeStoreBuilder().withLogs(logs).build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should return undefined if pod name is undefined', () => { + const selectLogsForPod = selectPodLogs(mockState); + const result = selectLogsForPod(undefined); + expect(result).toBeUndefined(); + }); - const podLogsFn = selectPodLogs(state); - const podLogs = podLogsFn('pod1'); - expect(podLogs).toStrictEqual(logs['pod1']?.containers); + it('should return undefined if pod does not exist', () => { + const selectLogsForPod = selectPodLogs(mockState); + const result = selectLogsForPod('nonexistent-pod'); + expect(result).toBeUndefined(); }); }); diff --git a/packages/dashboard-frontend/src/store/Pods/Logs/actions.ts b/packages/dashboard-frontend/src/store/Pods/Logs/actions.ts new file mode 100644 index 000000000..f75179dc2 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Pods/Logs/actions.ts @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { V1Pod } from '@kubernetes/client-node'; +import { createAction } from '@reduxjs/toolkit'; + +import { container } from '@/inversify.config'; +import { WebsocketClient } from '@/services/backend-client/websocketClient'; +import { ChannelListener } from '@/services/backend-client/websocketClient/messageHandler'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { selectAllPods } from '@/store/Pods/selectors'; + +type PodLogsReceivePayload = { + podName: string; + containerName: string; + logs: string; + failure: boolean; +}; +export const podLogsReceiveAction = createAction('podLogs/receive'); + +export const podLogsDeleteAction = createAction('podLogs/delete'); + +export const actionCreators = { + watchPodLogs: + (pod: V1Pod): AppThunk => + async (dispatch, getState) => { + const podName = pod.metadata?.name; + if (podName === undefined) { + console.warn(`Can't watch pod logs: pod name is undefined.`, pod); + throw new Error(`Can't watch pod logs: pod name is undefined`); + } + + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const namespace = defaultKubernetesNamespace.name; + + const websocketClient = container.get(WebsocketClient); + await websocketClient.connect(); + + if (websocketClient.hasChannelMessageListener(api.webSocket.Channel.LOGS) === false) { + const listener: ChannelListener = message => + dispatch(actionCreators.handleWebSocketMessage(message)); + websocketClient.addChannelMessageListener(api.webSocket.Channel.LOGS, listener); + } + + websocketClient.unsubscribeFromChannel(api.webSocket.Channel.LOGS); + websocketClient.subscribeToChannel(api.webSocket.Channel.LOGS, namespace, { + podName, + }); + }, + + stopWatchingPodLogs: + (pod: V1Pod): AppThunk => + async dispatch => { + const podName = pod.metadata?.name; + if (podName === undefined) { + throw new Error(`Can't stop watching pod logs: pod name is undefined`); + } + + const websocketClient = container.get(WebsocketClient); + websocketClient.unsubscribeFromChannel(api.webSocket.Channel.LOGS); + + dispatch(podLogsDeleteAction(podName)); + }, + + handleWebSocketMessage: + (message: api.webSocket.NotificationMessage): AppThunk => + async (dispatch, getState) => { + if (api.webSocket.isStatusMessage(message)) { + const { params, status } = message; + + if (!api.webSocket.isWebSocketSubscribeLogsParams(params)) { + console.debug('WebSocket(LOGS): unexpected message:', message); + return; + } + + const errorMessage = status.message || 'Unknown error while watching logs'; + console.debug(`WebSocket(LOGS): status code ${status.code}, reason: ${errorMessage}`); + + /* if container name is specified, then it's a single container logs. */ + + if (params.containerName) { + dispatch( + podLogsReceiveAction({ + podName: params.podName, + containerName: params.containerName, + logs: errorMessage, + failure: true, + }), + ); + return; + } + + /* If container name is not specified, then backend failed to get pod to watch. We need to check if pod exists, and resubscribe to the channel. */ + + const websocketClient = container.get(WebsocketClient); + websocketClient.unsubscribeFromChannel(api.webSocket.Channel.LOGS); + + const allPods = selectAllPods(getState()); + if (allPods.find(pod => pod.metadata?.name === params.podName) === undefined) { + console.debug('WebSocket(LOGS): pod not found, stop watching logs:', params.podName); + return; + } + websocketClient.subscribeToChannel(api.webSocket.Channel.LOGS, params.namespace, { + podName: params.podName, + }); + return; + } + + if (api.webSocket.isLogsMessage(message)) { + const { containerName, logs, podName } = message; + + dispatch( + podLogsReceiveAction({ + podName, + containerName, + logs, + failure: false, + }), + ); + + return; + } + + console.warn('WebSocket: unexpected message:', message); + }, +}; diff --git a/packages/dashboard-frontend/src/store/Pods/Logs/index.ts b/packages/dashboard-frontend/src/store/Pods/Logs/index.ts index c8c6b7816..f5ed0a08a 100644 --- a/packages/dashboard-frontend/src/store/Pods/Logs/index.ts +++ b/packages/dashboard-frontend/src/store/Pods/Logs/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,211 +12,10 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; -import { V1Pod } from '@kubernetes/client-node'; -import { Action, Reducer } from 'redux'; - -import { container } from '@/inversify.config'; -import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import { ChannelListener } from '@/services/backend-client/websocketClient/messageHandler'; -import { AppThunk } from '@/store'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectAllPods } from '@/store/Pods/selectors'; - -export type ContainerLogs = { - logs: string; - failure: boolean; -}; - -export interface State { - logs: { - [podName: string]: - | { - containers: { - [containerName: string]: ContainerLogs | undefined; - }; - error?: string; - } - | undefined; - }; -} - -export enum Type { - RECEIVE_LOGS = 'RECEIVE_LOGS', - DELETE_LOGS = 'DELETE_LOGS', -} - -export interface ReceiveLogsAction { - type: Type.RECEIVE_LOGS; - podName: string; - containerName: string; - logs: string; - failure: boolean; -} - -export interface DeleteLogsAction { - type: Type.DELETE_LOGS; - podName: string; -} - -export type KnownAction = ReceiveLogsAction | DeleteLogsAction; - -export type ActionCreators = { - watchPodLogs: (pod: V1Pod) => AppThunk>; - stopWatchingPodLogs: (pod: V1Pod) => AppThunk>; - - handleWebSocketMessage: ( - message: api.webSocket.NotificationMessage, - ) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - watchPodLogs: - (pod: V1Pod): AppThunk> => - async (dispatch, getState): Promise => { - const podName = pod.metadata?.name; - if (podName === undefined) { - console.warn(`Can't watch pod logs: pod name is undefined.`, pod); - throw new Error(`Can't watch pod logs: pod name is undefined`); - } - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const namespace = defaultKubernetesNamespace.name; - - const websocketClient = container.get(WebsocketClient); - await websocketClient.connect(); - - if (websocketClient.hasChannelMessageListener(api.webSocket.Channel.LOGS) === false) { - const listener: ChannelListener = message => - dispatch(actionCreators.handleWebSocketMessage(message)); - websocketClient.addChannelMessageListener(api.webSocket.Channel.LOGS, listener); - } - - websocketClient.unsubscribeFromChannel(api.webSocket.Channel.LOGS); - websocketClient.subscribeToChannel(api.webSocket.Channel.LOGS, namespace, { - podName, - }); - }, - - stopWatchingPodLogs: - (pod: V1Pod): AppThunk> => - async (dispatch): Promise => { - const podName = pod.metadata?.name; - if (podName === undefined) { - throw new Error(`Can't stop watching pod logs: pod name is undefined`); - } - - const websocketClient = container.get(WebsocketClient); - websocketClient.unsubscribeFromChannel(api.webSocket.Channel.LOGS); - - dispatch({ - type: Type.DELETE_LOGS, - podName, - }); - }, - - handleWebSocketMessage: - (message: api.webSocket.NotificationMessage): AppThunk> => - async (dispatch, getState): Promise => { - if (api.webSocket.isStatusMessage(message)) { - const { params, status } = message; - - if (!api.webSocket.isWebSocketSubscribeLogsParams(params)) { - console.debug('WebSocket(LOGS): unexpected message:', message); - return; - } - - const errorMessage = status.message || 'Unknown error while watching logs'; - console.debug(`WebSocket(LOGS): status code ${status.code}, reason: ${errorMessage}`); - - /* if container name is specified, then it's a single container logs. */ - - if (params.containerName) { - dispatch({ - type: Type.RECEIVE_LOGS, - podName: params.podName, - containerName: params.containerName, - logs: status.message || errorMessage, - failure: true, - }); - return; - } - - /* If container name is not specified, then backend failed to get pod to watch. We need to check if pod exists, and resubscribe to the channel. */ - - const websocketClient = container.get(WebsocketClient); - websocketClient.unsubscribeFromChannel(api.webSocket.Channel.LOGS); - - const allPods = selectAllPods(getState()); - if (allPods.find(pod => pod.metadata?.name === params.podName) === undefined) { - console.debug('WebSocket(LOGS): pod not found, stop watching logs:', params.podName); - return; - } - websocketClient.subscribeToChannel(api.webSocket.Channel.LOGS, params.namespace, { - podName: params.podName, - }); - return; - } - - if (api.webSocket.isLogsMessage(message)) { - const { containerName, logs, podName } = message; - - dispatch({ - type: Type.RECEIVE_LOGS, - podName, - containerName, - logs, - failure: false, - }); - - return; - } - - console.warn('WebSocket: unexpected message:', message); - }, -}; - -const unloadedState: State = { - logs: {}, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - state = unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.RECEIVE_LOGS: { - const _pod = state.logs[action.podName]; - const _containers = _pod?.containers; - const _containerLogs = _containers?.[action.containerName]; - const _logs = action.failure === _containerLogs?.failure ? _containerLogs.logs : ''; - return createObject(state, { - logs: createObject(state.logs, { - [action.podName]: createObject(_pod, { - error: undefined, - containers: createObject(_containers, { - [action.containerName]: { - logs: _logs + action.logs, - failure: action.failure, - }, - }), - }), - }), - }); - } - case Type.DELETE_LOGS: - return createObject(state, { - logs: createObject(state.logs, { - [action.podName]: undefined, - }), - }); - default: - return state; - } -}; +export { actionCreators as podLogsActionCreators } from '@/store/Pods/Logs/actions'; +export { + ContainerLogs, + reducer as podLogsReducer, + State as PodLogsState, +} from '@/store/Pods/Logs/reducer'; +export * from '@/store/Pods/Logs/selectors'; diff --git a/packages/dashboard-frontend/src/store/Pods/Logs/reducer.ts b/packages/dashboard-frontend/src/store/Pods/Logs/reducer.ts new file mode 100644 index 000000000..ef962831a --- /dev/null +++ b/packages/dashboard-frontend/src/store/Pods/Logs/reducer.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { podLogsDeleteAction, podLogsReceiveAction } from '@/store/Pods/Logs/actions'; + +export type ContainerLogs = { + logs: string; + failure: boolean; +}; + +export interface State { + logs: { + [podName: string]: + | { + containers: { + [containerName: string]: ContainerLogs | undefined; + }; + error?: string; + } + | undefined; + }; +} + +export const unloadedState: State = { + logs: {}, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(podLogsReceiveAction, (state, action) => { + const _pod = state.logs[action.payload.podName]; + const _containers = _pod?.containers; + const _containerLogs = _containers?.[action.payload.containerName]; + const _logs = action.payload.failure === _containerLogs?.failure ? _containerLogs.logs : ''; + + state.logs[action.payload.podName] = { + error: undefined, + containers: { + ..._containers, + [action.payload.containerName]: { + logs: _logs + action.payload.logs, + failure: action.payload.failure, + }, + }, + }; + }) + .addCase(podLogsDeleteAction, (state, action) => { + delete state.logs[action.payload]; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Pods/Logs/selectors.ts b/packages/dashboard-frontend/src/store/Pods/Logs/selectors.ts index c1570b655..ae06bf566 100644 --- a/packages/dashboard-frontend/src/store/Pods/Logs/selectors.ts +++ b/packages/dashboard-frontend/src/store/Pods/Logs/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.logs; +const selectState = (state: RootState) => state.logs; export const selectAllLogs = createSelector(selectState, state => state.logs); diff --git a/packages/dashboard-frontend/src/store/Pods/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Pods/__tests__/actions.spec.ts index 6384aec37..1aaa7a424 100644 --- a/packages/dashboard-frontend/src/store/Pods/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/Pods/__tests__/actions.spec.ts @@ -10,205 +10,194 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; -import { V1Status } from '@kubernetes/client-node'; -import mockAxios, { AxiosError } from 'axios'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; +import { api, helpers } from '@eclipse-che/common'; +import { V1Pod } from '@kubernetes/client-node'; import { container } from '@/inversify.config'; +import { fetchPods } from '@/services/backend-client/podsApi'; import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { pod1, pod2 } from '@/store/Pods/__tests__/stub'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as infrastructureNamespacesSelector from '@/store/InfrastructureNamespaces/selectors'; +import { + actionCreators, + podDeleteAction, + podListErrorAction, + podListReceiveAction, + podListRequestAction, + podModifyAction, + podReceiveAction, +} from '@/store/Pods/actions'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@/services/backend-client/podsApi'); +jest.mock('@/store/SanityCheck'); + +const mockNamespace = 'test-namespace'; +jest.spyOn(infrastructureNamespacesSelector, 'selectDefaultNamespace').mockReturnValue({ + name: mockNamespace, + attributes: { phase: 'Active' }, +}); +(verifyAuthorized as jest.Mock).mockResolvedValue(true); -describe('Pods store, actions', () => { - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; +describe('Pods Actions', () => { + let store: ReturnType; beforeEach(() => { - appStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - }); - - afterEach(() => { + store = createMockStore({ + pods: { + pods: [], + isLoading: false, + error: undefined, + resourceVersion: '1234', + }, + }); jest.clearAllMocks(); }); - it('should create REQUEST_PODS and RECEIVE_PODS when fetching pods', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: { items: [pod1, pod2], metadata: { resourceVersion: '123' } }, - }); + describe('requestPods', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockPods = [{ metadata: { name: 'pod1' } }] as V1Pod[]; + const mockResourceVersion = '12345'; - await appStore.dispatch(testStore.actionCreators.requestPods()); + (fetchPods as jest.Mock).mockResolvedValue({ + items: mockPods, + metadata: { resourceVersion: mockResourceVersion }, + }); - const actions = appStore.getActions(); + await store.dispatch(actionCreators.requestPods()); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_PODS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_PODS, - pods: [pod1, pod2], - resourceVersion: '123', - }, - ]; + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(podListRequestAction()); + expect(actions[1]).toEqual( + podListReceiveAction({ + pods: mockPods, + resourceVersion: mockResourceVersion, + }), + ); + }); - expect(actions).toEqual(expectedActions); - }); + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; - it('should create REQUEST_PODS and RECEIVE_ERROR when fails to fetch pods', async () => { - (mockAxios.get as jest.Mock).mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - try { - await appStore.dispatch(testStore.actionCreators.requestPods()); - } catch (e) { - // noop - } - - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_PODS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: expect.stringContaining('Something unexpected happened.'), - }, - ]; + (fetchPods as jest.Mock).mockRejectedValue(new Error(errorMessage)); + jest.spyOn(helpers.errors, 'getMessage').mockReturnValue(errorMessage); - expect(actions).toEqual(expectedActions); - }); + await expect(store.dispatch(actionCreators.requestPods())).rejects.toThrow(errorMessage); - describe('handle WebSocket messages', () => { - it('should create RECEIVE_POD in case of ADDED event phase', () => { - appStore.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - pod: pod1, - eventPhase: api.webSocket.EventPhase.ADDED, - }), + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(podListRequestAction()); + expect(actions[1]).toEqual( + podListErrorAction(`Failed to fetch pods, reason: ${errorMessage}`), ); + }); + }); - const actions = appStore.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.RECEIVE_POD, - pod: pod1, - }, - ]; + describe('handleWebSocketMessage', () => { + let mockUnsubscribeFromChannel: jest.Mock; + let mockSubscribeToChannel: jest.Mock; + + beforeEach(() => { + mockUnsubscribeFromChannel = jest.fn(); + mockSubscribeToChannel = jest.fn(); + + class MockWebsocketClient extends WebsocketClient { + public unsubscribeFromChannel( + ...args: Parameters + ) { + mockUnsubscribeFromChannel(...args); + } + public subscribeToChannel(...args: Parameters) { + mockSubscribeToChannel(...args); + } + } + + container.snapshot(); + container.rebind(WebsocketClient).to(MockWebsocketClient).inSingletonScope(); + }); - expect(actions).toEqual(expectedActions); + afterEach(() => { + container.restore(); }); - it('should create MODIFY_POD in case of MODIFIED event phase', () => { - appStore.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - pod: pod1, - eventPhase: api.webSocket.EventPhase.MODIFIED, + it('should handle status message and re-subscribe on error status', async () => { + const mockMessage = { + status: { code: 500, message: 'Internal Server Error' }, + eventPhase: api.webSocket.EventPhase.ERROR, + } as api.webSocket.StatusMessage; + (fetchPods as jest.Mock).mockResolvedValue({ + items: [], + }); + + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(podListRequestAction()); + expect(actions[1]).toEqual( + podListReceiveAction({ + pods: [], + resourceVersion: undefined, }), ); + expect(mockUnsubscribeFromChannel).toHaveBeenCalledWith(api.webSocket.Channel.POD); + expect(mockSubscribeToChannel).toHaveBeenCalledWith( + api.webSocket.Channel.POD, + mockNamespace, + expect.any(Object), + ); + }); - const actions = appStore.getActions(); + it('should handle pod message with ADDED phase', async () => { + const mockPod = { metadata: { name: 'pod1', resourceVersion: '12345' } } as V1Pod; + const mockMessage = { + pod: mockPod, + eventPhase: api.webSocket.EventPhase.ADDED, + } as api.webSocket.NotificationMessage; - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.MODIFY_POD, - pod: pod1, - }, - ]; + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - expect(actions).toEqual(expectedActions); + const actions = store.getActions(); + expect(actions[0]).toEqual(podReceiveAction(mockPod)); }); - it('should create DELETE_POD and DELETE_EVENTS in case of DELETED event phase', () => { - appStore.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - pod: pod1, - eventPhase: api.webSocket.EventPhase.DELETED, - }), - ); + it('should handle pod message with MODIFIED phase', async () => { + const mockPod = { metadata: { name: 'pod1' } } as V1Pod; + const mockMessage = { + pod: mockPod, + eventPhase: api.webSocket.EventPhase.MODIFIED, + } as api.webSocket.NotificationMessage; + + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); - const actions = appStore.getActions(); + const actions = store.getActions(); + expect(actions[0]).toEqual(podModifyAction(mockPod)); + }); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.DELETE_POD, - pod: pod1, - }, - ]; + it('should handle pod message with DELETED phase', async () => { + const mockPod = { metadata: { name: 'pod1' } } as V1Pod; + const mockMessage = { + pod: mockPod, + eventPhase: api.webSocket.EventPhase.DELETED, + } as api.webSocket.NotificationMessage; - expect(actions).toEqual(expectedActions); + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(podDeleteAction(mockPod)); }); - it('should create REQUEST_PODS and RECEIVE_PODS and resubscribe to channel', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: { items: [pod1, pod2], metadata: { resourceVersion: '123' } }, - }); + it('should log unexpected message', async () => { + const mockMessage = { + unexpectedField: 'unexpectedValue', + } as unknown as api.webSocket.NotificationMessage; - const websocketClient = container.get(WebsocketClient); - const unsubscribeFromChannelSpy = jest - .spyOn(websocketClient, 'unsubscribeFromChannel') - .mockReturnValue(undefined); - const subscribeToChannelSpy = jest - .spyOn(websocketClient, 'subscribeToChannel') - .mockReturnValue(undefined); - - const namespace = 'user-che'; - const appStoreWithNamespace = new FakeStoreBuilder() - .withInfrastructureNamespace([{ name: namespace, attributes: { phase: 'Active' } }]) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - await appStoreWithNamespace.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - status: { - code: 410, - message: 'The resourceVersion for the provided watch is too old.', - } as V1Status, - eventPhase: api.webSocket.EventPhase.ERROR, - params: { - namespace, - resourceVersion: '123', - }, - }), - ); + console.warn = jest.fn(); - const actions = appStoreWithNamespace.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - check: AUTHORIZED, - type: testStore.Type.REQUEST_PODS, - }, - { - type: testStore.Type.RECEIVE_PODS, - pods: [pod1, pod2], - resourceVersion: '123', - }, - ]; - - expect(actions).toEqual(expectedActions); - expect(unsubscribeFromChannelSpy).toHaveBeenCalledWith(api.webSocket.Channel.POD); - expect(subscribeToChannelSpy).toHaveBeenCalledWith(api.webSocket.Channel.POD, namespace, { - getResourceVersion: expect.any(Function), - }); + await store.dispatch(actionCreators.handleWebSocketMessage(mockMessage)); + + expect(console.warn).toHaveBeenCalledWith('WebSocket: unexpected message:', mockMessage); }); }); }); diff --git a/packages/dashboard-frontend/src/store/Pods/__tests__/isSamePod.spec.ts b/packages/dashboard-frontend/src/store/Pods/__tests__/isSamePod.spec.ts index e6466089d..6fa7bedd6 100644 --- a/packages/dashboard-frontend/src/store/Pods/__tests__/isSamePod.spec.ts +++ b/packages/dashboard-frontend/src/store/Pods/__tests__/isSamePod.spec.ts @@ -10,8 +10,6 @@ * Red Hat, Inc. - initial API and implementation */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - import { pod1, pod2 } from '@/store/Pods/__tests__/stub'; import isSamePod from '@/store/Pods/isSamePod'; diff --git a/packages/dashboard-frontend/src/store/Pods/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/Pods/__tests__/reducers.spec.ts index fb783edda..5847c2b66 100644 --- a/packages/dashboard-frontend/src/store/Pods/__tests__/reducers.spec.ts +++ b/packages/dashboard-frontend/src/store/Pods/__tests__/reducers.spec.ts @@ -10,197 +10,121 @@ * Red Hat, Inc. - initial API and implementation */ -import { cloneDeep } from 'lodash'; -import { AnyAction } from 'redux'; - -import { pod1, pod2 } from '@/store/Pods/__tests__/stub'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; - -describe('Pods store, reducers', () => { - it('should return initial state', () => { - const incomingAction: testStore.RequestPodsAction = { - type: testStore.Type.REQUEST_PODS, - check: AUTHORIZED, - }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - pods: [], - resourceVersion: '0', - }; - - expect(initialState).toEqual(expectedState); +import { V1Pod } from '@kubernetes/client-node'; +import { UnknownAction } from 'redux'; + +import { getNewerResourceVersion } from '@/services/helpers/resourceVersion'; +import { + podDeleteAction, + podListErrorAction, + podListReceiveAction, + podListRequestAction, + podModifyAction, + podReceiveAction, +} from '@/store/Pods/actions'; +import isSamePod from '@/store/Pods/isSamePod'; +import { reducer, State, unloadedState } from '@/store/Pods/reducer'; + +jest.mock('@/services/helpers/resourceVersion'); +jest.mock('@/store/Pods/isSamePod'); + +describe('Pods reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + jest.clearAllMocks(); }); - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { + it('should handle podListRequestAction', () => { + const action = podListRequestAction(); + const expectedState: State = { + ...initialState, isLoading: true, - pods: [pod1, pod2], - resourceVersion: '0', + error: undefined, }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - const expectedState: testStore.State = { - isLoading: true, - pods: [pod1, pod2], - resourceVersion: '0', - }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle REQUEST_PODS', () => { - const initialState: testStore.State = { + it('should handle podListReceiveAction', () => { + const pods = [{ metadata: { name: 'pod1' } }] as V1Pod[]; + const resourceVersion = '12345'; + (getNewerResourceVersion as jest.Mock).mockReturnValue(resourceVersion); + const action = podListReceiveAction({ pods, resourceVersion }); + const expectedState: State = { + ...initialState, isLoading: false, - pods: [], - error: 'unexpected error', - resourceVersion: '0', + pods, + resourceVersion, }; - const incomingAction: testStore.RequestPodsAction = { - type: testStore.Type.REQUEST_PODS, - check: AUTHORIZED, - }; - - const newState = testStore.reducer(initialState, incomingAction); - const expectedState: testStore.State = { - isLoading: true, - pods: [], - resourceVersion: '0', - }; - - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_PODS', () => { - const initialState: testStore.State = { - isLoading: true, - pods: [], - resourceVersion: '0', - }; - const incomingAction: testStore.ReceivePodsAction = { - type: testStore.Type.RECEIVE_PODS, - pods: [pod1, pod2], - resourceVersion: '1', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { + it('should handle podListErrorAction', () => { + const error = 'Error message'; + const action = podListErrorAction(error); + const expectedState: State = { + ...initialState, isLoading: false, - pods: [pod1, pod2], - resourceVersion: '1', + error, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - pods: [], - resourceVersion: '0', - }; - const incomingAction: testStore.ReceiveErrorAction = { - type: testStore.Type.RECEIVE_ERROR, - error: 'unexpected error', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - pods: [], - error: 'unexpected error', - resourceVersion: '0', + it('should handle podReceiveAction', () => { + const pod = { metadata: { name: 'pod1', resourceVersion: '12345' } } as V1Pod; + (getNewerResourceVersion as jest.Mock).mockReturnValue('12345'); + const action = podReceiveAction(pod); + const expectedState: State = { + ...initialState, + pods: [pod], + resourceVersion: '12345', }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_POD', () => { - const initialState: testStore.State = { - isLoading: false, - pods: [pod1], - resourceVersion: '0', + it('should handle podModifyAction', () => { + const initialStateWithPods: State = { + ...initialState, + pods: [{ metadata: { name: 'pod1' } }, { metadata: { name: 'pod2' } }] as V1Pod[], }; - - const newPod = cloneDeep(pod2); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - newPod.metadata!.resourceVersion = '1234'; - - const incomingAction: testStore.ReceivePodAction = { - type: testStore.Type.RECEIVE_POD, - pod: newPod, + const modifiedPod = { metadata: { name: 'pod1', resourceVersion: '12345' } } as V1Pod; + (isSamePod as jest.Mock).mockReturnValueOnce(true).mockReturnValue(false); + (getNewerResourceVersion as jest.Mock).mockReturnValue('12345'); + const action = podModifyAction(modifiedPod); + const expectedState: State = { + ...initialStateWithPods, + pods: [modifiedPod, initialStateWithPods.pods[1]], + resourceVersion: '12345', }; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - pods: [pod1, newPod], - resourceVersion: '1234', - }; - - expect(newState).toEqual(expectedState); + expect(reducer(initialStateWithPods, action)).toEqual(expectedState); }); - it('should handle MODIFY_POD', () => { - const initialState: testStore.State = { - isLoading: false, - pods: [pod1, pod2], - resourceVersion: '0', - }; - - const modifiedPod = cloneDeep(pod1); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - modifiedPod.metadata!.resourceVersion = '2345'; - - const incomingAction: testStore.ModifyPodAction = { - type: testStore.Type.MODIFY_POD, - pod: modifiedPod, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - pods: [modifiedPod, pod2], - resourceVersion: '2345', + it('should handle podDeleteAction', () => { + const initialStateWithPods: State = { + ...initialState, + pods: [{ metadata: { name: 'pod1' } }] as V1Pod[], + }; + const podToDelete = { metadata: { name: 'pod1', resourceVersion: '12345' } } as V1Pod; + (isSamePod as jest.Mock).mockReturnValue(true); + (getNewerResourceVersion as jest.Mock).mockReturnValue('12345'); + const action = podDeleteAction(podToDelete); + const expectedState: State = { + ...initialStateWithPods, + pods: [], + resourceVersion: '12345', }; - expect(newState).toEqual(expectedState); + expect(reducer(initialStateWithPods, action)).toEqual(expectedState); }); - it('should handle DELETE_POD', () => { - const initialState: testStore.State = { - isLoading: false, - pods: [pod1, pod2], - resourceVersion: '0', - }; - - const deletedPod = cloneDeep(pod1); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - deletedPod.metadata!.resourceVersion = '3456'; - - const incomingAction: testStore.DeletePodAction = { - type: testStore.Type.DELETE_POD, - pod: deletedPod, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - pods: [pod2], - resourceVersion: '3456', - }; - - expect(newState).toEqual(expectedState); + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/Pods/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Pods/__tests__/selectors.spec.ts index 872801a00..c31105baa 100644 --- a/packages/dashboard-frontend/src/store/Pods/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/Pods/__tests__/selectors.spec.ts @@ -10,53 +10,41 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { pod1, pod2 } from '@/store/Pods/__tests__/stub'; -import { selectAllPods, selectPodsError, selectPodsResourceVersion } from '@/store/Pods/selectors'; - -import * as store from '..'; - -describe('Pods store, selectors', () => { - it('should return the error', () => { - const fakeStore = new FakeStoreBuilder() - .withPods({ error: 'Something unexpected', pods: [] }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedError = selectPodsError(state); - expect(selectedError).toEqual('Something unexpected'); +import { RootState } from '@/store'; +import { + selectAllPods, + selectPodsError, + selectPodsIsLoading, + selectPodsResourceVersion, +} from '@/store/Pods'; + +describe('Pods Selectors', () => { + const mockState = { + pods: { + pods: [{ metadata: { name: 'pod1' } }, { metadata: { name: 'pod2' } }], + error: 'Something went wrong', + isLoading: true, + resourceVersion: '12345', + }, + } as RootState; + + it('should select all pods', () => { + const result = selectAllPods(mockState); + expect(result).toEqual([{ metadata: { name: 'pod1' } }, { metadata: { name: 'pod2' } }]); }); - it('should return all pods', () => { - const fakeStore = new FakeStoreBuilder() - .withPods({ pods: [pod1, pod2] }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const allPods = selectAllPods(state); - expect(allPods).toEqual([pod1, pod2]); + it('should select pods error', () => { + const result = selectPodsError(mockState); + expect(result).toEqual('Something went wrong'); }); - it('should return the resource version', () => { - const fakeStore = new FakeStoreBuilder() - .withPods({ pods: [pod1, pod2], resourceVersion: '1234' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select pods isLoading', () => { + const result = selectPodsIsLoading(mockState); + expect(result).toBe(true); + }); - const resourceVersion = selectPodsResourceVersion(state); - expect(resourceVersion).toEqual('1234'); + it('should select pods resource version', () => { + const result = selectPodsResourceVersion(mockState); + expect(result).toEqual('12345'); }); }); diff --git a/packages/dashboard-frontend/src/store/Pods/actions.ts b/packages/dashboard-frontend/src/store/Pods/actions.ts new file mode 100644 index 000000000..67ff1b8ca --- /dev/null +++ b/packages/dashboard-frontend/src/store/Pods/actions.ts @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { V1Pod } from '@kubernetes/client-node'; +import { createAction } from '@reduxjs/toolkit'; + +import { container } from '@/inversify.config'; +import { fetchPods } from '@/services/backend-client/podsApi'; +import { WebsocketClient } from '@/services/backend-client/websocketClient'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +/** Pod List */ + +export const podListRequestAction = createAction('pods/request'); + +type PodListReceivePayload = { + pods: V1Pod[]; + resourceVersion: string | undefined; +}; +export const podListReceiveAction = createAction('pods/receive'); + +export const podListErrorAction = createAction('pods/error'); + +/** Pod */ + +export const podReceiveAction = createAction('pod/receive'); +export const podModifyAction = createAction('pod/modify'); +export const podDeleteAction = createAction('pod/delete'); + +export const actionCreators = { + requestPods: (): AppThunk => async (dispatch, getState) => { + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const defaultNamespace = defaultKubernetesNamespace.name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(podListRequestAction()); + + const podsList = await fetchPods(defaultNamespace); + + dispatch( + podListReceiveAction({ + pods: podsList.items, + resourceVersion: podsList.metadata?.resourceVersion, + }), + ); + } catch (e) { + const errorMessage = 'Failed to fetch pods, reason: ' + helpers.errors.getMessage(e); + dispatch(podListErrorAction(errorMessage)); + throw e; + } + }, + + handleWebSocketMessage: + (message: api.webSocket.NotificationMessage): AppThunk => + async (dispatch, getState) => { + if (api.webSocket.isStatusMessage(message)) { + const { status } = message; + + const errorMessage = `WebSocket(POD): status code ${status.code}, reason: ${status.message}`; + console.debug(errorMessage); + + if (status.code !== 200) { + /* in case of error status trying to fetch all pods and re-subscribe to websocket channel */ + + const websocketClient = container.get(WebsocketClient); + + websocketClient.unsubscribeFromChannel(api.webSocket.Channel.POD); + + await dispatch(actionCreators.requestPods()); + + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const namespace = defaultKubernetesNamespace.name; + const getResourceVersion = () => { + const state = getState(); + return state.pods.resourceVersion; + }; + websocketClient.subscribeToChannel(api.webSocket.Channel.POD, namespace, { + getResourceVersion, + }); + } + return; + } + + if (api.webSocket.isPodMessage(message)) { + const { pod, eventPhase } = message; + switch (eventPhase) { + case api.webSocket.EventPhase.ADDED: { + dispatch(podReceiveAction(pod)); + return; + } + case api.webSocket.EventPhase.MODIFIED: { + dispatch(podModifyAction(pod)); + return; + } + case api.webSocket.EventPhase.DELETED: { + dispatch(podDeleteAction(pod)); + return; + } + } + } + + console.warn('WebSocket: unexpected message:', message); + }, +}; diff --git a/packages/dashboard-frontend/src/store/Pods/index.ts b/packages/dashboard-frontend/src/store/Pods/index.ts index 07d4d0eae..e583a5d74 100644 --- a/packages/dashboard-frontend/src/store/Pods/index.ts +++ b/packages/dashboard-frontend/src/store/Pods/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,236 +12,6 @@ * Red Hat, Inc. - initial API and implementation */ -import { api, helpers } from '@eclipse-che/common'; -import { V1Pod } from '@kubernetes/client-node'; -import { Action, Reducer } from 'redux'; - -import { container } from '@/inversify.config'; -import { fetchPods } from '@/services/backend-client/podsApi'; -import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import { getNewerResourceVersion } from '@/services/helpers/resourceVersion'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import isSamePod from '@/store/Pods/isSamePod'; -import { selectPodsResourceVersion } from '@/store/Pods/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -import { AppThunk } from '..'; - -export interface State { - isLoading: boolean; - pods: V1Pod[]; - resourceVersion: string; - error?: string; -} - -export enum Type { - REQUEST_PODS = 'REQUEST_PODS', - RECEIVE_PODS = 'RECEIVE_PODS', - RECEIVE_ERROR = 'RECEIVE_ERROR', - RECEIVE_POD = 'RECEIVE_POD', - MODIFY_POD = 'MODIFY_POD', - DELETE_POD = 'DELETE_POD', -} - -export interface RequestPodsAction extends Action, SanityCheckAction { - type: Type.REQUEST_PODS; -} - -export interface ReceivePodsAction { - type: Type.RECEIVE_PODS; - pods: V1Pod[]; - resourceVersion: string | undefined; -} - -export interface ReceiveErrorAction { - type: Type.RECEIVE_ERROR; - error: string; -} - -export interface ReceivePodAction { - type: Type.RECEIVE_POD; - pod: V1Pod; -} - -export interface ModifyPodAction { - type: Type.MODIFY_POD; - pod: V1Pod; -} - -export interface DeletePodAction { - type: Type.DELETE_POD; - pod: V1Pod; -} - -export type KnownAction = - | RequestPodsAction - | ReceivePodsAction - | ReceiveErrorAction - | ReceivePodAction - | ModifyPodAction - | DeletePodAction; - -export type ActionCreators = { - requestPods: () => AppThunk>; - - handleWebSocketMessage: ( - message: api.webSocket.NotificationMessage, - ) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestPods: - (): AppThunk> => - async (dispatch, getState): Promise => { - await dispatch({ - type: Type.REQUEST_PODS, - check: AUTHORIZED, - }); - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const defaultNamespace = defaultKubernetesNamespace.name; - - try { - const podsList = await fetchPods(defaultNamespace); - - dispatch({ - type: Type.RECEIVE_PODS, - pods: podsList.items, - resourceVersion: podsList.metadata?.resourceVersion, - }); - } catch (e) { - const errorMessage = 'Failed to fetch pods, reason: ' + helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - handleWebSocketMessage: - (message: api.webSocket.NotificationMessage): AppThunk> => - async (dispatch, getState): Promise => { - if (api.webSocket.isStatusMessage(message)) { - const { status } = message; - - const errorMessage = `WebSocket(POD): status code ${status.code}, reason: ${status.message}`; - console.debug(errorMessage); - - if (status.code !== 200) { - /* in case of error status trying to fetch all pods and re-subscribe to websocket channel */ - - const websocketClient = container.get(WebsocketClient); - - websocketClient.unsubscribeFromChannel(api.webSocket.Channel.POD); - - await dispatch(actionCreators.requestPods()); - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const namespace = defaultKubernetesNamespace.name; - const getResourceVersion = () => { - const state = getState(); - return selectPodsResourceVersion(state); - }; - websocketClient.subscribeToChannel(api.webSocket.Channel.POD, namespace, { - getResourceVersion, - }); - } - return; - } - - if (api.webSocket.isPodMessage(message)) { - const { pod, eventPhase } = message; - switch (eventPhase) { - case api.webSocket.EventPhase.ADDED: { - dispatch({ - type: Type.RECEIVE_POD, - pod, - }); - break; - } - case api.webSocket.EventPhase.MODIFIED: { - dispatch({ - type: Type.MODIFY_POD, - pod, - }); - break; - } - case api.webSocket.EventPhase.DELETED: { - dispatch({ - type: Type.DELETE_POD, - pod, - }); - break; - } - default: - console.warn('WebSocket: unexpected eventPhase:', message); - } - return; - } - - console.warn('WebSocket: unexpected message:', message); - }, -}; - -const unloadedState: State = { - isLoading: false, - pods: [], - resourceVersion: '0', -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_PODS: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_PODS: - return createObject(state, { - isLoading: false, - pods: action.pods, - resourceVersion: getNewerResourceVersion(action.resourceVersion, state.resourceVersion), - }); - case Type.RECEIVE_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - case Type.RECEIVE_POD: - return createObject(state, { - pods: state.pods.concat([action.pod]), - resourceVersion: getNewerResourceVersion( - action.pod.metadata?.resourceVersion, - state.resourceVersion, - ), - }); - case Type.MODIFY_POD: - return createObject(state, { - pods: state.pods.map(pod => (isSamePod(pod, action.pod) ? action.pod : pod)), - resourceVersion: getNewerResourceVersion( - action.pod.metadata?.resourceVersion, - state.resourceVersion, - ), - }); - case Type.DELETE_POD: - return createObject(state, { - pods: state.pods.filter(pod => isSamePod(pod, action.pod) === false), - resourceVersion: getNewerResourceVersion( - action.pod.metadata?.resourceVersion, - state.resourceVersion, - ), - }); - default: - return state; - } -}; +export { actionCreators as podsActionCreators } from '@/store/Pods/actions'; +export { reducer as podsReducer, State as PodsState } from '@/store/Pods/reducer'; +export * from '@/store/Pods/selectors'; diff --git a/packages/dashboard-frontend/src/store/Pods/reducer.ts b/packages/dashboard-frontend/src/store/Pods/reducer.ts new file mode 100644 index 000000000..38da80de5 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Pods/reducer.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { V1Pod } from '@kubernetes/client-node'; +import { createReducer } from '@reduxjs/toolkit'; + +import { getNewerResourceVersion } from '@/services/helpers/resourceVersion'; +import { + podDeleteAction, + podListErrorAction, + podListReceiveAction, + podListRequestAction, + podModifyAction, + podReceiveAction, +} from '@/store/Pods/actions'; +import isSamePod from '@/store/Pods/isSamePod'; + +export interface State { + isLoading: boolean; + pods: V1Pod[]; + resourceVersion: string; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + pods: [], + resourceVersion: '0', +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(podListRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(podListReceiveAction, (state, action) => { + state.isLoading = false; + state.pods = action.payload.pods; + state.resourceVersion = getNewerResourceVersion( + action.payload.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(podListErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addCase(podReceiveAction, (state, action) => { + state.pods.push(action.payload); + state.resourceVersion = getNewerResourceVersion( + action.payload.metadata?.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(podModifyAction, (state, action) => { + state.pods = state.pods.map(pod => (isSamePod(pod, action.payload) ? action.payload : pod)); + state.resourceVersion = getNewerResourceVersion( + action.payload.metadata?.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(podDeleteAction, (state, action) => { + state.pods = state.pods.filter(pod => isSamePod(pod, action.payload) === false); + state.resourceVersion = getNewerResourceVersion( + action.payload.metadata?.resourceVersion, + state.resourceVersion, + ); + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Pods/selectors.ts b/packages/dashboard-frontend/src/store/Pods/selectors.ts index a7ce49b61..2f5a262a9 100644 --- a/packages/dashboard-frontend/src/store/Pods/selectors.ts +++ b/packages/dashboard-frontend/src/store/Pods/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.pods; +const selectState = (state: RootState) => state.pods; export const selectAllPods = createSelector(selectState, state => state.pods); diff --git a/packages/dashboard-frontend/src/store/SanityCheck/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/actions.spec.ts new file mode 100644 index 000000000..f6240fe2c --- /dev/null +++ b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/actions.spec.ts @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { helpers } from '@eclipse-che/common'; + +import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { signIn } from '@/services/helpers/login'; +import { + getErrorMessage, + hasLoginPage, + isForbidden, + isUnauthorized, +} from '@/services/workspace-client/helpers'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { SanityCheckState } from '@/store/SanityCheck'; +import { + actionCreators, + backendCheckErrorAction, + backendCheckReceiveAction, + backendCheckRequestAction, +} from '@/store/SanityCheck/actions'; + +jest.mock('@/services/backend-client/kubernetesNamespaceApi'); +jest.mock('@/services/helpers/deferred'); +jest.mock('@/services/helpers/delay'); +jest.mock('@/services/helpers/login'); +jest.mock('@/services/workspace-client/helpers'); +jest.mock('@eclipse-che/common'); + +describe('SanityCheck, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({ + sanityCheck: { + lastFetched: 0, + } as SanityCheckState, + }); + + (provisionKubernetesNamespace as jest.Mock).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('testBackends', () => { + it('should dispatch backendCheckRequestAction if timeElapsed is greater than timeToStale', async () => { + store = createMockStore({ + sanityCheck: { + lastFetched: 0, + } as SanityCheckState, + }); + + await store.dispatch(actionCreators.testBackends()); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual( + backendCheckRequestAction({ + lastFetched: expect.any(Number), + }), + ); + expect(actions[1]).toEqual(backendCheckReceiveAction()); + }); + + it('should not dispatch backendCheckRequestAction if timeElapsed is less than timeToStale', async () => { + store = createMockStore({ + sanityCheck: { + lastFetched: Date.now(), + } as SanityCheckState, + }); + + await store.dispatch(actionCreators.testBackends()); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + }); + + it('should dispatch backendCheckReceiveAction on successful provision', async () => { + await store.dispatch(actionCreators.testBackends()); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual( + backendCheckRequestAction({ + lastFetched: expect.any(Number), + }), + ); + expect(actions[1]).toEqual(backendCheckReceiveAction()); + }); + + it('should dispatch backendCheckErrorAction on failed provision', async () => { + const errorMessage = 'Network error'; + (provisionKubernetesNamespace as jest.Mock).mockRejectedValue({ + message: errorMessage, + response: { + data: { + trace: ['Error 1', 'Error 2'], + }, + }, + }); + (isUnauthorized as jest.Mock).mockReturnValueOnce(false); + (isForbidden as jest.Mock).mockReturnValueOnce(false); + (getErrorMessage as jest.Mock).mockReturnValueOnce(errorMessage); + (helpers.errors.getMessage as jest.Mock).mockReturnValueOnce(errorMessage); + jest.spyOn(helpers.errors, 'includesAxiosResponse').mockReturnValueOnce(true); + console.error = jest.fn(); + + await expect(store.dispatch(actionCreators.testBackends())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual( + backendCheckRequestAction({ + lastFetched: expect.any(Number), + }), + ); + expect(actions[1]).toEqual(backendCheckErrorAction(errorMessage)); + + expect(console.error).toHaveBeenCalledTimes(2); + expect(console.error).toHaveBeenNthCalledWith(1, errorMessage); + expect(console.error).toHaveBeenNthCalledWith(2, 'Error 1\nError 2'); + }); + + it('should call signIn if unauthorized or forbidden with login page', async () => { + const errorMessage = 'Unauthorized'; + const error = new Error(errorMessage); + (provisionKubernetesNamespace as jest.Mock).mockRejectedValue(error); + (isUnauthorized as jest.Mock).mockReturnValueOnce(true); + (isForbidden as jest.Mock).mockReturnValueOnce(true); + (hasLoginPage as jest.Mock).mockReturnValueOnce(true); + (getErrorMessage as jest.Mock).mockReturnValueOnce(errorMessage); + (helpers.errors.getMessage as jest.Mock).mockReturnValueOnce(errorMessage); + console.error = jest.fn(); + + await expect(store.dispatch(actionCreators.testBackends())).rejects.toThrow(errorMessage); + + expect(signIn).toHaveBeenCalled(); + + expect(console.error).toHaveBeenCalledWith(errorMessage); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/SanityCheck/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/index.spec.ts new file mode 100644 index 000000000..8449230c8 --- /dev/null +++ b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/index.spec.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; + +import { RootState } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +const mockTestBackends = jest.fn(); +jest.mock('@/store/SanityCheck/actions', () => { + const originalModule = jest.requireActual('@/store/SanityCheck/actions'); + + return { + ...originalModule, + actionCreators: { + testBackends: () => mockTestBackends(), + }, + }; +}); + +describe('SanityCheck, verifyAuthorized', () => { + let dispatch: ThunkDispatch; + let getState: () => RootState; + + beforeEach(() => { + dispatch = jest.fn(); + getState = jest.fn(); + + jest.clearAllMocks(); + }); + + it('should dispatch testBackends and resolve if authorized', async () => { + const mockState = { + sanityCheck: { + authorized: true, + error: undefined, + }, + } as RootState; + + (getState as jest.Mock).mockReturnValue(mockState); + + await verifyAuthorized(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith(mockTestBackends()); + }); + + it('should dispatch testBackends and throw error if not authorized', async () => { + const errorMessage = 'Not authorized'; + const mockState = { + sanityCheck: { + authorized: false, + error: errorMessage, + }, + } as RootState; + + (getState as jest.Mock).mockReturnValue(mockState); + + await expect(verifyAuthorized(dispatch, getState)).rejects.toThrow(errorMessage); + + expect(dispatch).toHaveBeenCalledWith(mockTestBackends()); + }); + + it('should dispatch testBackends and throw error if not authorized and error is undefined', async () => { + const mockState = { + sanityCheck: { + authorized: false, + error: undefined, + }, + } as RootState; + + (getState as jest.Mock).mockReturnValue(mockState); + + await expect(verifyAuthorized(dispatch, getState)).rejects.toThrow(''); + + expect(dispatch).toHaveBeenCalledWith(mockTestBackends()); + }); +}); diff --git a/packages/dashboard-frontend/src/store/SanityCheck/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/reducer.spec.ts new file mode 100644 index 000000000..51f963bbf --- /dev/null +++ b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/reducer.spec.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import { + backendCheckErrorAction, + backendCheckReceiveAction, + backendCheckRequestAction, +} from '@/store/SanityCheck/actions'; +import { reducer, State, unloadedState } from '@/store/SanityCheck/reducer'; + +describe('SanityCheck reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle backendCheckRequestAction', () => { + const payload = { + lastFetched: Date.now(), + }; + const action = backendCheckRequestAction(payload); + const expectedState: State = { + ...initialState, + lastFetched: payload.lastFetched, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle backendCheckReceiveAction', () => { + const action = backendCheckReceiveAction(); + const expectedState: State = { + ...initialState, + error: undefined, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle backendCheckErrorAction', () => { + const error = 'Error message'; + const action = backendCheckErrorAction(error); + const expectedState: State = { + ...initialState, + authorized: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/SanityCheck/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/selectors.spec.ts new file mode 100644 index 000000000..d8c536ffb --- /dev/null +++ b/packages/dashboard-frontend/src/store/SanityCheck/__tests__/selectors.spec.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; + +describe('SanityCheck Selectors', () => { + const mockState = { + sanityCheck: { + authorized: true, + error: 'Something went wrong', + }, + } as RootState; + + it('should select async isAuthorized', async () => { + const result = await selectAsyncIsAuthorized(mockState); + expect(result).toBe(true); + }); + + it('should return false if an error occurs in selectAsyncIsAuthorized', async () => { + const mockStateWithError = { + sanityCheck: { + authorized: false, + }, + } as RootState; + + await expect(selectAsyncIsAuthorized(mockStateWithError)).resolves.toEqual(false); + }); + + it('should select sanity check error', () => { + const result = selectSanityCheckError(mockState); + expect(result).toBe('Something went wrong'); + }); + + it('should return an empty string if error is not set', () => { + const stateWithoutError = { + ...mockState, + sanityCheck: { ...mockState.sanityCheck, error: undefined }, + } as RootState; + const result = selectSanityCheckError(stateWithoutError); + expect(result).toBe(''); + }); +}); diff --git a/packages/dashboard-frontend/src/store/SanityCheck/actions.ts b/packages/dashboard-frontend/src/store/SanityCheck/actions.ts new file mode 100644 index 000000000..5a4678bdd --- /dev/null +++ b/packages/dashboard-frontend/src/store/SanityCheck/actions.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { helpers } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; +import { delay } from '@/services/helpers/delay'; +import { signIn } from '@/services/helpers/login'; +import { + getErrorMessage, + hasLoginPage, + isForbidden, + isUnauthorized, +} from '@/services/workspace-client/helpers'; +import { AppThunk } from '@/store'; + +const secToStale = 15; +const timeToStale = secToStale * 1000; +const maxAttemptsNumber = 2; + +interface BackendCheckRequestPayload { + lastFetched: number; +} +export const backendCheckRequestAction = + createAction('backendCheck/request'); + +export const backendCheckReceiveAction = createAction('backendCheck/receive'); +export const backendCheckErrorAction = createAction('backendCheck/error'); + +export const actionCreators = { + testBackends: (): AppThunk => async (dispatch, getState) => { + const { lastFetched } = getState().sanityCheck; + const timeElapsed = Date.now() - lastFetched; + if (timeElapsed < timeToStale) { + return; + } + + try { + dispatch( + backendCheckRequestAction({ + lastFetched: Date.now(), + }), + ); + for (let attempt = 1; attempt <= maxAttemptsNumber; attempt++) { + try { + await provisionKubernetesNamespace(); + dispatch(backendCheckReceiveAction()); + break; + } catch (e) { + if (attempt === maxAttemptsNumber) { + throw e; + } + await delay(1000); + } + } + } catch (e) { + if (isUnauthorized(e) || (isForbidden(e) && hasLoginPage(e))) { + signIn(); + } + const errorMessage = getErrorMessage(e); + dispatch(backendCheckErrorAction(errorMessage)); + console.error(helpers.errors.getMessage(e)); + if ( + helpers.errors.includesAxiosResponse(e) && + e.response.data.trace && + Array.isArray(e.response.data.trace) + ) { + console.error(e.response.data.trace.join('\n')); + } + throw new Error(errorMessage); + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/SanityCheck/index.ts b/packages/dashboard-frontend/src/store/SanityCheck/index.ts index 2dc8cbe7a..699460f7e 100644 --- a/packages/dashboard-frontend/src/store/SanityCheck/index.ts +++ b/packages/dashboard-frontend/src/store/SanityCheck/index.ts @@ -10,162 +10,31 @@ * Red Hat, Inc. - initial API and implementation */ -import { helpers } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; -import { getDefer } from '@/services/helpers/deferred'; -import { delay } from '@/services/helpers/delay'; -import { signIn } from '@/services/helpers/login'; -import { - getErrorMessage, - hasLoginPage, - isForbidden, - isUnauthorized, -} from '@/services/workspace-client/helpers'; -import { createObject } from '@/store/helpers'; - -import { AppThunk } from '..'; - -const secToStale = 15; -const timeToStale = secToStale * 1000; -const maxAttemptsNumber = 2; - -export interface State { - authorized: Promise; - lastFetched: number; - error?: string; -} - -export enum Type { - REQUEST_BACKEND_CHECK = 'REQUEST_BACKEND_CHECK', - ABORT_BACKEND_CHECK = 'ABORT_BACKEND_CHECK', - RECEIVED_BACKEND_CHECK = 'RECEIVED_BACKEND_CHECK', - RECEIVED_BACKEND_CHECK_ERROR = 'RECEIVED_BACKEND_CHECK_ERROR', -} - -export interface RequestBackendCheckAction extends Action { - type: Type.REQUEST_BACKEND_CHECK; - authorized: Promise; - lastFetched: number; -} - -export interface AbortBackendCheckAction extends Action { - type: Type.ABORT_BACKEND_CHECK; -} - -export interface ReceivedBackendCheckAction extends Action { - type: Type.RECEIVED_BACKEND_CHECK; -} - -export interface ReceivedBackendCheckErrorAction extends Action { - type: Type.RECEIVED_BACKEND_CHECK_ERROR; - error: string; -} - -type KnownAction = - | RequestBackendCheckAction - | AbortBackendCheckAction - | ReceivedBackendCheckAction - | ReceivedBackendCheckErrorAction; - -export type ActionCreators = { - testBackends: () => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - testBackends: - (): AppThunk> => - async (dispatch, getState): Promise => { - const { lastFetched } = getState().sanityCheck; - const timeElapsed = Date.now() - lastFetched; - - if (timeElapsed < timeToStale) { - dispatch({ - type: Type.ABORT_BACKEND_CHECK, - }); - return; - } - - // do not reject this promise because it causes Uncaught promise rejection error - const deferred = getDefer(); - try { - dispatch({ - type: Type.REQUEST_BACKEND_CHECK, - authorized: deferred.promise, - lastFetched: Date.now(), - }); - - for (let attempt = 1; attempt <= maxAttemptsNumber; attempt++) { - try { - await provisionKubernetesNamespace(); - - deferred.resolve(true); - dispatch({ - type: Type.RECEIVED_BACKEND_CHECK, - }); - - break; - } catch (e) { - if (attempt === maxAttemptsNumber) { - throw e; - } - await delay(1000); - } - } - } catch (e) { - if (isUnauthorized(e) || (isForbidden(e) && hasLoginPage(e))) { - signIn(); - } - const errorMessage = getErrorMessage(e); - dispatch({ - type: Type.RECEIVED_BACKEND_CHECK_ERROR, - error: errorMessage, - }); - deferred.resolve(false); - console.error(helpers.errors.getMessage(e)); - if ( - helpers.errors.includesAxiosResponse(e) && - e.response.data.trace && - Array.isArray(e.response.data.trace) - ) { - console.error(e.response.data.trace.join('\n')); - } - throw new Error(errorMessage); - } - }, -}; - -const unloadedState: State = { - authorized: Promise.resolve(true), - lastFetched: 0, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; +import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; + +import { RootState } from '@/store'; +import { actionCreators } from '@/store/SanityCheck/actions'; + +/* c8 ignore start */ + +export { actionCreators as sanityCheckActionCreators } from '@/store/SanityCheck/actions'; +export { + reducer as sanityCheckReducer, + State as SanityCheckState, +} from '@/store/SanityCheck/reducer'; +export * from '@/store/SanityCheck/selectors'; + +/* c8 ignore stop */ + +export async function verifyAuthorized( + dispatch: ThunkDispatch, + getState: () => RootState, +): Promise { + await dispatch(actionCreators.testBackends()); + const state = getState(); + const authorized = await state.sanityCheck.authorized; + if (authorized === false) { + const error = state.sanityCheck.error || ''; + throw new Error(error); } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_BACKEND_CHECK: - return createObject(state, { - error: undefined, - authorized: action.authorized, - lastFetched: action.lastFetched, - }); - case Type.RECEIVED_BACKEND_CHECK: - return createObject(state, {}); - case Type.RECEIVED_BACKEND_CHECK_ERROR: - return createObject(state, { - authorized: state.authorized, - lastFetched: state.lastFetched, - error: action.error, - }); - default: - return state; - } -}; +} diff --git a/packages/dashboard-frontend/src/store/SanityCheck/reducer.ts b/packages/dashboard-frontend/src/store/SanityCheck/reducer.ts new file mode 100644 index 000000000..42da50b36 --- /dev/null +++ b/packages/dashboard-frontend/src/store/SanityCheck/reducer.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { + backendCheckErrorAction, + backendCheckReceiveAction, + backendCheckRequestAction, +} from '@/store/SanityCheck/actions'; + +export interface State { + authorized: boolean; + lastFetched: number; + error?: string; +} + +export const unloadedState: State = { + authorized: true, + lastFetched: 0, +}; + +export const reducer = createReducer(unloadedState, builder => { + builder + .addCase(backendCheckRequestAction, (state, action) => { + state.lastFetched = action.payload.lastFetched; + }) + .addCase(backendCheckReceiveAction, state => { + state.authorized = true; + state.error = undefined; + }) + .addCase(backendCheckErrorAction, (state, action) => { + state.authorized = false; + state.error = action.payload; + }) + .addDefaultCase(state => state); +}); diff --git a/packages/dashboard-frontend/src/store/SanityCheck/selectors.ts b/packages/dashboard-frontend/src/store/SanityCheck/selectors.ts index b2ec29760..8f204c56a 100644 --- a/packages/dashboard-frontend/src/store/SanityCheck/selectors.ts +++ b/packages/dashboard-frontend/src/store/SanityCheck/selectors.ts @@ -10,17 +10,18 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '..'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.sanityCheck; +const selectState = (state: RootState) => state.sanityCheck; export const selectAsyncIsAuthorized = createSelector( selectState, async (state): Promise => { try { - return state.authorized; + const isAuthorized = await state.authorized; + return isAuthorized; } catch (e) { return false; } diff --git a/packages/dashboard-frontend/src/store/ServerConfig/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/ServerConfig/__tests__/actions.spec.ts new file mode 100644 index 000000000..0ea88a98c --- /dev/null +++ b/packages/dashboard-frontend/src/store/ServerConfig/__tests__/actions.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api } from '@eclipse-che/common'; + +import * as ServerConfigApi from '@/services/backend-client/serverConfigApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + actionCreators, + serverConfigErrorAction, + serverConfigReceiveAction, + serverConfigRequestAction, +} from '@/store/ServerConfig/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/serverConfigApi'); +jest.mock('@/store/SanityCheck'); + +describe('ServerConfig, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestServerConfig', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockConfig = { + allowedSourceUrls: ['https://github.com'], + // ... + } as api.IServerConfig; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (ServerConfigApi.fetchServerConfig as jest.Mock).mockResolvedValue(mockConfig); + + await store.dispatch(actionCreators.requestServerConfig()); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(serverConfigRequestAction()); + expect(actions[1]).toEqual(serverConfigReceiveAction(mockConfig)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (ServerConfigApi.fetchServerConfig as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestServerConfig())).rejects.toThrow( + `Failed to fetch workspace defaults. ${errorMessage}`, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(serverConfigRequestAction()); + expect(actions[1]).toEqual(serverConfigErrorAction(errorMessage)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/ServerConfig/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/ServerConfig/__tests__/index.spec.ts deleted file mode 100644 index b51ccb636..000000000 --- a/packages/dashboard-frontend/src/store/ServerConfig/__tests__/index.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import mockAxios from 'axios'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as dwServerConfigStore from '@/store/ServerConfig'; -import { serverConfig } from '@/store/ServerConfig/__tests__/stubs'; - -// mute the outputs -console.error = jest.fn(); - -describe('dwPlugins store', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('actions', () => { - it('should create RECEIVE_DW_SERVER_CONFIG when fetching server config', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: serverConfig, - }); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(dwServerConfigStore.actionCreators.requestServerConfig()); - - const actions = store.getActions(); - - const expectedActions: dwServerConfigStore.KnownAction[] = [ - { - type: 'REQUEST_DW_SERVER_CONFIG', - }, - { - type: 'RECEIVE_DW_SERVER_CONFIG', - config: serverConfig, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - }); - - it('should create RECEIVE_DW_SERVER_CONFIG_ERROR when fetching server and got an error', async () => { - (mockAxios.get as jest.Mock).mockRejectedValue('Test error'); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(dwServerConfigStore.actionCreators.requestServerConfig()); - } catch (e) { - // noop - } - - const actions = store.getActions(); - - const expectedActions: dwServerConfigStore.KnownAction[] = [ - { - type: 'REQUEST_DW_SERVER_CONFIG', - }, - { - type: 'RECEIVE_DW_SERVER_CONFIG_ERROR', - error: 'Test error', - }, - ]; - expect(actions).toEqual(expectedActions); - }); -}); diff --git a/packages/dashboard-frontend/src/store/ServerConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/ServerConfig/__tests__/reducer.spec.ts new file mode 100644 index 000000000..9ba00effd --- /dev/null +++ b/packages/dashboard-frontend/src/store/ServerConfig/__tests__/reducer.spec.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { V230DevfileComponents } from '@devfile/api'; +import { api } from '@eclipse-che/common'; +import { UnknownAction } from 'redux'; + +import { + serverConfigErrorAction, + serverConfigReceiveAction, + serverConfigRequestAction, +} from '@/store/ServerConfig/actions'; +import { reducer, State, unloadedState } from '@/store/ServerConfig/reducer'; + +describe('ServerConfig, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle serverConfigRequestAction', () => { + const action = serverConfigRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle serverConfigReceiveAction', () => { + const config = { + containerBuild: {}, + defaults: { + editor: 'editor', + components: [ + { + name: 'component1', + }, + ] as V230DevfileComponents[], + plugins: [ + { + plugins: ['plugin1'], + }, + ] as api.IWorkspacesDefaultPlugins[], + pvcStrategy: 'strategy', + }, + devfileRegistry: { + disableInternalRegistry: true, + externalDevfileRegistries: [], + }, + pluginRegistry: { + openVSXURL: 'url', + }, + timeouts: { + inactivityTimeout: 100, + runTimeout: 200, + startTimeout: 300, + }, + defaultNamespace: { + autoProvision: false, + }, + cheNamespace: 'namespace', + pluginRegistryURL: 'pluginURL', + pluginRegistryInternalURL: 'internalURL', + allowedSourceUrls: ['url1'], + } as api.IServerConfig; + const action = serverConfigReceiveAction(config); + const expectedState: State = { + ...initialState, + isLoading: false, + config, + error: undefined, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle serverConfigErrorAction', () => { + const error = 'Error message'; + const action = serverConfigErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/ServerConfig/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/ServerConfig/__tests__/selectors.spec.ts index d9c1af2d7..4fafb4697 100644 --- a/packages/dashboard-frontend/src/store/ServerConfig/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/ServerConfig/__tests__/selectors.spec.ts @@ -10,14 +10,10 @@ * Red Hat, Inc. - initial API and implementation */ +import { V230DevfileComponents } from '@devfile/api'; import { api } from '@eclipse-che/common'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { serverConfig } from '@/store/ServerConfig/__tests__/stubs'; +import { RootState } from '@/store'; import { selectAdvancedAuthorization, selectAllowedSources, @@ -30,300 +26,255 @@ import { selectOpenVSXUrl, selectPluginRegistryInternalUrl, selectPluginRegistryUrl, + selectPvcStrategy, + selectServerConfigError, + selectServerConfigState, + selectStartTimeout, } from '@/store/ServerConfig/selectors'; -describe('serverConfig selectors', () => { - describe('selectDefaultComponents', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig(serverConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedDefaultComponents = selectDefaultComponents(state); - expect(selectedDefaultComponents).toEqual([ - { - container: { - image: 'quay.io/devfile/universal-developer-image:ubi8-latest', +describe('ServerConfig Selectors', () => { + const mockState = { + dwServerConfig: { + config: { + defaults: { + components: [ + { + name: 'component1', + }, + { + name: 'component2', + }, + ] as V230DevfileComponents[], + editor: 'che-incubator/che-code/latest', + plugins: [ + { + plugins: ['plugin1'], + }, + { + plugins: ['plugin2'], + }, + ] as api.IWorkspacesDefaultPlugins[], + pvcStrategy: 'strategy', + }, + pluginRegistry: { + disableInternalRegistry: false, + openVSXURL: 'https://openvsx.org', + }, + pluginRegistryURL: 'https://plugin.registry', + pluginRegistryInternalURL: 'https://internal.plugin.registry', + timeouts: { + startTimeout: 300, + }, + dashboardLogo: { + base64data: 'base64data', + mediatype: 'image/png', + }, + networking: { + auth: { + advancedAuthorization: { + allowUsers: ['user1'], + }, }, - name: 'universal-developer-image', }, - ]); - }); - - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedDefaultComponents = selectDefaultComponents(state); - expect(selectedDefaultComponents).toEqual([]); - }); + defaultNamespace: { + autoProvision: true, + }, + allowedSourceUrls: ['https://allowed.source'], + }, + isLoading: false, + error: 'Something went wrong', + }, + } as RootState; + + it('should select the server config state', () => { + const result = selectServerConfigState(mockState); + expect(result).toEqual(mockState.dwServerConfig); }); - describe('selectDefaultEditor', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig(serverConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedDefaultEditor = selectDefaultEditor(state); - expect(selectedDefaultEditor).toEqual('eclipse/theia/next'); - }); - - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedDefaultEditor = selectDefaultEditor(state); - expect(selectedDefaultEditor).toEqual('che-incubator/che-code/latest'); - }); + it('should select default components', () => { + const result = selectDefaultComponents(mockState); + expect(result).toEqual([{ name: 'component1' }, { name: 'component2' }]); }); - describe('selectDefaultPlugins', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig(serverConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedDefaultPlugins = selectDefaultPlugins(state); - expect(selectedDefaultPlugins).toEqual([ - { - editor: 'eclipse/theia/next', - plugins: ['https://test.com/devfile.yaml'], - }, - ]); - }); - - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedDefaultPlugins = selectDefaultPlugins(state); - expect(selectedDefaultPlugins).toEqual([]); - }); + it('should select default editor', () => { + const result = selectDefaultEditor(mockState); + expect(result).toEqual('che-incubator/che-code/latest'); }); - describe('selectPluginRegistryUrl', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig(serverConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedPluginRegistryUrl = selectPluginRegistryUrl(state); - expect(selectedPluginRegistryUrl).toEqual('https://test/plugin-registry/v3'); - }); - - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedPluginRegistryUrl = selectPluginRegistryUrl(state); - expect(selectedPluginRegistryUrl).toEqual(''); - }); + it('should select default plugins', () => { + const result = selectDefaultPlugins(mockState); + expect(result).toEqual([{ plugins: ['plugin1'] }, { plugins: ['plugin2'] }]); }); - describe('selectPluginRegistryInternalUrl', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig(serverConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedPluginRegistryInternalUrl = selectPluginRegistryInternalUrl(state); - expect(selectedPluginRegistryInternalUrl).toEqual( - 'http://plugin-registry.eclipse-che.svc:8080/v3', - ); - }); - - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedPluginRegistryInternalUrl = selectPluginRegistryInternalUrl(state); - expect(selectedPluginRegistryInternalUrl).toEqual(''); - }); + it('should select plugin registry URL', () => { + const result = selectPluginRegistryUrl(mockState); + expect(result).toEqual('https://plugin.registry'); }); - describe('selectOpenVSXUrl', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig(serverConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedOpenVSXUr = selectOpenVSXUrl(state); - expect(selectedOpenVSXUr).toEqual('https://open-vsx.org'); - }); - - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedOpenVSXUr = selectOpenVSXUrl(state); - expect(selectedOpenVSXUr).toEqual(''); - }); + it('should select plugin registry internal URL', () => { + const result = selectPluginRegistryInternalUrl(mockState); + expect(result).toEqual('https://internal.plugin.registry'); }); - describe('selectDashboardLogo', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig(serverConfig) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedDashboardLogo = selectDashboardLogo(state); - expect(selectedDashboardLogo).toEqual({ - base64data: 'base64-encoded-data', - mediatype: 'image/png', - }); - }); - - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedDashboardLogo = selectDashboardLogo(state); - expect(selectedDashboardLogo).toBeUndefined(); - }); + it('should select OpenVSX URL', () => { + const result = selectOpenVSXUrl(mockState); + expect(result).toEqual('https://openvsx.org'); }); - describe('selectAdvancedAuthorization', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig( - Object.assign({}, serverConfig, { - networking: { - auth: { - advancedAuthorization: { - allowUsers: ['user0'], - }, - }, - }, - } as api.IServerConfig), - ) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); - - const selectedAdvancedAuthorization = selectAdvancedAuthorization(state); - expect(selectedAdvancedAuthorization).toEqual({ allowUsers: ['user0'] }); - }); + it('should select PVC strategy', () => { + const result = selectPvcStrategy(mockState); + expect(result).toEqual('strategy'); + }); - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select start timeout', () => { + const result = selectStartTimeout(mockState); + expect(result).toEqual(300); + }); - const selectedAdvancedAuthorization = selectAdvancedAuthorization(state); - expect(selectedAdvancedAuthorization).toBeUndefined(); - }); + it('should select dashboard logo', () => { + const result = selectDashboardLogo(mockState); + expect(result).toEqual({ base64data: 'base64data', mediatype: 'image/png' }); }); - describe('selectAutoProvision', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig( - Object.assign({}, serverConfig, { - defaultNamespace: { - autoProvision: false, - }, - } as api.IServerConfig), - ) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); + it('should select advanced authorization', () => { + const result = selectAdvancedAuthorization(mockState); + expect(result).toEqual({ allowUsers: ['user1'] }); + }); - const selectedDashboardLogo = selectAutoProvision(state); - expect(selectedDashboardLogo).toBeFalsy(); - }); + it('should select auto provision', () => { + const result = selectAutoProvision(mockState); + expect(result).toEqual(true); + }); - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select allowed sources', () => { + const result = selectAllowedSources(mockState); + expect(result).toEqual(['https://allowed.source']); + }); - const selectedDashboardLogo = selectAutoProvision(state); - expect(selectedDashboardLogo).toBeTruthy(); - }); + it('should select if allowed sources are configured', () => { + const result = selectIsAllowedSourcesConfigured(mockState); + expect(result).toEqual(true); }); - describe('selectAllowedSources', () => { - it('should return provided value', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig( - Object.assign({}, serverConfig, { - allowedSourceUrls: ['https://test.com'], - } as api.IServerConfig), - ) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); + it('should select server config error', () => { + const result = selectServerConfigError(mockState); + expect(result).toEqual('Something went wrong'); + }); - const allowedSourceUrls = selectAllowedSources(state); - expect(allowedSourceUrls).toEqual(['https://test.com']); - }); + it('should return an empty array if default components are not set', () => { + const stateWithoutComponents = { + ...mockState, + dwServerConfig: { + ...mockState.dwServerConfig, + config: { + ...mockState.dwServerConfig.config, + defaults: { + ...mockState.dwServerConfig.config.defaults, + components: undefined as unknown, + }, + }, + }, + } as Partial as RootState; + const result = selectDefaultComponents(stateWithoutComponents); + expect(result).toEqual([]); + }); - it('should return default value', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should return default editor if not set', () => { + const stateWithoutEditor = { + ...mockState, + dwServerConfig: { + ...mockState.dwServerConfig, + config: { + ...mockState.dwServerConfig.config, + defaults: { + ...mockState.dwServerConfig.config.defaults, + editor: undefined, + }, + }, + }, + } as RootState; + const result = selectDefaultEditor(stateWithoutEditor); + expect(result).toEqual('che-incubator/che-code/latest'); + }); - const allowedSourceUrls = selectAllowedSources(state); - expect(allowedSourceUrls).toEqual([]); - }); + it('should return an empty array if default plugins are not set', () => { + const stateWithoutPlugins = { + ...mockState, + dwServerConfig: { + ...mockState.dwServerConfig, + config: { + ...mockState.dwServerConfig.config, + defaults: { + ...mockState.dwServerConfig.config.defaults, + plugins: undefined as unknown, + }, + }, + }, + } as RootState; + const result = selectDefaultPlugins(stateWithoutPlugins); + expect(result).toEqual([]); }); - describe('selectIsAllowedSourcesConfigured', () => { - it('allowed sources configured', () => { - const fakeStore = new FakeStoreBuilder() - .withDwServerConfig( - Object.assign({}, serverConfig, { - allowedSourceUrls: ['https://test.com'], - } as api.IServerConfig), - ) - .build() as MockStoreEnhanced>; - const state = fakeStore.getState(); + it('should return an empty string if plugin registry URL is not set', () => { + const stateWithoutPluginRegistryUrl = { + ...mockState, + dwServerConfig: { + ...mockState.dwServerConfig, + config: { + ...mockState.dwServerConfig.config, + pluginRegistryURL: undefined as unknown, + }, + }, + } as RootState; + const result = selectPluginRegistryUrl(stateWithoutPluginRegistryUrl); + expect(result).toEqual(''); + }); - const isAllowedSourcesConfigured = selectIsAllowedSourcesConfigured(state); - expect(isAllowedSourcesConfigured).toBeTruthy(); - }); + it('should return an empty string if PVC strategy is not set', () => { + const stateWithoutPvcStrategy = { + ...mockState, + dwServerConfig: { + ...mockState.dwServerConfig, + config: { + ...mockState.dwServerConfig.config, + defaults: { + ...mockState.dwServerConfig.config.defaults, + pvcStrategy: undefined, + }, + }, + }, + } as RootState; + const result = selectPvcStrategy(stateWithoutPvcStrategy); + expect(result).toEqual(''); + }); - it('allowed sources NOT configured', () => { - const fakeStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should return an empty array if allowed sources are not set', () => { + const stateWithoutAllowedSources = { + ...mockState, + dwServerConfig: { + ...mockState.dwServerConfig, + config: { + ...mockState.dwServerConfig.config, + allowedSourceUrls: undefined as unknown, + }, + }, + } as RootState; + const result = selectAllowedSources(stateWithoutAllowedSources); + expect(result).toEqual([]); + }); - const isAllowedSourcesConfigured = selectIsAllowedSourcesConfigured(state); - expect(isAllowedSourcesConfigured).toBeFalsy(); - }); + it('should return false if allowed sources are not configured', () => { + const stateWithoutAllowedSources = { + ...mockState, + dwServerConfig: { + ...mockState.dwServerConfig, + config: { + ...mockState.dwServerConfig.config, + allowedSourceUrls: [], + }, + }, + } as RootState; + const result = selectIsAllowedSourcesConfigured(stateWithoutAllowedSources); + expect(result).toEqual(false); }); }); diff --git a/packages/dashboard-frontend/src/store/ServerConfig/actions.ts b/packages/dashboard-frontend/src/store/ServerConfig/actions.ts new file mode 100644 index 000000000..365060bfc --- /dev/null +++ b/packages/dashboard-frontend/src/store/ServerConfig/actions.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import * as ServerConfigApi from '@/services/backend-client/serverConfigApi'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const serverConfigRequestAction = createAction('serverConfig/request'); +export const serverConfigReceiveAction = createAction('serverConfig/receive'); +export const serverConfigErrorAction = createAction('serverConfig/error'); + +export const actionCreators = { + requestServerConfig: (): AppThunk => async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(serverConfigRequestAction()); + + const config = await ServerConfigApi.fetchServerConfig(); + + dispatch(serverConfigReceiveAction(config)); + } catch (e) { + const error = common.helpers.errors.getMessage(e); + dispatch(serverConfigErrorAction(error)); + throw new Error(`Failed to fetch workspace defaults. ${error}`); + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/ServerConfig/index.ts b/packages/dashboard-frontend/src/store/ServerConfig/index.ts index 2bada9991..9554b6eda 100644 --- a/packages/dashboard-frontend/src/store/ServerConfig/index.ts +++ b/packages/dashboard-frontend/src/store/ServerConfig/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,122 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -import common, { api } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import * as ServerConfigApi from '@/services/backend-client/serverConfigApi'; -import { AppThunk } from '@/store'; -import { createObject } from '@/store/helpers'; - -export interface State { - isLoading: boolean; - config: api.IServerConfig; - error?: string; -} - -export interface RequestDwServerConfigAction { - type: 'REQUEST_DW_SERVER_CONFIG'; -} - -export interface ReceiveDwServerConfigAction { - type: 'RECEIVE_DW_SERVER_CONFIG'; - config: api.IServerConfig; -} - -export interface ReceiveDwServerConfigErrorAction { - type: 'RECEIVE_DW_SERVER_CONFIG_ERROR'; - error: string; -} - -export type KnownAction = - | ReceiveDwServerConfigAction - | ReceiveDwServerConfigErrorAction - | RequestDwServerConfigAction; - -export type ActionCreators = { - requestServerConfig: () => AppThunk>; -}; -export const actionCreators: ActionCreators = { - requestServerConfig: - (): AppThunk> => - async (dispatch): Promise => { - dispatch({ type: 'REQUEST_DW_SERVER_CONFIG' }); - try { - const config = await ServerConfigApi.fetchServerConfig(); - dispatch({ - type: 'RECEIVE_DW_SERVER_CONFIG', - config, - }); - } catch (e) { - const error = common.helpers.errors.getMessage(e); - dispatch({ - type: 'RECEIVE_DW_SERVER_CONFIG_ERROR', - error, - }); - throw new Error(`Failed to fetch workspace defaults. ${error}`); - } - }, -}; - -const unloadedState: State = { - isLoading: false, - config: { - containerBuild: {}, - defaults: { - editor: undefined, - components: [], - plugins: [], - pvcStrategy: '', - }, - devfileRegistry: { - disableInternalRegistry: false, - externalDevfileRegistries: [], - }, - pluginRegistry: { - openVSXURL: '', - }, - timeouts: { - inactivityTimeout: -1, - runTimeout: -1, - startTimeout: 300, - }, - defaultNamespace: { - autoProvision: true, - }, - cheNamespace: '', - pluginRegistryURL: '', - pluginRegistryInternalURL: '', - allowedSourceUrls: [], - }, - error: undefined, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case 'REQUEST_DW_SERVER_CONFIG': - return createObject(state, { - isLoading: true, - }); - case 'RECEIVE_DW_SERVER_CONFIG': - return createObject(state, { - isLoading: false, - config: action.config, - error: undefined, - }); - case 'RECEIVE_DW_SERVER_CONFIG_ERROR': - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as serverConfigActionCreators } from '@/store/ServerConfig/actions'; +export { + reducer as serverConfigReducer, + State as ServerConfigState, +} from '@/store/ServerConfig/reducer'; +export * from '@/store/ServerConfig/selectors'; diff --git a/packages/dashboard-frontend/src/store/ServerConfig/reducer.ts b/packages/dashboard-frontend/src/store/ServerConfig/reducer.ts new file mode 100644 index 000000000..0ddfb997d --- /dev/null +++ b/packages/dashboard-frontend/src/store/ServerConfig/reducer.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + serverConfigErrorAction, + serverConfigReceiveAction, + serverConfigRequestAction, +} from '@/store/ServerConfig/actions'; + +export interface State { + isLoading: boolean; + config: api.IServerConfig; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + config: { + containerBuild: {}, + defaults: { + editor: undefined, + components: [], + plugins: [], + pvcStrategy: '', + }, + devfileRegistry: { + disableInternalRegistry: false, + externalDevfileRegistries: [], + }, + pluginRegistry: { + openVSXURL: '', + }, + timeouts: { + inactivityTimeout: -1, + runTimeout: -1, + startTimeout: 300, + }, + defaultNamespace: { + autoProvision: true, + }, + cheNamespace: '', + pluginRegistryURL: '', + pluginRegistryInternalURL: '', + allowedSourceUrls: [], + }, + error: undefined, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(serverConfigRequestAction, state => { + state.isLoading = true; + }) + .addCase(serverConfigReceiveAction, (state, action) => { + state.isLoading = false; + state.config = action.payload; + state.error = undefined; + }) + .addCase(serverConfigErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/ServerConfig/selectors.ts b/packages/dashboard-frontend/src/store/ServerConfig/selectors.ts index 8044328f5..be998ffd3 100644 --- a/packages/dashboard-frontend/src/store/ServerConfig/selectors.ts +++ b/packages/dashboard-frontend/src/store/ServerConfig/selectors.ts @@ -10,13 +10,12 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; import { che } from '@/services/models'; +import { RootState } from '@/store'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.dwServerConfig; +const selectState = (state: RootState) => state.dwServerConfig; export const selectServerConfigState = selectState; export const selectDefaultComponents = createSelector( @@ -58,7 +57,7 @@ export const selectPvcStrategy = createSelector( export const selectStartTimeout = createSelector( selectState, - state => (state.config.timeouts as any)?.startTimeout, + state => state.config.timeouts?.startTimeout, ); export const selectDashboardLogo = createSelector(selectState, state => state.config.dashboardLogo); @@ -70,7 +69,7 @@ export const selectAdvancedAuthorization = createSelector( export const selectAutoProvision = createSelector( selectState, - state => state.config.defaultNamespace.autoProvision, + state => state.config.defaultNamespace?.autoProvision, ); export const selectAllowedSources = createSelector( diff --git a/packages/dashboard-frontend/src/store/SshKeys/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/SshKeys/__tests__/actions.spec.ts index e084b6cca..17322a556 100644 --- a/packages/dashboard-frontend/src/store/SshKeys/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/SshKeys/__tests__/actions.spec.ts @@ -10,205 +10,127 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import * as KubernetesNamespaceApi from '@/services/backend-client/kubernetesNamespaceApi'; -import * as sshKeysApi from '@/services/backend-client/sshKeysApi'; -import { che } from '@/services/models'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; -import { key1, key2, newKey } from '@/store/SshKeys/__tests__/stub'; - -import * as testStore from '..'; - -jest.mock( - '@/services/backend-client/kubernetesNamespaceApi', - () => - ({ - provisionKubernetesNamespace: () => Promise.resolve({} as che.KubernetesNamespace), - }) as typeof KubernetesNamespaceApi, -); - -const mockFetchSshKeys = jest.fn(); -const mockAddSshKey = jest.fn(); -const mockRemoveSshKey = jest.fn(); -jest.mock( - '@/services/backend-client/sshKeysApi', - () => - ({ - fetchSshKeys: (...args) => mockFetchSshKeys(...args), - addSshKey: (...args) => mockAddSshKey(...args), - removeSshKey: (...args) => mockRemoveSshKey(...args), - }) as typeof sshKeysApi, -); - -// mute the outputs -console.error = jest.fn(); - -describe('SSH keys, actions', () => { - afterEach(() => { +import { api, helpers } from '@eclipse-che/common'; + +import { addSshKey, fetchSshKeys, removeSshKey } from '@/services/backend-client/sshKeysApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as infrastructureNamespacesSelectors from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + actionCreators, + keysAddAction, + keysErrorAction, + keysReceiveAction, + keysRemoveAction, + keysRequestAction, +} from '@/store/SshKeys/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/sshKeysApi'); +jest.mock('@/store/SanityCheck'); + +const mockNamespace = 'test-namespace'; +jest + .spyOn(infrastructureNamespacesSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { default: 'true', phase: 'Active' } }); + +describe('SshKeys, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); jest.clearAllMocks(); }); - it('should create REQUEST_KEYS and RECEIVE_KEYS when requesting SSH keys', async () => { - mockFetchSshKeys.mockResolvedValueOnce([key1, key2]); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(testStore.actionCreators.requestSshKeys()); - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_KEYS, - keys: [key1, key2], - }, - ]; - expect(actions).toEqual(expectedActions); - }); + describe('requestSshKeys', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockKeys = [{ name: 'key1' }] as api.SshKey[]; - it('should create REQUEST_KEYS and RECEIVE_ERROR when requesting SSH keys', async () => { - const errorMessage = 'Something bad happened'; - mockFetchSshKeys.mockRejectedValueOnce(new Error(errorMessage)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.requestSshKeys()); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: errorMessage, - }, - ]; - expect(actions).toEqual(expectedActions); - }); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchSshKeys as jest.Mock).mockResolvedValue(mockKeys); - it('should create REQUEST_KEYS and ADD_KEY when adding SSH keys', async () => { - mockAddSshKey.mockResolvedValueOnce(key1); - mockFetchSshKeys.mockResolvedValueOnce([key1]); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(testStore.actionCreators.addSshKey(newKey)); - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, - }, - { - type: testStore.Type.ADD_KEY, - key: key1, - }, - ]; - expect(actions).toEqual(expectedActions); - }); + await store.dispatch(actionCreators.requestSshKeys()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(keysRequestAction()); + expect(actions[1]).toEqual(keysReceiveAction(mockKeys)); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchSshKeys as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); - it('should create REQUEST_KEYS and RECEIVE_ERROR when adding SSH keys', async () => { - const errorMessage = 'Something bad happened'; - mockAddSshKey.mockRejectedValueOnce(new Error(errorMessage)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.addSshKey(newKey)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: errorMessage, - }, - ]; - expect(actions).toEqual(expectedActions); + await expect(store.dispatch(actionCreators.requestSshKeys())).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(keysRequestAction()); + expect(actions[1]).toEqual(keysErrorAction(errorMessage)); + }); }); - it('should create REQUEST_KEYS and REMOVE_KEY when deleting SSH key', async () => { - mockRemoveSshKey.mockResolvedValueOnce(key1); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - await store.dispatch(testStore.actionCreators.removeSshKey(key1)); - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, - }, - { - type: testStore.Type.REMOVE_KEY, - key: key1, - }, - ]; - expect(actions).toEqual(expectedActions); + describe('addSshKey', () => { + it('should dispatch add action on successful add', async () => { + const mockKey = { name: 'key1' } as api.NewSshKey; + const mockNewKey = { name: 'key1' } as api.SshKey; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (addSshKey as jest.Mock).mockResolvedValue(mockNewKey); + + await store.dispatch(actionCreators.addSshKey(mockKey)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(keysRequestAction()); + expect(actions[1]).toEqual(keysAddAction(mockNewKey)); + }); + + it('should dispatch error action on failed add', async () => { + const mockKey = { name: 'key1' } as api.NewSshKey; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (addSshKey as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.addSshKey(mockKey))).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(keysRequestAction()); + expect(actions[1]).toEqual(keysErrorAction(errorMessage)); + }); }); - it('should create REQUEST_KEYS and RECEIVE_ERROR when deleting SSH key', async () => { - const errorMessage = 'Something bad happened'; - mockRemoveSshKey.mockRejectedValueOnce(new Error(errorMessage)); - - const store = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - try { - await store.dispatch(testStore.actionCreators.removeSshKey(key1)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_ERROR, - error: errorMessage, - }, - ]; - expect(actions).toEqual(expectedActions); + describe('removeSshKey', () => { + it('should dispatch remove action on successful remove', async () => { + const mockKey = { name: 'key1' } as api.SshKey; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (removeSshKey as jest.Mock).mockResolvedValue(undefined); + + await store.dispatch(actionCreators.removeSshKey(mockKey)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(keysRequestAction()); + expect(actions[1]).toEqual(keysRemoveAction(mockKey)); + }); + + it('should dispatch error action on failed remove', async () => { + const mockKey = { name: 'key1' } as api.SshKey; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (removeSshKey as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.removeSshKey(mockKey))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(keysRequestAction()); + expect(actions[1]).toEqual(keysErrorAction(errorMessage)); + }); }); }); diff --git a/packages/dashboard-frontend/src/store/SshKeys/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/SshKeys/__tests__/reducers.spec.ts index a379e1124..3ab4fbb0a 100644 --- a/packages/dashboard-frontend/src/store/SshKeys/__tests__/reducers.spec.ts +++ b/packages/dashboard-frontend/src/store/SshKeys/__tests__/reducers.spec.ts @@ -11,154 +11,87 @@ */ import { api } from '@eclipse-che/common'; -import { AnyAction } from 'redux'; - -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; -import { key1, key2 } from '@/store/SshKeys/__tests__/stub'; - -import * as testStore from '..'; - -describe('Personal Access Token store', () => { - afterEach(() => { - jest.clearAllMocks(); +import { UnknownAction } from 'redux'; + +import { + keysAddAction, + keysErrorAction, + keysReceiveAction, + keysRemoveAction, + keysRequestAction, +} from '@/store/SshKeys/actions'; +import { reducer, State, unloadedState } from '@/store/SshKeys/reducer'; + +describe('SshKeys, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; }); - it('should return initial state', () => { - const incomingAction: testStore.RequestKeysAction = { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, + it('should handle keysRequestAction', () => { + const action = keysRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - keys: [], - error: undefined, - }; - expect(initialState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { + it('should handle keysReceiveAction', () => { + const keys = [{ name: 'key1' }] as api.SshKey[]; + const action = keysReceiveAction(keys); + const expectedState: State = { + ...initialState, isLoading: false, - keys: [key1, key2], - error: undefined, + keys, }; - const incomingAction = { - type: 'NOT_MATCHED', - check: AUTHORIZED, - } as AnyAction; - const state = testStore.reducer(initialState, incomingAction); - - expect(state).toEqual(initialState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle REQUEST_KEYS', () => { - const initialState: testStore.State = { + it('should handle keysAddAction', () => { + const key = { name: 'key1' } as api.SshKey; + const action = keysAddAction(key); + const expectedState: State = { + ...initialState, isLoading: false, - keys: [key1, key2], - error: 'an error', + keys: [key], }; - const incomingAction: testStore.RequestKeysAction = { - type: testStore.Type.REQUEST_KEYS, - check: AUTHORIZED, - }; - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - keys: [key1, key2], - error: undefined, - }; - expect(state).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_KEYS', () => { - const initialState: testStore.State = { - isLoading: true, - keys: [key1], - error: undefined, - }; - - const newKey1 = { - ...key1, - data: 'new-key-data', - } as api.SshKey; - const incomingAction: testStore.ReceiveKeysAction = { - type: testStore.Type.RECEIVE_KEYS, - keys: [newKey1, key2], + it('should handle keysRemoveAction', () => { + const initialStateWithKeys: State = { + ...initialState, + keys: [{ name: 'key1' }, { name: 'key2' }] as api.SshKey[], }; - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { + const action = keysRemoveAction({ name: 'key1' } as api.SshKey); + const expectedState: State = { + ...initialStateWithKeys, isLoading: false, - keys: [newKey1, key2], - error: undefined, + keys: [{ name: 'key2' }] as api.SshKey[], }; - expect(state).toEqual(expectedState); - }); - it('should handle ADD_KEY', () => { - const incomingAction: testStore.AddKeyAction = { - type: testStore.Type.ADD_KEY, - key: key2, - }; - const initialState: testStore.State = { - isLoading: false, - keys: [key1], - error: undefined, - }; - const state = testStore.reducer(initialState, incomingAction); - const expectedState: testStore.State = { - isLoading: false, - keys: [key1, key2], - error: undefined, - }; - expect(state).toEqual(expectedState); + expect(reducer(initialStateWithKeys, action)).toEqual(expectedState); }); - it('should handle REMOVE_KEY', () => { - const initialState: testStore.State = { + it('should handle keysErrorAction', () => { + const error = 'Error message'; + const action = keysErrorAction(error); + const expectedState: State = { + ...initialState, isLoading: false, - keys: [key1, key2], - error: undefined, - }; - - const incomingAction: testStore.RemoveKeyAction = { - type: testStore.Type.REMOVE_KEY, - key: key1, + error, }; - const state = testStore.reducer(initialState, incomingAction); - const expectedState: testStore.State = { - isLoading: false, - keys: [key2], - error: undefined, - }; - expect(state).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - keys: [key1], - error: undefined, - }; - - const incomingAction: testStore.ReceiveErrorAction = { - type: testStore.Type.RECEIVE_ERROR, - error: 'error', - }; - const state = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - keys: [key1], - error: 'error', - }; - expect(state).toEqual(expectedState); + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/SshKeys/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/SshKeys/__tests__/selectors.spec.ts index 2e1fc4f5a..846466b95 100644 --- a/packages/dashboard-frontend/src/store/SshKeys/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/SshKeys/__tests__/selectors.spec.ts @@ -10,61 +10,34 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { key1, key2 } from '@/store/SshKeys/__tests__/stub'; +import { RootState } from '@/store'; import { selectSshKeys, selectSshKeysError, selectSshKeysIsLoading, } from '@/store/SshKeys/selectors'; -import * as store from '..'; - -describe('SSH Keys, selectors', () => { - afterEach(() => { - jest.clearAllMocks(); +describe('SshKeys, selectors', () => { + const mockState = { + sshKeys: { + isLoading: true, + keys: [{ name: 'key1' }, { name: 'key2' }], + error: 'Something went wrong', + }, + } as RootState; + + it('should select isLoading', () => { + const result = selectSshKeysIsLoading(mockState); + expect(result).toBe(true); }); - it('should return the error', () => { - const fakeStore = new FakeStoreBuilder() - .withSshKeys({ keys: [], error: 'Something unexpected' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedError = selectSshKeysError(state); - expect(selectedError).toEqual('Something unexpected'); + it('should select ssh keys', () => { + const result = selectSshKeys(mockState); + expect(result).toEqual([{ name: 'key1' }, { name: 'key2' }]); }); - it('should return all tokens', () => { - const fakeStore = new FakeStoreBuilder() - .withSshKeys({ keys: [key1, key2] }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const allSshKeys = selectSshKeys(state); - expect(allSshKeys).toEqual([key1, key2]); - }); - - it('should return isLoading state', () => { - const fakeStore = new FakeStoreBuilder() - .withSshKeys({ keys: [key1, key2] }, true) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const isLoading = selectSshKeysIsLoading(state); - expect(isLoading).toEqual(true); + it('should select ssh keys error', () => { + const result = selectSshKeysError(mockState); + expect(result).toEqual('Something went wrong'); }); }); diff --git a/packages/dashboard-frontend/src/store/SshKeys/__tests__/stub.ts b/packages/dashboard-frontend/src/store/SshKeys/__tests__/stub.ts deleted file mode 100644 index c3b824935..000000000 --- a/packages/dashboard-frontend/src/store/SshKeys/__tests__/stub.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; - -export const key1: api.SshKey = { - name: 'key-name-1', - creationTimestamp: undefined, - keyPub: 'key-pub-data-1', -}; - -export const key2: api.SshKey = { - name: 'key-name-2', - creationTimestamp: undefined, - keyPub: 'key-pub-data-2', -}; - -export const newKey: api.NewSshKey = { - name: 'key-name-3', - key: 'key-data-3', - keyPub: 'key-pub-data-3', -}; diff --git a/packages/dashboard-frontend/src/store/SshKeys/actions.ts b/packages/dashboard-frontend/src/store/SshKeys/actions.ts new file mode 100644 index 000000000..5fbdadac1 --- /dev/null +++ b/packages/dashboard-frontend/src/store/SshKeys/actions.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { addSshKey, fetchSshKeys, removeSshKey } from '@/services/backend-client/sshKeysApi'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const keysRequestAction = createAction('keys/request'); +export const keysReceiveAction = createAction('keys/receive'); +export const keysAddAction = createAction('keys/add'); +export const keysRemoveAction = createAction('keys/remove'); +export const keysErrorAction = createAction('keys/error'); + +export const actionCreators = { + requestSshKeys: (): AppThunk => async (dispatch, getState) => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(keysRequestAction()); + + const keys = await fetchSshKeys(namespace); + dispatch(keysReceiveAction(keys)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(keysErrorAction(errorMessage)); + throw e; + } + }, + + addSshKey: + (key: api.NewSshKey): AppThunk => + async (dispatch, getState) => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(keysRequestAction()); + + const newSshKey = await addSshKey(namespace, key); + dispatch(keysAddAction(newSshKey)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(keysErrorAction(errorMessage)); + throw e; + } + }, + + removeSshKey: + (key: api.SshKey): AppThunk => + async (dispatch, getState) => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(keysRequestAction()); + + await removeSshKey(namespace, key); + dispatch(keysRemoveAction(key)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(keysErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/SshKeys/index.ts b/packages/dashboard-frontend/src/store/SshKeys/index.ts index 8f049177f..759ba5732 100644 --- a/packages/dashboard-frontend/src/store/SshKeys/index.ts +++ b/packages/dashboard-frontend/src/store/SshKeys/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,210 +12,6 @@ * Red Hat, Inc. - initial API and implementation */ -import { api, helpers } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { addSshKey, fetchSshKeys, removeSshKey } from '@/services/backend-client/sshKeysApi'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; -import { State } from '@/store/SshKeys/state'; - -import { AppThunk } from '..'; - -export * from './state'; - -export enum Type { - RECEIVE_ERROR = 'RECEIVE_ERROR', - RECEIVE_KEYS = 'RECEIVE_KEYS', - REQUEST_KEYS = 'REQUEST_KEYS', - ADD_KEY = 'ADD_KEY', - REMOVE_KEY = 'REMOVE_KEY', -} - -export interface RequestKeysAction extends Action, SanityCheckAction { - type: Type.REQUEST_KEYS; -} - -export interface ReceiveKeysAction extends Action { - type: Type.RECEIVE_KEYS; - keys: api.SshKey[]; -} - -export interface AddKeyAction extends Action { - type: Type.ADD_KEY; - key: api.SshKey; -} - -export interface RemoveKeyAction extends Action { - type: Type.REMOVE_KEY; - key: api.SshKey; -} - -export interface ReceiveErrorAction extends Action { - type: Type.RECEIVE_ERROR; - error: string; -} - -export type KnownAction = - | AddKeyAction - | ReceiveErrorAction - | ReceiveKeysAction - | RequestKeysAction - | RemoveKeyAction; - -export type ActionCreators = { - requestSshKeys: () => AppThunk>; - addSshKey: (key: api.NewSshKey) => AppThunk>; - removeSshKey: (key: api.SshKey) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestSshKeys: - (): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_KEYS, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_ERROR, - error, - }); - throw new Error(error); - } - - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - const keys = await fetchSshKeys(namespace); - dispatch({ - type: Type.RECEIVE_KEYS, - keys, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - addSshKey: - (key: api.NewSshKey): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_KEYS, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_ERROR, - error, - }); - throw new Error(error); - } - - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - const newSshKey = await addSshKey(namespace, key); - dispatch({ - type: Type.ADD_KEY, - key: newSshKey, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - removeSshKey: - (key: api.SshKey): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_KEYS, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.RECEIVE_ERROR, - error, - }); - throw new Error(error); - } - - const state = getState(); - const namespace = selectDefaultNamespace(state).name; - try { - await removeSshKey(namespace, key); - dispatch({ - type: Type.REMOVE_KEY, - key, - }); - } catch (e) { - const errorMessage = helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_ERROR, - error: errorMessage, - }); - throw e; - } - }, -}; - -const unloadedState: State = { - isLoading: false, - keys: [], -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_KEYS: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_KEYS: - return createObject(state, { - isLoading: false, - keys: action.keys, - }); - case Type.ADD_KEY: - return createObject(state, { - isLoading: false, - keys: [...state.keys, action.key], - }); - case Type.REMOVE_KEY: - return createObject(state, { - isLoading: false, - keys: state.keys.filter(key => key.name !== action.key.name), - }); - case Type.RECEIVE_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as sshKeysActionCreators } from '@/store/SshKeys/actions'; +export { reducer as sshKeysReducer, State as SshKeysState } from '@/store/SshKeys/reducer'; +export * from '@/store/SshKeys/selectors'; diff --git a/packages/dashboard-frontend/src/store/SshKeys/reducer.ts b/packages/dashboard-frontend/src/store/SshKeys/reducer.ts new file mode 100644 index 000000000..6207a1e9c --- /dev/null +++ b/packages/dashboard-frontend/src/store/SshKeys/reducer.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + keysAddAction, + keysErrorAction, + keysReceiveAction, + keysRemoveAction, + keysRequestAction, +} from '@/store/SshKeys/actions'; + +export interface State { + isLoading: boolean; + keys: api.SshKey[]; + error?: string; +} + +export const unloadedState: State = { + isLoading: false, + keys: [], +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(keysRequestAction, state => { + state.isLoading = true; + }) + .addCase(keysReceiveAction, (state, action) => { + state.isLoading = false; + state.keys = action.payload; + }) + .addCase(keysAddAction, (state, action) => { + state.isLoading = false; + state.keys.push(action.payload); + }) + .addCase(keysRemoveAction, (state, action) => { + state.isLoading = false; + state.keys = state.keys.filter(key => key.name !== action.payload.name); + }) + .addCase(keysErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/SshKeys/selectors.ts b/packages/dashboard-frontend/src/store/SshKeys/selectors.ts index 62bd09885..72fe43aea 100644 --- a/packages/dashboard-frontend/src/store/SshKeys/selectors.ts +++ b/packages/dashboard-frontend/src/store/SshKeys/selectors.ts @@ -10,16 +10,14 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { State } from '@/store/SshKeys/state'; +import { RootState } from '@/store'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.sshKeys; +const selectState = (state: RootState) => state.sshKeys; export const selectSshKeysIsLoading = createSelector(selectState, state => state.isLoading); -export const selectSshKeys = createSelector(selectState, (state: State) => state.keys); +export const selectSshKeys = createSelector(selectState, state => state.keys); export const selectSshKeysError = createSelector(selectState, state => state.error); diff --git a/packages/dashboard-frontend/src/store/User/Id/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/User/Id/__tests__/actions.spec.ts index 4bf87006d..1499650b7 100644 --- a/packages/dashboard-frontend/src/store/User/Id/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/User/Id/__tests__/actions.spec.ts @@ -10,82 +10,58 @@ * Red Hat, Inc. - initial API and implementation */ -import mockAxios, { AxiosError } from 'axios'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; +import common from '@eclipse-che/common'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; +import { fetchCheUserId } from '@/services/che-user-id'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + actionCreators, + cheUserIdErrorAction, + cheUserIdReceiveAction, + cheUserIdRequestAction, +} from '@/store/User/Id/actions'; -import * as testStore from '..'; +jest.mock('@eclipse-che/common'); +jest.mock('@/services/che-user-id'); +jest.mock('@/store/SanityCheck'); -const cheUserId = 'che-user-id'; - -describe('UserId store, actions', () => { - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; +describe('CheUserId, actions', () => { + let store: ReturnType; beforeEach(() => { - appStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - }); - - afterEach(() => { + store = createMockStore({}); jest.clearAllMocks(); }); - it('should create REQUEST_CHE_USER_ID and RECEIVE_CHE_USER_ID when fetching user ID', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: cheUserId, - }); + describe('requestCheUserId', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockCheUserId = 'test-user-id'; - await appStore.dispatch(testStore.actionCreators.requestCheUserId()); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchCheUserId as jest.Mock).mockResolvedValue(mockCheUserId); - const actions = appStore.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_CHE_USER_ID, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_CHE_USER_ID, - cheUserId, - }, - ]; + await store.dispatch(actionCreators.requestCheUserId()); - expect(actions).toEqual(expectedActions); - }); + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(cheUserIdRequestAction()); + expect(actions[1]).toEqual(cheUserIdReceiveAction(mockCheUserId)); + }); - it('should create REQUEST_CHE_USER_ID and RECEIVE_CHE_USER_ID_ERROR when fails to fetch user ID', async () => { - (mockAxios.get as jest.Mock).mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; - try { - await appStore.dispatch(testStore.actionCreators.requestCheUserId()); - } catch (e) { - // noop - } + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchCheUserId as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); - const actions = appStore.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_CHE_USER_ID, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_CHE_USER_ID_ERROR, - error: expect.stringContaining('Something unexpected happened.'), - }, - ]; + await expect(store.dispatch(actionCreators.requestCheUserId())).rejects.toThrow(errorMessage); - expect(actions).toEqual(expectedActions); + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(cheUserIdRequestAction()); + expect(actions[1]).toEqual(cheUserIdErrorAction(errorMessage)); + }); }); }); diff --git a/packages/dashboard-frontend/src/store/User/Id/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/User/Id/__tests__/reducers.spec.ts index d2f4645f5..83772ab9b 100644 --- a/packages/dashboard-frontend/src/store/User/Id/__tests__/reducers.spec.ts +++ b/packages/dashboard-frontend/src/store/User/Id/__tests__/reducers.spec.ts @@ -10,108 +10,58 @@ * Red Hat, Inc. - initial API and implementation */ -import { AnyAction } from 'redux'; +import { UnknownAction } from 'redux'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; +import { + cheUserIdErrorAction, + cheUserIdReceiveAction, + cheUserIdRequestAction, +} from '@/store/User/Id/actions'; +import { reducer, State, unloadedState } from '@/store/User/Id/reducer'; -import * as testStore from '..'; +describe('CheUserId, reducer', () => { + let initialState: State; -const cheUserId = 'che-user-id'; - -describe('UserId store, reducers', () => { - // let userProfile: api.IUserProfile; - - it('should return initial state', () => { - const incomingAction: testStore.RequestCheUserIdAction = { - type: testStore.Type.REQUEST_CHE_USER_ID, - check: AUTHORIZED, - }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - cheUserId: '', - }; - - expect(initialState).toEqual(expectedState); + beforeEach(() => { + initialState = { ...unloadedState }; }); - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { + it('should handle cheUserIdRequestAction', () => { + const action = cheUserIdRequestAction(); + const expectedState: State = { + ...initialState, isLoading: true, - cheUserId: '', }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - const expectedState: testStore.State = { - isLoading: true, - cheUserId: '', - }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle REQUEST_CHE_USER_ID', () => { - const initialState: testStore.State = { + it('should handle cheUserIdReceiveAction', () => { + const cheUserId = 'test-user-id'; + const action = cheUserIdReceiveAction(cheUserId); + const expectedState: State = { + ...initialState, isLoading: false, - cheUserId: '', - error: 'unexpected error', - }; - const incomingAction: testStore.RequestCheUserIdAction = { - type: testStore.Type.REQUEST_CHE_USER_ID, - check: AUTHORIZED, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - cheUserId: '', - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_CHE_USER_ID', () => { - const initialState: testStore.State = { - isLoading: true, - cheUserId: '', - }; - const incomingAction: testStore.ReceiveCheUserAction = { - type: testStore.Type.RECEIVE_CHE_USER_ID, cheUserId, }; - const newState = testStore.reducer(initialState, incomingAction); + expect(reducer(initialState, action)).toEqual(expectedState); + }); - const expectedState: testStore.State = { + it('should handle cheUserIdErrorAction', () => { + const error = 'Error message'; + const action = cheUserIdErrorAction(error); + const expectedState: State = { + ...initialState, isLoading: false, - cheUserId, + error, }; - expect(newState).toEqual(expectedState); + expect(reducer(initialState, action)).toEqual(expectedState); }); - it('should handle RECEIVE_CHE_USER_ID_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - cheUserId: '', - }; - const incomingAction: testStore.ReceiveCheUserErrorAction = { - type: testStore.Type.RECEIVE_CHE_USER_ID_ERROR, - error: 'unexpected error', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - cheUserId: '', - error: 'unexpected error', - }; - - expect(newState).toEqual(expectedState); + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/User/Id/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/User/Id/__tests__/selectors.spec.ts index 0562df4d5..c9fe77264 100644 --- a/packages/dashboard-frontend/src/store/User/Id/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/User/Id/__tests__/selectors.spec.ts @@ -10,39 +10,34 @@ * Red Hat, Inc. - initial API and implementation */ -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; +import { RootState } from '@/store'; +import { + selectCheUserId, + selectCheUserIdError, + selectCheUserIsLoading, +} from '@/store/User/Id/selectors'; -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { selectCheUserId, selectCheUserIdError } from '@/store/User/Id/selectors'; +describe('CheUserId, selectors', () => { + const mockState = { + userId: { + isLoading: true, + cheUserId: 'test-user-id', + error: 'Something went wrong', + }, + } as RootState; -import * as store from '..'; - -describe('Pods store, selectors', () => { - it('should return the error', () => { - const fakeStore = new FakeStoreBuilder() - .withCheUserId({ error: 'Something unexpected', cheUserId: 'che-user-id' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); - - const selectedError = selectCheUserIdError(state); - expect(selectedError).toEqual('Something unexpected'); + it('should select isLoading', () => { + const result = selectCheUserIsLoading(mockState); + expect(result).toBe(true); }); - it('should return Che user ID', () => { - const fakeStore = new FakeStoreBuilder() - .withCheUserId({ cheUserId: 'che-user-id' }, false) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - const state = fakeStore.getState(); + it('should select cheUserId', () => { + const result = selectCheUserId(mockState); + expect(result).toEqual('test-user-id'); + }); - const allPods = selectCheUserId(state); - expect(allPods).toEqual('che-user-id'); + it('should select cheUserId error', () => { + const result = selectCheUserIdError(mockState); + expect(result).toEqual('Something went wrong'); }); }); diff --git a/packages/dashboard-frontend/src/store/User/Id/actions.ts b/packages/dashboard-frontend/src/store/User/Id/actions.ts new file mode 100644 index 000000000..cdf204ce0 --- /dev/null +++ b/packages/dashboard-frontend/src/store/User/Id/actions.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { fetchCheUserId } from '@/services/che-user-id'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const cheUserIdRequestAction = createAction('cheUserId/request'); +export const cheUserIdReceiveAction = createAction('cheUserId/receive'); +export const cheUserIdErrorAction = createAction('cheUserId/Error'); + +export const actionCreators = { + requestCheUserId: (): AppThunk => async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(cheUserIdRequestAction()); + + const cheUserId = await fetchCheUserId(); + dispatch(cheUserIdReceiveAction(cheUserId)); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(cheUserIdErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/User/Id/index.ts b/packages/dashboard-frontend/src/store/User/Id/index.ts index 394e3db2b..163fd2958 100644 --- a/packages/dashboard-frontend/src/store/User/Id/index.ts +++ b/packages/dashboard-frontend/src/store/User/Id/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,104 +12,6 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { fetchCheUserId } from '@/services/che-user-id'; -import { createObject } from '@/store/helpers'; -import { AppThunk } from '@/store/index'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -export interface State { - cheUserId: string; - error?: string; - isLoading: boolean; -} - -export enum Type { - REQUEST_CHE_USER_ID = 'REQUEST_CHE_USER_ID', - RECEIVE_CHE_USER_ID = 'RECEIVE_CHE_USER_ID', - RECEIVE_CHE_USER_ID_ERROR = 'RECEIVE_CHE_USER_ID_ERROR', -} - -export interface RequestCheUserIdAction extends Action, SanityCheckAction { - type: Type.REQUEST_CHE_USER_ID; -} - -export interface ReceiveCheUserAction { - type: Type.RECEIVE_CHE_USER_ID; - cheUserId: string; -} - -export interface ReceiveCheUserErrorAction { - type: Type.RECEIVE_CHE_USER_ID_ERROR; - error: string; -} - -export type KnownAction = RequestCheUserIdAction | ReceiveCheUserAction | ReceiveCheUserErrorAction; - -export type ActionCreators = { - requestCheUserId: () => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestCheUserId: - (): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_CHE_USER_ID, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const cheUserId = await fetchCheUserId(); - dispatch({ - type: Type.RECEIVE_CHE_USER_ID, - cheUserId, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_CHE_USER_ID_ERROR, - error: errorMessage, - }); - throw e; - } - }, -}; - -const unloadedState: State = { - cheUserId: '', - isLoading: false, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_CHE_USER_ID: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_CHE_USER_ID: - return createObject(state, { - isLoading: false, - cheUserId: action.cheUserId, - }); - case Type.RECEIVE_CHE_USER_ID_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as userIdActionCreators } from '@/store/User/Id/actions'; +export { reducer as UserIdReducer, State as UserIdState } from '@/store/User/Id/reducer'; +export * from '@/store/User/Id/selectors'; diff --git a/packages/dashboard-frontend/src/store/User/Id/reducer.ts b/packages/dashboard-frontend/src/store/User/Id/reducer.ts new file mode 100644 index 000000000..32cb390f3 --- /dev/null +++ b/packages/dashboard-frontend/src/store/User/Id/reducer.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { + cheUserIdErrorAction, + cheUserIdReceiveAction, + cheUserIdRequestAction, +} from '@/store/User/Id/actions'; + +export interface State { + cheUserId: string; + error?: string; + isLoading: boolean; +} + +export const unloadedState: State = { + cheUserId: '', + isLoading: false, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(cheUserIdRequestAction, state => { + state.isLoading = true; + }) + .addCase(cheUserIdReceiveAction, (state, action) => { + state.isLoading = false; + state.cheUserId = action.payload; + }) + .addCase(cheUserIdErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/User/Id/selectors.ts b/packages/dashboard-frontend/src/store/User/Id/selectors.ts index 05f34b9ff..292389adb 100644 --- a/packages/dashboard-frontend/src/store/User/Id/selectors.ts +++ b/packages/dashboard-frontend/src/store/User/Id/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.userId; +const selectState = (state: RootState) => state.userId; export const selectCheUserIsLoading = createSelector(selectState, state => state.isLoading); diff --git a/packages/dashboard-frontend/src/store/User/Profile/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/User/Profile/__tests__/actions.spec.ts new file mode 100644 index 000000000..0e76891dd --- /dev/null +++ b/packages/dashboard-frontend/src/store/User/Profile/__tests__/actions.spec.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api } from '@eclipse-che/common'; + +import { fetchUserProfile } from '@/services/backend-client/userProfileApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + actionCreators, + userProfileErrorAction, + userProfileReceiveAction, + userProfileRequestAction, +} from '@/store/User/Profile/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/userProfileApi'); +jest.mock('@/store/SanityCheck'); + +describe('UserProfile, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestUserProfile', () => { + it('should dispatch receive action on successful fetch', async () => { + const mockNamespace = 'test-namespace'; + const mockUserProfile = { username: 'test-user' } as api.IUserProfile; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchUserProfile as jest.Mock).mockResolvedValue(mockUserProfile); + + await store.dispatch(actionCreators.requestUserProfile(mockNamespace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(userProfileRequestAction()); + expect(actions[1]).toEqual(userProfileReceiveAction(mockUserProfile)); + }); + + it('should dispatch error action on failed fetch', async () => { + const mockNamespace = 'test-namespace'; + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchUserProfile as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.requestUserProfile(mockNamespace)), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(userProfileRequestAction()); + expect(actions[1]).toEqual(userProfileErrorAction(errorMessage)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/User/Profile/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/User/Profile/__tests__/index.spec.ts deleted file mode 100644 index 6ab654766..000000000 --- a/packages/dashboard-frontend/src/store/User/Profile/__tests__/index.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; -import mockAxios, { AxiosError } from 'axios'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; - -import * as testStore from '..'; - -const namespace = 'user1-che'; - -describe('UserPreferences store', () => { - let userProfile: api.IUserProfile; - - beforeEach(() => { - userProfile = { - email: 'user1@che', - username: 'user1', - }; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('actions', () => { - let appStore: MockStoreEnhanced< - AppState, - ThunkDispatch - >; - - beforeEach(() => { - appStore = new FakeStoreBuilder().build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - }); - - it('should create REQUEST_USER_PROFILE and RECEIVE_USER_PROFILE when fetching user profile', async () => { - (mockAxios.get as jest.Mock).mockResolvedValueOnce({ - data: userProfile, - }); - - await appStore.dispatch(testStore.actionCreators.requestUserProfile(namespace)); - - const actions = appStore.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_USER_PROFILE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_USER_PROFILE, - userProfile, - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - - it('should create REQUEST_USER_PROFILE and RECEIVE_USER_PROFILE_ERROR when fails to fetch user profile', async () => { - (mockAxios.get as jest.Mock).mockRejectedValueOnce({ - isAxiosError: true, - code: '500', - message: 'Something unexpected happened.', - } as AxiosError); - - try { - await appStore.dispatch(testStore.actionCreators.requestUserProfile(namespace)); - } catch (e) { - // noop - } - - const actions = appStore.getActions(); - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_USER_PROFILE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_USER_PROFILE_ERROR, - error: expect.stringContaining('Something unexpected happened.'), - }, - ]; - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('reducers', () => { - it('should return initial state', () => { - const incomingAction: testStore.RequestUserProfileAction = { - type: testStore.Type.REQUEST_USER_PROFILE, - check: AUTHORIZED, - }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - userProfile: { - email: '', - username: 'unknown', - }, - }; - - expect(initialState).toEqual(expectedState); - }); - - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { - isLoading: true, - userProfile: { - email: '', - username: 'unknown', - }, - }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - userProfile: { - email: '', - username: 'unknown', - }, - }; - expect(newState).toEqual(expectedState); - }); - - it('should handle REQUEST_USER_PROFILE', () => { - const initialState: testStore.State = { - isLoading: false, - userProfile: { - email: '', - username: 'unknown', - }, - error: 'unexpected error', - }; - const incomingAction: testStore.RequestUserProfileAction = { - type: testStore.Type.REQUEST_USER_PROFILE, - check: AUTHORIZED, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - userProfile: { - email: '', - username: 'unknown', - }, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_USER_PROFILE', () => { - const initialState: testStore.State = { - isLoading: true, - userProfile: { - email: '', - username: 'unknown', - }, - }; - const incomingAction: testStore.ReceiveUserProfileAction = { - type: testStore.Type.RECEIVE_USER_PROFILE, - userProfile, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - userProfile, - }; - - expect(newState).toEqual(expectedState); - }); - - it('should handle RECEIVE_USER_PROFILE_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - userProfile: { - email: '', - username: 'unknown', - }, - }; - const incomingAction: testStore.ReceiveUserProfileErrorAction = { - type: testStore.Type.RECEIVE_USER_PROFILE_ERROR, - error: 'unexpected error', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - userProfile: { - email: '', - username: 'unknown', - }, - error: 'unexpected error', - }; - - expect(newState).toEqual(expectedState); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/User/Profile/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/User/Profile/__tests__/reducer.spec.ts new file mode 100644 index 000000000..f587d79ef --- /dev/null +++ b/packages/dashboard-frontend/src/store/User/Profile/__tests__/reducer.spec.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { UnknownAction } from 'redux'; + +import { + userProfileErrorAction, + userProfileReceiveAction, + userProfileRequestAction, +} from '@/store/User/Profile/actions'; +import { reducer, State, unloadedState } from '@/store/User/Profile/reducer'; + +describe('UserProfile, reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle userProfileRequestAction', () => { + const action = userProfileRequestAction(); + const expectedState: State = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle userProfileReceiveAction', () => { + const userProfile = { email: 'test@example.com', username: 'testuser' } as api.IUserProfile; + const action = userProfileReceiveAction(userProfile); + const expectedState: State = { + ...initialState, + isLoading: false, + userProfile, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle userProfileErrorAction', () => { + const error = 'Error message'; + const action = userProfileErrorAction(error); + const expectedState: State = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/User/Profile/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/User/Profile/__tests__/selectors.spec.ts new file mode 100644 index 000000000..ec20dc570 --- /dev/null +++ b/packages/dashboard-frontend/src/store/User/Profile/__tests__/selectors.spec.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { + selectUserProfile, + selectUserProfileError, + selectUserProfileState, +} from '@/store/User/Profile/selectors'; + +describe('UserProfile Selectors', () => { + const mockState = { + userProfile: { + userProfile: { + email: 'test@example.com', + username: 'testuser', + }, + error: 'Something went wrong', + }, + } as RootState; + + it('should select the user profile state', () => { + const result = selectUserProfileState(mockState); + expect(result).toEqual(mockState.userProfile); + }); + + it('should select the user profile', () => { + const result = selectUserProfile(mockState); + expect(result).toEqual({ + email: 'test@example.com', + username: 'testuser', + }); + }); + + it('should select the user profile error', () => { + const result = selectUserProfileError(mockState); + expect(result).toEqual('Something went wrong'); + }); +}); diff --git a/packages/dashboard-frontend/src/store/User/Profile/actions.ts b/packages/dashboard-frontend/src/store/User/Profile/actions.ts new file mode 100644 index 000000000..056b8fc16 --- /dev/null +++ b/packages/dashboard-frontend/src/store/User/Profile/actions.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { fetchUserProfile } from '@/services/backend-client/userProfileApi'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const userProfileRequestAction = createAction('userProfile/request'); +export const userProfileReceiveAction = createAction('userProfile/receive'); +export const userProfileErrorAction = createAction('userProfile/error'); + +export const actionCreators = { + requestUserProfile: + (namespace: string): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(userProfileRequestAction()); + + const userProfile = await fetchUserProfile(namespace); + dispatch(userProfileReceiveAction(userProfile)); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(userProfileErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/User/Profile/index.ts b/packages/dashboard-frontend/src/store/User/Profile/index.ts index bde57d218..ed62d3c5a 100644 --- a/packages/dashboard-frontend/src/store/User/Profile/index.ts +++ b/packages/dashboard-frontend/src/store/User/Profile/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,115 +12,9 @@ * Red Hat, Inc. - initial API and implementation */ -// This state defines the type of data maintained in the Redux store. - -import common, { api } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; - -import { fetchUserProfile } from '@/services/backend-client/userProfileApi'; -import { createObject } from '@/store/helpers'; -import { AppThunk } from '@/store/index'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -export interface State { - userProfile: api.IUserProfile; - error?: string; - isLoading: boolean; -} - -export enum Type { - REQUEST_USER_PROFILE = 'REQUEST_USER_PROFILE', - RECEIVE_USER_PROFILE = 'RECEIVE_USER_PROFILE', - RECEIVE_USER_PROFILE_ERROR = 'RECEIVE_USER_PROFILE_ERROR', -} - -export interface RequestUserProfileAction extends Action, SanityCheckAction { - type: Type.REQUEST_USER_PROFILE; -} - -export interface ReceiveUserProfileAction { - type: Type.RECEIVE_USER_PROFILE; - userProfile: api.IUserProfile; -} - -export interface ReceiveUserProfileErrorAction { - type: Type.RECEIVE_USER_PROFILE_ERROR; - error: string; -} - -export type KnownAction = - | RequestUserProfileAction - | ReceiveUserProfileAction - | ReceiveUserProfileErrorAction; - -export type ActionCreators = { - requestUserProfile: (namespace: string) => AppThunk>; -}; - -export const actionCreators: ActionCreators = { - requestUserProfile: - (namespace: string): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_USER_PROFILE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const userProfile = await fetchUserProfile(namespace); - dispatch({ - type: Type.RECEIVE_USER_PROFILE, - userProfile, - }); - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_USER_PROFILE_ERROR, - error: errorMessage, - }); - if (common.helpers.errors.isError(e)) { - throw e; - } - throw new Error(errorMessage); - } - }, -}; - -const unloadedState: State = { - userProfile: { - email: '', - username: 'unknown', - }, - isLoading: false, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_USER_PROFILE: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_USER_PROFILE: - return createObject(state, { - isLoading: false, - userProfile: action.userProfile, - }); - case Type.RECEIVE_USER_PROFILE_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - default: - return state; - } -}; +export { actionCreators as userProfileActionCreators } from '@/store/User/Profile/actions'; +export { + reducer as UserProfileReducer, + State as UserProfileState, +} from '@/store/User/Profile/reducer'; +export * from '@/store/User/Profile/selectors'; diff --git a/packages/dashboard-frontend/src/store/User/Profile/reducer.ts b/packages/dashboard-frontend/src/store/User/Profile/reducer.ts new file mode 100644 index 000000000..8b3acd02b --- /dev/null +++ b/packages/dashboard-frontend/src/store/User/Profile/reducer.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + userProfileErrorAction, + userProfileReceiveAction, + userProfileRequestAction, +} from '@/store/User/Profile/actions'; + +export interface State { + userProfile: api.IUserProfile; + error?: string; + isLoading: boolean; +} + +export const unloadedState: State = { + userProfile: { + email: '', + username: 'unknown', + }, + isLoading: false, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(userProfileRequestAction, state => { + state.isLoading = true; + }) + .addCase(userProfileReceiveAction, (state, action) => { + state.isLoading = false; + state.userProfile = action.payload; + }) + .addCase(userProfileErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/User/Profile/selectors.ts b/packages/dashboard-frontend/src/store/User/Profile/selectors.ts index bd864f213..69d68a680 100644 --- a/packages/dashboard-frontend/src/store/User/Profile/selectors.ts +++ b/packages/dashboard-frontend/src/store/User/Profile/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.userProfile; +const selectState = (state: RootState) => state.userProfile; export const selectUserProfileState = selectState; export const selectUserProfile = createSelector(selectState, state => state.userProfile); diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/actions.spec.ts index d13aaad80..65b145c95 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/actions.spec.ts @@ -10,52 +10,37 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { AppState } from '@/store'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; -import { actionCreators } from '@/store/Workspaces/Preferences/actions'; -import { KnownAction } from '@/store/Workspaces/Preferences/types'; - -const mockIsTrustedRepo = jest.fn(); -jest.mock('@/store/Workspaces/Preferences/helpers', () => ({ - isTrustedRepo: () => mockIsTrustedRepo, -})); - -const mockGetWorkspacePreferences = jest.fn().mockResolvedValue({ - 'skip-authorisation': [], - 'trusted-sources': '*', -} as api.IWorkspacePreferences); -const mockAddTrustedSource = jest.fn().mockResolvedValue(undefined); -jest.mock('@/services/backend-client/workspacePreferencesApi', () => ({ - getWorkspacePreferences: (...args: unknown[]) => mockGetWorkspacePreferences(...args), - addTrustedSource: (...args: unknown[]) => mockAddTrustedSource(...args), -})); - -const mockSelectAsyncIsAuthorized = jest.fn().mockResolvedValue(true); -const mockSelectSanityCheckError = jest.fn().mockReturnValue(''); -jest.mock('@/store/SanityCheck/selectors', () => ({ - selectAsyncIsAuthorized: () => mockSelectAsyncIsAuthorized(), - selectSanityCheckError: () => mockSelectSanityCheckError(), -})); - -describe('workspace preferences, actionCreators', () => { - let store: MockStoreEnhanced>; +import common, { api } from '@eclipse-che/common'; + +import { + addTrustedSource, + getWorkspacePreferences, +} from '@/services/backend-client/workspacePreferencesApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + actionCreators, + preferencesErrorAction, + preferencesReceiveAction, + preferencesRequestAction, +} from '@/store/Workspaces/Preferences/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/workspacePreferencesApi'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/InfrastructureNamespaces/selectors'); + +describe('Preferences, actions', () => { + let store: ReturnType; beforeEach(() => { - store = new FakeStoreBuilder() - .withInfrastructureNamespace([ - { - name: 'user-che', - attributes: { - phase: 'Active', - }, - }, - ]) - .build(); + store = createMockStore({}); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + const defaultNamespace = { name: 'test-namespace' }; + (selectDefaultNamespace as unknown as jest.Mock).mockReturnValue(defaultNamespace); }); afterEach(() => { @@ -63,158 +48,73 @@ describe('workspace preferences, actionCreators', () => { }); describe('requestPreferences', () => { - it('should dispatch REQUEST_PREFERENCES and RECEIVE_PREFERENCES', async () => { - await expect(store.dispatch(actionCreators.requestPreferences())).resolves.toBeUndefined(); - - expect(mockGetWorkspacePreferences).toHaveBeenCalled(); - - const actions = store.getActions(); - const expectedActions = [ - { - type: 'REQUEST_PREFERENCES', - check: AUTHORIZED, - }, - { - type: 'RECEIVE_PREFERENCES', - preferences: { - 'skip-authorisation': [], - 'trusted-sources': '*', - }, - }, - ]; - expect(actions).toEqual(expectedActions); - }); + it('should dispatch receive action on successful fetch', async () => { + const mockPreferences = { + 'skip-authorisation': ['github'], + } as api.IWorkspacePreferences; - it('should dispatch REQUEST_PREFERENCES and ERROR_PREFERENCES when user is not authorized', async () => { - mockSelectAsyncIsAuthorized.mockResolvedValueOnce(false); - mockSelectSanityCheckError.mockReturnValueOnce('not authorized'); - - await expect(store.dispatch(actionCreators.requestPreferences())).rejects.toEqual( - new Error('not authorized'), - ); + (getWorkspacePreferences as jest.Mock).mockResolvedValue(mockPreferences); - expect(mockGetWorkspacePreferences).not.toHaveBeenCalled(); + await store.dispatch(actionCreators.requestPreferences()); const actions = store.getActions(); - const expectedActions = [ - { - type: 'REQUEST_PREFERENCES', - check: AUTHORIZED, - }, - { - type: 'ERROR_PREFERENCES', - error: 'not authorized', - }, - ]; - expect(actions).toEqual(expectedActions); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(preferencesRequestAction()); + expect(actions[1]).toEqual(preferencesReceiveAction(mockPreferences)); }); - it('should dispatch REQUEST_PREFERENCES adn ERROR_PREFERENCES when getWorkspacePreferences fails', async () => { - const error = new Error('unexpected error'); - mockGetWorkspacePreferences.mockRejectedValueOnce(error); + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; - await expect(store.dispatch(actionCreators.requestPreferences())).rejects.toEqual(error); + (getWorkspacePreferences as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); - expect(mockGetWorkspacePreferences).toHaveBeenCalled(); + await expect(store.dispatch(actionCreators.requestPreferences())).rejects.toThrow( + errorMessage, + ); const actions = store.getActions(); - const expectedActions = [ - { - type: 'REQUEST_PREFERENCES', - check: AUTHORIZED, - }, - { - type: 'ERROR_PREFERENCES', - error: error.message, - }, - ]; - expect(actions).toEqual(expectedActions); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(preferencesRequestAction()); + expect(actions[1]).toEqual(preferencesErrorAction(errorMessage)); }); }); describe('addTrustedSource', () => { - it('should dispatch REQUEST_PREFERENCES and UPDATE_PREFERENCES', async () => { - const sourceUrl = 'https://github.com/user/repo'; - await expect( - store.dispatch(actionCreators.addTrustedSource(sourceUrl)), - ).resolves.toBeUndefined(); - - expect(mockAddTrustedSource).toHaveBeenCalledWith('user-che', sourceUrl); - - const actions = store.getActions(); - const expectedActions = [ - { - type: 'REQUEST_PREFERENCES', - check: AUTHORIZED, - }, - { - type: 'UPDATE_PREFERENCES', - }, - // requestPreferences is called after - { - type: 'REQUEST_PREFERENCES', - check: AUTHORIZED, - }, - { - type: 'RECEIVE_PREFERENCES', - preferences: { - 'skip-authorisation': [], - 'trusted-sources': '*', - }, - }, - ]; - expect(actions).toEqual(expectedActions); - }); - - it('should dispatch REQUEST_PREFERENCES and ERROR_PREFERENCES when user is not authorized', async () => { - mockSelectAsyncIsAuthorized.mockResolvedValueOnce(false); - mockSelectSanityCheckError.mockReturnValueOnce('not authorized'); + it('should dispatch preferences and requestPreferences on success', async () => { + const trustedSource = 'https://trusted-source.com' as api.TrustedSourceUrl; + const mockPreferences = { + 'skip-authorisation': ['gitlab'], + } as api.IWorkspacePreferences; - const sourceUrl = 'https://github.com/user/repo'; - await expect(store.dispatch(actionCreators.addTrustedSource(sourceUrl))).rejects.toEqual( - new Error('not authorized'), - ); + (addTrustedSource as jest.Mock).mockResolvedValue(undefined); + (getWorkspacePreferences as jest.Mock).mockResolvedValue(mockPreferences); - expect(mockAddTrustedSource).not.toHaveBeenCalled(); + await store.dispatch(actionCreators.addTrustedSource(trustedSource)); const actions = store.getActions(); - const expectedActions = [ - { - type: 'REQUEST_PREFERENCES', - check: AUTHORIZED, - }, - { - type: 'ERROR_PREFERENCES', - error: 'not authorized', - }, - ]; - expect(actions).toEqual(expectedActions); + expect(actions).toHaveLength(4); + expect(actions[0]).toEqual(preferencesRequestAction()); + expect(actions[1]).toEqual(preferencesReceiveAction(undefined)); + expect(actions[2]).toEqual(preferencesRequestAction()); + expect(actions[3]).toEqual(preferencesReceiveAction(mockPreferences)); }); - it('should dispatch REQUEST_PREFERENCES and ERROR_PREFERENCES when addTrustedSource fails', async () => { - const error = new Error('unexpected error'); - mockAddTrustedSource.mockRejectedValueOnce(error); + it('should dispatch error action on failed addTrustedSource', async () => { + const errorMessage = 'Network error'; + const trustedSource = 'https://trusted-source.com' as api.TrustedSourceUrl; - const sourceUrl = 'https://github.com/user/repo'; + (addTrustedSource as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); - await expect(store.dispatch(actionCreators.addTrustedSource(sourceUrl))).rejects.toEqual( - error, + await expect(store.dispatch(actionCreators.addTrustedSource(trustedSource))).rejects.toThrow( + errorMessage, ); - expect(mockAddTrustedSource).toHaveBeenCalledWith('user-che', sourceUrl); - const actions = store.getActions(); - const expectedActions = [ - { - type: 'REQUEST_PREFERENCES', - check: AUTHORIZED, - }, - { - type: 'ERROR_PREFERENCES', - error: error.message, - }, - ]; - expect(actions).toEqual(expectedActions); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(preferencesRequestAction()); + expect(actions[1]).toEqual(preferencesErrorAction(errorMessage)); }); }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/index.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/index.spec.ts deleted file mode 100644 index 4e1cc2f66..000000000 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/index.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import * as WorkspacePreferences from '@/store/Workspaces/Preferences'; - -describe('Workspace preferences', () => { - test('re-exports', () => { - expect(WorkspacePreferences.workspacePreferencesActionCreators).toBeDefined(); - expect(WorkspacePreferences.workspacePreferencesReducer).toBeDefined(); - expect(WorkspacePreferences.selectPreferences).toBeDefined(); - }); -}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/reducer.spec.ts index aa1ef84ae..8323fbd00 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/reducer.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/reducer.spec.ts @@ -10,78 +10,72 @@ * Red Hat, Inc. - initial API and implementation */ -import { Action } from 'redux'; +import { api } from '@eclipse-che/common'; +import { UnknownAction } from 'redux'; -import { reducer, unloadedState } from '@/store/Workspaces/Preferences/reducer'; -import { KnownAction, State, Type } from '@/store/Workspaces/Preferences/types'; +import { + preferencesErrorAction, + preferencesReceiveAction, + preferencesRequestAction, +} from '@/store/Workspaces/Preferences/actions'; +import { reducer, State, unloadedState } from '@/store/Workspaces/Preferences/reducer'; -describe('Workspace preferences, reducer', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - const initialState = unloadedState; - - it('should return the initial state when an unknown action is provided', () => { - const unknownAction: Action = { type: 'UNKNOWN_ACTION' }; - - const expectedState = initialState; - - const state = reducer(undefined, unknownAction); +describe('Preferences, reducer', () => { + let initialState: State; - expect(state).toEqual(expectedState); + beforeEach(() => { + initialState = { ...unloadedState }; }); - it('should handle REQUEST_PREFERENCES action', () => { - const action: Action = { - type: Type.REQUEST_PREFERENCES, - }; - + it('should handle preferencesRequestAction', () => { + const action = preferencesRequestAction(); const expectedState: State = { ...initialState, isLoading: true, }; - - const state = reducer(initialState, action); - - expect(state).toEqual(expectedState); + const newState = reducer(initialState, action); + expect(newState).toEqual(expectedState); }); - it('should handle RECEIVE_PREFERENCES action', () => { - const preferences = { - 'skip-authorisation': [], - }; - const action: KnownAction = { - type: Type.RECEIVE_PREFERENCES, - preferences, - }; - + it('should handle preferencesReceiveAction with payload', () => { + const mockPreferences = { + 'skip-authorisation': ['azure-devops'], + } as api.IWorkspacePreferences; + const action = preferencesReceiveAction(mockPreferences); const expectedState: State = { ...initialState, isLoading: false, - preferences, + preferences: mockPreferences, }; - - const state = reducer(initialState, action); - - expect(state).toEqual(expectedState); + const newState = reducer(initialState, action); + expect(newState).toEqual(expectedState); }); - it('should handle ERROR_PREFERENCES action', () => { - const error = 'error'; - const action: KnownAction = { - type: Type.ERROR_PREFERENCES, - error, + it('should handle preferencesReceiveAction without payload', () => { + const action = preferencesReceiveAction(undefined); + const expectedState: State = { + ...initialState, + isLoading: false, }; + const newState = reducer(initialState, action); + expect(newState).toEqual(expectedState); + }); + it('should handle preferencesErrorAction', () => { + const errorMessage = 'An error occurred'; + const action = preferencesErrorAction(errorMessage); const expectedState: State = { ...initialState, isLoading: false, - error, + error: errorMessage, }; + const newState = reducer(initialState, action); + expect(newState).toEqual(expectedState); + }); - const state = reducer(initialState, action); - - expect(state).toEqual(expectedState); + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + const newState = reducer(initialState, unknownAction); + expect(newState).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/selectors.spec.ts index cbcd58e53..3279451b2 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/Preferences/__tests__/selectors.spec.ts @@ -10,9 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; - -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { RootState } from '@/store'; import { selectPreferences, selectPreferencesError, @@ -20,47 +18,35 @@ import { selectPreferencesTrustedSources, } from '@/store/Workspaces/Preferences/selectors'; -describe('Workspace preferences, selectors', () => { +describe('Preferences, selectors', () => { const mockState = { - preferences: { - 'skip-authorisation': ['github'] as api.GitProvider[], - 'trusted-sources': ['source1', 'source2'], + workspacePreferences: { + isLoading: false, + error: 'Test error', + preferences: { + 'skip-authorisation': ['azure-devops', 'bitbucket-server'], + 'trusted-sources': ['https://trusted-source.com'], + }, }, - error: 'Some error', - }; - const store = new FakeStoreBuilder() - .withWorkspacePreferences({ - 'skip-authorisation': mockState.preferences['skip-authorisation'], - 'trusted-sources': mockState.preferences['trusted-sources'], - error: mockState.error, - }) - .build(); + } as Partial as RootState; it('should select preferences', () => { - const state = store.getState(); - const result = selectPreferences(state); - - expect(result).toEqual(mockState.preferences); + const result = selectPreferences(mockState); + expect(result).toEqual(mockState.workspacePreferences.preferences); }); it('should select preferences error', () => { - const state = store.getState(); - const result = selectPreferencesError(state); - - expect(result).toEqual(mockState.error); + const result = selectPreferencesError(mockState); + expect(result).toEqual('Test error'); }); - it('should select skip-authorization preference', () => { - const state = store.getState(); - const result = selectPreferencesSkipAuthorization(state); - - expect(result).toEqual(mockState.preferences['skip-authorisation']); + it('should select preferences skip authorization', () => { + const result = selectPreferencesSkipAuthorization(mockState); + expect(result).toEqual(['azure-devops', 'bitbucket-server']); }); - it('should select trusted sources preference', () => { - const state = store.getState(); - const result = selectPreferencesTrustedSources(state); - - expect(result).toEqual(mockState.preferences['trusted-sources']); + it('should select preferences trusted sources', () => { + const result = selectPreferencesTrustedSources(mockState); + expect(result).toEqual(['https://trusted-source.com']); }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/actions.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/actions.ts index ee9d8d22f..1972871aa 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/actions.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/Preferences/actions.ts @@ -11,6 +11,7 @@ */ import common, { api } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; import { addTrustedSource, @@ -18,71 +19,48 @@ import { } from '@/services/backend-client/workspacePreferencesApi'; import { AppThunk } from '@/store'; import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; -import { Type } from '@/store/Workspaces/Preferences/types'; -import { ActionCreators, KnownAction } from '@/store/Workspaces/Preferences/types'; +import { verifyAuthorized } from '@/store/SanityCheck'; +export const preferencesRequestAction = createAction('preferences/request'); +export const preferencesReceiveAction = createAction( + 'preferences/receive', +); +export const preferencesErrorAction = createAction('preferences/Error'); -export const actionCreators: ActionCreators = { - requestPreferences: - (): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_PREFERENCES, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.ERROR_PREFERENCES, - error, - }); - throw new Error(error); - } +export const actionCreators = { + requestPreferences: (): AppThunk => async (dispatch, getState) => { + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - try { - const preferences = await getWorkspacePreferences(defaultKubernetesNamespace.name); - dispatch({ - type: Type.RECEIVE_PREFERENCES, - preferences, - }); - } catch (error) { - const errorMessage = common.helpers.errors.getMessage(error); - dispatch({ type: Type.ERROR_PREFERENCES, error: errorMessage }); - throw error; - } - }, + try { + await verifyAuthorized(dispatch, getState); - addTrustedSource: - ( - trustedSource: api.TrustedSourceAll | api.TrustedSourceUrl, - ): AppThunk> => - async (dispatch, getState): Promise => { - dispatch({ - type: Type.REQUEST_PREFERENCES, - check: AUTHORIZED, - }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - dispatch({ - type: Type.ERROR_PREFERENCES, - error, - }); - throw new Error(error); - } + dispatch(preferencesRequestAction()); + const preferences = await getWorkspacePreferences(defaultKubernetesNamespace.name); + dispatch(preferencesReceiveAction(preferences)); + } catch (error) { + const errorMessage = common.helpers.errors.getMessage(error); + dispatch(preferencesErrorAction(errorMessage)); + throw error; + } + }, + + addTrustedSource: + (trustedSource: api.TrustedSourceAll | api.TrustedSourceUrl): AppThunk => + async (dispatch, getState) => { const state = getState(); const defaultKubernetesNamespace = selectDefaultNamespace(state); + try { + await verifyAuthorized(dispatch, getState); + + dispatch(preferencesRequestAction()); + await addTrustedSource(defaultKubernetesNamespace.name, trustedSource); - dispatch({ - type: Type.UPDATE_PREFERENCES, - }); + dispatch(preferencesReceiveAction(undefined)); } catch (error) { const errorMessage = common.helpers.errors.getMessage(error); - dispatch({ type: Type.ERROR_PREFERENCES, error: errorMessage }); + dispatch(preferencesErrorAction(errorMessage)); throw error; } diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/index.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/index.ts index c1c63ee61..8e576e24b 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/index.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/Preferences/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -11,9 +13,8 @@ */ export { actionCreators as workspacePreferencesActionCreators } from '@/store/Workspaces/Preferences/actions'; -export { reducer as workspacePreferencesReducer } from '@/store/Workspaces/Preferences/reducer'; -export * from '@/store/Workspaces/Preferences/selectors'; export { - ActionCreators as WorkspacePreferencesActionCreators, + reducer as workspacePreferencesReducer, State as WorkspacePreferencesState, -} from '@/store/Workspaces/Preferences/types'; +} from '@/store/Workspaces/Preferences/reducer'; +export * from '@/store/Workspaces/Preferences/selectors'; diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/reducer.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/reducer.ts index 78a0f4bc3..c88c1777f 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/reducer.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/Preferences/reducer.ts @@ -11,11 +11,19 @@ */ import { api } from '@eclipse-che/common'; -import { Action, Reducer } from 'redux'; +import { createReducer } from '@reduxjs/toolkit'; -import { KnownAction } from '@/store/Workspaces/Preferences/types'; -import { Type } from '@/store/Workspaces/Preferences/types'; -import { State } from '@/store/Workspaces/Preferences/types'; +import { + preferencesErrorAction, + preferencesReceiveAction, + preferencesRequestAction, +} from '@/store/Workspaces/Preferences/actions'; + +export interface State { + isLoading: boolean; + preferences: api.IWorkspacePreferences; + error?: string; +} export const unloadedState: State = { isLoading: false, @@ -24,34 +32,23 @@ export const unloadedState: State = { } as api.IWorkspacePreferences, }; -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_PREFERENCES: - return { - ...state, - isLoading: true, - }; - case Type.RECEIVE_PREFERENCES: - return { - ...state, - isLoading: false, - preferences: action.preferences, - }; - case Type.ERROR_PREFERENCES: - return { - ...state, - isLoading: false, - error: action.error, - }; - default: - return state; - } -}; +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(preferencesRequestAction, state => { + state.isLoading = true; + }) + .addCase(preferencesReceiveAction, (state, action) => { + state.isLoading = false; + if (action.payload) { + state.preferences = action.payload; + } else { + // trusted resources update + // no-op + } + }) + .addCase(preferencesErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/selectors.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/selectors.ts index d3caf831c..f9b606e6d 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/selectors.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/Preferences/selectors.ts @@ -10,11 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; -const selectState = (state: AppState) => state.workspacePreferences; +const selectState = (state: RootState) => state.workspacePreferences; export const selectPreferences = createSelector(selectState, state => state.preferences); diff --git a/packages/dashboard-frontend/src/store/Workspaces/Preferences/types.ts b/packages/dashboard-frontend/src/store/Workspaces/Preferences/types.ts deleted file mode 100644 index 1b843712f..000000000 --- a/packages/dashboard-frontend/src/store/Workspaces/Preferences/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api } from '@eclipse-che/common'; -import { Action } from 'redux'; - -import { AppThunk } from '@/store'; -import { SanityCheckAction } from '@/store/sanityCheckMiddleware'; - -export interface State { - isLoading: boolean; - preferences: api.IWorkspacePreferences; - error?: string; -} - -export enum Type { - REQUEST_PREFERENCES = 'REQUEST_PREFERENCES', - RECEIVE_PREFERENCES = 'RECEIVE_PREFERENCES', - ERROR_PREFERENCES = 'ERROR_PREFERENCES', - UPDATE_PREFERENCES = 'UPDATE_PREFERENCES', -} -export interface RequestPreferencesAction extends Action, SanityCheckAction { - type: Type.REQUEST_PREFERENCES; -} - -export interface ReceivePreferencesAction extends Action { - type: Type.RECEIVE_PREFERENCES; - preferences: api.IWorkspacePreferences; -} - -export interface ErrorPreferencesAction extends Action { - type: Type.ERROR_PREFERENCES; - error: string; -} - -export interface UpdatePreferencesAction extends Action { - type: Type.UPDATE_PREFERENCES; -} -export type KnownAction = - | RequestPreferencesAction - | ReceivePreferencesAction - | UpdatePreferencesAction - | ErrorPreferencesAction; - -export type ActionCreators = { - requestPreferences: () => AppThunk>; - addTrustedSource: ( - trustedSource: api.TrustedSourceAll | api.TrustedSourceUrl, - ) => AppThunk>; -}; diff --git a/packages/dashboard-frontend/src/store/Workspaces/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/__tests__/actions.spec.ts new file mode 100644 index 000000000..de68275a4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/__tests__/actions.spec.ts @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import devfileApi from '@/services/devfileApi'; +import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; +import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + qualifiedNameClearAction, + qualifiedNameSetAction, + workspaceUIDClearAction, + workspaceUIDSetAction, +} from '@/store/Workspaces/actions'; +import { devWorkspacesActionCreators } from '@/store/Workspaces/devWorkspaces'; +import * as devWorkspaces from '@/store/Workspaces/devWorkspaces'; + +jest.mock('@/services/devfileApi'); + +describe('Workspaces Actions', () => { + let store: ReturnType; + let mockWorkspace: Workspace; + + beforeEach(() => { + store = createMockStore({}); + + const devWorkspace = new DevWorkspaceBuilder().withName('test-workspace').build(); + mockWorkspace = constructWorkspace(devWorkspace); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('requestWorkspaces', () => { + it('should dispatch requestWorkspaces action', async () => { + const mockRequestDevWorkspaces = jest.fn(); + jest + .spyOn(devWorkspacesActionCreators, 'requestWorkspaces') + .mockImplementationOnce(() => async () => mockRequestDevWorkspaces()); + + await store.dispatch(actionCreators.requestWorkspaces()); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockRequestDevWorkspaces).toHaveBeenCalled(); + }); + }); + + describe('requestWorkspace', () => { + it('should dispatch requestWorkspace action', async () => { + const mockRequestDevWorkspace = jest.fn(); + jest + .spyOn(devWorkspaces.devWorkspacesActionCreators, 'requestWorkspace') + .mockImplementationOnce( + (...args: unknown[]) => + async () => + mockRequestDevWorkspace(...args), + ); + + await store.dispatch(actionCreators.requestWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockRequestDevWorkspace).toHaveBeenCalledWith(mockWorkspace.ref); + }); + }); + + describe('startWorkspace', () => { + it('should dispatch startWorkspace action with debugWorkspace', async () => { + const params = { 'debug-workspace-start': true }; + + const mockStartWorkspace = jest.fn(); + jest.spyOn(devWorkspacesActionCreators, 'startWorkspace').mockImplementationOnce( + (...args: unknown[]) => + async () => + mockStartWorkspace(...args), + ); + + await store.dispatch(actionCreators.startWorkspace(mockWorkspace, params)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockStartWorkspace).toHaveBeenCalledWith(mockWorkspace.ref, true); + }); + + it('should dispatch startWorkspace action without debugWorkspace', async () => { + const mockStartWorkspace = jest.fn(); + jest.spyOn(devWorkspacesActionCreators, 'startWorkspace').mockImplementationOnce( + (...args: unknown[]) => + async () => + mockStartWorkspace(...args), + ); + + await store.dispatch(actionCreators.startWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockStartWorkspace).toHaveBeenCalledWith(mockWorkspace.ref, undefined); + }); + }); + + describe('restartWorkspace', () => { + it('should dispatch restartWorkspace action', async () => { + const mockRestartWorkspace = jest.fn(); + jest.spyOn(devWorkspacesActionCreators, 'restartWorkspace').mockImplementationOnce( + (...args: unknown[]) => + async () => + mockRestartWorkspace(...args), + ); + + await store.dispatch(actionCreators.restartWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockRestartWorkspace).toHaveBeenCalledWith(mockWorkspace.ref); + }); + }); + + describe('stopWorkspace', () => { + it('should dispatch stopWorkspace action', async () => { + const mockStopWorkspace = jest.fn(); + jest.spyOn(devWorkspacesActionCreators, 'stopWorkspace').mockImplementationOnce( + (...args: unknown[]) => + async () => + mockStopWorkspace(...args), + ); + + await store.dispatch(actionCreators.stopWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockStopWorkspace).toHaveBeenCalledWith(mockWorkspace.ref); + }); + }); + + describe('deleteWorkspace', () => { + it('should dispatch deleteWorkspace action', async () => { + const mockDeleteWorkspace = jest.fn(); + jest.spyOn(devWorkspacesActionCreators, 'terminateWorkspace').mockImplementationOnce( + (...args: unknown[]) => + async () => + mockDeleteWorkspace(...args), + ); + + await store.dispatch(actionCreators.deleteWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockDeleteWorkspace).toHaveBeenCalledWith(mockWorkspace.ref); + }); + }); + + describe('updateWorkspace', () => { + it('should dispatch updateWorkspace action', async () => { + const mockUpdateWorkspace = jest.fn(); + jest.spyOn(devWorkspacesActionCreators, 'updateWorkspace').mockImplementationOnce( + (...args: unknown[]) => + async () => + mockUpdateWorkspace(...args), + ); + + await store.dispatch(actionCreators.updateWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockUpdateWorkspace).toHaveBeenCalledWith(mockWorkspace.ref); + }); + }); + + describe('updateWorkspaceWithDefaultDevfile', () => { + it('should dispatch updateWorkspaceWithDefaultDevfile action', async () => { + const mockUpdateWorkspace = jest.fn(); + jest + .spyOn(devWorkspacesActionCreators, 'updateWorkspaceWithDefaultDevfile') + .mockImplementationOnce( + (...args: unknown[]) => + async () => + mockUpdateWorkspace(...args), + ); + + await store.dispatch(actionCreators.updateWorkspaceWithDefaultDevfile(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockUpdateWorkspace).toHaveBeenCalledWith(mockWorkspace.ref); + }); + }); + + describe('createWorkspaceFromDevfile', () => { + it('should dispatch createWorkspaceFromDevfile action', async () => { + const mockDevfile = {} as devfileApi.Devfile; + const mockAttributes = {} as Partial; + const mockOptionalFilesContent = { 'README.md': 'Content' }; + + const mockCreateWorkspace = jest.fn(); + jest.spyOn(devWorkspacesActionCreators, 'createWorkspaceFromDevfile').mockImplementationOnce( + (...args: unknown[]) => + async () => + mockCreateWorkspace(...args), + ); + + await store.dispatch( + actionCreators.createWorkspaceFromDevfile( + mockDevfile, + mockAttributes, + mockOptionalFilesContent, + ), + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + + expect(mockCreateWorkspace).toHaveBeenCalledWith( + mockDevfile, + mockAttributes, + mockOptionalFilesContent, + ); + }); + }); + + describe('setWorkspaceQualifiedName', () => { + it('should dispatch setWorkspaceQualifiedName action', () => { + const namespace = 'test-namespace'; + const workspaceName = 'test-workspace'; + + store.dispatch(actionCreators.setWorkspaceQualifiedName(namespace, workspaceName)); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(qualifiedNameSetAction({ namespace, workspaceName })); + }); + }); + + describe('clearWorkspaceQualifiedName', () => { + it('should dispatch clearWorkspaceQualifiedName action', () => { + store.dispatch(actionCreators.clearWorkspaceQualifiedName()); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(qualifiedNameClearAction()); + }); + }); + + describe('setWorkspaceUID', () => { + it('should dispatch setWorkspaceUID action', () => { + const workspaceUID = 'test-uid'; + + store.dispatch(actionCreators.setWorkspaceUID(workspaceUID)); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(workspaceUIDSetAction(workspaceUID)); + }); + }); + + describe('clearWorkspaceUID', () => { + it('should dispatch clearWorkspaceUID action', () => { + store.dispatch(actionCreators.clearWorkspaceUID()); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(workspaceUIDClearAction()); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/__tests__/reducer.spec.ts new file mode 100644 index 000000000..b96b5441c --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/__tests__/reducer.spec.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { UnknownAction } from 'redux'; + +import { + qualifiedNameClearAction, + qualifiedNameSetAction, + workspaceUIDClearAction, + workspaceUIDSetAction, +} from '@/store/Workspaces/actions'; +import { reducer, State, unloadedState } from '@/store/Workspaces/reducer'; + +describe('Workspaces reducer', () => { + let initialState: State; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle qualifiedNameClearAction', () => { + const action = qualifiedNameClearAction(); + const expectedState: State = { + ...initialState, + namespace: '', + workspaceName: '', + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle qualifiedNameSetAction', () => { + const payload = { namespace: 'test-namespace', workspaceName: 'test-workspace' }; + const action = qualifiedNameSetAction(payload); + const expectedState: State = { + ...initialState, + namespace: payload.namespace, + workspaceName: payload.workspaceName, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle workspaceUIDClearAction', () => { + const action = workspaceUIDClearAction(); + const expectedState: State = { + ...initialState, + workspaceUID: '', + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle workspaceUIDSetAction', () => { + const payload = 'test-uid'; + const action = workspaceUIDSetAction(payload); + const expectedState: State = { + ...initialState, + workspaceUID: payload, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/actions.ts b/packages/dashboard-frontend/src/store/Workspaces/actions.ts new file mode 100644 index 000000000..2f28135d8 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/actions.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createAction } from '@reduxjs/toolkit'; + +import devfileApi from '@/services/devfileApi'; +import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; +import { Workspace } from '@/services/workspace-adapter'; +import { AppThunk } from '@/store'; +import { devWorkspacesActionCreators } from '@/store/Workspaces/devWorkspaces'; + +type WorkspaceQualifiedNamePayload = { + namespace: string; + workspaceName: string; +}; +export const qualifiedNameSetAction = + createAction('qualifiedName/set'); +export const qualifiedNameClearAction = createAction('qualifiedName/clear'); + +export const workspaceUIDSetAction = createAction('workspaceUID/set'); +export const workspaceUIDClearAction = createAction('workspaceUID/clear'); + +export type ResourceQueryParams = { + 'debug-workspace-start': boolean; + [propName: string]: string | boolean | undefined; +}; + +export const actionCreators = { + requestWorkspaces: (): AppThunk => async dispatch => { + await dispatch(devWorkspacesActionCreators.requestWorkspaces()); + }, + + requestWorkspace: + (workspace: Workspace): AppThunk => + async dispatch => { + await dispatch(devWorkspacesActionCreators.requestWorkspace(workspace.ref)); + }, + + startWorkspace: + (workspace: Workspace, params?: ResourceQueryParams): AppThunk => + async dispatch => { + const debugWorkspace = params && params['debug-workspace-start']; + await dispatch(devWorkspacesActionCreators.startWorkspace(workspace.ref, debugWorkspace)); + }, + + restartWorkspace: + (workspace: Workspace): AppThunk => + async dispatch => { + await dispatch(devWorkspacesActionCreators.restartWorkspace(workspace.ref)); + }, + + stopWorkspace: + (workspace: Workspace): AppThunk => + async dispatch => { + await dispatch(devWorkspacesActionCreators.stopWorkspace(workspace.ref)); + }, + + deleteWorkspace: + (workspace: Workspace): AppThunk => + async dispatch => { + await dispatch(devWorkspacesActionCreators.terminateWorkspace(workspace.ref)); + }, + + updateWorkspace: + (workspace: Workspace): AppThunk => + async dispatch => { + await dispatch( + devWorkspacesActionCreators.updateWorkspace(workspace.ref as devfileApi.DevWorkspace), + ); + }, + + updateWorkspaceWithDefaultDevfile: + (workspace: Workspace): AppThunk => + async dispatch => { + await dispatch( + devWorkspacesActionCreators.updateWorkspaceWithDefaultDevfile( + workspace.ref as devfileApi.DevWorkspace, + ), + ); + }, + + createWorkspaceFromDevfile: + ( + devfile: devfileApi.Devfile, + attributes: Partial, + optionalFilesContent?: { + [fileName: string]: string; + }, + ): AppThunk => + async dispatch => { + await dispatch( + devWorkspacesActionCreators.createWorkspaceFromDevfile( + devfile, + attributes, + optionalFilesContent || {}, + ), + ); + }, + + setWorkspaceQualifiedName: + (namespace: string, workspaceName: string): AppThunk => + dispatch => { + dispatch(qualifiedNameSetAction({ namespace, workspaceName })); + }, + + clearWorkspaceQualifiedName: (): AppThunk => dispatch => { + dispatch(qualifiedNameClearAction()); + }, + + setWorkspaceUID: + (workspaceUID: string): AppThunk => + dispatch => { + dispatch(workspaceUIDSetAction(workspaceUID)); + }, + + clearWorkspaceUID: (): AppThunk => dispatch => { + dispatch(workspaceUIDClearAction()); + }, +}; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/actions.spec.ts deleted file mode 100644 index d82399c65..000000000 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/actions.spec.ts +++ /dev/null @@ -1,1594 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import common, { api } from '@eclipse-che/common'; -import { V1Status } from '@kubernetes/client-node'; -import { dump } from 'js-yaml'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { container } from '@/inversify.config'; -import { isRunningDevWorkspacesClusterLimitExceeded } from '@/services/backend-client/devWorkspaceClusterApi'; -import * as factoryApi from '@/services/backend-client/factoryApi'; -import { fetchServerConfig } from '@/services/backend-client/serverConfigApi'; -import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import devfileApi from '@/services/devfileApi'; -import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import { che } from '@/services/models'; -import { DevWorkspaceClient } from '@/services/workspace-client/devworkspace/devWorkspaceClient'; -import { AppState } from '@/store'; -import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as getEditorModule from '@/store/DevfileRegistries/getEditor'; -import * as testDevWorkspaceClusterStore from '@/store/DevWorkspacesCluster'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; -import * as ServerConfigStore from '@/store/ServerConfig'; -import { checkRunningWorkspacesLimit } from '@/store/Workspaces/devWorkspaces/checkRunningWorkspacesLimit'; - -import * as testStore from '..'; - -jest.mock('@/services/backend-client/serverConfigApi'); -jest.mock('@/services/helpers/delay', () => ({ - delay: jest.fn().mockResolvedValue(undefined), -})); -jest.mock('../checkRunningWorkspacesLimit.ts'); -jest.mock('@/services/backend-client/devWorkspaceClusterApi'); - -jest.mock('@/services/backend-client/devworkspaceResourcesApi', () => ({ - fetchResources: () => ` -apiVersion: workspace.devfile.io/v1alpha2 -kind: DevWorkspaceTemplate -metadata: - name: che-code -spec: - components: - - name: che-code-runtime-description - container: - image: quay.io/devfile/universal-developer-image:next - endpoints: - - name: che-code - attributes: - type: main - cookiesAuthEnabled: true - discoverable: false - urlRewriteSupported: true - targetPort: 3100 - exposure: public - secure: false - protocol: https - - name: code-redirect-1 - attributes: - discoverable: false - urlRewriteSupported: false - targetPort: 13131 - exposure: public - protocol: http - - name: code-redirect-2 - attributes: - discoverable: false - urlRewriteSupported: false - targetPort: 13132 - exposure: public - protocol: http - - name: code-redirect-3 - attributes: - discoverable: false - urlRewriteSupported: false - targetPort: 13133 - exposure: public - protocol: http ---- -apiVersion: workspace.devfile.io/v1alpha2 -kind: DevWorkspace -metadata: - name: che -spec: - routingClass: che - template: - components: [] -`, -})); - -const mockPatchTemplate = jest.fn(); -jest.mock('@/services/backend-client/devWorkspaceTemplateApi', () => ({ - getTemplateByName: (namespace: string, name: string) => ({ - apiVersion: 'workspace.devfile.io/v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - name, - namespace, - ownerReferences: [{ uid: 'testDevWorkspaceUID' }], - }, - }), - patchTemplate: (templateNamespace, templateName, targetTemplatePatch) => - mockPatchTemplate(templateNamespace, templateName, targetTemplatePatch), -})); -const mockPatchWorkspace = jest.fn(); -jest.mock('@/services/backend-client/devWorkspaceApi', () => ({ - patchWorkspace: (namespace, workspaceName, patch) => - mockPatchWorkspace(namespace, workspaceName, patch), -})); -const mockGetEditorName = jest.fn(); -const mockGetLifeTimeMs = jest.fn(); -const mockCheckForEditorUpdate = jest.fn(); -jest.mock('@/store/Workspaces/devWorkspaces/updateEditor', () => ({ - getEditorName: (workspace: devfileApi.DevWorkspace) => mockGetEditorName(workspace), - getLifeTimeMs: (workspace: devfileApi.DevWorkspace) => mockGetLifeTimeMs(workspace), - updateEditor: (editorName: string, getState: () => AppState) => - mockCheckForEditorUpdate(editorName, getState), -})); - -// DevWorkspaceClient mocks -const mockChangeWorkspaceStatus = jest.fn(); -const mockCheckForDevWorkspaceError = jest.fn(); -const mockCreateDevWorkspace = jest.fn(); -const mockCreateDevWorkspaceTemplate = jest.fn(); -const mockUpdateDevWorkspace = jest.fn(); -const mockDelete = jest.fn(); -const mockGetAllWorkspaces = jest.fn(); -const mockGetWorkspaceByName = jest.fn(); -const mockManageContainerBuildAttribute = jest.fn(); -const mockManageDebugMode = jest.fn(); -const mockManagePvcStrategy = jest.fn(); -const mockOnStart = jest.fn(); -const mockUpdate = jest.fn(); -const mockUpdateAnnotation = jest.fn(); - -const refreshFactoryOauthTokenSpy = jest.spyOn(factoryApi, 'refreshFactoryOauthToken'); - -describe('DevWorkspace store, actions', () => { - const devWorkspaceClient = container.get(DevWorkspaceClient); - let storeBuilder: FakeStoreBuilder; - let store: MockStoreEnhanced>; - beforeEach(() => { - container.snapshot(); - devWorkspaceClient.changeWorkspaceStatus = mockChangeWorkspaceStatus; - devWorkspaceClient.checkForDevWorkspaceError = mockCheckForDevWorkspaceError; - devWorkspaceClient.createDevWorkspace = mockCreateDevWorkspace; - devWorkspaceClient.createDevWorkspaceTemplate = mockCreateDevWorkspaceTemplate; - devWorkspaceClient.updateDevWorkspace = mockUpdateDevWorkspace; - devWorkspaceClient.delete = mockDelete; - devWorkspaceClient.getAllWorkspaces = mockGetAllWorkspaces; - devWorkspaceClient.getWorkspaceByName = mockGetWorkspaceByName; - devWorkspaceClient.manageContainerBuildAttribute = mockManageContainerBuildAttribute; - devWorkspaceClient.manageDebugMode = mockManageDebugMode; - devWorkspaceClient.managePvcStrategy = mockManagePvcStrategy; - devWorkspaceClient.onStart = mockOnStart; - devWorkspaceClient.update = mockUpdate; - devWorkspaceClient.updateAnnotation = mockUpdateAnnotation; - - storeBuilder = new FakeStoreBuilder().withInfrastructureNamespace([ - { name: 'user-che', attributes: { default: 'true', phase: 'Active' } }, - ]); - store = storeBuilder - .withDwPlugins({}, {}, false, [], undefined, 'che-incubator/che-code/latest') - .withDwServerConfig({ - defaults: { - editor: 'che-incubator/che-code/latest', - }, - pluginRegistryURL: 'https://dummy.registry', - } as api.IServerConfig) - .withDevfileRegistries({ - devfiles: { - ['https://dummy.registry/plugins/che-incubator/che-code/latest/devfile.yaml']: { - content: dump(new DevWorkspaceBuilder().build()), - }, - }, - }) - .build(); - }); - - afterEach(() => { - container.restore(); - jest.resetAllMocks(); - }); - - describe('requestWorkspaces', () => { - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE when fetching DevWorkspaces', async () => { - mockGetAllWorkspaces.mockResolvedValueOnce({ workspaces: [], resourceVersion: '' }); - - await store.dispatch(testStore.actionCreators.requestWorkspaces()); - const actions = store.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_DEVWORKSPACE, - workspaces: [], - resourceVersion: '', - }, - { - type: testStore.Type.UPDATE_STARTED_WORKSPACES, - workspaces: [], - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE, RECEIVE_DEVWORKSPACE and UPDATE_DEVWORKSPACE when fetching DevWorkspaces', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspace.metadata.resourceVersion = '1234'; - - mockGetAllWorkspaces.mockResolvedValueOnce({ - workspaces: [devWorkspace], - resourceVersion: '1234', - }); - mockUpdate.mockResolvedValueOnce(devWorkspace); - - await store.dispatch(testStore.actionCreators.requestWorkspaces()); - const actions = store.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_DEVWORKSPACE, - resourceVersion: '1234', - workspaces: [devWorkspace], - }, - { - type: testStore.Type.UPDATE_STARTED_WORKSPACES, - workspaces: [devWorkspace], - }, - { - check: AUTHORIZED, - type: testStore.Type.REQUEST_DEVWORKSPACE, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE_ERROR when fails to fetch DevWorkspaces', async () => { - mockGetAllWorkspaces.mockRejectedValueOnce(new Error('Something unexpected happened.')); - - try { - await store.dispatch(testStore.actionCreators.requestWorkspaces()); - } catch (e) { - // noop - } - - const actions = store.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - error: 'Failed to fetch available workspaces, reason: Something unexpected happened.', - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - }); - - describe('requestWorkspace', () => { - it('should create REQUEST_DEVWORKSPACE and UPDATE_DEVWORKSPACE when updating DevWorkspace', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspace.metadata.resourceVersion = '1234'; - - mockGetWorkspaceByName.mockResolvedValueOnce(devWorkspace); - mockUpdate.mockResolvedValueOnce(devWorkspace); - - await store.dispatch(testStore.actionCreators.requestWorkspace(devWorkspace)); - const actions = store.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - { - check: AUTHORIZED, - type: testStore.Type.REQUEST_DEVWORKSPACE, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE_ERROR when fails to fetch a DevWorkspace', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockGetWorkspaceByName.mockRejectedValueOnce(new Error('Something unexpected happened.')); - - try { - await store.dispatch(testStore.actionCreators.requestWorkspace(devWorkspace)); - } catch (e) { - // noop - } - - const actions = store.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - error: `Failed to fetch the workspace ${devWorkspace.metadata.name}, reason: Something unexpected happened.`, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - }); - - describe('startWorkspace', () => { - let devWorkspace: devfileApi.DevWorkspace; - - beforeEach(() => { - (fetchServerConfig as jest.Mock).mockResolvedValueOnce({}); - - devWorkspace = new DevWorkspaceBuilder().build(); - mockChangeWorkspaceStatus.mockResolvedValueOnce(devWorkspace); - mockManageContainerBuildAttribute.mockResolvedValueOnce(devWorkspace); - mockManageDebugMode.mockResolvedValueOnce(devWorkspace); - mockManagePvcStrategy.mockResolvedValueOnce(devWorkspace); - mockOnStart.mockResolvedValueOnce(devWorkspace); - mockUpdate.mockResolvedValueOnce(devWorkspace); - mockOnStart.mockResolvedValueOnce(devWorkspace); - mockCheckForDevWorkspaceError.mockReturnValueOnce(devWorkspace); - }); - - describe('updateEditor', () => { - it('should check for update if the target devWorkspase has an editor name and the lifeTime > 30s', async () => { - mockGetEditorName.mockReturnValueOnce('che-code'); - mockGetLifeTimeMs.mockReturnValueOnce(60000); - - mockCheckForEditorUpdate.mockResolvedValueOnce([]); - - const store = storeBuilder.withDevWorkspaces({ workspaces: [devWorkspace] }).build(); - - await store.dispatch(testStore.actionCreators.startWorkspace(devWorkspace)); - - expect(mockCheckForEditorUpdate).toHaveBeenCalledWith('che-code', store.getState); - }); - - it('should not check for update if the lifeTime less then 30s', async () => { - mockGetEditorName.mockReturnValueOnce('che-code'); - mockGetLifeTimeMs.mockReturnValueOnce(1000); - - mockCheckForEditorUpdate.mockResolvedValueOnce([]); - - const store = storeBuilder.withDevWorkspaces({ workspaces: [devWorkspace] }).build(); - - await store.dispatch(testStore.actionCreators.startWorkspace(devWorkspace)); - - expect(mockCheckForEditorUpdate).not.toHaveBeenCalled(); - }); - - it('should not check for update without editor name', async () => { - mockGetEditorName.mockReturnValueOnce(undefined); - mockGetLifeTimeMs.mockReturnValueOnce(60000); - - mockCheckForEditorUpdate.mockResolvedValueOnce([]); - - const store = storeBuilder.withDevWorkspaces({ workspaces: [devWorkspace] }).build(); - - await store.dispatch(testStore.actionCreators.startWorkspace(devWorkspace)); - - expect(mockCheckForEditorUpdate).not.toHaveBeenCalled(); - }); - }); - it('should create REQUEST_DEVWORKSPACE and UPDATE_DEVWORKSPACE when starting DevWorkspace', async () => { - (checkRunningWorkspacesLimit as jest.Mock).mockImplementation(() => undefined); - (isRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockReturnValue( - Promise.resolve(true), - ); - - const store = storeBuilder.withDevWorkspaces({ workspaces: [devWorkspace] }).build(); - - await store.dispatch(testStore.actionCreators.startWorkspace(devWorkspace)); - - const actions = store.getActions(); - - const expectedActions: Array< - | testStore.KnownAction - | testDevWorkspaceClusterStore.KnownAction - | ServerConfigStore.KnownAction - > = [ - { - type: testDevWorkspaceClusterStore.Type.REQUEST_DEVWORKSPACES_CLUSTER, - check: AUTHORIZED, - }, - { - type: testDevWorkspaceClusterStore.Type.RECEIVED_DEVWORKSPACES_CLUSTER, - isRunningDevWorkspacesClusterLimitExceeded: true, - }, - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: 'REQUEST_DW_SERVER_CONFIG', - }, - { - config: {} as api.IServerConfig, - type: 'RECEIVE_DW_SERVER_CONFIG', - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE_ERROR when failed to start a DevWorkspace', async () => { - (checkRunningWorkspacesLimit as jest.Mock).mockImplementation(() => { - throw new Error('Limit reached.'); - }); - (isRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockReturnValue( - Promise.resolve(true), - ); - - const store = storeBuilder.withDevWorkspaces({ workspaces: [devWorkspace] }).build(); - - try { - await store.dispatch(testStore.actionCreators.startWorkspace(devWorkspace)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - - const expectedActions: Array< - | testStore.KnownAction - | testDevWorkspaceClusterStore.KnownAction - | ServerConfigStore.KnownAction - > = [ - { - type: testDevWorkspaceClusterStore.Type.REQUEST_DEVWORKSPACES_CLUSTER, - check: AUTHORIZED, - }, - { - type: testDevWorkspaceClusterStore.Type.RECEIVED_DEVWORKSPACES_CLUSTER, - isRunningDevWorkspacesClusterLimitExceeded: true, - }, - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - error: `Failed to start the workspace ${devWorkspace.metadata.name}, reason: Limit reached.`, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - it('should refresh token', async () => { - // given - const projects = [ - { - name: 'project', - git: { - remotes: { - origin: 'origin:project', - }, - }, - }, - ]; - const devWorkspace = new DevWorkspaceBuilder().withProjects(projects).build(); - const store = storeBuilder.withDevWorkspaces({ workspaces: [devWorkspace] }).build(); - refreshFactoryOauthTokenSpy.mockResolvedValueOnce(); - - // when - await store.dispatch(testStore.actionCreators.startWorkspace(devWorkspace)); - - // then - expect(refreshFactoryOauthTokenSpy).toHaveBeenCalledWith('origin:project'); - }); - - it('should dispatch notification on refresh token failure', async () => { - // given - const projects = [ - { - name: 'project', - git: { - remotes: { - origin: 'origin:project', - }, - }, - }, - ]; - const devWorkspace = new DevWorkspaceBuilder().withProjects(projects).build(); - const store = storeBuilder.withDevWorkspaces({ workspaces: [devWorkspace] }).build(); - const error = { - response: { - data: { attributes: { provider: 'github' }, message: 'test message' }, - }, - }; - jest.spyOn(common.helpers.errors, 'includesAxiosResponse').mockImplementation(() => true); - refreshFactoryOauthTokenSpy.mockRejectedValueOnce(error); - - // when - await store.dispatch(testStore.actionCreators.startWorkspace(devWorkspace)); - - // then - const actions = store.getActions(); - expect(actions[0].type).toBe('UPDATE_WARNING'); - expect(actions[0].warning).toBe( - "GitHub might not be operational, please check the provider's status page.", - ); - }); - }); - - describe('updateWorkspaceWithDefaultDevfile', () => { - const timestampNew = '2023-08-15T11:59:18.331Z'; - beforeEach(() => { - class MockDate extends Date { - constructor() { - super(timestampNew); - } - } - window.Date = MockDate as DateConstructor; - }); - - it('should create REQUEST_DEVWORKSPACE and UPDATE_DEVWORKSPACE when update DevWorkspace with default devfile', async () => { - const devWorkspace = new DevWorkspaceBuilder() - .withName('dev-wksp') - .withNamespace('test-che') - .withUID('testDevWorkspaceUID') - .build(); - - mockGetEditorName.mockReturnValueOnce('che-code'); - mockPatchTemplate.mockResolvedValueOnce({}); - mockPatchWorkspace.mockResolvedValueOnce({ devWorkspace: devWorkspace }); - - const editors = [ - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const store = storeBuilder - .withDevWorkspaces({ workspaces: [devWorkspace] }) - .withDevfileRegistries({ - registries: { - ['https://registry-url']: { - metadata: [ - { - displayName: 'Empty Workspace', - description: 'Start an empty remote development environment', - tags: ['Empty'], - icon: '/images/empty.svg', - links: { - v2: 'https://resources-url', - }, - } as che.DevfileMetaData, - ], - }, - }, - devfiles: { - ['https://resources-url']: { - content: dump({ - schemaVersion: '2.1.0', - metadata: { - generateName: 'empty', - }, - } as devfileApi.Devfile), - }, - 'https://dummy.registry/plugins/che-incubator/che-code/latest/devfile.yaml': { - content: dump({ - apiVersion: 'workspace.devfile.io/v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - name: 'che-code', - }, - spec: { - components: [], - }, - }), - }, - }, - }) - .withDwPlugins({}, {}, false, editors, undefined, 'che-incubator/che-code/latest') - .build(); - - await store.dispatch( - testStore.actionCreators.updateWorkspaceWithDefaultDevfile(devWorkspace), - ); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(mockPatchTemplate).toHaveBeenCalledWith('test-che', 'che-code', [ - { - op: 'add', - path: '/metadata/annotations', - value: { - 'che.eclipse.org/components-update-policy': 'managed', - 'che.eclipse.org/plugin-registry-url': 'che-incubator/che-code/latest', - }, - }, - { - op: 'replace', - path: '/spec', - value: { - components: [ - { - name: 'che-code-runtime-description', - container: { - image: 'quay.io/devfile/universal-developer-image:next', - endpoints: [ - { - name: 'che-code', - attributes: { - type: 'main', - discoverable: false, - cookiesAuthEnabled: true, - urlRewriteSupported: true, - }, - targetPort: 3100, - exposure: 'public', - secure: false, - protocol: 'https', - }, - ], - env: [ - { - name: 'CHE_DASHBOARD_URL', - value: 'http://localhost', - }, - { - name: 'CHE_PLUGIN_REGISTRY_URL', - value: 'https://dummy.registry', - }, - { - name: 'CHE_PLUGIN_REGISTRY_INTERNAL_URL', - value: '', - }, - { - name: 'OPENVSX_REGISTRY_URL', - value: '', - }, - ], - }, - }, - ], - }, - }, - ]); - expect(mockPatchWorkspace).toHaveBeenCalledWith('test-che', 'dev-wksp', [ - { - op: 'replace', - path: '/metadata/annotations', - value: { - 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest', - 'che.eclipse.org/last-updated-timestamp': '2023-08-15T11:59:18.331Z', - }, - }, - { - op: 'replace', - path: '/spec', - value: { - contributions: undefined, - routingClass: 'che', - started: false, - template: { - components: [], - projects: undefined, - }, - }, - }, - ]); - expect(actions).toStrictEqual(expectedActions); - }); - }); - - describe('stopWorkspace', () => { - it('should create no actions', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockChangeWorkspaceStatus.mockResolvedValueOnce(devWorkspace); - - await store.dispatch(testStore.actionCreators.stopWorkspace(devWorkspace)); - - const actions = store.getActions(); - - const expectedActions: Array = []; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create RECEIVE_DEVWORKSPACE_ERROR when fails to stop DevWorkspace', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockChangeWorkspaceStatus.mockRejectedValueOnce(new Error('Something unexpected happened.')); - - try { - await store.dispatch(testStore.actionCreators.stopWorkspace(devWorkspace)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - error: `Failed to stop the workspace ${devWorkspace.metadata.name}, reason: Something unexpected happened.`, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - }); - - describe('terminateWorkspace', () => { - it('should create TERMINATE_DEVWORKSPACE when succeeded to terminate a DevWorkspace', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockDelete.mockResolvedValueOnce(undefined); - - await store.dispatch(testStore.actionCreators.terminateWorkspace(devWorkspace)); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - message: 'Cleaning up resources for deletion', - type: testStore.Type.TERMINATE_DEVWORKSPACE, - workspaceUID: devWorkspace.metadata.uid, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create RECEIVE_DEVWORKSPACE_ERROR when fails to terminate a DevWorkspace', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockDelete.mockRejectedValueOnce('Something unexpected happened.'); - - try { - await store.dispatch(testStore.actionCreators.terminateWorkspace(devWorkspace)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - error: `Failed to delete the workspace ${devWorkspace.metadata.name}, reason: Something unexpected happened.`, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - }); - - describe('updateWorkspaceAnnotation', () => { - it('should create REQUEST_DEVWORKSPACE and UPDATE_DEVWORKSPACE when updating a workspace annotation', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockUpdateAnnotation.mockResolvedValueOnce(devWorkspace); - - await store.dispatch(testStore.actionCreators.updateWorkspaceAnnotation(devWorkspace)); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE_ERROR when fails to update a workspace annotation', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockUpdateAnnotation.mockRejectedValueOnce(new Error('Something unexpected happened.')); - - try { - await store.dispatch(testStore.actionCreators.updateWorkspaceAnnotation(devWorkspace)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - error: `Failed to update the workspace ${devWorkspace.metadata.name}, reason: Something unexpected happened.`, - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - }); - - describe('updateWorkspace', () => { - it('should create REQUEST_DEVWORKSPACE and UPDATE_DEVWORKSPACE when updating a workspace annotation', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspace.metadata.resourceVersion = '1234'; - - mockUpdate.mockResolvedValueOnce(devWorkspace); - - await store.dispatch(testStore.actionCreators.updateWorkspace(devWorkspace)); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE_ERROR when fails to update a workspace annotation', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - mockUpdate.mockRejectedValueOnce(new Error('Something unexpected happened.')); - - try { - await store.dispatch(testStore.actionCreators.updateWorkspace(devWorkspace)); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - error: `Failed to update the workspace ${devWorkspace.metadata.name}, reason: Something unexpected happened.`, - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should update workspace with older resourceVersion', async () => { - const oldDevWorkspace = new DevWorkspaceBuilder() - .withMetadata({ - resourceVersion: '1234', - }) - .withStatus({ - devworkspaceId: '1234', - }) - .build(); - const newDevWorkspace = new DevWorkspaceBuilder() - .withMetadata({ - resourceVersion: '5678', - }) - .withStatus({ - devworkspaceId: '1234', - }) - .build(); - - // older devWorkspace is in the store - const store = storeBuilder.withDevWorkspaces({ workspaces: [oldDevWorkspace] }).build(); - - mockUpdate.mockResolvedValueOnce(newDevWorkspace); - - await store.dispatch(testStore.actionCreators.updateWorkspace(newDevWorkspace)); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: newDevWorkspace, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should NOT update workspace with newer resourceVersion', async () => { - const oldDevWorkspace = new DevWorkspaceBuilder() - .withMetadata({ - resourceVersion: '1234', - }) - .withStatus({ - devworkspaceId: '1234', - }) - .build(); - const newDevWorkspace = new DevWorkspaceBuilder() - .withMetadata({ - resourceVersion: '5678', - }) - .withStatus({ - devworkspaceId: '1234', - }) - .build(); - - // newer devWorkspace is already in the store - const store = storeBuilder.withDevWorkspaces({ workspaces: [newDevWorkspace] }).build(); - - mockUpdate.mockResolvedValueOnce(oldDevWorkspace); - - await store.dispatch(testStore.actionCreators.updateWorkspace(oldDevWorkspace)); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: undefined, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - }); - - describe('createWorkspaceFromResources', () => { - it('should create ADD_DEVWORKSPACE when creating a new workspace from resources', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - const devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate = { - apiVersion: 'v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - name: 'template', - namespace: 'user-che', - annotations: {}, - }, - }; - - mockCreateDevWorkspace.mockResolvedValueOnce({ - headers: { warning: 'Unsupported Devfile feature' }, - devWorkspace, - }); - mockCreateDevWorkspaceTemplate.mockResolvedValueOnce({ headers: {}, devWorkspaceTemplate }); - mockCreateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockUpdateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockOnStart.mockResolvedValueOnce(undefined); - - await store.dispatch( - testStore.actionCreators.createWorkspaceFromResources( - devWorkspace, - devWorkspaceTemplate, - {}, - ), - ); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.UPDATE_WARNING, - workspace: devWorkspace, - warning: 'Unsupported Devfile feature', - }, - { - type: testStore.Type.ADD_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create RECEIVE_DEVWORKSPACE_ERROR when fails to create a new workspace from resources', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - const devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate = { - apiVersion: 'v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - name: 'template', - namespace: 'user-che', - annotations: {}, - }, - }; - - mockCreateDevWorkspace.mockRejectedValueOnce(new Error('Something unexpected happened.')); - - try { - await store.dispatch( - testStore.actionCreators.createWorkspaceFromResources( - devWorkspace, - devWorkspaceTemplate, - {}, - ), - ); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - error: `Failed to create a new workspace, reason: Something unexpected happened.`, - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - describe('should test editor id', () => { - let devWorkspace; - let devWorkspaceTemplate; - beforeEach(() => { - devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspaceTemplate = { - apiVersion: 'v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - name: 'template', - namespace: 'user-che', - annotations: {}, - }, - }; - mockCreateDevWorkspace.mockResolvedValueOnce({ - headers: { warning: 'Unsupported Devfile feature' }, - devWorkspace, - }); - mockCreateDevWorkspaceTemplate.mockResolvedValueOnce({ headers: {}, devWorkspaceTemplate }); - mockCreateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockUpdateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockOnStart.mockResolvedValueOnce(undefined); - }); - - it('should provide default editor id when creating a new workspace from resources', async () => { - await store.dispatch( - testStore.actionCreators.createWorkspaceFromResources( - devWorkspace, - devWorkspaceTemplate, - {}, - ), - ); - - expect(mockCreateDevWorkspace).toHaveBeenCalledWith( - expect.any(String), - devWorkspace, - 'che-incubator/che-code/latest', - ); - }); - - it('should use provided editor id when creating a new workspace from resources', async () => { - await store.dispatch( - testStore.actionCreators.createWorkspaceFromResources( - devWorkspace, - devWorkspaceTemplate, - {}, - 'editorid', - ), - ); - - expect(mockCreateDevWorkspace).toHaveBeenCalledWith( - expect.any(String), - devWorkspace, - 'editorid', - ); - }); - }); - }); - - describe('createWorkspaceFromDevfile', () => { - it('should create ADD_DEVWORKSPACE when creating a new workspace from devfile', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - const devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate = { - apiVersion: 'v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - name: 'template', - namespace: 'user-che', - annotations: {}, - }, - }; - - const editors = [ - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const store = storeBuilder - .withDwPlugins({}, {}, false, editors, undefined, 'che-incubator/che-code/latest') - .build(); - - mockCreateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockCreateDevWorkspaceTemplate.mockResolvedValueOnce({ headers: {}, devWorkspaceTemplate }); - mockUpdateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockOnStart.mockResolvedValueOnce(undefined); - - const devfile: devfileApi.Devfile = { - schemaVersion: '2.1.0', - metadata: { - name: 'che-dashboard', - namespace: 'admin-che', - }, - components: [ - { - name: 'tools', - container: { - image: 'quay.io/devfile/universal-developer-image:ubi8', - }, - }, - ], - }; - await store.dispatch(testStore.actionCreators.createWorkspaceFromDevfile(devfile, {}, {})); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.ADD_DEVWORKSPACE, - workspace: devWorkspace, - }, - ]; - - expect(mockCreateDevWorkspace.mock.calls).toEqual([ - expect.arrayContaining([ - { - apiVersion: 'workspace.devfile.io/v1alpha2', - kind: 'DevWorkspace', - metadata: { - annotations: {}, - name: 'che', - }, - spec: { - routingClass: 'che', - template: { - components: [], - }, - }, - }, - ]), - ]); - expect(mockCreateDevWorkspaceTemplate.mock.calls).toEqual([ - expect.arrayContaining([ - expect.objectContaining({ - apiVersion: 'workspace.devfile.io/v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - annotations: { - 'che.eclipse.org/components-update-policy': 'managed', - 'che.eclipse.org/plugin-registry-url': 'che-incubator/che-code/latest', - }, - name: 'che-code', - }, - }), - ]), - ]); - expect(mockOnStart.mock.calls).toEqual([]); - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE_ERROR when fails to create a new workspace from devfile', async () => { - const devfile: devfileApi.Devfile = { - schemaVersion: '2.1.0', - metadata: { - name: 'test', - namespace: 'user-che', - }, - }; - const attr: Partial = {}; - - mockCreateDevWorkspace.mockRejectedValueOnce(new Error('Something unexpected happened.')); - - const editors = [ - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const store = storeBuilder - .withDwPlugins({}, {}, false, editors, undefined, 'che-incubator/che-code/latest') - .build(); - - try { - await store.dispatch( - testStore.actionCreators.createWorkspaceFromDevfile(devfile, attr, {}), - ); - } catch (e) { - // no-op - } - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }, - { - error: 'Failed to create a new workspace, reason: Something unexpected happened.', - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - describe('verify editor id', () => { - let devWorkspace: devfileApi.DevWorkspace; - let devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate; - let devfile: devfileApi.Devfile; - - beforeEach(() => { - devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspaceTemplate = { - apiVersion: 'v1alpha2', - kind: 'DevWorkspaceTemplate', - metadata: { - name: 'template', - namespace: 'user-che', - annotations: {}, - }, - }; - devfile = { - schemaVersion: '2.1.0', - metadata: { - name: 'che-dashboard', - namespace: 'admin-che', - }, - components: [ - { - name: 'tools', - container: { - image: 'quay.io/devfile/universal-developer-image:ubi8', - }, - }, - ], - }; - - mockCreateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockCreateDevWorkspaceTemplate.mockResolvedValueOnce({ headers: {}, devWorkspaceTemplate }); - mockUpdateDevWorkspace.mockResolvedValueOnce({ headers: {}, devWorkspace }); - mockOnStart.mockResolvedValueOnce(undefined); - }); - - it('should provide default editor id to createDevWorkspace', async () => { - const editors = [ - { - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - schemaVersion: '2.2.2', - } as devfileApi.Devfile, - ]; - - const store = storeBuilder - .withDwPlugins({}, {}, false, editors, undefined, 'che-incubator/che-code/latest') - .build(); - - await store.dispatch(testStore.actionCreators.createWorkspaceFromDevfile(devfile, {}, {})); - - expect(mockCreateDevWorkspace.mock.calls).toEqual([ - expect.arrayContaining(['che-incubator/che-code/latest']), - ]); - }); - - it('should provide the editor id from attributes to createDevWorkspace', async () => { - const editorContent = { - schemaVersion: '2.1.0', - metadata: { - name: 'test-editor', - namespace: 'che', - }, - }; - - jest.spyOn(getEditorModule, 'getEditor').mockResolvedValueOnce({ - editorYamlUrl: 'test-editor-yaml', - content: JSON.stringify(editorContent), - }); - await store.dispatch( - testStore.actionCreators.createWorkspaceFromDevfile( - devfile, - { cheEditor: 'test-editor' }, - {}, - ), - ); - expect(mockCreateDevWorkspace.mock.calls).toEqual([ - expect.arrayContaining(['test-editor']), - ]); - }); - }); - }); - - describe('handleWebSocketMessage', () => { - it('should create ADD_DEVWORKSPACE when event phase equals ADDED', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspace.metadata.resourceVersion = '123'; - - await store.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - eventPhase: api.webSocket.EventPhase.ADDED, - devWorkspace, - }), - ); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.ADD_DEVWORKSPACE, - workspace: devWorkspace, - }, - { - type: testStore.Type.UPDATE_STARTED_WORKSPACES, - workspaces: [devWorkspace], - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create UPDATE_DEVWORKSPACE when event phase equals MODIFIED', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspace.metadata.resourceVersion = '123'; - - await store.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - eventPhase: api.webSocket.EventPhase.MODIFIED, - devWorkspace, - }), - ); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }, - { - type: testStore.Type.UPDATE_STARTED_WORKSPACES, - workspaces: [devWorkspace], - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should NOT create UPDATE_DEVWORKSPACE if devWorkspace resource version is old', async () => { - const oldDevWorkspace = new DevWorkspaceBuilder() - .withMetadata({ - resourceVersion: '123', - }) - .withStatus({ - devworkspaceId: '123', - }) - .build(); - const newDevWorkspace = new DevWorkspaceBuilder() - .withMetadata({ - resourceVersion: '456', - }) - .withStatus({ - devworkspaceId: '123', - }) - .build(); - - const store = storeBuilder.withDevWorkspaces({ workspaces: [newDevWorkspace] }).build(); - - await store.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - eventPhase: api.webSocket.EventPhase.MODIFIED, - devWorkspace: oldDevWorkspace, - }), - ); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: undefined, - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create DELETE_DEVWORKSPACE when event phase equals DELETED', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - devWorkspace.metadata.resourceVersion = '123'; - - await store.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - eventPhase: api.webSocket.EventPhase.DELETED, - devWorkspace, - }), - ); - - const actions = store.getActions(); - - const expectedActions: Array = [ - { - type: testStore.Type.DELETE_DEVWORKSPACE, - workspace: devWorkspace, - }, - { - type: testStore.Type.UPDATE_STARTED_WORKSPACES, - workspaces: [devWorkspace], - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - }); - - it('should create REQUEST_DEVWORKSPACE and RECEIVE_DEVWORKSPACE and resubscribe to channel', async () => { - mockGetAllWorkspaces.mockResolvedValueOnce({ workspaces: [], resourceVersion: '123' }); - - const websocketClient = container.get(WebsocketClient); - const unsubscribeFromChannelSpy = jest - .spyOn(websocketClient, 'unsubscribeFromChannel') - .mockReturnValue(undefined); - const subscribeToChannelSpy = jest - .spyOn(websocketClient, 'subscribeToChannel') - .mockReturnValue(undefined); - - const namespace = 'user-che'; - const appStoreWithNamespace = new FakeStoreBuilder() - .withInfrastructureNamespace([{ name: namespace, attributes: { phase: 'Active' } }]) - .build() as MockStoreEnhanced< - AppState, - ThunkDispatch - >; - await appStoreWithNamespace.dispatch( - testStore.actionCreators.handleWebSocketMessage({ - status: { - code: 410, - message: 'The resourceVersion for the provided watch is too old.', - } as V1Status, - eventPhase: api.webSocket.EventPhase.ERROR, - params: { namespace, resourceVersion: '123' }, - }), - ); - - const actions = appStoreWithNamespace.getActions(); - - const expectedActions: testStore.KnownAction[] = [ - { - check: AUTHORIZED, - type: testStore.Type.REQUEST_DEVWORKSPACE, - }, - { - type: testStore.Type.RECEIVE_DEVWORKSPACE, - workspaces: [], - resourceVersion: '123', - }, - { - type: testStore.Type.UPDATE_STARTED_WORKSPACES, - workspaces: [], - }, - ]; - - expect(actions).toStrictEqual(expectedActions); - expect(unsubscribeFromChannelSpy).toHaveBeenCalledWith(api.webSocket.Channel.DEV_WORKSPACE); - expect(subscribeToChannelSpy).toHaveBeenCalledWith( - api.webSocket.Channel.DEV_WORKSPACE, - namespace, - { getResourceVersion: expect.any(Function) }, - ); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/reducers.spec.ts index 0d730f5fd..7ce40e6cd 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/reducers.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/reducers.spec.ts @@ -10,297 +10,216 @@ * Red Hat, Inc. - initial API and implementation */ -import { cloneDeep } from 'lodash'; -import { AnyAction } from 'redux'; +import { UnknownAction } from 'redux'; import devfileApi from '@/services/devfileApi'; import { DevWorkspaceStatus } from '@/services/helpers/types'; -import { WorkspaceAdapter } from '@/services/workspace-adapter'; -import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { AUTHORIZED } from '@/store/sanityCheckMiddleware'; +import * as actions from '@/store/Workspaces/devWorkspaces/actions/actions'; +import { reducer, State, unloadedState } from '@/store/Workspaces/devWorkspaces/reducer'; -import * as testStore from '..'; - -describe('DevWorkspace, reducers', () => { - let devWorkspace: devfileApi.DevWorkspace; +describe('devWorkspaces reducer', () => { + let initialState: State; beforeEach(() => { - devWorkspace = new DevWorkspaceBuilder() - .withStatus({ devworkspaceId: 'devworkspaceId' }) - .build(); - }); - - it('should return initial state', () => { - const incomingAction: testStore.RequestDevWorkspacesAction = { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }; - const initialState = testStore.reducer(undefined, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; - - expect(initialState).toStrictEqual(expectedState); + initialState = { ...unloadedState }; }); - it('should return state if action type is not matched', () => { - const initialState: testStore.State = { + it('should handle devWorkspacesRequestAction', () => { + const action = actions.devWorkspacesRequestAction(); + const expectedState: State = { + ...initialState, isLoading: true, - workspaces: [devWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; - const incomingAction = { - type: 'OTHER_ACTION', - } as AnyAction; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - workspaces: [devWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; - expect(newState).toStrictEqual(expectedState); - }); - - it('should handle REQUEST_DEVWORKSPACE', () => { - const initialState: testStore.State = { - isLoading: false, - workspaces: [], - startedWorkspaces: {}, - error: 'unexpected error', - resourceVersion: '0', - warnings: {}, - }; - const incomingAction: testStore.RequestDevWorkspacesAction = { - type: testStore.Type.REQUEST_DEVWORKSPACE, - check: AUTHORIZED, - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: true, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, error: undefined, }; - expect(newState).toStrictEqual(expectedState); + const newState = reducer(initialState, action); + + expect(newState).toEqual(expectedState); }); - it('should handle RECEIVE_DEVWORKSPACE', () => { - const initialState: testStore.State = { - isLoading: true, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; - const incomingAction: testStore.ReceiveWorkspacesAction = { - type: testStore.Type.RECEIVE_DEVWORKSPACE, - workspaces: [devWorkspace], + it('should handle devWorkspacesReceiveAction', () => { + const workspaces = [ + { metadata: { uid: '1', resourceVersion: '1' } }, + ] as devfileApi.DevWorkspace[]; + const action = actions.devWorkspacesReceiveAction({ + workspaces, resourceVersion: '1', - }; - - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { + }); + const expectedState: State = { + ...initialState, isLoading: false, - workspaces: [devWorkspace], - startedWorkspaces: {}, + workspaces, resourceVersion: '1', - warnings: {}, }; - expect(newState).toStrictEqual(expectedState); - }); - - it('should handle RECEIVE_DEVWORKSPACE_ERROR', () => { - const initialState: testStore.State = { - isLoading: true, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; - const incomingAction: testStore.ReceiveErrorAction = { - type: testStore.Type.RECEIVE_DEVWORKSPACE_ERROR, - error: 'Error', - }; + const newState = reducer(initialState, action); - const newState = testStore.reducer(initialState, incomingAction); + expect(newState).toEqual(expectedState); + }); - const expectedState: testStore.State = { + it('should handle devWorkspacesErrorAction', () => { + const errorMessage = 'An error occurred'; + const action = actions.devWorkspacesErrorAction(errorMessage); + const expectedState: State = { + ...initialState, isLoading: false, - workspaces: [], - startedWorkspaces: {}, - error: 'Error', - resourceVersion: '0', - warnings: {}, + error: errorMessage, }; - expect(newState).toStrictEqual(expectedState); - }); + const newState = reducer(initialState, action); - it('should handle UPDATE_DEVWORKSPACE', () => { - const initialState: testStore.State = { - isLoading: true, - workspaces: [devWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; + expect(newState).toEqual(expectedState); + }); - const updatedWorkspace = cloneDeep(devWorkspace); - updatedWorkspace.status = { - phase: 'Running', - devworkspaceId: WorkspaceAdapter.getId(devWorkspace), + it('should handle devWorkspacesUpdateAction with undefined payload', () => { + const action = actions.devWorkspacesUpdateAction(undefined); + const expectedState: State = { + ...initialState, + isLoading: false, }; - const incomingAction: testStore.UpdateWorkspaceAction = { - type: testStore.Type.UPDATE_DEVWORKSPACE, - workspace: updatedWorkspace, - }; + const newState = reducer(initialState, action); - const newState = testStore.reducer(initialState, incomingAction); + expect(newState).toEqual(expectedState); + }); - const expectedState: testStore.State = { + it('should handle devWorkspacesUpdateAction with valid payload', () => { + const existingWorkspace = { + metadata: { uid: '1', resourceVersion: '1' }, + } as devfileApi.DevWorkspace; + const updatedWorkspace = { + metadata: { uid: '1', resourceVersion: '2' }, + } as devfileApi.DevWorkspace; + const initialStateWithWorkspaces = { + ...initialState, + workspaces: [existingWorkspace], + }; + const action = actions.devWorkspacesUpdateAction(updatedWorkspace); + const expectedState: State = { + ...initialStateWithWorkspaces, isLoading: false, workspaces: [updatedWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; - - expect(newState).toStrictEqual(expectedState); - }); - - it('should handle ADD_DEVWORKSPACE', () => { - const initialState: testStore.State = { - isLoading: true, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, + resourceVersion: '2', }; - const incomingAction: testStore.AddWorkspaceAction = { - type: testStore.Type.ADD_DEVWORKSPACE, - workspace: devWorkspace, - }; + const newState = reducer(initialStateWithWorkspaces, action); - const newState = testStore.reducer(initialState, incomingAction); + expect(newState).toEqual(expectedState); + }); - const expectedState: testStore.State = { + it('should handle devWorkspacesAddAction', () => { + const workspace = { metadata: { uid: '1', resourceVersion: '1' } } as devfileApi.DevWorkspace; + const action = actions.devWorkspacesAddAction(workspace); + const expectedState: State = { + ...initialState, isLoading: false, - workspaces: [devWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, + workspaces: [workspace], + resourceVersion: '1', }; - expect(newState).toStrictEqual(expectedState); + const newState = reducer(initialState, action); + + expect(newState).toEqual(expectedState); }); - it('should handle TERMINATE_DEVWORKSPACE', () => { - const initialState: testStore.State = { - isLoading: true, - workspaces: [devWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, + it('should handle devWorkspacesTerminateAction', () => { + const workspace = { + metadata: { uid: '1', resourceVersion: '1' }, + status: { + phase: DevWorkspaceStatus.RUNNING, + message: '', + }, + } as devfileApi.DevWorkspace; + const initialStateWithWorkspaces = { + ...initialState, + workspaces: [workspace], + }; + const action = actions.devWorkspacesTerminateAction({ + workspaceUID: '1', + message: 'Terminating workspace', + }); + const expectedWorkspace = { + ...workspace, + status: { + phase: DevWorkspaceStatus.TERMINATING, + message: 'Terminating workspace', + }, + } as devfileApi.DevWorkspace; + const expectedState: State = { + ...initialStateWithWorkspaces, + isLoading: false, + workspaces: [expectedWorkspace], }; - const incomingAction: testStore.TerminateWorkspaceAction = { - type: testStore.Type.TERMINATE_DEVWORKSPACE, - workspaceUID: WorkspaceAdapter.getUID(devWorkspace), - message: 'Terminated', - }; + const newState = reducer(initialStateWithWorkspaces, action); - const newState = testStore.reducer(initialState, incomingAction); + expect(newState).toEqual(expectedState); + }); - const updatedWorkspace = cloneDeep(devWorkspace); - updatedWorkspace.status = { - phase: DevWorkspaceStatus.TERMINATING, - devworkspaceId: WorkspaceAdapter.getId(devWorkspace), - message: 'Terminated', - }; - const expectedState: testStore.State = { + it('should handle devWorkspacesDeleteAction', () => { + const workspaceToDelete = { + metadata: { uid: '1', resourceVersion: '1' }, + } as devfileApi.DevWorkspace; + const workspaceToKeep = { + metadata: { uid: '2', resourceVersion: '1' }, + } as devfileApi.DevWorkspace; + const initialStateWithWorkspaces = { + ...initialState, + workspaces: [workspaceToDelete, workspaceToKeep], + }; + const action = actions.devWorkspacesDeleteAction(workspaceToDelete); + const expectedState: State = { + ...initialStateWithWorkspaces, isLoading: false, - workspaces: [updatedWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, + workspaces: [workspaceToKeep], + resourceVersion: '1', }; - expect(newState).toStrictEqual(expectedState); - }); + const newState = reducer(initialStateWithWorkspaces, action); - it('should handle DELETE_DEVWORKSPACE', () => { - const initialState: testStore.State = { - isLoading: true, - workspaces: [devWorkspace], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; + expect(newState).toEqual(expectedState); + }); - const incomingAction: testStore.DeleteWorkspaceAction = { - type: testStore.Type.DELETE_DEVWORKSPACE, - workspace: devWorkspace, + it('should handle devWorkspacesUpdateStartedAction', () => { + const workspace = { + metadata: { uid: '1', resourceVersion: '1' }, + spec: { started: true }, + } as devfileApi.DevWorkspace; + const action = actions.devWorkspacesUpdateStartedAction([workspace]); + const expectedState: State = { + ...initialState, + startedWorkspaces: { + '1': '1', + }, }; - const newState = testStore.reducer(initialState, incomingAction); - - const expectedState: testStore.State = { - isLoading: false, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, - }; + const newState = reducer(initialState, action); - expect(newState).toStrictEqual(expectedState); + expect(newState).toEqual(expectedState); }); - it('should handle UPDATE_WARNING', () => { - const initialState: testStore.State = { - isLoading: false, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: {}, + it('should handle devWorkspaceWarningUpdateAction', () => { + const workspace = { metadata: { uid: '1' } } as devfileApi.DevWorkspace; + const action = actions.devWorkspaceWarningUpdateAction({ + workspace, + warning: 'This is a warning', + }); + const expectedState: State = { + ...initialState, + warnings: { + '1': 'This is a warning', + }, }; - const incomingAction: testStore.UpdateWarningAction = { - type: testStore.Type.UPDATE_WARNING, - workspace: devWorkspace, - warning: 'Unsupported Devfile feature', - }; + const newState = reducer(initialState, action); - const newState = testStore.reducer(initialState, incomingAction); + expect(newState).toEqual(expectedState); + }); - const expectedState: testStore.State = { - isLoading: false, - workspaces: [], - startedWorkspaces: {}, - resourceVersion: '0', - warnings: { - [WorkspaceAdapter.getUID(devWorkspace)]: 'Unsupported Devfile feature', - }, - }; + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + const newState = reducer(initialState, unknownAction); - expect(newState).toStrictEqual(expectedState); + expect(newState).toEqual(initialState); }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/selectors.spec.ts new file mode 100644 index 000000000..484736434 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/selectors.spec.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import devfileApi from '@/services/devfileApi'; +import { DevWorkspaceStatus } from '@/services/helpers/types'; +import { RootState } from '@/store'; +import { ClusterConfigState } from '@/store/ClusterConfig'; +import { DevWorkspacesState } from '@/store/Workspaces/devWorkspaces'; +import { + selectAllDevWorkspaces, + selectDevWorkspacesError, + selectDevWorkspacesResourceVersion, + selectDevWorkspacesState, + selectDevWorkspaceWarnings, + selectRunningDevWorkspaces, + selectRunningDevWorkspacesLimitExceeded, + selectStartedWorkspaces, +} from '@/store/Workspaces/devWorkspaces/selectors'; + +describe('DevWorkspaces Selectors', () => { + const mockState = { + devWorkspaces: { + isLoading: true, + workspaces: [ + { metadata: { uid: '1' }, status: { phase: DevWorkspaceStatus.RUNNING } }, + { metadata: { uid: '2' }, status: { phase: DevWorkspaceStatus.STOPPED } }, + { metadata: { uid: '3' }, status: { phase: DevWorkspaceStatus.STARTING } }, + ] as devfileApi.DevWorkspace[], + resourceVersion: '12345', + error: 'Something went wrong', + startedWorkspaces: { '1': '1' }, + warnings: { '2': 'This is a warning' }, + } as DevWorkspacesState, + clusterConfig: { + clusterConfig: { + runningWorkspacesLimit: 2, + }, + } as ClusterConfigState, + } as Partial as RootState; + + it('should select devWorkspaces state', () => { + const result = selectDevWorkspacesState(mockState); + expect(result).toEqual(mockState.devWorkspaces); + }); + + it('should select devWorkspaces resource version', () => { + const result = selectDevWorkspacesResourceVersion(mockState); + expect(result).toEqual('12345'); + }); + + it('should select all devWorkspaces', () => { + const result = selectAllDevWorkspaces(mockState); + expect(result).toEqual(mockState.devWorkspaces.workspaces); + }); + + it('should select devWorkspaces error', () => { + const result = selectDevWorkspacesError(mockState); + expect(result).toEqual('Something went wrong'); + }); + + it('should select running devWorkspaces', () => { + const result = selectRunningDevWorkspaces(mockState); + expect(result).toEqual([ + { metadata: { uid: '1' }, status: { phase: DevWorkspaceStatus.RUNNING } }, + { metadata: { uid: '3' }, status: { phase: DevWorkspaceStatus.STARTING } }, + ]); + }); + + it('should determine if running devWorkspaces limit is exceeded', () => { + const result = selectRunningDevWorkspacesLimitExceeded(mockState); + expect(result).toBe(true); + }); + + it('should select started workspaces', () => { + const result = selectStartedWorkspaces(mockState); + expect(result).toEqual({ '1': '1' }); + }); + + it('should select devWorkspace warnings', () => { + const result = selectDevWorkspaceWarnings(mockState); + expect(result).toEqual({ '2': 'This is a warning' }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/updateEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/updateEditor.spec.ts deleted file mode 100644 index 922155ea2..000000000 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/updateEditor.spec.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import { api, ApplicationId } from '@eclipse-che/common'; -import { AnyAction } from 'redux'; -import { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { container } from '@/inversify.config'; -import devfileApi from '@/services/devfileApi'; -import { DevWorkspaceClient } from '@/services/workspace-client/devworkspace/devWorkspaceClient'; -import { AppState } from '@/store'; -import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { - getEditorName, - getLifeTimeMs, - updateEditor, -} from '@/store/Workspaces/devWorkspaces/updateEditor'; - -const mockPatchTemplate = jest.fn(); -jest.mock('@/services/backend-client/devWorkspaceTemplateApi', () => ({ - patchTemplate: (namespace: string, templateName: string, patch: api.IPatch[]) => - mockPatchTemplate(namespace, templateName, patch), -})); - -describe('updateEditor, functions', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - describe('getEditorName', () => { - it('should return undefined if the target devworkspace dos not have an editor', async () => { - const devWorkspace = new DevWorkspaceBuilder() - .withContributions([ - { - name: 'default', - uri: 'https://test.com/devfile.yaml', - attributes: { 'che.eclipse.org/default-plugin': true }, - }, - ]) - .build(); - - const name = getEditorName(devWorkspace); - - expect(name).toBeUndefined(); - }); - - it('should return the editor name', async () => { - const devWorkspace = new DevWorkspaceBuilder() - .withContributions([ - { - name: 'default', - uri: 'https://test.com/devfile.yaml', - attributes: { 'che.eclipse.org/default-plugin': true }, - }, - { - name: 'editor', - kubernetes: { - name: 'che-code', - }, - }, - ]) - .build(); - - const editorName = getEditorName(devWorkspace); - - expect(editorName).toBe('che-code'); - }); - }); - - describe('getLifeTimeMs', () => { - it('should return 0 without creationTimestamp', async () => { - const devWorkspace = new DevWorkspaceBuilder().build(); - - const result = getLifeTimeMs(devWorkspace); - - expect(result).toBe(0); - }); - - it('should return the devWorkspace lifetime', async () => { - const lifeTime = 1234; - const devWorkspace = new DevWorkspaceBuilder() - .withMetadata({ - creationTimestamp: new Date(Date.now() - lifeTime * 1000), - }) - .build(); - - const result = Math.floor(getLifeTimeMs(devWorkspace) / 1000); - - expect(result).toBe(lifeTime); - }); - }); - - describe('updateEditor', () => { - const mockCheckForEditorUpdate = jest.fn(); - const devWorkspaceClient = container.get(DevWorkspaceClient); - devWorkspaceClient.checkForTemplatesUpdate = mockCheckForEditorUpdate; - - const namespace = 'user-che'; - const pluginRegistryURL = 'https://dummy.registry'; - const clusterConsole = { - id: ApplicationId.CLUSTER_CONSOLE, - url: 'https://console-url', - icon: 'https://console-icon-url', - title: 'Cluster console', - }; - const editorId = 'che-incubator/che-code/latest'; - const editors = [ - { - schemaVersion: '2.2.0', - metadata: { - name: 'che-code', - attributes: { - publisher: 'che-incubator', - version: 'latest', - }, - }, - } as devfileApi.Devfile, - ]; - let store: MockStoreEnhanced>; - beforeEach(() => { - store = new FakeStoreBuilder() - .withInfrastructureNamespace([ - { name: namespace, attributes: { default: 'true', phase: 'Active' } }, - ]) - .withClusterInfo({ applications: [clusterConsole] }) - .withDwServerConfig({ - pluginRegistry: { openVSXURL: 'https://openvsx.org' }, - pluginRegistryInternalURL: 'https://internal.registry', - pluginRegistryURL, - }) - .withDwPlugins({}, {}, false, editors, undefined, editorId) - .build(); - }); - - it('should check for update and do nothing', async () => { - mockCheckForEditorUpdate.mockResolvedValueOnce([]); - - await updateEditor('che-code', store.getState); - - expect(mockCheckForEditorUpdate).toHaveBeenCalledWith( - 'che-code', - namespace, - editors, - pluginRegistryURL, - 'https://internal.registry', - 'https://openvsx.org', - clusterConsole, - ); - expect(mockPatchTemplate).not.toHaveBeenCalled(); - }); - - it('should update the target devWorkspaceTemplate', async () => { - mockCheckForEditorUpdate.mockResolvedValueOnce([ - { op: 'replace', path: '/spec/commands', value: [] }, - ]); - - await updateEditor('che-code', store.getState); - - expect(mockCheckForEditorUpdate).toHaveBeenCalledWith( - 'che-code', - namespace, - editors, - pluginRegistryURL, - 'https://internal.registry', - 'https://openvsx.org', - clusterConsole, - ); - expect(mockPatchTemplate).toHaveBeenCalledWith('user-che', 'che-code', [ - { - op: 'replace', - path: '/spec/commands', - value: [], - }, - ]); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/createWorkspaceFromDevfile.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/createWorkspaceFromDevfile.spec.ts new file mode 100644 index 000000000..46d93e863 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/createWorkspaceFromDevfile.spec.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { dump } from 'js-yaml'; + +import { fetchResources } from '@/services/backend-client/devworkspaceResourcesApi'; +import devfileApi from '@/services/devfileApi'; +import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; +import { loadResourcesContent } from '@/services/registry/resources'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { getEditor } from '@/store/DevfileRegistries/getEditor'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { actionCreators } from '@/store/Workspaces/devWorkspaces/actions/actionCreators'; +import { createWorkspaceFromDevfile } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromDevfile'; +import { updateEditorDevfile } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'; +import { getCustomEditor } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/getCustomEditor'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('js-yaml'); +jest.mock('@/services/backend-client/devworkspaceResourcesApi'); +jest.mock('@/services/registry/resources'); +jest.mock('@/store/DevfileRegistries/getEditor'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/getCustomEditor'); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createWorkspaceFromDevfile', () => { + let store: ReturnType; + const mockDevfile = {} as devfileApi.Devfile; + const mockParams = {} as Partial; + const mockOptionalFilesContent = {}; + + beforeEach(() => { + store = createMockStore({ + dwServerConfig: { + config: { + defaults: { + editor: 'default-editor', + }, + }, + }, + } as RootState); + + (getEditor as jest.Mock).mockResolvedValue({ + content: 'editor-content', + editorYamlUrl: 'https://editor-url.com', + }); + (getCustomEditor as jest.Mock).mockResolvedValue('custom-editor-content'); + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (updateEditorDevfile as jest.Mock).mockReturnValue('updated-editor-content'); + (dump as jest.Mock).mockReturnValue('devfile-yaml'); + (fetchResources as jest.Mock).mockResolvedValue('resources-content'); + (loadResourcesContent as jest.Mock).mockReturnValue([ + { + kind: 'DevWorkspace', + metadata: { annotations: {} }, + }, + { + kind: 'DevWorkspaceTemplate', + metadata: { annotations: {} }, + }, + ]); + (actionCreators.createWorkspaceFromResources as jest.Mock).mockImplementation( + () => async () => {}, + ); + (common.helpers.errors.getMessage as jest.Mock).mockImplementation((e: Error) => e.message); + }); + + it('should create workspace with provided editor in params', async () => { + const paramsWithEditor = { cheEditor: 'custom-editor' } as Partial; + + await store.dispatch( + createWorkspaceFromDevfile(mockDevfile, paramsWithEditor, mockOptionalFilesContent), + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + + expect(getEditor).toHaveBeenCalledWith( + 'custom-editor', + expect.any(Function), + expect.any(Function), + ); + expect(getCustomEditor).not.toHaveBeenCalled(); + expect(verifyAuthorized).toHaveBeenCalled(); + expect(updateEditorDevfile).toHaveBeenCalledWith('editor-content', undefined); + expect(fetchResources).toHaveBeenCalledWith({ + devfileContent: 'devfile-yaml', + editorPath: undefined, + editorContent: 'updated-editor-content', + }); + expect(loadResourcesContent).toHaveBeenCalledWith('resources-content'); + expect(actionCreators.createWorkspaceFromResources).toHaveBeenCalled(); + }); + + it('should create workspace with custom editor from optional files', async () => { + (getCustomEditor as jest.Mock).mockResolvedValue('custom-editor-content'); + + await store.dispatch( + createWorkspaceFromDevfile(mockDevfile, mockParams, mockOptionalFilesContent), + ); + + expect(getEditor).not.toHaveBeenCalled(); + expect(getCustomEditor).toHaveBeenCalledWith( + mockOptionalFilesContent, + expect.any(Function), + expect.any(Function), + ); + expect(verifyAuthorized).toHaveBeenCalled(); + expect(updateEditorDevfile).toHaveBeenCalledWith('custom-editor-content', undefined); + expect(fetchResources).toHaveBeenCalledWith({ + devfileContent: 'devfile-yaml', + editorPath: undefined, + editorContent: 'updated-editor-content', + }); + expect(loadResourcesContent).toHaveBeenCalledWith('resources-content'); + expect(actionCreators.createWorkspaceFromResources).toHaveBeenCalled(); + }); + + it('should create workspace with default editor when no editor is specified', async () => { + (getCustomEditor as jest.Mock).mockResolvedValue(undefined); + + await store.dispatch( + createWorkspaceFromDevfile(mockDevfile, mockParams, mockOptionalFilesContent), + ); + + expect(getCustomEditor).toHaveBeenCalled(); + expect(getEditor).toHaveBeenCalledWith( + 'default-editor', + expect.any(Function), + expect.any(Function), + ); + expect(verifyAuthorized).toHaveBeenCalled(); + expect(updateEditorDevfile).toHaveBeenCalledWith('editor-content', undefined); + expect(fetchResources).toHaveBeenCalledWith({ + devfileContent: 'devfile-yaml', + editorPath: undefined, + editorContent: 'updated-editor-content', + }); + expect(loadResourcesContent).toHaveBeenCalledWith('resources-content'); + expect(actionCreators.createWorkspaceFromResources).toHaveBeenCalled(); + }); + + it('should throw error if default editor is not defined', async () => { + const storeWithNoDefaults = createMockStore({ + dwServerConfig: { + config: { + defaults: { + editor: undefined, + }, + }, + }, + } as RootState); + (getCustomEditor as jest.Mock).mockResolvedValue(undefined); + + await expect( + storeWithNoDefaults.dispatch( + createWorkspaceFromDevfile(mockDevfile, mockParams, mockOptionalFilesContent), + ), + ).rejects.toThrow('Cannot define default editor'); + }); + + it('should handle error and dispatch error action', async () => { + const error = new Error('Test error'); + (fetchResources as jest.Mock).mockRejectedValueOnce(error); + + await expect( + store.dispatch( + createWorkspaceFromDevfile(mockDevfile, mockParams, mockOptionalFilesContent), + ), + ).rejects.toThrow('Test error'); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesErrorAction('Test error')); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/createWorkspaceFromResources.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/createWorkspaceFromResources.spec.ts new file mode 100644 index 000000000..92422364e --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/createWorkspaceFromResources.spec.ts @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { ApplicationId } from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; +import { che } from '@/services/models'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as clusterInfo from '@/store/ClusterInfo'; +import * as infrastructureNamespaces from '@/store/InfrastructureNamespaces'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import * as serverConfig from '@/store/ServerConfig'; +import { createWorkspaceFromResources } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { updateDevWorkspaceTemplate } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'; +import { + devWorkspacesAddAction, + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspaceWarningUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'); +jest.mock('@/store/ServerConfig'); +jest.mock('@/store/InfrastructureNamespaces'); +jest.mock('@/store/ClusterInfo'); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createWorkspaceFromResources', () => { + let store: ReturnType; + const mockCreateDevWorkspace = jest.fn(); + const mockCreateDevWorkspaceTemplate = jest.fn(); + const mockUpdateDevWorkspace = jest.fn(); + const mockVerifyAuthorized = verifyAuthorized as jest.MockedFunction; + const mockUpdateDevWorkspaceTemplate = updateDevWorkspaceTemplate as jest.MockedFunction< + typeof updateDevWorkspaceTemplate + >; + + const mockWorkspace = { + metadata: { + namespace: 'test-namespace', + name: 'test-workspace', + uid: '1', + }, + } as devfileApi.DevWorkspace; + + const mockWorkspaceTemplate = {} as devfileApi.DevWorkspaceTemplate; + + const mockFactoryParams = {} as Partial; + + beforeEach(() => { + store = createMockStore({} as Partial as RootState); + + jest.spyOn(infrastructureNamespaces, 'selectDefaultNamespace').mockReturnValue({ + name: 'default-namespace', + } as che.KubernetesNamespace); + jest.spyOn(serverConfig, 'selectOpenVSXUrl').mockReturnValue('https://openvsx.org'); + jest + .spyOn(serverConfig, 'selectPluginRegistryUrl') + .mockReturnValue('https://plugin-registry.com'); + jest + .spyOn(serverConfig, 'selectPluginRegistryInternalUrl') + .mockReturnValue('https://internal-plugin-registry.com'); + jest.spyOn(serverConfig, 'selectDefaultEditor').mockReturnValue('che-editor'); + jest.spyOn(clusterInfo, 'selectApplications').mockReturnValue([ + { + id: ApplicationId.CLUSTER_CONSOLE, + url: 'https://cluster-console.com', + icon: '', + title: 'Console', + }, + ]); + + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + createDevWorkspace: mockCreateDevWorkspace, + createDevWorkspaceTemplate: mockCreateDevWorkspaceTemplate, + updateDevWorkspace: mockUpdateDevWorkspace, + }); + + mockCreateDevWorkspace.mockResolvedValue({ + devWorkspace: mockWorkspace, + headers: {}, + }); + + mockUpdateDevWorkspace.mockResolvedValue({ + devWorkspace: mockWorkspace, + headers: {}, + }); + + mockUpdateDevWorkspaceTemplate.mockReturnValue(mockWorkspaceTemplate); + + (mockVerifyAuthorized as jest.Mock).mockResolvedValue(true); + + (common.helpers.errors.getMessage as jest.Mock).mockImplementation((e: Error) => e.message); + }); + + it('should dispatch add action on successful workspace creation', async () => { + await store.dispatch( + createWorkspaceFromResources(mockWorkspace, mockWorkspaceTemplate, mockFactoryParams), + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesAddAction(mockWorkspace)); + + expect(mockVerifyAuthorized).toHaveBeenCalled(); + + expect(mockCreateDevWorkspace).toHaveBeenCalledWith( + 'default-namespace', + mockWorkspace, + 'che-editor', + ); + + expect(mockCreateDevWorkspaceTemplate).toHaveBeenCalled(); + + expect(mockUpdateDevWorkspace).toHaveBeenCalledWith(mockWorkspace); + }); + + it('should handle warnings from createDevWorkspace and dispatch warning action', async () => { + mockCreateDevWorkspace.mockResolvedValueOnce({ + devWorkspace: mockWorkspace, + headers: { + warning: '299 - Some warning message', + }, + }); + + await store.dispatch( + createWorkspaceFromResources(mockWorkspace, mockWorkspaceTemplate, mockFactoryParams), + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspaceWarningUpdateAction({ + warning: 'Some warning message', + workspace: mockWorkspace, + }), + ); + expect(actions[2]).toEqual(devWorkspacesAddAction(mockWorkspace)); + }); + + it('should handle warnings from updateDevWorkspace and dispatch warning action', async () => { + mockUpdateDevWorkspace.mockResolvedValueOnce({ + devWorkspace: mockWorkspace, + headers: { + warning: '299 - Another warning message', + }, + }); + + await store.dispatch( + createWorkspaceFromResources(mockWorkspace, mockWorkspaceTemplate, mockFactoryParams), + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspaceWarningUpdateAction({ + warning: 'Another warning message', + workspace: mockWorkspace, + }), + ); + expect(actions[2]).toEqual(devWorkspacesAddAction(mockWorkspace)); + }); + + it('should use provided editor if specified', async () => { + await store.dispatch( + createWorkspaceFromResources( + mockWorkspace, + mockWorkspaceTemplate, + mockFactoryParams, + 'custom-editor', + ), + ); + + expect(mockCreateDevWorkspace).toHaveBeenCalledWith( + 'default-namespace', + mockWorkspace, + 'custom-editor', + ); + }); + + it('should handle errors during workspace creation', async () => { + const errorMessage = 'Creation failed'; + mockCreateDevWorkspace.mockRejectedValueOnce(new Error(errorMessage)); + + await expect( + store.dispatch( + createWorkspaceFromResources(mockWorkspace, mockWorkspaceTemplate, mockFactoryParams), + ), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesErrorAction(`Failed to create a new workspace, reason: ${errorMessage}`), + ); + }); + + it('should handle authorization failures', async () => { + const errorMessage = 'Not authorized'; + mockVerifyAuthorized.mockRejectedValueOnce(new Error(errorMessage)); + + await expect( + store.dispatch( + createWorkspaceFromResources(mockWorkspace, mockWorkspaceTemplate, mockFactoryParams), + ), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction(`Failed to create a new workspace, reason: ${errorMessage}`), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/handleWebSocketMessage.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/handleWebSocketMessage.spec.ts new file mode 100644 index 000000000..8039c340f --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/handleWebSocketMessage.spec.ts @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; + +import { container } from '@/inversify.config'; +import { injectKubeConfig, podmanLogin } from '@/services/backend-client/devWorkspaceApi'; +import { WebsocketClient } from '@/services/backend-client/websocketClient'; +import devfileApi, * as devfileApiService from '@/services/devfileApi'; +import { DevWorkspaceStatus } from '@/services/helpers/types'; +import { che } from '@/services/models'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as infrastructureNamespaces from '@/store/InfrastructureNamespaces'; +import { + actionCreators, + onStatusChangeCallbacks, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators'; +import { handleWebSocketMessage } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/handleWebSocketMessage'; +import { shouldUpdateDevWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + devWorkspacesAddAction, + devWorkspacesDeleteAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/devWorkspaceApi'); +jest.mock('@/services/devfileApi', () => ({ + ...jest.requireActual('@/services/devfileApi'), + isDevWorkspace: jest.fn(), +})); +jest.mock('@/services/workspace-adapter'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/store/InfrastructureNamespaces'); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleWebSocketMessage', () => { + let store: ReturnType; + const mockWebsocketClient = { + unsubscribeFromChannel: jest.fn(), + subscribeToChannel: jest.fn(), + }; + + beforeEach(() => { + store = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '1', + startedWorkspaces: {}, + warnings: {}, + workspaces: [], + }, + } as Partial as RootState); + + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValue({ name: 'default-namespace' } as che.KubernetesNamespace); + (actionCreators.requestWorkspaces as jest.Mock).mockReturnValue(async () => {}); + (WorkspaceAdapter.getId as jest.Mock).mockImplementation(w => w.metadata.uid); + (WorkspaceAdapter.getUID as jest.Mock).mockImplementation(w => w.metadata.uid); + + container.snapshot(); + container + .rebind(WebsocketClient) + .toConstantValue(mockWebsocketClient as unknown as WebsocketClient); + }); + + it('should handle status message and resubscribe on error status code', async () => { + const message = { + status: { + code: 500, + message: 'Internal Server Error', + }, + } as api.webSocket.StatusMessage; + + (api.webSocket.isStatusMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + await store.dispatch(handleWebSocketMessage(message)); + + expect(mockWebsocketClient.unsubscribeFromChannel).toHaveBeenCalledWith( + api.webSocket.Channel.DEV_WORKSPACE, + ); + expect(actionCreators.requestWorkspaces).toHaveBeenCalled(); + expect(mockWebsocketClient.subscribeToChannel).toHaveBeenCalledWith( + api.webSocket.Channel.DEV_WORKSPACE, + 'default-namespace', + { getResourceVersion: expect.any(Function) }, + ); + }); + + it('should handle dev workspace added event', async () => { + const workspace = { metadata: { uid: 'workspace-uid' } } as devfileApi.DevWorkspace; + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: api.webSocket.EventPhase.ADDED, + devWorkspace: workspace, + }; + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + await store.dispatch(handleWebSocketMessage(message)); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(devWorkspacesAddAction(workspace)); + }); + + it('should handle dev workspace modified event and update workspace', async () => { + const prevWorkspace = { + metadata: { uid: 'workspace-uid', resourceVersion: '1' }, + status: { phase: 'Starting' }, + } as devfileApi.DevWorkspace; + const workspace = { + metadata: { uid: 'workspace-uid', resourceVersion: '2' }, + status: { phase: 'Running' }, + } as devfileApi.DevWorkspace; + + const storeWithWorkspace = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '1', + startedWorkspaces: {}, + warnings: {}, + workspaces: [prevWorkspace], + }, + } as RootState); + + (shouldUpdateDevWorkspace as jest.Mock).mockReturnValueOnce(true); + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: api.webSocket.EventPhase.MODIFIED, + devWorkspace: workspace, + }; + + await storeWithWorkspace.dispatch(handleWebSocketMessage(message)); + + const actions = storeWithWorkspace.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(devWorkspacesUpdateAction(workspace)); + }); + + it('should handle dev workspace deleted event', async () => { + const workspace = { metadata: { uid: 'workspace-uid' } } as devfileApi.DevWorkspace; + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: api.webSocket.EventPhase.DELETED, + devWorkspace: workspace, + }; + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + await store.dispatch(handleWebSocketMessage(message)); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual(devWorkspacesDeleteAction(workspace)); + }); + + it('should not dispatch if dev workspace is invalid', async () => { + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: api.webSocket.EventPhase.ADDED, + devWorkspace: {}, + }; + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(false); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + await store.dispatch(handleWebSocketMessage(message)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + }); + + it('should handle unknown event phase', async () => { + const workspace = { metadata: { uid: 'workspace-uid' } } as devfileApi.DevWorkspace; + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: 'UNKNOWN_PHASE' as unknown, + devWorkspace: workspace, + } as api.webSocket.DevWorkspaceMessage; + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + console.warn = jest.fn(); + + await store.dispatch(handleWebSocketMessage(message)); + + expect(console.warn).toHaveBeenCalledWith('Unknown event phase in message: ', message); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + }); + + it('should call injectKubeConfig and podmanLogin when workspace status changes to RUNNING', async () => { + const prevWorkspace = { + metadata: { uid: 'workspace-uid' }, + status: { phase: DevWorkspaceStatus.STARTING }, + }; + const workspace = { + metadata: { uid: 'workspace-uid', namespace: 'test-namespace' }, + status: { phase: DevWorkspaceStatus.RUNNING, devworkspaceId: 'devworkspace-id' }, + }; + + (shouldUpdateDevWorkspace as jest.Mock).mockReturnValue(true); + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + const storeWithWorkspace = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '1', + startedWorkspaces: {}, + warnings: {}, + workspaces: [prevWorkspace], + }, + } as Partial as RootState); + + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: api.webSocket.EventPhase.MODIFIED, + devWorkspace: workspace, + }; + + await storeWithWorkspace.dispatch(handleWebSocketMessage(message)); + + expect(injectKubeConfig).toHaveBeenCalledWith('test-namespace', 'devworkspace-id'); + expect(podmanLogin).toHaveBeenCalledWith('test-namespace', 'devworkspace-id'); + }); + + it('should call status change listeners when workspace status changes', async () => { + const prevWorkspace = { + metadata: { uid: 'workspace-uid' }, + status: { phase: DevWorkspaceStatus.STARTING }, + } as devfileApi.DevWorkspace; + const workspace = { + metadata: { uid: 'workspace-uid' }, + status: { phase: DevWorkspaceStatus.RUNNING }, + } as devfileApi.DevWorkspace; + + const statusChangeCallback = jest.fn(); + onStatusChangeCallbacks.set('workspace-uid', statusChangeCallback); + + (shouldUpdateDevWorkspace as jest.Mock).mockReturnValue(true); + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + const storeWithWorkspace = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '1', + startedWorkspaces: {}, + warnings: {}, + workspaces: [prevWorkspace], + }, + } as Partial as RootState); + + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: api.webSocket.EventPhase.MODIFIED, + devWorkspace: workspace, + }; + + await storeWithWorkspace.dispatch(handleWebSocketMessage(message)); + + expect(statusChangeCallback).toHaveBeenCalledWith(DevWorkspaceStatus.RUNNING); + }); + + it('should not call injectKubeConfig and podmanLogin if devworkspaceId is undefined', async () => { + const prevWorkspace = { + metadata: { uid: 'workspace-uid' }, + status: { phase: DevWorkspaceStatus.STARTING }, + } as devfileApi.DevWorkspace; + const workspace = { + metadata: { uid: 'workspace-uid', namespace: 'test-namespace' }, + status: { phase: DevWorkspaceStatus.RUNNING }, + } as devfileApi.DevWorkspace; + + (shouldUpdateDevWorkspace as jest.Mock).mockReturnValue(true); + + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + (api.webSocket.isDevWorkspaceMessage as unknown as jest.Mock).mockReturnValueOnce(true); + + const storeWithWorkspace = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '1', + startedWorkspaces: {}, + warnings: {}, + workspaces: [prevWorkspace], + }, + } as Partial as RootState); + + const message: api.webSocket.DevWorkspaceMessage = { + eventPhase: api.webSocket.EventPhase.MODIFIED, + devWorkspace: workspace, + }; + + await storeWithWorkspace.dispatch(handleWebSocketMessage(message)); + + expect(injectKubeConfig).not.toHaveBeenCalled(); + expect(podmanLogin).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/helpers.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/helpers.spec.ts new file mode 100644 index 000000000..afd7dbd8e --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/helpers.spec.ts @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { helpers } from '@eclipse-che/common'; + +import { container } from '@/inversify.config'; +import devfileApi, * as devfileApiService from '@/services/devfileApi'; +import { devWorkspaceKind } from '@/services/devfileApi/devWorkspace'; +import { compareStringsAsNumbers } from '@/services/helpers/resourceVersion'; +import { + DEVWORKSPACE_NEXT_START_ANNOTATION, + DevWorkspaceClient, +} from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { RootState } from '@/store'; +import { selectRunningWorkspacesLimit } from '@/store/ClusterConfig/selectors'; +import { RunningWorkspacesExceededError } from '@/store/Workspaces/devWorkspaces'; +import { + checkDevWorkspaceNextStartAnnotation, + checkRunningWorkspacesLimit, + getDevWorkspaceClient, + getWarningFromResponse, + shouldUpdateDevWorkspace, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { selectRunningDevWorkspacesLimitExceeded } from '@/store/Workspaces/devWorkspaces/selectors'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/inversify.config'); +jest.mock('@/services/devfileApi', () => ({ + ...jest.requireActual('@/services/devfileApi'), + isDevWorkspace: jest.fn(), +})); +jest.mock('@/services/helpers/resourceVersion'); +jest.mock('@/store/ClusterConfig/selectors'); +jest.mock('@/store/Workspaces/devWorkspaces/selectors'); + +describe('devWorkspaces, helpers', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getWarningFromResponse', () => { + it('should return undefined if error does not include Axios response', () => { + const error = new Error('Test error'); + (helpers.errors.includesAxiosResponse as unknown as jest.Mock).mockReturnValue(false); + + const result = getWarningFromResponse(error); + expect(result).toBeUndefined(); + }); + + it('should return provider-specific warning if provider is GitHub', () => { + const error = { + response: { + data: { + attributes: { + provider: 'github', + }, + message: 'GitHub error message', + }, + }, + }; + (helpers.errors.includesAxiosResponse as unknown as jest.Mock).mockReturnValue(true); + + const result = getWarningFromResponse(error); + expect(result).toBe( + "GitHub might not be operational, please check the provider's status page.", + ); + }); + + it('should return provider-specific warning if provider is Gitlab', () => { + const error = { + response: { + data: { + attributes: { + provider: 'gitlab', + }, + message: 'Gitlab error message', + }, + }, + }; + (helpers.errors.includesAxiosResponse as unknown as jest.Mock).mockReturnValue(true); + + const result = getWarningFromResponse(error); + expect(result).toBe( + "Gitlab might not be operational, please check the provider's status page.", + ); + }); + + it('should return generic message if provider is unknown', () => { + const error = { + response: { + data: { + attributes: { + provider: 'unknown', + }, + message: 'Unknown provider error', + }, + }, + }; + (helpers.errors.includesAxiosResponse as unknown as jest.Mock).mockReturnValue(true); + + const result = getWarningFromResponse(error); + expect(result).toBe('Unknown provider error'); + }); + + it('should return message from response data if attributes are undefined', () => { + const error = { + response: { + data: { + message: 'Error message without attributes', + }, + }, + }; + (helpers.errors.includesAxiosResponse as unknown as jest.Mock).mockReturnValue(true); + + const result = getWarningFromResponse(error); + expect(result).toBe('Error message without attributes'); + }); + }); + + describe('checkDevWorkspaceNextStartAnnotation', () => { + let devWorkspaceClient: DevWorkspaceClient; + let workspace: devfileApi.DevWorkspace; + + beforeEach(() => { + devWorkspaceClient = { + update: jest.fn().mockResolvedValue({}), + } as unknown as DevWorkspaceClient; + + workspace = { + metadata: { + annotations: {}, + }, + spec: { + template: {}, + started: false, + }, + } as devfileApi.DevWorkspace; + }); + + it('should not update workspace if annotation is not present', async () => { + await checkDevWorkspaceNextStartAnnotation(devWorkspaceClient, workspace); + expect(devWorkspaceClient.update).not.toHaveBeenCalled(); + }); + + it('should update workspace when annotation is present and valid', async () => { + const storedDevWorkspace = { + kind: devWorkspaceKind, + spec: { + template: { components: [] }, + }, + }; + workspace.metadata.annotations![DEVWORKSPACE_NEXT_START_ANNOTATION] = + JSON.stringify(storedDevWorkspace); + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(true); + + await checkDevWorkspaceNextStartAnnotation(devWorkspaceClient, workspace); + + expect(devWorkspaceClient.update).toHaveBeenCalledWith(workspace); + expect(workspace.spec.template).toEqual(storedDevWorkspace.spec.template); + expect(workspace.spec.started).toBe(false); + expect(workspace.metadata.annotations![DEVWORKSPACE_NEXT_START_ANNOTATION]).toBeUndefined(); + }); + + it('should throw error if storedDevWorkspace is invalid', async () => { + const storedDevWorkspace = {}; + workspace.metadata.annotations![DEVWORKSPACE_NEXT_START_ANNOTATION] = + JSON.stringify(storedDevWorkspace); + jest.spyOn(devfileApiService, 'isDevWorkspace').mockReturnValueOnce(false); + console.error = jest.fn(); + + await expect( + checkDevWorkspaceNextStartAnnotation(devWorkspaceClient, workspace), + ).rejects.toThrow( + 'Unexpected error happened. Please check the Console tab of Developer tools.', + ); + + expect(devWorkspaceClient.update).not.toHaveBeenCalled(); + }); + }); + + describe('checkRunningWorkspacesLimit', () => { + it('should not throw error if running limit is not exceeded', () => { + const state = {} as RootState; + (selectRunningDevWorkspacesLimitExceeded as unknown as jest.Mock).mockReturnValue(false); + + expect(() => checkRunningWorkspacesLimit(state)).not.toThrow(); + }); + + it('should throw RunningWorkspacesExceededError if running limit is exceeded', () => { + const state = {} as RootState; + (selectRunningDevWorkspacesLimitExceeded as unknown as jest.Mock).mockReturnValue(true); + (selectRunningWorkspacesLimit as unknown as jest.Mock).mockReturnValue(2); + + expect(() => checkRunningWorkspacesLimit(state)).toThrow(RunningWorkspacesExceededError); + }); + }); + + describe('getDevWorkspaceClient', () => { + it('should return DevWorkspaceClient instance from container', () => { + const mockClient = {}; + (container.get as jest.Mock).mockReturnValue(mockClient); + + const result = getDevWorkspaceClient(); + + expect(container.get).toHaveBeenCalledWith(DevWorkspaceClient); + expect(result).toBe(mockClient); + }); + }); + + describe('shouldUpdateDevWorkspace', () => { + it('should return false if resourceVersion is undefined', () => { + const prevDevWorkspace = { metadata: {} } as devfileApi.DevWorkspace; + const devWorkspace = { metadata: {} } as devfileApi.DevWorkspace; + + const result = shouldUpdateDevWorkspace(prevDevWorkspace, devWorkspace); + expect(result).toBe(false); + }); + + it('should return true if prevResourceVersion is undefined', () => { + const prevDevWorkspace = { metadata: {} } as devfileApi.DevWorkspace; + const devWorkspace = { metadata: { resourceVersion: '2' } } as devfileApi.DevWorkspace; + + const result = shouldUpdateDevWorkspace(prevDevWorkspace, devWorkspace); + expect(result).toBe(true); + }); + + it('should compare resource versions and return true if newer', () => { + const prevDevWorkspace = { metadata: { resourceVersion: '1' } } as devfileApi.DevWorkspace; + const devWorkspace = { metadata: { resourceVersion: '2' } } as devfileApi.DevWorkspace; + (compareStringsAsNumbers as jest.Mock).mockReturnValue(-1); + + const result = shouldUpdateDevWorkspace(prevDevWorkspace, devWorkspace); + expect(result).toBe(true); + expect(compareStringsAsNumbers).toHaveBeenCalledWith('1', '2'); + }); + + it('should compare resource versions and return false if older or equal', () => { + const prevDevWorkspace = { metadata: { resourceVersion: '2' } } as devfileApi.DevWorkspace; + const devWorkspace = { metadata: { resourceVersion: '2' } } as devfileApi.DevWorkspace; + (compareStringsAsNumbers as jest.Mock).mockReturnValue(0); + + const result = shouldUpdateDevWorkspace(prevDevWorkspace, devWorkspace); + expect(result).toBe(false); + expect(compareStringsAsNumbers).toHaveBeenCalledWith('2', '2'); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/requestWorkspace.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/requestWorkspace.spec.ts new file mode 100644 index 000000000..6df8f2755 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/requestWorkspace.spec.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { helpers } from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION } from '@/services/devfileApi/devWorkspace/metadata'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { actionCreators } from '@/store/Workspaces/devWorkspaces/actions/actionCreators'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { requestWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspace'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@eclipse-che/common'); + +(verifyAuthorized as jest.Mock).mockResolvedValue(true); + +const mockNamespace = 'test-namespace'; +const mockName = 'test-workspace'; +const mockWorkspace = { + metadata: { + namespace: mockNamespace, + name: mockName, + resourceVersion: '1', + }, +} as devfileApi.DevWorkspace; +const mockReceivedWorkspace = { + metadata: { + namespace: mockNamespace, + name: mockName, + resourceVersion: '2', + }, +} as devfileApi.DevWorkspace; + +const mockGetWorkspaceByName = jest.fn().mockImplementation(() => { + return mockReceivedWorkspace; +}); +(getDevWorkspaceClient as jest.Mock).mockReturnValue({ + getWorkspaceByName: mockGetWorkspaceByName, +}); + +(actionCreators.updateWorkspace as jest.Mock).mockImplementation(() => async () => {}); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('requestWorkspace', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + }); + + it('should handle error when authorization fails', async () => { + const errorMessage = 'You are not authorized to perform this action'; + (verifyAuthorized as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + (common.helpers.errors.getMessage as jest.Mock).mockReturnValueOnce(errorMessage); + + await expect(store.dispatch(requestWorkspace(mockWorkspace))).rejects.toThrow( + 'You are not authorized to perform this action', + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction( + `Failed to fetch the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + + it('should dispatch update action on successful fetch, and then call the updateWorkspace action', async () => { + await store.dispatch(requestWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesUpdateAction(mockReceivedWorkspace)); + + expect(mockGetWorkspaceByName).toHaveBeenCalledWith(mockNamespace, mockName); + expect(actionCreators.updateWorkspace).toHaveBeenCalledWith(mockReceivedWorkspace); + }); + + it('should dispatch update action on successful fetch, and do not call the updateWorkspace action', async () => { + const mockWorkspaceWithAnnotations = { + ...mockWorkspace, + metadata: { + ...mockWorkspace.metadata, + annotations: { + [DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION]: '12345', + }, + }, + } as devfileApi.DevWorkspace; + mockGetWorkspaceByName.mockReturnValueOnce(mockWorkspaceWithAnnotations); + + await store.dispatch(requestWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesUpdateAction(mockWorkspaceWithAnnotations)); + + expect(mockGetWorkspaceByName).toHaveBeenCalledWith(mockNamespace, mockName); + expect(actionCreators.updateWorkspace).not.toHaveBeenCalled(); + }); + + it('should handle errors when fetching the workspace', async () => { + const errorMessage = 'Network error'; + + mockGetWorkspaceByName.mockRejectedValueOnce(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValueOnce(errorMessage); + + await expect(store.dispatch(requestWorkspace(mockWorkspace))).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesErrorAction( + `Failed to fetch the workspace ${mockName}, reason: ${errorMessage}`, + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/requestWorkspaces.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/requestWorkspaces.spec.ts new file mode 100644 index 000000000..dde6de9de --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/requestWorkspaces.spec.ts @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION } from '@/services/devfileApi/devWorkspace/metadata'; +import { che } from '@/services/models'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as infrastructureNamespaces from '@/store/InfrastructureNamespaces'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { actionCreators } from '@/store/Workspaces/devWorkspaces/actions'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { requestWorkspaces } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspaces'; +import { + devWorkspacesErrorAction, + devWorkspacesReceiveAction, + devWorkspacesRequestAction, + devWorkspacesUpdateStartedAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@/store/InfrastructureNamespaces'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@eclipse-che/common'); + +const mockNamespace = { + name: 'test-namespace', + attributes: { + phase: 'Active', + }, +}; + +jest.spyOn(infrastructureNamespaces, 'selectDefaultNamespace').mockReturnValue(mockNamespace); +(verifyAuthorized as jest.Mock).mockResolvedValue(true); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('requestWorkspaces', () => { + let store: ReturnType; + const mockGetAllWorkspaces = jest.fn(); + const mockUpdateWorkspace = jest.fn(); + + beforeEach(() => { + store = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + getAllWorkspaces: mockGetAllWorkspaces, + }); + + (actionCreators.updateWorkspace as jest.Mock).mockImplementation(() => mockUpdateWorkspace); + }); + + it('should dispatch receive action on successful fetch', async () => { + const mockWorkspaces = [ + { + metadata: { name: 'workspace1', annotations: {} }, + }, + { + metadata: { + name: 'workspace2', + annotations: { [DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION]: '12345' }, + }, + }, + ] as devfileApi.DevWorkspace[]; + const mockResourceVersion = '12345'; + + mockGetAllWorkspaces.mockResolvedValue({ + workspaces: mockWorkspaces, + resourceVersion: mockResourceVersion, + }); + + await store.dispatch(requestWorkspaces()); + + const actions = store.getActions(); + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesReceiveAction({ + workspaces: mockWorkspaces, + resourceVersion: mockResourceVersion, + }), + ); + expect(actions[2]).toEqual(devWorkspacesUpdateStartedAction(mockWorkspaces)); + + // updateWorkspace should be called only for the workspace without timestamp annotation + expect(actionCreators.updateWorkspace).toHaveBeenCalledTimes(1); + expect(actionCreators.updateWorkspace).toHaveBeenCalledWith(mockWorkspaces[0]); + }); + + it('should handle when there is no default namespace', async () => { + jest + .spyOn(infrastructureNamespaces, 'selectDefaultNamespace') + .mockReturnValueOnce({} as che.KubernetesNamespace); + + await store.dispatch(requestWorkspaces()); + + const actions = store.getActions(); + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesReceiveAction({ + workspaces: [], + resourceVersion: '', + }), + ); + expect(actions[2]).toEqual(devWorkspacesUpdateStartedAction([])); + }); + + it('should handle errors during fetching workspaces', async () => { + const error = new Error('Test Error'); + mockGetAllWorkspaces.mockRejectedValue(error); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue('Test Error Message'); + + await expect(store.dispatch(requestWorkspaces())).rejects.toThrow(error); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesErrorAction( + 'Failed to fetch available workspaces, reason: Test Error Message', + ), + ); + }); + + it('should handle authorization failure', async () => { + const error = new Error('Authorization failed'); + (verifyAuthorized as jest.Mock).mockRejectedValue(error); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue('Authorization failed'); + + await expect(store.dispatch(requestWorkspaces())).rejects.toThrow(error); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction( + 'Failed to fetch available workspaces, reason: Authorization failed', + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/restartWorkspace.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/restartWorkspace.spec.ts new file mode 100644 index 000000000..612d94fc7 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/restartWorkspace.spec.ts @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import devfileApi from '@/services/devfileApi'; +import { DevWorkspaceStatus } from '@/services/helpers/types'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { actionCreators, onStatusChangeCallbacks } from '@/store/Workspaces/devWorkspaces/actions'; +import { restartWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/restartWorkspace'; + +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators'); + +(actionCreators.startWorkspace as jest.Mock).mockImplementation(() => async () => {}); +(actionCreators.stopWorkspace as jest.Mock).mockImplementation(() => async () => {}); + +const mockNamespace = 'test-namespace'; +const mockName = 'test-workspace'; +const mockWorkspace = { + metadata: { + namespace: mockNamespace, + name: mockName, + annotations: {}, + }, + status: { + phase: DevWorkspaceStatus.RUNNING, + }, +} as devfileApi.DevWorkspace; + +describe('devWorkspaces, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [mockWorkspace], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('restartWorkspace', () => { + it('should call startWorkspace if the workspace is stopped', async () => { + const mockStoppedWorkspace = { + ...mockWorkspace, + status: { + phase: DevWorkspaceStatus.STOPPED, + }, + } as devfileApi.DevWorkspace; + + store.dispatch(restartWorkspace(mockStoppedWorkspace)); + + expect(actionCreators.startWorkspace).toHaveBeenCalled(); + expect(actionCreators.stopWorkspace).not.toHaveBeenCalled(); + }); + + it('should dispatch start action on successful restart', async () => { + // do not await, as we need to trigger the status change callback + // using await will block the test + store.dispatch(restartWorkspace(mockWorkspace)); + + expect(actionCreators.stopWorkspace).toHaveBeenCalled(); + expect(actionCreators.startWorkspace).not.toHaveBeenCalled(); + + expect(onStatusChangeCallbacks.size).toBe(1); + + // trigger workspace status change to start the workspace + const handler = onStatusChangeCallbacks.get(WorkspaceAdapter.getUID(mockWorkspace))!; + handler(DevWorkspaceStatus.STOPPED as string); + + expect(actionCreators.startWorkspace).toHaveBeenCalled(); + }); + + it('should handle errors when stopping the workspace', async () => { + const errorMessage = 'Network error'; + + (actionCreators.stopWorkspace as jest.Mock).mockImplementation(() => async () => { + throw new Error(errorMessage); + }); + + await expect(store.dispatch(restartWorkspace(mockWorkspace))).rejects.toThrow(errorMessage); + }); + + it('should handle errors when starting the workspace', async () => { + const errorMessage = 'Network error'; + + (actionCreators.startWorkspace as jest.Mock).mockImplementation(() => async () => { + throw new Error(errorMessage); + }); + + // do not await, as we need to trigger the status change callback + // using await will block the test + const promise = store.dispatch(restartWorkspace(mockWorkspace)); + + expect(actionCreators.stopWorkspace).toHaveBeenCalled(); + expect(actionCreators.startWorkspace).not.toHaveBeenCalled(); + + expect(onStatusChangeCallbacks.size).toBe(1); + + // trigger workspace status change to start the workspace + const handler = onStatusChangeCallbacks.get(WorkspaceAdapter.getUID(mockWorkspace))!; + handler(DevWorkspaceStatus.STOPPED as string); + + await expect(promise).rejects.toThrow(errorMessage); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/startWorkspace.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/startWorkspace.spec.ts new file mode 100644 index 000000000..b96d88f4c --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/startWorkspace.spec.ts @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { OAuthService } from '@/services/oauth'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + checkRunningDevWorkspacesClusterLimitExceeded, + devWorkspacesClusterActionCreators, +} from '@/store/DevWorkspacesCluster'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + checkDevWorkspaceNextStartAnnotation, + checkRunningWorkspacesLimit, + getDevWorkspaceClient, + getWarningFromResponse, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + getEditorName, + getLifeTimeMs, + updateEditor, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'; +import { startWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, + devWorkspaceWarningUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@/store/SanityCheck'); +jest.mock('@/services/oauth'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/store/DevWorkspacesCluster'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'); +jest.mock('@eclipse-che/common'); + +const mockNamespace = 'test-namespace'; +const mockName = 'test-workspace'; +const mockWorkspace = { + metadata: { + namespace: mockNamespace, + name: mockName, + uid: '1', + annotations: {}, + }, + spec: { + started: false, + template: {}, + }, +} as devfileApi.DevWorkspace; + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('startWorkspace', () => { + let store: ReturnType; + const mockChangeWorkspaceStatus = jest.fn().mockResolvedValue(mockWorkspace); + const mockManagePvcStrategy = jest.fn().mockResolvedValue(mockWorkspace); + const mockManageContainerBuildAttribute = jest.fn().mockResolvedValue(mockWorkspace); + const mockManageDebugMode = jest.fn().mockResolvedValue(mockWorkspace); + const mockOnStart = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + store = createMockStore({ + dwPlugins: { + defaultPlugins: {}, + }, + dwServerConfig: { + config: {}, + }, + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [mockWorkspace], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + changeWorkspaceStatus: mockChangeWorkspaceStatus, + managePvcStrategy: mockManagePvcStrategy, + manageContainerBuildAttribute: mockManageContainerBuildAttribute, + manageDebugMode: mockManageDebugMode, + onStart: mockOnStart, + }); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (OAuthService.refreshTokenIfProjectExists as jest.Mock).mockResolvedValue(undefined); + (getWarningFromResponse as jest.Mock).mockReturnValue('Warning message'); + ( + devWorkspacesClusterActionCreators.requestRunningDevWorkspacesClusterLimitExceeded as jest.Mock + ).mockImplementation(() => async () => {}); + (checkRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockReturnValue(undefined); + (checkRunningWorkspacesLimit as jest.Mock).mockReturnValue(undefined); + (checkDevWorkspaceNextStartAnnotation as jest.Mock).mockResolvedValue(undefined); + (updateEditor as jest.Mock).mockResolvedValue(undefined); + (getEditorName as jest.Mock).mockReturnValue('custom-editor'); + (getLifeTimeMs as jest.Mock).mockReturnValue(30001); + }); + + it('should handle when the workspace is not found', async () => { + const mockWorkspaceNotFound = { + ...mockWorkspace, + metadata: { + ...mockWorkspace.metadata, + uid: '2', + }, + }; + await store.dispatch(startWorkspace(mockWorkspaceNotFound)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + }); + + it('should handle when the workspace is already started', async () => { + const mockStartedWorkspace = { + ...mockWorkspace, + spec: { + ...mockWorkspace.spec, + started: true, + }, + }; + const storeWithStartedWorkspace = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [mockStartedWorkspace], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + + await storeWithStartedWorkspace.dispatch(startWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(0); + }); + + it('should handle authorization failure', async () => { + const errorMessage = 'You are not authorized to perform this action'; + (verifyAuthorized as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + (common.helpers.errors.getMessage as jest.Mock).mockReturnValueOnce(errorMessage); + + await expect(store.dispatch(startWorkspace(mockWorkspace))).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction( + `Failed to start the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + + it('should handle OAuth token refresh failure and dispatch a warning', async () => { + const mockError = { + response: { + data: { + message: 'Invalid token', + }, + }, + }; + + (OAuthService.refreshTokenIfProjectExists as jest.Mock).mockRejectedValueOnce(mockError); + (getWarningFromResponse as jest.Mock).mockReturnValueOnce('Invalid token for provider'); + + // let's stop the workspace start at this point + // as we want to test the warning dispatch only + (checkRunningDevWorkspacesClusterLimitExceeded as jest.Mock).mockImplementationOnce(() => { + throw new Error('Cluster limit exceeded'); + }); + + await expect(store.dispatch(startWorkspace(mockWorkspace))).rejects.toThrow(); + + const actions = store.getActions(); + expect(actions).toHaveLength(3); + expect(actions[0]).toEqual( + devWorkspaceWarningUpdateAction({ + workspace: mockWorkspace, + warning: 'Invalid token for provider', + }), + ); + expect(actions[1]).toEqual(devWorkspacesRequestAction()); + expect(actions[2]).toEqual(devWorkspacesErrorAction(expect.any(String))); + }); + + it('should dispatch update action on successful start', async () => { + await store.dispatch(startWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesUpdateAction(mockWorkspace)); + + expect(mockChangeWorkspaceStatus).toHaveBeenCalledWith(mockWorkspace, true, true); + }); + + it('should handle errors when starting the workspace', async () => { + const errorMessage = 'Failed to start'; + const error = new Error(errorMessage); + mockChangeWorkspaceStatus.mockRejectedValueOnce(error); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(startWorkspace(mockWorkspace))).rejects.toThrow(error); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesErrorAction( + `Failed to start the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/stopWorkspace.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/stopWorkspace.spec.ts new file mode 100644 index 000000000..43ffff4a3 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/stopWorkspace.spec.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { stopWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/stopWorkspace'; +import { devWorkspacesErrorAction } from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@eclipse-che/common'); + +(verifyAuthorized as jest.Mock).mockResolvedValue(true); + +const mockNamespace = 'test-namespace'; +const mockName = 'test-workspace'; +const mockWorkspace = { + metadata: { + namespace: mockNamespace, + name: mockName, + annotations: {}, + }, + status: { + phase: 'Running', + }, +} as devfileApi.DevWorkspace; + +describe('devWorkspaces, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('stopWorkspace', () => { + it('should stop the workspace successfully', async () => { + const changeWorkspaceStatus = jest.fn().mockResolvedValue(undefined); + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ changeWorkspaceStatus }); + + await store.dispatch(stopWorkspace(mockWorkspace)); + + expect(changeWorkspaceStatus).toHaveBeenCalledWith(mockWorkspace, false); + }); + + it('should handle errors during stopping the workspace', async () => { + const error = new Error('Test Error'); + const changeWorkspaceStatus = jest.fn().mockRejectedValue(error); + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ changeWorkspaceStatus }); + (common.helpers.errors.getMessage as jest.Mock).mockReturnValue('Test Error Message'); + + await expect(store.dispatch(stopWorkspace(mockWorkspace))).rejects.toThrow('Test Error'); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction( + 'Failed to stop the workspace test-workspace, reason: Test Error Message', + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/terminateWorkspace.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/terminateWorkspace.spec.ts new file mode 100644 index 000000000..d263774a6 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/terminateWorkspace.spec.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { terminateWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/terminateWorkspace'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesTerminateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/services/workspace-adapter'); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('terminateWorkspace', () => { + let store: ReturnType; + const mockDelete = jest.fn(); + const mockWorkspace = { + metadata: { + namespace: 'test-namespace', + name: 'test-workspace', + uid: '1', + }, + } as devfileApi.DevWorkspace; + + beforeEach(() => { + store = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [mockWorkspace], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + delete: mockDelete, + }); + (WorkspaceAdapter.getUID as jest.Mock).mockReturnValue(mockWorkspace.metadata.uid); + (common.helpers.errors.getMessage as jest.Mock).mockImplementation((e: Error) => e.message); + }); + + it('should dispatch terminate action on successful deletion', async () => { + await store.dispatch(terminateWorkspace(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesTerminateAction({ + workspaceUID: mockWorkspace.metadata.uid, + message: 'Cleaning up resources for deletion', + }), + ); + + expect(mockDelete).toHaveBeenCalledWith( + mockWorkspace.metadata.namespace, + mockWorkspace.metadata.name, + ); + }); + + it('should handle authorization failure', async () => { + const errorMessage = 'Not authorized'; + (verifyAuthorized as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + await expect(store.dispatch(terminateWorkspace(mockWorkspace))).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction( + `Failed to delete the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + + it('should handle deletion failure', async () => { + const errorMessage = 'Deletion failed'; + mockDelete.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(store.dispatch(terminateWorkspace(mockWorkspace))).rejects.toThrow(errorMessage); + + expect(verifyAuthorized).toHaveBeenCalled(); + expect(mockDelete).toHaveBeenCalledWith( + mockWorkspace.metadata.namespace, + mockWorkspace.metadata.name, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesErrorAction( + `Failed to delete the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspace.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspace.spec.ts new file mode 100644 index 000000000..a63d5d53f --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspace.spec.ts @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + getDevWorkspaceClient, + shouldUpdateDevWorkspace, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { updateWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspace'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/services/workspace-adapter'); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('updateWorkspace', () => { + let store: ReturnType; + const mockUpdate = jest.fn(); + const mockWorkspace = { + metadata: { + namespace: 'test-namespace', + name: 'test-workspace', + uid: '1', + }, + } as devfileApi.DevWorkspace; + + beforeEach(() => { + store = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [mockWorkspace], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + update: mockUpdate, + }); + (common.helpers.errors.getMessage as jest.Mock).mockImplementation((e: Error) => e.message); + (WorkspaceAdapter.getId as jest.Mock).mockImplementation( + (w: devfileApi.DevWorkspace) => w.metadata.uid, + ); + (shouldUpdateDevWorkspace as jest.Mock).mockReturnValue(true); + }); + + it('should dispatch update action on successful update', async () => { + const updatedWorkspace = { + ...mockWorkspace, + metadata: { + ...mockWorkspace.metadata, + annotations: { + newAnnotation: 'value', + }, + }, + }; + mockUpdate.mockResolvedValueOnce(updatedWorkspace); + + await store.dispatch(updateWorkspace(mockWorkspace)); + + expect(verifyAuthorized).toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith(mockWorkspace); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesUpdateAction(updatedWorkspace)); + }); + + it('should dispatch update action with undefined when shouldUpdateDevWorkspace returns false', async () => { + const updatedWorkspace = { + ...mockWorkspace, + metadata: { + ...mockWorkspace.metadata, + annotations: { + newAnnotation: 'value', + }, + }, + }; + mockUpdate.mockResolvedValueOnce(updatedWorkspace); + (shouldUpdateDevWorkspace as jest.Mock).mockReturnValueOnce(false); + + await store.dispatch(updateWorkspace(mockWorkspace)); + + expect(verifyAuthorized).toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith(mockWorkspace); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesUpdateAction(undefined)); + }); + + it('should handle authorization failure', async () => { + const errorMessage = 'Not authorized'; + (verifyAuthorized as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + await expect(store.dispatch(updateWorkspace(mockWorkspace))).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction( + `Failed to update the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + + it('should handle update failure', async () => { + const errorMessage = 'Update failed'; + mockUpdate.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(store.dispatch(updateWorkspace(mockWorkspace))).rejects.toThrow(errorMessage); + + expect(verifyAuthorized).toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith(mockWorkspace); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesErrorAction( + `Failed to update the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspaceAnnotation.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspaceAnnotation.spec.ts new file mode 100644 index 000000000..15f2092cf --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspaceAnnotation.spec.ts @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { updateWorkspaceAnnotation } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceAnnotation'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('updateWorkspaceAnnotation', () => { + let store: ReturnType; + const mockUpdateAnnotation = jest.fn(); + const mockWorkspace = { + metadata: { + namespace: 'test-namespace', + name: 'test-workspace', + annotations: {}, + }, + } as devfileApi.DevWorkspace; + + beforeEach(() => { + store = createMockStore({ + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [mockWorkspace], + startedWorkspaces: {}, + warnings: {}, + }, + } as Partial as RootState); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + updateAnnotation: mockUpdateAnnotation, + }); + (common.helpers.errors.getMessage as jest.Mock).mockImplementation((e: Error) => e.message); + }); + + it('should dispatch update action on successful annotation update', async () => { + const updatedWorkspace = { + ...mockWorkspace, + metadata: { + ...mockWorkspace.metadata, + annotations: { + newAnnotation: 'value', + }, + }, + }; + mockUpdateAnnotation.mockResolvedValueOnce(updatedWorkspace); + + await store.dispatch(updateWorkspaceAnnotation(mockWorkspace)); + + expect(verifyAuthorized).toHaveBeenCalled(); + expect(mockUpdateAnnotation).toHaveBeenCalledWith(mockWorkspace); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual(devWorkspacesUpdateAction(updatedWorkspace)); + }); + + it('should handle authorization failure', async () => { + const errorMessage = 'Not authorized'; + (verifyAuthorized as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + await expect(store.dispatch(updateWorkspaceAnnotation(mockWorkspace))).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual( + devWorkspacesErrorAction( + `Failed to update the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + + it('should handle update failure', async () => { + const errorMessage = 'Update failed'; + mockUpdateAnnotation.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(store.dispatch(updateWorkspaceAnnotation(mockWorkspace))).rejects.toThrow( + errorMessage, + ); + + expect(verifyAuthorized).toHaveBeenCalled(); + expect(mockUpdateAnnotation).toHaveBeenCalledWith(mockWorkspace); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toEqual( + devWorkspacesErrorAction( + `Failed to update the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspaceWithDefaultDevfile.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspaceWithDefaultDevfile.spec.ts new file mode 100644 index 000000000..bc4ddb2ac --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/updateWorkspaceWithDefaultDevfile.spec.ts @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { ApplicationId } from '@eclipse-che/common'; +import { dump } from 'js-yaml'; + +import * as DwApi from '@/services/backend-client/devWorkspaceApi'; +import { fetchResources } from '@/services/backend-client/devworkspaceResourcesApi'; +import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; +import devfileApi from '@/services/devfileApi'; +import { loadResourcesContent } from '@/services/registry/resources'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as clusterInfo from '@/store/ClusterInfo'; +import * as DevfileRegistries from '@/store/DevfileRegistries'; +import { getEditor } from '@/store/DevfileRegistries/getEditor'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import * as serverConfig from '@/store/ServerConfig'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + getEditorImage, + updateEditorDevfile, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'; +import { getEditorName } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'; +import { updateWorkspaceWithDefaultDevfile } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceWithDefaultDevfile'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +jest.mock('@/services/backend-client/devWorkspaceApi'); +jest.mock('@/services/backend-client/devworkspaceResourcesApi'); +jest.mock('@/services/backend-client/devWorkspaceTemplateApi'); +jest.mock('@/services/devfileApi'); +jest.mock('@/services/registry/resources'); +jest.mock('@/store/ClusterInfo'); +jest.mock('@/store/DevfileRegistries'); +jest.mock('@/store/DevfileRegistries/getEditor'); +jest.mock('@/store/SanityCheck'); +jest.mock('@/store/ServerConfig'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'); +jest.mock('@eclipse-che/common'); +jest.mock('js-yaml'); + +describe('devWorkspaces, actions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('updateWorkspaceWithDefaultDevfile', () => { + let store: ReturnType; + const mockWorkspace = { + metadata: { + name: 'test-workspace', + namespace: 'test-namespace', + annotations: {}, + }, + spec: { + template: {}, + }, + } as devfileApi.DevWorkspace; + + const mockState = { + devWorkspaces: { + isLoading: false, + resourceVersion: '', + workspaces: [mockWorkspace], + startedWorkspaces: {}, + warnings: {}, + }, + }; + + beforeEach(() => { + store = createMockStore(mockState as Partial as RootState); + + jest.spyOn(DevfileRegistries, 'selectDefaultDevfile').mockReturnValue({ + metadata: { + name: 'default-devfile', + }, + } as devfileApi.Devfile); + + jest.spyOn(serverConfig, 'selectDefaultEditor').mockReturnValue('che-editor'); + jest.spyOn(serverConfig, 'selectOpenVSXUrl').mockReturnValue('https://openvsx.org'); + jest + .spyOn(serverConfig, 'selectPluginRegistryUrl') + .mockReturnValue('https://plugin-registry.com'); + jest + .spyOn(serverConfig, 'selectPluginRegistryInternalUrl') + .mockReturnValue('https://internal-plugin-registry.com'); + jest.spyOn(clusterInfo, 'selectApplications').mockReturnValue([ + { + id: ApplicationId.CLUSTER_CONSOLE, + url: 'https://cluster-console.com', + icon: '', + title: 'Console', + }, + ]); + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + + (getEditor as jest.Mock).mockResolvedValue({ + content: 'editor-content', + editorYamlUrl: 'https://editor-url.com/editor.yaml', + }); + + (dump as jest.Mock).mockReturnValue('dumped-devfile-content'); + + (fetchResources as jest.Mock).mockResolvedValue('resources-content'); + + (loadResourcesContent as jest.Mock).mockReturnValue([ + { + kind: 'DevWorkspace', + metadata: { annotations: {} }, + spec: { + template: { + components: [], + }, + started: false, + routingClass: 'che', + }, + }, + { + kind: 'DevWorkspaceTemplate', + metadata: { annotations: {} }, + spec: { + components: [], + }, + }, + ]); + + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + addEnvVarsToContainers: jest.fn(), + }); + + (getEditorImage as jest.Mock).mockReturnValue('editor-image'); + + (updateEditorDevfile as jest.Mock).mockReturnValue('updated-editor-content'); + + (DwtApi.getTemplateByName as jest.Mock).mockResolvedValue({ + metadata: { annotations: {} }, + spec: {}, + }); + + (DwtApi.patchTemplate as jest.Mock).mockResolvedValue({}); + + (DwApi.patchWorkspace as jest.Mock).mockResolvedValue({ + devWorkspace: { + metadata: {}, + }, + }); + + (getEditorName as jest.Mock).mockReturnValue('editor-name'); + + (common.helpers.errors.getMessage as jest.Mock).mockImplementation( + (err: Error) => err.message, + ); + }); + + it('should dispatch update action on successful update', async () => { + await store.dispatch(updateWorkspaceWithDefaultDevfile(mockWorkspace)); + + const actions = store.getActions(); + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual(devWorkspacesRequestAction()); + expect(actions[1]).toStrictEqual( + devWorkspacesUpdateAction({ metadata: {} } as devfileApi.DevWorkspace), + ); + + expect(verifyAuthorized).toHaveBeenCalledTimes(1); + expect(getEditor).toHaveBeenCalledWith( + 'che-editor', + expect.any(Function), + expect.any(Function), + ); + + expect(fetchResources).toHaveBeenCalledWith({ + devfileContent: 'dumped-devfile-content', + editorPath: undefined, + editorContent: 'updated-editor-content', + }); + + expect(DwtApi.getTemplateByName).toHaveBeenCalledWith('test-namespace', 'editor-name'); + expect(DwtApi.patchTemplate).toHaveBeenCalled(); + + expect(DwApi.patchWorkspace).toHaveBeenCalledWith( + 'test-namespace', + 'test-workspace', + expect.any(Array), + ); + }); + + it('should handle errors and dispatch error action', async () => { + const errorMessage = 'Failed to update'; + (DwApi.patchWorkspace as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + await expect( + store.dispatch(updateWorkspaceWithDefaultDevfile(mockWorkspace)), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions).toEqual([ + devWorkspacesRequestAction(), + devWorkspacesErrorAction( + `Failed to update the workspace ${mockWorkspace.metadata.name}, reason: ${errorMessage}`, + ), + ]); + }); + + it('should throw error if default devfile is not defined', async () => { + jest.spyOn(DevfileRegistries, 'selectDefaultDevfile').mockReturnValue(undefined); + + await expect( + store.dispatch(updateWorkspaceWithDefaultDevfile(mockWorkspace)), + ).rejects.toThrow('Cannot define default devfile'); + + const actions = store.getActions(); + expect(actions).toEqual([]); + }); + + it('should throw error if default editor is not defined', async () => { + jest.spyOn(serverConfig, 'selectDefaultEditor').mockReturnValue(''); + + await expect( + store.dispatch(updateWorkspaceWithDefaultDevfile(mockWorkspace)), + ).rejects.toThrow('Cannot define default editor'); + + const actions = store.getActions(); + expect(actions).toEqual([]); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromDevfile.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromDevfile.ts new file mode 100644 index 000000000..af32bf937 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromDevfile.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { dump } from 'js-yaml'; + +import { fetchResources } from '@/services/backend-client/devworkspaceResourcesApi'; +import devfileApi from '@/services/devfileApi'; +import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; +import { loadResourcesContent } from '@/services/registry/resources'; +import { + COMPONENT_UPDATE_POLICY, + REGISTRY_URL, +} from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { AppThunk } from '@/store'; +import { getEditor } from '@/store/DevfileRegistries/getEditor'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { actionCreators } from '@/store/Workspaces/devWorkspaces/actions'; +import { updateEditorDevfile } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'; +import { getCustomEditor } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/getCustomEditor'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const createWorkspaceFromDevfile = + ( + devfile: devfileApi.Devfile, + params: Partial, + optionalFilesContent: { + [fileName: string]: string; + }, + ): AppThunk => + async (dispatch, getState) => { + const state = getState(); + let devWorkspaceResource: devfileApi.DevWorkspace; + let devWorkspaceTemplateResource: devfileApi.DevWorkspaceTemplate; + let editorContent: string | undefined; + let editorYamlUrl: string | undefined; + // do we have an optional editor parameter ? + let editor = params.cheEditor; + if (editor) { + const response = await getEditor(editor, dispatch, getState); + if (response.content) { + editorContent = response.content; + editorYamlUrl = response.editorYamlUrl; + } else { + throw new Error(response.error); + } + } else { + // do we have the custom editor in `.che/che-editor.yaml` ? + try { + editorContent = await getCustomEditor(optionalFilesContent, dispatch, getState); + if (!editorContent) { + console.warn('No custom editor found'); + } + } catch (e) { + console.warn('Failed to get custom editor', e); + } + if (!editorContent) { + const defaultsEditor = state.dwServerConfig.config.defaults.editor; + if (!defaultsEditor) { + throw new Error('Cannot define default editor'); + } + const response = await getEditor(defaultsEditor, dispatch, getState); + if (response.content) { + editorContent = response.content; + editorYamlUrl = response.editorYamlUrl; + } else { + throw new Error(response.error); + } + editor = defaultsEditor; + console.debug(`Using default editor ${defaultsEditor}`); + } + } + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + editorContent = updateEditorDevfile(editorContent, params.editorImage); + const resourcesContent = await fetchResources({ + devfileContent: dump(devfile), + editorPath: undefined, + editorContent: editorContent, + }); + const resources = loadResourcesContent(resourcesContent); + devWorkspaceResource = resources.find( + resource => resource.kind === 'DevWorkspace', + ) as devfileApi.DevWorkspace; + if (devWorkspaceResource === undefined) { + throw new Error('Failed to find a DevWorkspace in the fetched resources.'); + } + if (devWorkspaceResource.metadata) { + if (!devWorkspaceResource.metadata.annotations) { + devWorkspaceResource.metadata.annotations = {}; + } + } + devWorkspaceTemplateResource = resources.find( + resource => resource.kind === 'DevWorkspaceTemplate', + ) as devfileApi.DevWorkspaceTemplate; + if (devWorkspaceTemplateResource === undefined) { + throw new Error('Failed to find a DevWorkspaceTemplate in the fetched resources.'); + } + if (editorYamlUrl && devWorkspaceTemplateResource.metadata) { + if (!devWorkspaceTemplateResource.metadata.annotations) { + devWorkspaceTemplateResource.metadata.annotations = {}; + } + devWorkspaceTemplateResource.metadata.annotations[COMPONENT_UPDATE_POLICY] = 'managed'; + devWorkspaceTemplateResource.metadata.annotations[REGISTRY_URL] = editorYamlUrl; + } + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + + await dispatch( + actionCreators.createWorkspaceFromResources( + devWorkspaceResource, + devWorkspaceTemplateResource, + params, + editor ? editor : editorContent, + ), + ); + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources.ts new file mode 100644 index 000000000..2d9e782a5 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources.ts @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { ApplicationId } from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; +import { AppThunk } from '@/store'; +import { selectApplications } from '@/store/ClusterInfo'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + selectDefaultEditor, + selectOpenVSXUrl, + selectPluginRegistryInternalUrl, + selectPluginRegistryUrl, +} from '@/store/ServerConfig'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { updateDevWorkspaceTemplate } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'; +import { + devWorkspacesAddAction, + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspaceWarningUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const createWorkspaceFromResources = + ( + devWorkspace: devfileApi.DevWorkspace, + devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate, + params: Partial, + editor?: string, + ): AppThunk => + async (dispatch, getState) => { + const state = getState(); + const defaultKubernetesNamespace = selectDefaultNamespace(state); + const openVSXUrl = selectOpenVSXUrl(state); + const pluginRegistryUrl = selectPluginRegistryUrl(state); + const pluginRegistryInternalUrl = selectPluginRegistryInternalUrl(state); + const cheEditor = editor ? editor : selectDefaultEditor(state); + const defaultNamespace = defaultKubernetesNamespace.name; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + /* create a new DevWorkspace */ + const createResp = await getDevWorkspaceClient().createDevWorkspace( + defaultNamespace, + devWorkspace, + cheEditor, + ); + + if (createResp.headers.warning) { + dispatch( + devWorkspaceWarningUpdateAction({ + warning: cleanupMessage(createResp.headers.warning), + workspace: createResp.devWorkspace, + }), + ); + } + + const clusterConsole = selectApplications(state).find( + app => app.id === ApplicationId.CLUSTER_CONSOLE, + ); + + devWorkspaceTemplate = updateDevWorkspaceTemplate(devWorkspaceTemplate, params.editorImage); + + /* create a new DevWorkspaceTemplate */ + + await getDevWorkspaceClient().createDevWorkspaceTemplate( + defaultNamespace, + createResp.devWorkspace, + devWorkspaceTemplate, + pluginRegistryUrl, + pluginRegistryInternalUrl, + openVSXUrl, + clusterConsole, + ); + + /* update the DevWorkspace */ + + const updateResp = await getDevWorkspaceClient().updateDevWorkspace(createResp.devWorkspace); + + if (updateResp.headers.warning) { + dispatch( + devWorkspaceWarningUpdateAction({ + warning: cleanupMessage(updateResp.headers.warning), + workspace: updateResp.devWorkspace, + }), + ); + } + + dispatch(devWorkspacesAddAction(updateResp.devWorkspace)); + } catch (e) { + const errorMessage = + 'Failed to create a new workspace, reason: ' + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; + +/** + * Get rid of the status code from the message. + */ +function cleanupMessage(message: string) { + return message.replace(/^\d+\s+?-\s+?/g, ''); +} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/handleWebSocketMessage.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/handleWebSocketMessage.ts new file mode 100644 index 000000000..2c809fb50 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/handleWebSocketMessage.ts @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; + +import { container } from '@/inversify.config'; +import { injectKubeConfig, podmanLogin } from '@/services/backend-client/devWorkspaceApi'; +import { WebsocketClient } from '@/services/backend-client/websocketClient'; +import devfileApi, { isDevWorkspace } from '@/services/devfileApi'; +import { DevWorkspaceStatus } from '@/services/helpers/types'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces'; +import { + actionCreators, + onStatusChangeCallbacks, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators'; +import { shouldUpdateDevWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + devWorkspacesAddAction, + devWorkspacesDeleteAction, + devWorkspacesUpdateAction, + devWorkspacesUpdateStartedAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const handleWebSocketMessage = + (message: api.webSocket.NotificationMessage): AppThunk => + async (dispatch, getState) => { + if (api.webSocket.isStatusMessage(message)) { + const { status } = message; + + const errorMessage = `WebSocket(DEV_WORKSPACE): status code ${status.code}, reason: ${status.message}`; + console.debug(errorMessage); + + if (status.code !== 200) { + /* in case of error status trying to fetch all devWorkspaces and re-subscribe to websocket channel */ + + const websocketClient = container.get(WebsocketClient); + + websocketClient.unsubscribeFromChannel(api.webSocket.Channel.DEV_WORKSPACE); + + await dispatch(actionCreators.requestWorkspaces()); + + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const namespace = defaultKubernetesNamespace.name; + const getResourceVersion = () => { + const state = getState(); + return state.devWorkspaces.resourceVersion; + }; + websocketClient.subscribeToChannel(api.webSocket.Channel.DEV_WORKSPACE, namespace, { + getResourceVersion, + }); + } + return; + } + + if (api.webSocket.isDevWorkspaceMessage(message)) { + const { eventPhase, devWorkspace } = message; + + if (isDevWorkspace(devWorkspace) === false) { + return; + } + + const workspace = devWorkspace as devfileApi.DevWorkspace; + + // previous state of the workspace is needed for notifying about workspace status changes. + const prevWorkspace = getState().devWorkspaces.workspaces.find( + w => WorkspaceAdapter.getId(w) === WorkspaceAdapter.getId(workspace), + ); + + // update the workspace in the store + switch (eventPhase) { + case api.webSocket.EventPhase.ADDED: + dispatch(devWorkspacesAddAction(workspace)); + break; + case api.webSocket.EventPhase.MODIFIED: + // update workspace only if it has newer resource version + if (shouldUpdateDevWorkspace(prevWorkspace, workspace)) { + dispatch(devWorkspacesUpdateAction(workspace)); + } + break; + case api.webSocket.EventPhase.DELETED: + dispatch(devWorkspacesDeleteAction(workspace)); + break; + default: + console.warn(`Unknown event phase in message: `, message); + } + if (shouldUpdateDevWorkspace(prevWorkspace, workspace)) { + // store workspace status only if the workspace has newer resource version + dispatch(devWorkspacesUpdateStartedAction([workspace])); + } + + // notify about workspace status changes + const devworkspaceId = workspace.status?.devworkspaceId; + const phase = workspace.status?.phase; + const prevPhase = prevWorkspace?.status?.phase; + const workspaceUID = WorkspaceAdapter.getUID(workspace); + if (shouldUpdateDevWorkspace(prevWorkspace, workspace) && phase && prevPhase !== phase) { + // notify about workspace status changes only if the workspace has newer resource version + const onStatusChangeListener = onStatusChangeCallbacks.get(workspaceUID); + + if (onStatusChangeListener) { + onStatusChangeListener(phase); + } + } + + if ( + phase === DevWorkspaceStatus.RUNNING && + phase !== prevPhase && + devworkspaceId !== undefined + ) { + try { + // inject the kube config + await injectKubeConfig(workspace.metadata.namespace, devworkspaceId); + // inject the 'podman login' + await podmanLogin(workspace.metadata.namespace, devworkspaceId); + } catch (e) { + console.error(e); + } + } + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers.ts new file mode 100644 index 000000000..209c0c8d4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { helpers } from '@eclipse-che/common'; + +import { container } from '@/inversify.config'; +import devfileApi, { isDevWorkspace } from '@/services/devfileApi'; +import { devWorkspaceKind } from '@/services/devfileApi/devWorkspace'; +import { compareStringsAsNumbers } from '@/services/helpers/resourceVersion'; +import { + DEVWORKSPACE_NEXT_START_ANNOTATION, + DevWorkspaceClient, +} from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { RootState } from '@/store'; +import { selectRunningWorkspacesLimit } from '@/store/ClusterConfig/selectors'; +import { throwRunningWorkspacesExceededError } from '@/store/Workspaces/devWorkspaces'; +import { selectRunningDevWorkspacesLimitExceeded } from '@/store/Workspaces/devWorkspaces/selectors'; + +/** + * This function is used to extract an error message from an error response. + */ +export function getWarningFromResponse(e: unknown): string | undefined { + if (!helpers.errors.includesAxiosResponse(e)) { + return; + } + + const response = e.response; + const attributes = response.data.attributes; + let provider = ''; + if (attributes !== undefined && attributes.provider !== undefined) { + const providerAttribute: string = attributes.provider; + if (providerAttribute.startsWith('github')) { + provider = 'GitHub'; + } else if (providerAttribute.startsWith('gitlab')) { + provider = 'Gitlab'; + } else if (providerAttribute.startsWith('bitbucket')) { + provider = 'Bitbucket'; + } + } + + if (provider.length > 0) { + // eslint-disable-next-line no-warning-comments + // TODO add status page url for each provider when https://github.com/eclipse-che/che/issues/23142 is fixed + return `${provider} might not be operational, please check the provider's status page.`; + } else { + return response.data.message; + } +} + +/** + * Update the DevWorkspace with the next start annotation if it exists. + */ +export async function checkDevWorkspaceNextStartAnnotation( + devWorkspaceClient: DevWorkspaceClient, + workspace: devfileApi.DevWorkspace, +) { + if (workspace.metadata.annotations?.[DEVWORKSPACE_NEXT_START_ANNOTATION]) { + const storedDevWorkspace = JSON.parse( + workspace.metadata.annotations[DEVWORKSPACE_NEXT_START_ANNOTATION], + ) as unknown; + if (!isDevWorkspace(storedDevWorkspace)) { + console.error( + `The stored DevWorkspace either has wrong "kind" (not ${devWorkspaceKind}) or lacks some of mandatory fields: `, + storedDevWorkspace, + ); + throw new Error( + 'Unexpected error happened. Please check the Console tab of Developer tools.', + ); + } + + delete workspace.metadata.annotations[DEVWORKSPACE_NEXT_START_ANNOTATION]; + workspace.spec.template = storedDevWorkspace.spec.template; + workspace.spec.started = false; + workspace = await devWorkspaceClient.update(workspace); + } +} + +export function checkRunningWorkspacesLimit(state: RootState) { + const runningLimitExceeded = selectRunningDevWorkspacesLimitExceeded(state); + if (runningLimitExceeded === false) { + return; + } + + const runningLimit = selectRunningWorkspacesLimit(state); + throwRunningWorkspacesExceededError(runningLimit); +} + +/** + * Get the DevWorkspaceClient from the container. This function is used to make it easier to mock the DevWorkspaceClient in tests. + */ +export function getDevWorkspaceClient(): DevWorkspaceClient { + return container.get(DevWorkspaceClient); +} + +/** + * Check if the devWorkspace should be updated based on the resource version. Prevents updating with older resource versions. + */ +export function shouldUpdateDevWorkspace( + prevDevWorkspace: devfileApi.DevWorkspace | undefined, + devWorkspace: devfileApi.DevWorkspace, +): boolean { + const prevResourceVersion = prevDevWorkspace?.metadata.resourceVersion; + const resourceVersion = devWorkspace.metadata.resourceVersion; + if (resourceVersion === undefined) { + return false; + } + + if (prevResourceVersion === undefined) { + return true; + } + if (compareStringsAsNumbers(prevResourceVersion, resourceVersion) < 0) { + return true; + } + return false; +} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/editorImage.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/editorImage.spec.ts similarity index 98% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/editorImage.spec.ts rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/editorImage.spec.ts index e4f741522..c577f7dcc 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/editorImage.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/editorImage.spec.ts @@ -20,7 +20,7 @@ import { getEditorImage, updateDevWorkspaceTemplate, updateEditorDevfile, -} from '@/store/Workspaces/devWorkspaces/editorImage'; +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'; describe('Update editor image', () => { describe('devfile source annotation', () => { diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devfile-without-components.yaml b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devfile-without-components.yaml similarity index 100% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devfile-without-components.yaml rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devfile-without-components.yaml diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devworkspace-template-with-custom-image.yaml b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devworkspace-template-with-custom-image.yaml similarity index 100% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devworkspace-template-with-custom-image.yaml rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devworkspace-template-with-custom-image.yaml diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devworkspace-template-without-components.yaml b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devworkspace-template-without-components.yaml similarity index 100% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devworkspace-template-without-components.yaml rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devworkspace-template-without-components.yaml diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devworkspace-template.yaml b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devworkspace-template.yaml similarity index 100% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-devworkspace-template.yaml rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-devworkspace-template.yaml diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-editor-devfile-with-custom-image.yaml b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-editor-devfile-with-custom-image.yaml similarity index 100% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-editor-devfile-with-custom-image.yaml rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-editor-devfile-with-custom-image.yaml diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-editor-devfile.yaml b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-editor-devfile.yaml similarity index 100% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/__tests__/fixtures/test-editor-devfile.yaml rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/fixtures/test-editor-devfile.yaml diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/getCustomEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/getCustomEditor.spec.ts new file mode 100644 index 000000000..340e5e596 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/getCustomEditor.spec.ts @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; +import { dump } from 'js-yaml'; + +import devfileApi from '@/services/devfileApi'; +import { CHE_EDITOR_YAML_PATH } from '@/services/workspace-client/helpers'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { getCustomEditor } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/getCustomEditor'; + +describe('Look for the custom editor', () => { + let optionalFilesContent: { [fileName: string]: string }; + let editor: devfileApi.Devfile; + + beforeEach(() => { + optionalFilesContent = {}; + editor = buildEditor(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined without optionalFilesContent', async () => { + const store = new MockStoreBuilder().build(); + const customEditor = await getCustomEditor( + optionalFilesContent, + store.dispatch, + store.getState, + ); + + expect(customEditor).toBeUndefined(); + }); + + describe('inlined editor', () => { + it('should return inlined editor without changes', async () => { + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ inline: editor }); + const store = new MockStoreBuilder().build(); + + const customEditor = await getCustomEditor( + optionalFilesContent, + store.dispatch, + store.getState, + ); + + expect(customEditor).toEqual(dump(editor)); + }); + + it('should return an overridden devfile', async () => { + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ + inline: editor, + override: { + containers: [ + { + name: 'eclipse-ide', + memoryLimit: '1234Mi', + }, + ], + }, + }); + const store = new MockStoreBuilder().build(); + + const customEditor = await getCustomEditor( + optionalFilesContent, + store.dispatch, + store.getState, + ); + + expect(customEditor).toEqual(expect.stringContaining('memoryLimit: 1234Mi')); + }); + + it('should throw the "missing metadata.name" error message', async () => { + // set an empty value as a name + editor.metadata.name = ''; + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ inline: editor }); + const store = new MockStoreBuilder().build(); + + let errorText: string | undefined; + + try { + await getCustomEditor(optionalFilesContent, store.dispatch, store.getState); + } catch (e) { + errorText = common.helpers.errors.getMessage(e); + } + + expect(errorText).toEqual( + 'Failed to analyze the editor devfile, reason: Missing metadata.name attribute in the editor yaml file.', + ); + }); + }); + + describe('get editor by id ', () => { + describe('from the default registry', () => { + it('should return an editor without changes', async () => { + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ + id: 'che-incubator/che-idea/next', + }); + + const editors = [ + { + schemaVersion: '2.1.0', + metadata: { + name: 'che-idea', + namespace: 'che', + attributes: { + publisher: 'che-incubator', + version: 'next', + }, + }, + components: [ + { + name: 'eclipse-ide', + container: { + image: 'docker.io/wsskeleton/eclipse-broadway', + mountSources: true, + memoryLimit: '2048M', + }, + }, + ], + } as devfileApi.Devfile, + ]; + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, editors, 'che-incubator/che-idea/next') + .build(); + + const customEditor = await getCustomEditor( + optionalFilesContent, + store.dispatch, + store.getState, + ); + + expect(customEditor).toEqual(dump(editor)); + }); + + it('should return an overridden devfile', async () => { + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ + id: 'che-incubator/che-idea/next', + override: { + containers: [ + { + name: 'eclipse-ide', + memoryLimit: '1234Mi', + }, + ], + }, + }); + + const editors = [ + { + schemaVersion: '2.1.0', + metadata: { + name: 'che-idea', + namespace: 'che', + attributes: { + publisher: 'che-incubator', + version: 'next', + }, + }, + components: [ + { + name: 'eclipse-ide', + container: { + image: 'docker.io/wsskeleton/eclipse-broadway', + mountSources: true, + memoryLimit: '2048M', + }, + }, + ], + } as devfileApi.Devfile, + ]; + + const store = new MockStoreBuilder() + .withDevfileRegistries({ + devfiles: { + ['https://dummy-plugin-registry/plugins/che-incubator/che-idea/next/devfile.yaml']: { + content: dump(editor), + }, + }, + }) + .withDwPlugins({}, {}, false, editors, 'che-incubator/che-idea/next') + .build(); + + const customEditor = await getCustomEditor( + optionalFilesContent, + store.dispatch, + store.getState, + ); + + expect(customEditor).toEqual(expect.stringContaining('memoryLimit: 1234Mi')); + }); + + it('should failed fetching editor without metadata.name attribute', async () => { + // set an empty value as a name + editor.metadata.name = ''; + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ + id: 'che-incubator/che-idea/next', + }); + + const editors = [ + { + schemaVersion: '2.1.0', + metadata: { + namespace: 'che', + attributes: { + publisher: 'che-incubator', + version: 'next', + }, + }, + components: [ + { + name: 'eclipse-ide', + container: { + image: 'docker.io/wsskeleton/eclipse-broadway', + mountSources: true, + memoryLimit: '2048M', + }, + }, + ], + } as devfileApi.Devfile, + ]; + + const store = new MockStoreBuilder() + .withDevfileRegistries({ + devfiles: { + ['https://dummy-plugin-registry/plugins/che-incubator/che-idea/next/devfile.yaml']: { + content: dump(editor), + }, + }, + }) + .withDwPlugins({}, {}, false, editors, 'che-incubator/che-idea/next') + .build(); + + let errorText: string | undefined; + try { + await getCustomEditor(optionalFilesContent, store.dispatch, store.getState); + } catch (e) { + errorText = common.helpers.errors.getMessage(e); + } + + expect(errorText).toEqual( + 'Failed to fetch editor yaml by id: che-incubator/che-idea/next.', + ); + }); + }); + + describe('from the custom registry', () => { + it('should return an editor without changes', async () => { + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ + id: 'che-incubator/che-idea/next', + registryUrl: 'https://dummy/che-plugin-registry/main/v3', + }); + const store = new MockStoreBuilder() + .withDevfileRegistries({ + devfiles: { + ['https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml']: + { + content: dump(editor), + }, + }, + }) + .build(); + + const customEditor = await getCustomEditor( + optionalFilesContent, + store.dispatch, + store.getState, + ); + + expect(customEditor).toEqual(dump(editor)); + }); + + it('should return an overridden devfile', async () => { + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ + id: 'che-incubator/che-idea/next', + registryUrl: 'https://dummy/che-plugin-registry/main/v3', + override: { + containers: [ + { + name: 'eclipse-ide', + memoryLimit: '1234Mi', + }, + ], + }, + }); + const store = new MockStoreBuilder() + .withDevfileRegistries({ + devfiles: { + ['https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml']: + { + content: dump(editor), + }, + }, + }) + .build(); + + const customEditor = await getCustomEditor( + optionalFilesContent, + store.dispatch, + store.getState, + ); + + expect(customEditor).toEqual(expect.stringContaining('memoryLimit: 1234Mi')); + }); + + it('should throw the "missing metadata.name" error message', async () => { + // set an empty value as a name + editor.metadata.name = ''; + optionalFilesContent[CHE_EDITOR_YAML_PATH] = dump({ + id: 'che-incubator/che-idea/next', + registryUrl: 'https://dummy/che-plugin-registry/main/v3', + }); + const store = new MockStoreBuilder() + .withDevfileRegistries({ + devfiles: { + ['https://dummy/che-plugin-registry/main/v3/plugins/che-incubator/che-idea/next/devfile.yaml']: + { + content: dump(editor), + }, + }, + }) + .build(); + + let errorText: string | undefined; + try { + await getCustomEditor(optionalFilesContent, store.dispatch, store.getState); + } catch (e) { + errorText = common.helpers.errors.getMessage(e); + } + + expect(errorText).toEqual( + 'Failed to analyze the editor devfile, reason: Missing metadata.name attribute in the editor yaml file.', + ); + }); + }); + }); +}); + +function buildEditor(): devfileApi.Devfile { + return { + schemaVersion: '2.1.0', + metadata: { + name: 'che-idea', + namespace: 'che', + attributes: { + publisher: 'che-incubator', + version: 'next', + }, + }, + components: [ + { + name: 'eclipse-ide', + container: { + image: 'docker.io/wsskeleton/eclipse-broadway', + mountSources: true, + memoryLimit: '2048M', + }, + }, + ], + }; +} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/updateEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/updateEditor.spec.ts new file mode 100644 index 000000000..34e603f8f --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/__tests__/updateEditor.spec.ts @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ApplicationId } from '@eclipse-che/common'; + +import { container } from '@/inversify.config'; +import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; +import devfileApi from '@/services/devfileApi'; +import { che } from '@/services/models'; +import { DevWorkspaceClient } from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { RootState } from '@/store'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import * as clusterInfo from '@/store/ClusterInfo'; +import * as infrastructureNamespace from '@/store/InfrastructureNamespaces'; +import { selectDwEditorsPluginsList } from '@/store/Plugins/devWorkspacePlugins'; +import * as serverConfig from '@/store/ServerConfig'; +import { + getEditorName, + getLifeTimeMs, + updateEditor, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/inversify.config'); +jest.mock('@/services/backend-client/devWorkspaceTemplateApi'); +jest.mock('@/services/devfileApi'); +jest.mock('@/services/workspace-client/devworkspace/devWorkspaceClient'); +jest.mock('@/store/ClusterInfo/selectors'); +jest.mock('@/store/InfrastructureNamespaces/selectors'); +jest.mock('@/store/Plugins/devWorkspacePlugins/selectors'); +jest.mock('@/store/ServerConfig/selectors'); + +describe('updateEditor', () => { + let mockDevWorkspaceClient: DevWorkspaceClient; + let mockPatchTemplate: jest.Mock; + + beforeEach(() => { + mockDevWorkspaceClient = { + checkForTemplatesUpdate: jest.fn(), + } as unknown as DevWorkspaceClient; + mockPatchTemplate = jest.fn(); + + (container.get as jest.Mock).mockReturnValue(mockDevWorkspaceClient); + (DwtApi.patchTemplate as jest.Mock).mockImplementation(mockPatchTemplate); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should update editor if updates are available', async () => { + const editorName = 'test-editor'; + const namespace = 'test-namespace'; + const state = { + dwPlugins: { + defaultEditorName: 'default-editor', + cmEditors: ['editor1', 'editor2'] as unknown as devfileApi.Devfile[], + }, + } as Partial as RootState; + const store = createMockStore(state); + + jest + .spyOn(infrastructureNamespace, 'selectDefaultNamespace') + .mockReturnValue({ name: namespace } as che.KubernetesNamespace); + + (selectDwEditorsPluginsList as jest.Mock).mockReturnValue(() => [ + { url: 'plugin-url', devfile: {} }, + ]); + + jest.spyOn(serverConfig, 'selectOpenVSXUrl').mockReturnValue('https://openvsx.org'); + + jest + .spyOn(serverConfig, 'selectPluginRegistryUrl') + .mockReturnValue('https://plugin-registry.com'); + + jest + .spyOn(serverConfig, 'selectPluginRegistryInternalUrl') + .mockReturnValue('https://internal-plugin-registry.com'); + + jest.spyOn(clusterInfo, 'selectApplications').mockReturnValue([ + { + id: ApplicationId.CLUSTER_CONSOLE, + url: 'https://cluster-console.com', + icon: '', + title: 'Console', + }, + ]); + + (mockDevWorkspaceClient.checkForTemplatesUpdate as jest.Mock).mockResolvedValue(['update1']); + + await updateEditor(editorName, store.getState); + + expect(mockDevWorkspaceClient.checkForTemplatesUpdate).toHaveBeenCalledWith( + editorName, + namespace, + ['editor1', 'editor2'], + 'https://plugin-registry.com', + 'https://internal-plugin-registry.com', + 'https://openvsx.org', + { + id: ApplicationId.CLUSTER_CONSOLE, + url: 'https://cluster-console.com', + icon: '', + title: 'Console', + }, + ); + + expect(mockPatchTemplate).toHaveBeenCalledWith(namespace, editorName, ['update1']); + }); + + it('should not update editor if no updates are available', async () => { + const editorName = 'test-editor'; + const namespace = 'test-namespace'; + const state = { + dwPlugins: { + defaultEditorName: 'default-editor', + cmEditors: [] as unknown, + }, + } as Partial as RootState; + const store = createMockStore(state); + + jest + .spyOn(infrastructureNamespace, 'selectDefaultNamespace') + .mockReturnValue({ name: namespace } as che.KubernetesNamespace); + + (selectDwEditorsPluginsList as jest.Mock).mockReturnValue(() => []); + + jest.spyOn(serverConfig, 'selectOpenVSXUrl').mockReturnValue('https://openvsx.org'); + + jest + .spyOn(serverConfig, 'selectPluginRegistryUrl') + .mockReturnValue('https://plugin-registry.com'); + + jest + .spyOn(serverConfig, 'selectPluginRegistryInternalUrl') + .mockReturnValue('https://internal-plugin-registry.com'); + + jest.spyOn(clusterInfo, 'selectApplications').mockReturnValue([]); + + (mockDevWorkspaceClient.checkForTemplatesUpdate as jest.Mock).mockResolvedValue([]); + + await updateEditor(editorName, store.getState); + + expect(mockDevWorkspaceClient.checkForTemplatesUpdate).toHaveBeenCalled(); + expect(mockPatchTemplate).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const editorName = 'test-editor'; + const namespace = 'test-namespace'; + const state = { + dwPlugins: { + defaultEditorName: 'default-editor', + cmEditors: ['editor1'] as unknown[], + }, + } as Partial as RootState; + const store = createMockStore(state); + + jest + .spyOn(infrastructureNamespace, 'selectDefaultNamespace') + .mockReturnValue({ name: namespace } as che.KubernetesNamespace); + + (selectDwEditorsPluginsList as jest.Mock).mockReturnValue(() => [ + { url: 'plugin-url', devfile: {} }, + ]); + + jest.spyOn(serverConfig, 'selectOpenVSXUrl').mockReturnValue('https://openvsx.org'); + + jest + .spyOn(serverConfig, 'selectPluginRegistryUrl') + .mockReturnValue('https://plugin-registry.com'); + + jest + .spyOn(serverConfig, 'selectPluginRegistryInternalUrl') + .mockReturnValue('https://internal-plugin-registry.com'); + + jest.spyOn(clusterInfo, 'selectApplications').mockReturnValue([]); + + const error = new Error('Test error'); + (mockDevWorkspaceClient.checkForTemplatesUpdate as jest.Mock).mockRejectedValue(error); + console.error = jest.fn(); + + await updateEditor(editorName, store.getState); + + expect(mockDevWorkspaceClient.checkForTemplatesUpdate).toHaveBeenCalled(); + expect(mockPatchTemplate).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(error); + }); +}); + +describe('getEditorName', () => { + it('should return undefined if contributions are empty', () => { + const workspace = { + spec: { + contributions: [] as unknown[], + }, + } as devfileApi.DevWorkspace; + + const result = getEditorName(workspace); + expect(result).toBeUndefined(); + }); + + it('should return undefined if contributions are undefined', () => { + const workspace = { + spec: {}, + } as devfileApi.DevWorkspace; + + const result = getEditorName(workspace); + expect(result).toBeUndefined(); + }); + + it('should return editor name if contribution named "editor" exists', () => { + const workspace = { + spec: { + contributions: [ + { + name: 'editor', + kubernetes: { + name: 'code-editor', + }, + }, + ], + }, + } as devfileApi.DevWorkspace; + + const result = getEditorName(workspace); + expect(result).toBe('code-editor'); + }); + + it('should return undefined if "editor" contribution does not have kubernetes name', () => { + const workspace = { + spec: { + contributions: [ + { + name: 'editor', + kubernetes: {}, + }, + ], + }, + } as devfileApi.DevWorkspace; + + const result = getEditorName(workspace); + expect(result).toBeUndefined(); + }); + + it('should return undefined if there is no contribution named "editor"', () => { + const workspace = { + spec: { + contributions: [ + { + name: 'some-other-contribution', + kubernetes: { + name: 'other-editor', + }, + }, + ], + }, + } as devfileApi.DevWorkspace; + + const result = getEditorName(workspace); + expect(result).toBeUndefined(); + }); +}); + +describe('getLifeTimeMs', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2023-01-01T12:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return correct lifetime in milliseconds', () => { + const workspace = { + metadata: { + creationTimestamp: '2023-01-01T11:00:00Z' as unknown, + }, + } as devfileApi.DevWorkspace; + + const result = getLifeTimeMs(workspace); + expect(result).toBe(3600000); // 1 hour in milliseconds + }); + + it('should return 0 if creationTimestamp is undefined', () => { + const workspace = { + metadata: {}, + } as devfileApi.DevWorkspace; + + const result = getLifeTimeMs(workspace); + expect(result).toBe(0); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/editorImage.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage.ts similarity index 100% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/editorImage.ts rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage.ts diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/getCustomEditor.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/getCustomEditor.ts new file mode 100644 index 000000000..0514f2368 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/getCustomEditor.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; +import { dump, load } from 'js-yaml'; + +import devfileApi, { isDevfileV2 } from '@/services/devfileApi'; +import { ICheEditorYaml } from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { CHE_EDITOR_YAML_PATH } from '@/services/workspace-client/helpers'; +import { RootState } from '@/store'; +import { getEditor } from '@/store/DevfileRegistries/getEditor'; + +/** + * Look for the custom editor in .che/che-editor.yaml + */ +export async function getCustomEditor( + optionalFilesContent: { [fileName: string]: string }, + dispatch: ThunkDispatch, + getState: () => RootState, +): Promise { + let editorsDevfile: devfileApi.Devfile | undefined; + + // do we have a custom editor specified in the repository ? + const cheEditorYaml = optionalFilesContent[CHE_EDITOR_YAML_PATH] + ? (load(optionalFilesContent[CHE_EDITOR_YAML_PATH]) as ICheEditorYaml) + : undefined; + + if (cheEditorYaml) { + // check the content of cheEditor file + console.debug('Using the repository .che/che-editor.yaml file', cheEditorYaml); + + let repositoryEditorYaml: devfileApi.Devfile | undefined; + let editorReference: string | undefined; + // it's an inlined editor, use the inline content + if (cheEditorYaml.inline) { + console.debug('Using the inline content of the repository editor'); + repositoryEditorYaml = cheEditorYaml.inline; + } else if (cheEditorYaml.id) { + // load the content of this editor + console.debug(`Loading editor from its id ${cheEditorYaml.id}`); + + // registryUrl ? + if (cheEditorYaml.registryUrl) { + editorReference = `${cheEditorYaml.registryUrl}/plugins/${cheEditorYaml.id}/devfile.yaml`; + } else { + editorReference = cheEditorYaml.id; + } + } else if (cheEditorYaml.reference) { + // load the content of this editor + console.debug(`Loading editor from reference ${cheEditorYaml.reference}`); + editorReference = cheEditorYaml.reference; + } + if (editorReference) { + const response = await getEditor(editorReference, dispatch, getState); + if (response.content) { + const yaml = load(response.content); + repositoryEditorYaml = isDevfileV2(yaml) ? yaml : undefined; + } else { + throw new Error(response.error); + } + } + + // if there are some overrides, apply them + if (cheEditorYaml.override) { + console.debug(`Applying overrides ${JSON.stringify(cheEditorYaml.override)}...`); + cheEditorYaml.override.containers?.forEach(container => { + // search matching component + const matchingComponent = repositoryEditorYaml?.components + ? repositoryEditorYaml.components.find(component => component.name === container.name) + : undefined; + if (matchingComponent?.container) { + // apply overrides except the name + Object.keys(container).forEach(property => { + if (matchingComponent.container?.[property] && property !== 'name') { + console.debug( + `Updating property from ${matchingComponent.container[property]} to ${container[property]}`, + ); + matchingComponent.container[property] = container[property]; + } + }); + } + }); + } + + if (!repositoryEditorYaml) { + throw new Error( + 'Failed to analyze the editor devfile inside the repository, reason: Missing id, reference or inline content.', + ); + } + // Use the repository defined editor + editorsDevfile = repositoryEditorYaml; + } + + if (editorsDevfile) { + if (!editorsDevfile.metadata || !editorsDevfile.metadata.name) { + throw new Error( + 'Failed to analyze the editor devfile, reason: Missing metadata.name attribute in the editor yaml file.', + ); + } + return dump(editorsDevfile); + } + + return undefined; +} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/updateEditor.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor.ts similarity index 95% rename from packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/updateEditor.ts rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor.ts index ce7a4b1c9..87eab7001 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/updateEditor.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor.ts @@ -16,7 +16,7 @@ import { container } from '@/inversify.config'; import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; import devfileApi from '@/services/devfileApi'; import { DevWorkspaceClient } from '@/services/workspace-client/devworkspace/devWorkspaceClient'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectApplications } from '@/store/ClusterInfo/selectors'; import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; import { selectDwEditorsPluginsList } from '@/store/Plugins/devWorkspacePlugins/selectors'; @@ -26,9 +26,9 @@ import { selectPluginRegistryUrl, } from '@/store/ServerConfig/selectors'; -const devWorkspaceClient = container.get(DevWorkspaceClient); +export async function updateEditor(editorName: string, getState: () => RootState): Promise { + const devWorkspaceClient = container.get(DevWorkspaceClient); -export async function updateEditor(editorName: string, getState: () => AppState): Promise { const state = getState(); const namespace = selectDefaultNamespace(state).name; const pluginsByUrl: { [url: string]: devfileApi.Devfile } = {}; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/index.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/index.ts new file mode 100644 index 000000000..5ae57a40b --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/index.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createWorkspaceFromDevfile } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromDevfile'; +import { createWorkspaceFromResources } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources'; +import { handleWebSocketMessage } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/handleWebSocketMessage'; +import { requestWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspace'; +import { requestWorkspaces } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspaces'; +import { restartWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/restartWorkspace'; +import { startWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace'; +import { stopWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/stopWorkspace'; +import { terminateWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/terminateWorkspace'; +import { updateWorkspace } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspace'; +import { updateWorkspaceAnnotation } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceAnnotation'; +import { updateWorkspaceWithDefaultDevfile } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceWithDefaultDevfile'; + +export const onStatusChangeCallbacks = new Map void>(); + +export const actionCreators = { + requestWorkspaces, + requestWorkspace, + startWorkspace, + restartWorkspace, + stopWorkspace, + terminateWorkspace, + updateWorkspaceAnnotation, + updateWorkspace, + createWorkspaceFromResources, + updateWorkspaceWithDefaultDevfile, + createWorkspaceFromDevfile, + handleWebSocketMessage, +}; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspace.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspace.ts new file mode 100644 index 000000000..460a6f2d3 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspace.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION } from '@/services/devfileApi/devWorkspace/metadata'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { actionCreators } from '@/store/Workspaces/devWorkspaces/actions'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const requestWorkspace = + (workspace: devfileApi.DevWorkspace): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + const namespace = workspace.metadata.namespace; + const name = workspace.metadata.name; + const received = await getDevWorkspaceClient().getWorkspaceByName(namespace, name); + dispatch(devWorkspacesUpdateAction(received)); + + if ( + received.metadata.annotations?.[DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION] === undefined + ) { + // this will set updating timestamp to annotations and update the workspace + await dispatch(actionCreators.updateWorkspace(received)); + } + } catch (e) { + const errorMessage = + `Failed to fetch the workspace ${workspace.metadata.name}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspaces.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspaces.ts new file mode 100644 index 000000000..82cdbc780 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/requestWorkspaces.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import { DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION } from '@/services/devfileApi/devWorkspace/metadata'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { actionCreators } from '@/store/Workspaces/devWorkspaces/actions'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + devWorkspacesErrorAction, + devWorkspacesReceiveAction, + devWorkspacesRequestAction, + devWorkspacesUpdateStartedAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const requestWorkspaces = (): AppThunk => async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + const defaultKubernetesNamespace = selectDefaultNamespace(getState()); + const defaultNamespace = defaultKubernetesNamespace.name; + const { workspaces, resourceVersion } = defaultNamespace + ? await getDevWorkspaceClient().getAllWorkspaces(defaultNamespace) + : { + workspaces: [], + resourceVersion: '', + }; + + dispatch(devWorkspacesReceiveAction({ workspaces, resourceVersion })); + dispatch(devWorkspacesUpdateStartedAction(workspaces)); + + const promises = workspaces + .filter( + workspace => + workspace.metadata.annotations?.[DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION] === + undefined, + ) + .map(async workspace => { + // this will set updating timestamp to annotations and update the workspace + await dispatch(actionCreators.updateWorkspace(workspace)); + }); + await Promise.allSettled(promises); + } catch (e) { + const errorMessage = + 'Failed to fetch available workspaces, reason: ' + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } +}; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/restartWorkspace.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/restartWorkspace.ts new file mode 100644 index 000000000..97a798d36 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/restartWorkspace.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import devfileApi from '@/services/devfileApi'; +import { getDefer, IDeferred } from '@/services/helpers/deferred'; +import { DisposableCollection } from '@/services/helpers/disposable'; +import { DevWorkspaceStatus } from '@/services/helpers/types'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { AppThunk } from '@/store'; +import { actionCreators, onStatusChangeCallbacks } from '@/store/Workspaces/devWorkspaces/actions'; + +export const restartWorkspace = + (workspace: devfileApi.DevWorkspace): AppThunk => + async dispatch => { + const defer: IDeferred = getDefer(); + const toDispose = new DisposableCollection(); + + async function handleWorkspaceStatus(status: string) { + if (status === DevWorkspaceStatus.STOPPED || status === DevWorkspaceStatus.FAILED) { + toDispose.dispose(); + try { + await dispatch(actionCreators.startWorkspace(workspace)); + defer.resolve(); + } catch (e) { + defer.reject( + new Error(`Failed to restart the workspace ${workspace.metadata.name}. ${e}`), + ); + } + } + } + + if ( + workspace.status?.phase === DevWorkspaceStatus.STOPPED || + workspace.status?.phase === DevWorkspaceStatus.FAILED + ) { + await handleWorkspaceStatus(workspace.status.phase); + } else { + const workspaceUID = WorkspaceAdapter.getUID(workspace); + onStatusChangeCallbacks.set(workspaceUID, handleWorkspaceStatus); + toDispose.push({ + dispose: () => onStatusChangeCallbacks.delete(workspaceUID), + }); + if ( + workspace.status?.phase === DevWorkspaceStatus.RUNNING || + workspace.status?.phase === DevWorkspaceStatus.STARTING || + workspace.status?.phase === DevWorkspaceStatus.FAILING + ) { + try { + await dispatch(actionCreators.stopWorkspace(workspace)); + } catch (e) { + defer.reject( + new Error(`Failed to restart the workspace ${workspace.metadata.name}. ${e}`), + ); + } + } + } + + return defer.promise; + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace.ts new file mode 100644 index 000000000..49a25c121 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_CHE_EDITOR } from '@/services/devfileApi/devWorkspace/metadata'; +import { isOAuthResponse, OAuthService } from '@/services/oauth'; +import { AppThunk } from '@/store'; +import { + checkRunningDevWorkspacesClusterLimitExceeded, + devWorkspacesClusterActionCreators, +} from '@/store/DevWorkspacesCluster'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + checkDevWorkspaceNextStartAnnotation, + checkRunningWorkspacesLimit, + getDevWorkspaceClient, + getWarningFromResponse, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + getEditorName, + getLifeTimeMs, + updateEditor, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, + devWorkspaceWarningUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const startWorkspace = + (_workspace: devfileApi.DevWorkspace, debugWorkspace = false): AppThunk => + async (dispatch, getState) => { + let workspace = getState().devWorkspaces.workspaces.find( + w => w.metadata.uid === _workspace.metadata.uid, + ); + if (workspace === undefined) { + console.warn(`Can't find the target workspace ${_workspace.metadata.name}`); + return; + } + if (workspace.spec.started) { + console.warn(`Workspace ${_workspace.metadata.name} already started`); + return; + } + + try { + await verifyAuthorized(dispatch, getState); + + try { + await OAuthService.refreshTokenIfProjectExists(workspace); + } catch (e: unknown) { + // Do not interrupt the workspace start, but show a warning notification. + + const warnMessage = getWarningFromResponse(e); + if (warnMessage) { + dispatch(devWorkspaceWarningUpdateAction({ workspace, warning: warnMessage })); + } + } + + await dispatch( + devWorkspacesClusterActionCreators.requestRunningDevWorkspacesClusterLimitExceeded(), + ); + + dispatch(devWorkspacesRequestAction()); + + checkRunningDevWorkspacesClusterLimitExceeded(getState()); + checkRunningWorkspacesLimit(getState()); + + await checkDevWorkspaceNextStartAnnotation(getDevWorkspaceClient(), workspace); + + const config = getState().dwServerConfig.config; + workspace = await getDevWorkspaceClient().managePvcStrategy(workspace, config); + + // inject or remove the container build attribute + workspace = await getDevWorkspaceClient().manageContainerBuildAttribute(workspace, config); + + workspace = await getDevWorkspaceClient().manageDebugMode(workspace, debugWorkspace); + + const editorName = getEditorName(workspace); + const lifeTimeMs = getLifeTimeMs(workspace); + if (editorName && lifeTimeMs > 30000) { + await updateEditor(editorName, getState); + } + + const startingWorkspace = await getDevWorkspaceClient().changeWorkspaceStatus( + workspace, + true, + true, + ); + + const editor = startingWorkspace.metadata.annotations + ? startingWorkspace.metadata.annotations[DEVWORKSPACE_CHE_EDITOR] + : undefined; + const defaultPlugins = getState().dwPlugins.defaultPlugins; + await getDevWorkspaceClient().onStart(startingWorkspace, defaultPlugins, editor); + dispatch(devWorkspacesUpdateAction(startingWorkspace)); + } catch (e) { + // Skip authorization errors. The page is redirecting to an SCM authentication page. + if (common.helpers.errors.includesAxiosResponse(e) && isOAuthResponse(e.response.data)) { + return; + } + const errorMessage = + `Failed to start the workspace ${workspace.metadata.name}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + + throw e; + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/stopWorkspace.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/stopWorkspace.ts new file mode 100644 index 000000000..4c83b381b --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/stopWorkspace.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { devWorkspacesErrorAction } from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const stopWorkspace = + (workspace: devfileApi.DevWorkspace): AppThunk => + async (dispatch, getState): Promise => { + try { + await verifyAuthorized(dispatch, getState); + + await getDevWorkspaceClient().changeWorkspaceStatus(workspace, false); + } catch (e) { + const errorMessage = + `Failed to stop the workspace ${workspace.metadata.name}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/terminateWorkspace.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/terminateWorkspace.ts new file mode 100644 index 000000000..d35b3c024 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/terminateWorkspace.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesTerminateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const terminateWorkspace = + (workspace: devfileApi.DevWorkspace): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + const namespace = workspace.metadata.namespace; + const name = workspace.metadata.name; + await getDevWorkspaceClient().delete(namespace, name); + const workspaceUID = WorkspaceAdapter.getUID(workspace); + dispatch( + devWorkspacesTerminateAction({ + workspaceUID, + message: workspace.status?.message || 'Cleaning up resources for deletion', + }), + ); + } catch (e) { + const errorMessage = + `Failed to delete the workspace ${workspace.metadata.name}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspace.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspace.ts new file mode 100644 index 000000000..2d7c2ccae --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspace.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + getDevWorkspaceClient, + shouldUpdateDevWorkspace, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const updateWorkspace = + (workspace: devfileApi.DevWorkspace): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + const updated = await getDevWorkspaceClient().update(workspace); + + const prevWorkspace = getState().devWorkspaces.workspaces.find( + w => WorkspaceAdapter.getId(w) === WorkspaceAdapter.getId(updated), + ); + + dispatch( + devWorkspacesUpdateAction( + shouldUpdateDevWorkspace(prevWorkspace, updated) ? updated : undefined, + ), + ); + } catch (e) { + const errorMessage = + `Failed to update the workspace ${workspace.metadata.name}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceAnnotation.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceAnnotation.ts new file mode 100644 index 000000000..7cb0b44a9 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceAnnotation.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { AppThunk } from '@/store'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const updateWorkspaceAnnotation = + (workspace: devfileApi.DevWorkspace): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + const updated = await getDevWorkspaceClient().updateAnnotation(workspace); + dispatch(devWorkspacesUpdateAction(updated)); + } catch (e) { + const errorMessage = + `Failed to update the workspace ${workspace.metadata.name}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceWithDefaultDevfile.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceWithDefaultDevfile.ts new file mode 100644 index 000000000..679e5e998 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/updateWorkspaceWithDefaultDevfile.ts @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common, { api, ApplicationId } from '@eclipse-che/common'; +import { dump } from 'js-yaml'; + +import * as DwApi from '@/services/backend-client/devWorkspaceApi'; +import { fetchResources } from '@/services/backend-client/devworkspaceResourcesApi'; +import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; +import devfileApi from '@/services/devfileApi'; +import { + DEVWORKSPACE_CHE_EDITOR, + DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION, +} from '@/services/devfileApi/devWorkspace/metadata'; +import { loadResourcesContent } from '@/services/registry/resources'; +import { + COMPONENT_UPDATE_POLICY, + REGISTRY_URL, +} from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { AppThunk } from '@/store'; +import { selectApplications } from '@/store/ClusterInfo'; +import { selectDefaultDevfile } from '@/store/DevfileRegistries'; +import { getEditor } from '@/store/DevfileRegistries/getEditor'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { + selectDefaultEditor, + selectOpenVSXUrl, + selectPluginRegistryInternalUrl, + selectPluginRegistryUrl, +} from '@/store/ServerConfig'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { + getEditorImage, + updateEditorDevfile, +} from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/editorImage'; +import { getEditorName } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const updateWorkspaceWithDefaultDevfile = + (workspace: devfileApi.DevWorkspace): AppThunk => + async (dispatch, getState) => { + const state = getState(); + const defaultsDevfile = selectDefaultDevfile(state); + if (!defaultsDevfile) { + throw new Error('Cannot define default devfile'); + } + const defaultsEditor = selectDefaultEditor(state); + if (!defaultsEditor) { + throw new Error('Cannot define default editor'); + } + const openVSXUrl = selectOpenVSXUrl(state); + const pluginRegistryUrl = selectPluginRegistryUrl(state); + const pluginRegistryInternalUrl = selectPluginRegistryInternalUrl(state); + const clusterConsole = selectApplications(state).find( + app => app.id === ApplicationId.CLUSTER_CONSOLE, + ); + + let editorContent: string | undefined; + let editorYamlUrl: string | undefined; + let devWorkspaceResource: devfileApi.DevWorkspace; + let devWorkspaceTemplateResource: devfileApi.DevWorkspaceTemplate; + + try { + await verifyAuthorized(dispatch, getState); + + dispatch(devWorkspacesRequestAction()); + + const response = await getEditor(defaultsEditor, dispatch, getState); + if (response.content) { + editorContent = response.content; + editorYamlUrl = response.editorYamlUrl; + } else { + throw new Error(response.error); + } + console.debug(`Using default editor ${defaultsEditor}`); + + defaultsDevfile.metadata.name = workspace.metadata.name; + delete defaultsDevfile.metadata.generateName; + + const editorImage = getEditorImage(workspace); + if (editorImage) { + editorContent = updateEditorDevfile(editorContent, editorImage); + } + const resourcesContent = await fetchResources({ + devfileContent: dump(defaultsDevfile), + editorPath: undefined, + editorContent, + }); + const resources = loadResourcesContent(resourcesContent); + devWorkspaceResource = resources.find( + resource => resource.kind === 'DevWorkspace', + ) as devfileApi.DevWorkspace; + if (devWorkspaceResource === undefined) { + throw new Error('Failed to find a DevWorkspace in the fetched resources.'); + } + if (devWorkspaceResource.metadata) { + if (!devWorkspaceResource.metadata.annotations) { + devWorkspaceResource.metadata.annotations = {}; + } + } + if (!devWorkspaceResource.spec.routingClass) { + devWorkspaceResource.spec.routingClass = 'che'; + } + devWorkspaceResource.spec.started = false; + + getDevWorkspaceClient().addEnvVarsToContainers( + devWorkspaceResource.spec.template.components, + pluginRegistryUrl, + pluginRegistryInternalUrl, + openVSXUrl, + clusterConsole, + ); + if (!devWorkspaceResource.metadata.annotations) { + devWorkspaceResource.metadata.annotations = {}; + } + devWorkspaceResource.spec.contributions = workspace.spec.contributions; + + // add projects from the origin workspace + devWorkspaceResource.spec.template.projects = workspace.spec.template.projects; + + devWorkspaceTemplateResource = resources.find( + resource => resource.kind === 'DevWorkspaceTemplate', + ) as devfileApi.DevWorkspaceTemplate; + if (devWorkspaceTemplateResource === undefined) { + throw new Error('Failed to find a DevWorkspaceTemplate in the fetched resources.'); + } + if (!devWorkspaceTemplateResource.metadata.annotations) { + devWorkspaceTemplateResource.metadata.annotations = {}; + } + + // removes endpoints with 'urlRewriteSupport: false' + const components = devWorkspaceTemplateResource.spec?.components || []; + components.forEach(component => { + if (component.container && Array.isArray(component.container.endpoints)) { + component.container.endpoints = component.container.endpoints.filter(endpoint => { + const attributes = endpoint.attributes as { urlRewriteSupported: boolean }; + return attributes.urlRewriteSupported; + }); + } + }); + + if (editorYamlUrl) { + devWorkspaceTemplateResource.metadata.annotations[COMPONENT_UPDATE_POLICY] = 'managed'; + devWorkspaceTemplateResource.metadata.annotations[REGISTRY_URL] = editorYamlUrl; + } + + getDevWorkspaceClient().addEnvVarsToContainers( + devWorkspaceTemplateResource.spec?.components, + pluginRegistryUrl, + pluginRegistryInternalUrl, + openVSXUrl, + clusterConsole, + ); + let targetTemplate: devfileApi.DevWorkspaceTemplate | undefined; + const templateName = getEditorName(workspace); + const templateNamespace = workspace.metadata.namespace; + if (templateName && templateNamespace) { + targetTemplate = await DwtApi.getTemplateByName(templateNamespace, templateName); + } + if (!templateName || !templateNamespace || !targetTemplate) { + throw new Error('Cannot define the target template'); + } + + const targetTemplatePatch: api.IPatch[] = []; + if (targetTemplate.metadata.annotations) { + targetTemplatePatch.push({ + op: 'replace', + path: '/metadata/annotations', + value: devWorkspaceTemplateResource.metadata.annotations, + }); + } else { + targetTemplatePatch.push({ + op: 'add', + path: '/metadata/annotations', + value: devWorkspaceTemplateResource.metadata.annotations, + }); + } + targetTemplatePatch.push({ + op: 'replace', + path: '/spec', + value: devWorkspaceTemplateResource.spec, + }); + await DwtApi.patchTemplate(templateNamespace, templateName, targetTemplatePatch); + + const targetWorkspacePatch: api.IPatch[] = []; + devWorkspaceResource.metadata.annotations[DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION] = + new Date().toISOString(); + devWorkspaceResource.metadata.annotations[DEVWORKSPACE_CHE_EDITOR] = defaultsEditor; + if (workspace.metadata.annotations) { + targetWorkspacePatch.push({ + op: 'replace', + path: '/metadata/annotations', + value: devWorkspaceResource.metadata.annotations, + }); + } else { + targetWorkspacePatch.push({ + op: 'add', + path: '/metadata/annotations', + value: devWorkspaceResource.metadata.annotations, + }); + } + targetWorkspacePatch.push({ + op: 'replace', + path: '/spec', + value: devWorkspaceResource.spec, + }); + const { devWorkspace } = await DwApi.patchWorkspace( + workspace.metadata.namespace, + workspace.metadata.name, + targetWorkspacePatch, + ); + + dispatch(devWorkspacesUpdateAction(devWorkspace)); + } catch (e) { + const errorMessage = + `Failed to update the workspace ${workspace.metadata.name}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actions.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actions.ts new file mode 100644 index 000000000..33877b27e --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actions.ts @@ -0,0 +1,51 @@ +/* c8 ignore start */ + +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createAction } from '@reduxjs/toolkit'; + +import devfileApi from '@/services/devfileApi'; + +export const devWorkspacesRequestAction = createAction('devWorkspaces/request'); +export const devWorkspacesErrorAction = createAction('devWorkspaces/error'); +type DevWorkspacesReceivePayload = { + workspaces: devfileApi.DevWorkspace[]; + resourceVersion: string; +}; +export const devWorkspacesReceiveAction = + createAction('devWorkspaces/receive'); + +export const devWorkspacesUpdateAction = createAction( + 'devWorkspaces/update', +); +export const devWorkspacesDeleteAction = + createAction('devWorkspaces/delete'); +type DevWorkspaceTerminatePayload = { + workspaceUID: string; + message: string; +}; +export const devWorkspacesTerminateAction = + createAction('devWorkspaces/terminate'); + +export const devWorkspacesAddAction = createAction('devWorkspaces/add'); + +export const devWorkspacesUpdateStartedAction = createAction( + 'devWorkspaces/updateStarted', +); +type DevWorkspaceWarningUpdatePayload = { + workspace: devfileApi.DevWorkspace; + warning: string; +}; +export const devWorkspaceWarningUpdateAction = createAction( + 'devWorkspaceWarning/update', +); diff --git a/packages/dashboard-frontend/src/store/SshKeys/state.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/index.ts similarity index 69% rename from packages/dashboard-frontend/src/store/SshKeys/state.ts rename to packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/index.ts index e3946f7a4..34626e521 100644 --- a/packages/dashboard-frontend/src/store/SshKeys/state.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,10 +12,5 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; - -export interface State { - isLoading: boolean; - keys: api.SshKey[]; - error?: string; -} +export * from '@/store/Workspaces/devWorkspaces/actions'; +export * from '@/store/Workspaces/devWorkspaces/actions/actionCreators'; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/checkRunningWorkspacesLimit.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/checkRunningWorkspacesLimit.ts deleted file mode 100644 index cb1f07db2..000000000 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/checkRunningWorkspacesLimit.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { AppState } from '@/store'; -import { selectRunningWorkspacesLimit } from '@/store/ClusterConfig/selectors'; -import { selectRunningDevWorkspacesLimitExceeded } from '@/store/Workspaces/devWorkspaces/selectors'; - -import { RunningWorkspacesExceededError } from '.'; - -export function checkRunningWorkspacesLimit(state: AppState) { - const runningLimitExceeded = selectRunningDevWorkspacesLimitExceeded(state); - if (runningLimitExceeded === false) { - return; - } - - const runningLimit = selectRunningWorkspacesLimit(state); - throwRunningWorkspacesExceededError(runningLimit); -} - -export function throwRunningWorkspacesExceededError(runningLimit: number): never { - const message = `You can only have ${runningLimit} running workspace${ - runningLimit > 1 ? 's' : '' - } at a time.`; - throw new RunningWorkspacesExceededError(message); -} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts index 8b0b590c6..41a5e33a1 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts @@ -10,87 +10,16 @@ * Red Hat, Inc. - initial API and implementation */ -import { V1alpha2DevWorkspaceStatus } from '@devfile/api'; -import common, { api, ApplicationId } from '@eclipse-che/common'; -import { includesAxiosResponse } from '@eclipse-che/common/lib/helpers/errors'; -import { dump } from 'js-yaml'; -import { Action, Reducer } from 'redux'; +/* c8 ignore start */ -import { container } from '@/inversify.config'; -import * as DwApi from '@/services/backend-client/devWorkspaceApi'; -import { injectKubeConfig, podmanLogin } from '@/services/backend-client/devWorkspaceApi'; -import { fetchResources } from '@/services/backend-client/devworkspaceResourcesApi'; -import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; -import { WebsocketClient } from '@/services/backend-client/websocketClient'; -import devfileApi, { isDevWorkspace } from '@/services/devfileApi'; -import { devWorkspaceKind } from '@/services/devfileApi/devWorkspace'; -import { - DEVWORKSPACE_CHE_EDITOR, - DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION, -} from '@/services/devfileApi/devWorkspace/metadata'; -import { getDefer, IDeferred } from '@/services/helpers/deferred'; -import { delay } from '@/services/helpers/delay'; -import { DisposableCollection } from '@/services/helpers/disposable'; -import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import { - compareStringsAsNumbers, - getNewerResourceVersion, -} from '@/services/helpers/resourceVersion'; -import { DevWorkspaceStatus } from '@/services/helpers/types'; -import { isOAuthResponse, OAuthService } from '@/services/oauth'; -import { loadResourcesContent } from '@/services/registry/resources'; -import { WorkspaceAdapter } from '@/services/workspace-adapter'; -import { - COMPONENT_UPDATE_POLICY, - DEVWORKSPACE_NEXT_START_ANNOTATION, - DevWorkspaceClient, - REGISTRY_URL, -} from '@/services/workspace-client/devworkspace/devWorkspaceClient'; -import { getCustomEditor } from '@/services/workspace-client/helpers'; -import { AppThunk } from '@/store'; -import { selectApplications } from '@/store/ClusterInfo/selectors'; -import { getEditor } from '@/store/DevfileRegistries/getEditor'; -import { selectDefaultDevfile } from '@/store/DevfileRegistries/selectors'; -import * as DevWorkspacesCluster from '@/store/DevWorkspacesCluster'; -import { checkRunningDevWorkspacesClusterLimitExceeded } from '@/store/DevWorkspacesCluster'; -import { createObject } from '@/store/helpers'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import { selectDefaultEditor } from '@/store/Plugins/devWorkspacePlugins/selectors'; -import { selectAsyncIsAuthorized, selectSanityCheckError } from '@/store/SanityCheck/selectors'; -import { AUTHORIZED, SanityCheckAction } from '@/store/sanityCheckMiddleware'; -import * as DwServerConfigStore from '@/store/ServerConfig'; -import { - selectOpenVSXUrl, - selectPluginRegistryInternalUrl, - selectPluginRegistryUrl, -} from '@/store/ServerConfig/selectors'; -import { checkRunningWorkspacesLimit } from '@/store/Workspaces/devWorkspaces/checkRunningWorkspacesLimit'; -import { - getEditorImage, - updateDevWorkspaceTemplate, - updateEditorDevfile, -} from '@/store/Workspaces/devWorkspaces/editorImage'; -import { selectDevWorkspacesResourceVersion } from '@/store/Workspaces/devWorkspaces/selectors'; -import { - getEditorName, - getLifeTimeMs, - updateEditor, -} from '@/store/Workspaces/devWorkspaces/updateEditor'; +export { actionCreators as devWorkspacesActionCreators } from '@/store/Workspaces/devWorkspaces/actions'; +export { + reducer as devWorkspacesReducer, + State as DevWorkspacesState, +} from '@/store/Workspaces/devWorkspaces/reducer'; +export * from '@/store/Workspaces/devWorkspaces/selectors'; -export const onStatusChangeCallbacks = new Map void>(); - -export interface State { - isLoading: boolean; - workspaces: devfileApi.DevWorkspace[]; - resourceVersion: string; - error?: string; - startedWorkspaces: { - [workspaceUID: string]: string; - }; - warnings: { - [workspaceUID: string]: string; - }; -} +/* c8 ignore stop */ export class RunningWorkspacesExceededError extends Error { constructor(message: string) { @@ -99,1139 +28,9 @@ export class RunningWorkspacesExceededError extends Error { } } -export enum Type { - REQUEST_DEVWORKSPACE = 'REQUEST_DEVWORKSPACE', - RECEIVE_DEVWORKSPACE_ERROR = 'RECEIVE_DEVWORKSPACE_ERROR', - RECEIVE_DEVWORKSPACE = 'RECEIVE_DEVWORKSPACE', - UPDATE_DEVWORKSPACE = 'UPDATE_DEVWORKSPACE', - DELETE_DEVWORKSPACE = 'DELETE_DEVWORKSPACE', - TERMINATE_DEVWORKSPACE = 'TERMINATE_DEVWORKSPACE', - ADD_DEVWORKSPACE = 'ADD_DEVWORKSPACE', - UPDATE_STARTED_WORKSPACES = 'UPDATE_STARTED_WORKSPACES', - UPDATE_WARNING = 'UPDATE_WARNING', -} - -export interface RequestDevWorkspacesAction extends Action, SanityCheckAction { - type: Type.REQUEST_DEVWORKSPACE; -} - -export interface ReceiveErrorAction extends Action { - type: Type.RECEIVE_DEVWORKSPACE_ERROR; - error: string; -} - -export interface ReceiveWorkspacesAction extends Action { - type: Type.RECEIVE_DEVWORKSPACE; - workspaces: devfileApi.DevWorkspace[]; - resourceVersion: string; -} - -export interface UpdateWorkspaceAction extends Action { - type: Type.UPDATE_DEVWORKSPACE; - workspace: devfileApi.DevWorkspace | undefined; -} - -export interface DeleteWorkspaceAction extends Action { - type: Type.DELETE_DEVWORKSPACE; - workspace: devfileApi.DevWorkspace; -} - -export interface TerminateWorkspaceAction extends Action { - type: Type.TERMINATE_DEVWORKSPACE; - workspaceUID: string; - message: string; -} - -export interface AddWorkspaceAction extends Action { - type: Type.ADD_DEVWORKSPACE; - workspace: devfileApi.DevWorkspace; -} - -export interface UpdateStartedWorkspaceAction extends Action { - type: Type.UPDATE_STARTED_WORKSPACES; - workspaces: devfileApi.DevWorkspace[]; -} - -export interface UpdateWarningAction extends Action { - type: Type.UPDATE_WARNING; - workspace: devfileApi.DevWorkspace; - warning: string; -} - -export type KnownAction = - | RequestDevWorkspacesAction - | ReceiveErrorAction - | ReceiveWorkspacesAction - | UpdateWorkspaceAction - | DeleteWorkspaceAction - | TerminateWorkspaceAction - | AddWorkspaceAction - | UpdateStartedWorkspaceAction - | UpdateWarningAction; - -export type ResourceQueryParams = { - 'debug-workspace-start': boolean; - [propName: string]: string | boolean | undefined; -}; -export type ActionCreators = { - requestWorkspaces: () => AppThunk>; - requestWorkspace: (workspace: devfileApi.DevWorkspace) => AppThunk>; - startWorkspace: ( - workspace: devfileApi.DevWorkspace, - debugWorkspace?: boolean, - ) => AppThunk>; - restartWorkspace: (workspace: devfileApi.DevWorkspace) => AppThunk>; - stopWorkspace: (workspace: devfileApi.DevWorkspace) => AppThunk>; - terminateWorkspace: (workspace: devfileApi.DevWorkspace) => AppThunk>; - updateWorkspaceAnnotation: ( - workspace: devfileApi.DevWorkspace, - ) => AppThunk>; - updateWorkspace: (workspace: devfileApi.DevWorkspace) => AppThunk>; - updateWorkspaceWithDefaultDevfile: ( - workspace: devfileApi.DevWorkspace, - ) => AppThunk>; - createWorkspaceFromDevfile: ( - devfile: devfileApi.Devfile, - params: Partial, - optionalFilesContent: { - [fileName: string]: string; - }, - ) => AppThunk>; - createWorkspaceFromResources: ( - devWorkspace: devfileApi.DevWorkspace, - devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate, - params: Partial, - // it could be editorId or editorContent - editor?: string, - ) => AppThunk>; - - handleWebSocketMessage: ( - message: api.webSocket.NotificationMessage, - ) => AppThunk>; -}; -export const actionCreators: ActionCreators = { - requestWorkspaces: - (): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const defaultNamespace = defaultKubernetesNamespace.name; - const { workspaces, resourceVersion } = defaultNamespace - ? await getDevWorkspaceClient().getAllWorkspaces(defaultNamespace) - : { - workspaces: [], - resourceVersion: '', - }; - - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE, - workspaces, - resourceVersion, - }); - dispatch({ - type: Type.UPDATE_STARTED_WORKSPACES, - workspaces, - }); - const promises = workspaces - .filter( - workspace => - workspace.metadata.annotations?.[DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION] === - undefined, - ) - .map(async workspace => { - // this will set updating timestamp to annotations and update the workspace - await dispatch(actionCreators.updateWorkspace(workspace)); - }); - await Promise.allSettled(promises); - } catch (e) { - const errorMessage = - 'Failed to fetch available workspaces, reason: ' + common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - requestWorkspace: - (workspace: devfileApi.DevWorkspace): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const namespace = workspace.metadata.namespace; - const name = workspace.metadata.name; - const update = await getDevWorkspaceClient().getWorkspaceByName(namespace, name); - dispatch({ - type: Type.UPDATE_DEVWORKSPACE, - workspace: update, - }); - - if ( - update.metadata.annotations?.[DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION] === undefined - ) { - // this will set updating timestamp to annotations and update the workspace - await dispatch(actionCreators.updateWorkspace(update)); - } - } catch (e) { - const errorMessage = - `Failed to fetch the workspace ${workspace.metadata.name}, reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - startWorkspace: - ( - _workspace: devfileApi.DevWorkspace, - debugWorkspace = false, - ): AppThunk> => - async (dispatch, getState): Promise => { - let workspace = getState().devWorkspaces.workspaces.find( - w => w.metadata.uid === _workspace.metadata.uid, - ); - if (workspace === undefined) { - console.warn(`Can't find the target workspace ${_workspace.metadata.name}`); - return; - } - if (workspace.spec.started) { - console.warn(`Workspace ${_workspace.metadata.name} already started`); - return; - } - try { - await OAuthService.refreshTokenIfProjectExists(workspace); - } catch (e: unknown) { - if (includesAxiosResponse(e)) { - // Do not interrupt the workspace start, but show a warning notification. - const response = e.response; - const attributes = response.data.attributes; - let message = response.data.message; - let provider = ''; - if (attributes !== undefined && attributes.provider !== undefined) { - const providerAttribute: string = attributes.provider; - if (providerAttribute.startsWith('github')) { - provider = 'GitHub'; - } else if (providerAttribute.startsWith('gitlab')) { - provider = 'Gitlab'; - } else if (providerAttribute.startsWith('bitbucket')) { - provider = 'Bitbucket'; - } - } - if (provider.length > 0) { - // eslint-disable-next-line no-warning-comments - // TODO add status page url for each provider when https://github.com/eclipse-che/che/issues/23142 is fixed - message = `${provider} might not be operational, please check the provider's status page.`; - } - dispatch({ - type: Type.UPDATE_WARNING, - workspace: workspace, - warning: message, - }); - } - } - try { - await dispatch( - DevWorkspacesCluster.actionCreators.requestRunningDevWorkspacesClusterLimitExceeded(), - ); - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - checkRunningDevWorkspacesClusterLimitExceeded(getState()); - checkRunningWorkspacesLimit(getState()); - - if (workspace.metadata.annotations?.[DEVWORKSPACE_NEXT_START_ANNOTATION]) { - const storedDevWorkspace = JSON.parse( - workspace.metadata.annotations[DEVWORKSPACE_NEXT_START_ANNOTATION], - ) as unknown; - if (!isDevWorkspace(storedDevWorkspace)) { - console.error( - `The stored devworkspace either has wrong "kind" (not ${devWorkspaceKind}) or lacks some of mandatory fields: `, - storedDevWorkspace, - ); - throw new Error( - 'Unexpected error happened. Please check the Console tab of Developer tools.', - ); - } - - delete workspace.metadata.annotations[DEVWORKSPACE_NEXT_START_ANNOTATION]; - workspace.spec.template = storedDevWorkspace.spec.template; - workspace.spec.started = false; - workspace = await getDevWorkspaceClient().update(workspace); - } - - await dispatch(DwServerConfigStore.actionCreators.requestServerConfig()); - const config = getState().dwServerConfig.config; - workspace = await getDevWorkspaceClient().managePvcStrategy(workspace, config); - - // inject or remove the container build attribute - workspace = await getDevWorkspaceClient().manageContainerBuildAttribute(workspace, config); - - workspace = await getDevWorkspaceClient().manageDebugMode(workspace, debugWorkspace); - - const editorName = getEditorName(workspace); - const lifeTimeMs = getLifeTimeMs(workspace); - if (editorName && lifeTimeMs > 30000) { - await updateEditor(editorName, getState); - } - - const startingWorkspace = await getDevWorkspaceClient().changeWorkspaceStatus( - workspace, - true, - true, - ); - const editor = startingWorkspace.metadata.annotations - ? startingWorkspace.metadata.annotations[DEVWORKSPACE_CHE_EDITOR] - : undefined; - const defaultPlugins = getState().dwPlugins.defaultPlugins; - await getDevWorkspaceClient().onStart(startingWorkspace, defaultPlugins, editor); - dispatch({ - type: Type.UPDATE_DEVWORKSPACE, - workspace: startingWorkspace, - }); - - // sometimes workspace don't have enough time to change its status. - // wait for status to become STARTING or 10 seconds, whichever comes first - const defer: IDeferred = getDefer(); - const toDispose = new DisposableCollection(); - const statusStartingHandler = async (status: string) => { - if (status === DevWorkspaceStatus.STARTING) { - defer.resolve(); - } - }; - const workspaceUID = WorkspaceAdapter.getUID(workspace); - onStatusChangeCallbacks.set(workspaceUID, statusStartingHandler); - toDispose.push({ - dispose: () => onStatusChangeCallbacks.delete(workspaceUID), - }); - const startingTimeout = 10000; - await Promise.race([defer.promise, delay(startingTimeout)]); - toDispose.dispose(); - } catch (e) { - // Skip unauthorised errors. The page is redirecting to an SCM authentication page. - if (common.helpers.errors.includesAxiosResponse(e) && isOAuthResponse(e.response.data)) { - return; - } - const errorMessage = - `Failed to start the workspace ${workspace.metadata.name}, reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - - if (common.helpers.errors.isError(e)) { - throw e; - } - throw new Error(errorMessage); - } - }, - - restartWorkspace: - (workspace: devfileApi.DevWorkspace): AppThunk> => - async (dispatch): Promise => { - const defer: IDeferred = getDefer(); - const toDispose = new DisposableCollection(); - const onStatusChangeCallback = async (status: string) => { - if (status === DevWorkspaceStatus.STOPPED || status === DevWorkspaceStatus.FAILED) { - toDispose.dispose(); - try { - await dispatch(actionCreators.startWorkspace(workspace)); - defer.resolve(); - } catch (e) { - defer.reject(`Failed to restart the workspace ${workspace.metadata.name}. ${e}`); - } - } - }; - if ( - workspace.status?.phase === DevWorkspaceStatus.STOPPED || - workspace.status?.phase === DevWorkspaceStatus.FAILED - ) { - await onStatusChangeCallback(workspace.status.phase); - } else { - const workspaceUID = WorkspaceAdapter.getUID(workspace); - onStatusChangeCallbacks.set(workspaceUID, onStatusChangeCallback); - toDispose.push({ - dispose: () => onStatusChangeCallbacks.delete(workspaceUID), - }); - if ( - workspace.status?.phase === DevWorkspaceStatus.RUNNING || - workspace.status?.phase === DevWorkspaceStatus.STARTING || - workspace.status?.phase === DevWorkspaceStatus.FAILING - ) { - try { - await dispatch(actionCreators.stopWorkspace(workspace)); - } catch (e) { - defer.reject(`Failed to restart the workspace ${workspace.metadata.name}. ${e}`); - } - } - } - - return defer.promise; - }, - - stopWorkspace: - (workspace: devfileApi.DevWorkspace): AppThunk> => - async (dispatch): Promise => { - try { - await getDevWorkspaceClient().changeWorkspaceStatus(workspace, false); - } catch (e) { - const errorMessage = - `Failed to stop the workspace ${workspace.metadata.name}, reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - terminateWorkspace: - (workspace: devfileApi.DevWorkspace): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const namespace = workspace.metadata.namespace; - const name = workspace.metadata.name; - await getDevWorkspaceClient().delete(namespace, name); - const workspaceUID = WorkspaceAdapter.getUID(workspace); - dispatch({ - type: Type.TERMINATE_DEVWORKSPACE, - workspaceUID, - message: workspace.status?.message || 'Cleaning up resources for deletion', - }); - } catch (e) { - const resMessage = - `Failed to delete the workspace ${workspace.metadata.name}, reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: resMessage, - }); - - throw e; - } - }, - - updateWorkspaceAnnotation: - (workspace: devfileApi.DevWorkspace): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const updated = await getDevWorkspaceClient().updateAnnotation(workspace); - dispatch({ - type: Type.UPDATE_DEVWORKSPACE, - workspace: updated, - }); - } catch (e) { - const errorMessage = - `Failed to update the workspace ${workspace.metadata.name}, reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - updateWorkspace: - (workspace: devfileApi.DevWorkspace): AppThunk> => - async (dispatch, getState): Promise => { - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const updated = await getDevWorkspaceClient().update(workspace); - - const prevWorkspace = getState().devWorkspaces.workspaces.find( - w => WorkspaceAdapter.getId(w) === WorkspaceAdapter.getId(updated), - ); - - dispatch({ - type: Type.UPDATE_DEVWORKSPACE, - workspace: shouldUpdateDevWorkspace(prevWorkspace, updated) ? updated : undefined, - }); - } catch (e) { - const errorMessage = - `Failed to update the workspace ${workspace.metadata.name}, reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - createWorkspaceFromResources: - ( - devWorkspace: devfileApi.DevWorkspace, - devWorkspaceTemplate: devfileApi.DevWorkspaceTemplate, - params: Partial, - editor?: string, - ): AppThunk> => - async (dispatch, getState): Promise => { - const state = getState(); - const defaultKubernetesNamespace = selectDefaultNamespace(state); - const openVSXUrl = selectOpenVSXUrl(state); - const pluginRegistryUrl = selectPluginRegistryUrl(state); - const pluginRegistryInternalUrl = selectPluginRegistryInternalUrl(state); - const cheEditor = editor ? editor : selectDefaultEditor(state); - const defaultNamespace = defaultKubernetesNamespace.name; - - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - /* create a new DevWorkspace */ - const createResp = await getDevWorkspaceClient().createDevWorkspace( - defaultNamespace, - devWorkspace, - cheEditor, - ); - - if (createResp.headers.warning) { - // get rid of the status code - const warning = createResp.headers.warning.replace(/^\d+\s*?-\s*?/g, ''); - - dispatch({ - type: Type.UPDATE_WARNING, - workspace: createResp.devWorkspace, - warning, - }); - } - - const clusterConsole = selectApplications(state).find( - app => app.id === ApplicationId.CLUSTER_CONSOLE, - ); - - devWorkspaceTemplate = updateDevWorkspaceTemplate(devWorkspaceTemplate, params.editorImage); - /* create a new DevWorkspaceTemplate */ - await getDevWorkspaceClient().createDevWorkspaceTemplate( - defaultNamespace, - createResp.devWorkspace, - devWorkspaceTemplate, - pluginRegistryUrl, - pluginRegistryInternalUrl, - openVSXUrl, - clusterConsole, - ); - - /* update the DevWorkspace */ - - const updateResp = await getDevWorkspaceClient().updateDevWorkspace( - createResp.devWorkspace, - ); - - if (updateResp.headers.warning) { - dispatch({ - type: Type.UPDATE_WARNING, - workspace: updateResp.devWorkspace, - warning: updateResp.headers.warning, - }); - } - - dispatch({ - type: Type.ADD_DEVWORKSPACE, - workspace: updateResp.devWorkspace, - }); - } catch (e) { - const errorMessage = - 'Failed to create a new workspace, reason: ' + common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - updateWorkspaceWithDefaultDevfile: - (workspace: devfileApi.DevWorkspace): AppThunk> => - async (dispatch, getState): Promise => { - const state = getState(); - const defaultsDevfile = selectDefaultDevfile(state); - if (!defaultsDevfile) { - throw new Error('Cannot define default devfile'); - } - const defaultsEditor = selectDefaultEditor(state); - if (!defaultsEditor) { - throw new Error('Cannot define default editor'); - } - const openVSXUrl = selectOpenVSXUrl(state); - const pluginRegistryUrl = selectPluginRegistryUrl(state); - const pluginRegistryInternalUrl = selectPluginRegistryInternalUrl(state); - const clusterConsole = selectApplications(state).find( - app => app.id === ApplicationId.CLUSTER_CONSOLE, - ); - - let editorContent: string | undefined; - let editorYamlUrl: string | undefined; - let devWorkspaceResource: devfileApi.DevWorkspace; - let devWorkspaceTemplateResource: devfileApi.DevWorkspaceTemplate; - - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - const response = await getEditor(defaultsEditor, dispatch, getState); - if (response.content) { - editorContent = response.content; - editorYamlUrl = response.editorYamlUrl; - } else { - throw new Error(response.error); - } - console.debug(`Using default editor ${defaultsEditor}`); - - defaultsDevfile.metadata.name = workspace.metadata.name; - delete defaultsDevfile.metadata.generateName; - - const editorImage = getEditorImage(workspace); - if (editorImage) { - editorContent = updateEditorDevfile(editorContent, editorImage); - } - const resourcesContent = await fetchResources({ - devfileContent: dump(defaultsDevfile), - editorPath: undefined, - editorContent, - }); - const resources = loadResourcesContent(resourcesContent); - devWorkspaceResource = resources.find( - resource => resource.kind === 'DevWorkspace', - ) as devfileApi.DevWorkspace; - if (devWorkspaceResource === undefined) { - throw new Error('Failed to find a DevWorkspace in the fetched resources.'); - } - if (devWorkspaceResource.metadata) { - if (!devWorkspaceResource.metadata.annotations) { - devWorkspaceResource.metadata.annotations = {}; - } - } - if (!devWorkspaceResource.spec.routingClass) { - devWorkspaceResource.spec.routingClass = 'che'; - } - devWorkspaceResource.spec.started = false; - - getDevWorkspaceClient().addEnvVarsToContainers( - devWorkspaceResource.spec.template.components, - pluginRegistryUrl, - pluginRegistryInternalUrl, - openVSXUrl, - clusterConsole, - ); - if (!devWorkspaceResource.metadata.annotations) { - devWorkspaceResource.metadata.annotations = {}; - } - devWorkspaceResource.spec.contributions = workspace.spec.contributions; - - // add projects from the origin workspace - devWorkspaceResource.spec.template.projects = workspace.spec.template.projects; - - devWorkspaceTemplateResource = resources.find( - resource => resource.kind === 'DevWorkspaceTemplate', - ) as devfileApi.DevWorkspaceTemplate; - if (devWorkspaceTemplateResource === undefined) { - throw new Error('Failed to find a DevWorkspaceTemplate in the fetched resources.'); - } - if (!devWorkspaceTemplateResource.metadata.annotations) { - devWorkspaceTemplateResource.metadata.annotations = {}; - } - - // removes endpoints with 'urlRewriteSupport: false' - const components = devWorkspaceTemplateResource.spec?.components || []; - components.forEach(component => { - if (component.container && Array.isArray(component.container.endpoints)) { - component.container.endpoints = component.container.endpoints.filter(endpoint => { - const attributes = endpoint.attributes as { urlRewriteSupported: boolean }; - return attributes.urlRewriteSupported; - }); - } - }); - - if (editorYamlUrl) { - devWorkspaceTemplateResource.metadata.annotations[COMPONENT_UPDATE_POLICY] = 'managed'; - devWorkspaceTemplateResource.metadata.annotations[REGISTRY_URL] = editorYamlUrl; - } - - getDevWorkspaceClient().addEnvVarsToContainers( - devWorkspaceTemplateResource.spec?.components, - pluginRegistryUrl, - pluginRegistryInternalUrl, - openVSXUrl, - clusterConsole, - ); - let targetTemplate: devfileApi.DevWorkspaceTemplate | undefined; - const templateName = getEditorName(workspace); - const templateNamespace = workspace.metadata.namespace; - if (templateName && templateNamespace) { - targetTemplate = await DwtApi.getTemplateByName(templateNamespace, templateName); - } - if (!templateName || !templateNamespace || !targetTemplate) { - throw new Error('Cannot define the target template'); - } - - const targetTemplatePatch: api.IPatch[] = []; - if (targetTemplate.metadata.annotations) { - targetTemplatePatch.push({ - op: 'replace', - path: '/metadata/annotations', - value: devWorkspaceTemplateResource.metadata.annotations, - }); - } else { - targetTemplatePatch.push({ - op: 'add', - path: '/metadata/annotations', - value: devWorkspaceTemplateResource.metadata.annotations, - }); - } - targetTemplatePatch.push({ - op: 'replace', - path: '/spec', - value: devWorkspaceTemplateResource.spec, - }); - await DwtApi.patchTemplate(templateNamespace, templateName, targetTemplatePatch); - - const targetWorkspacePatch: api.IPatch[] = []; - devWorkspaceResource.metadata.annotations[DEVWORKSPACE_UPDATING_TIMESTAMP_ANNOTATION] = - new Date().toISOString(); - devWorkspaceResource.metadata.annotations[DEVWORKSPACE_CHE_EDITOR] = defaultsEditor; - if (workspace.metadata.annotations) { - targetWorkspacePatch.push({ - op: 'replace', - path: '/metadata/annotations', - value: devWorkspaceResource.metadata.annotations, - }); - } else { - targetWorkspacePatch.push({ - op: 'add', - path: '/metadata/annotations', - value: devWorkspaceResource.metadata.annotations, - }); - } - targetWorkspacePatch.push({ - op: 'replace', - path: '/spec', - value: devWorkspaceResource.spec, - }); - const { devWorkspace } = await DwApi.patchWorkspace( - workspace.metadata.namespace, - workspace.metadata.name, - targetWorkspacePatch, - ); - - dispatch({ - type: Type.UPDATE_DEVWORKSPACE, - workspace: devWorkspace, - }); - } catch (e) { - const errorMessage = - `Failed to update the workspace ${workspace.metadata.name}, reason: ` + - common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - }, - - createWorkspaceFromDevfile: - ( - devfile: devfileApi.Devfile, - params: Partial, - optionalFilesContent: { - [fileName: string]: string; - }, - ): AppThunk> => - async (dispatch, getState): Promise => { - const state = getState(); - let devWorkspaceResource: devfileApi.DevWorkspace; - let devWorkspaceTemplateResource: devfileApi.DevWorkspaceTemplate; - let editorContent: string | undefined; - let editorYamlUrl: string | undefined; - // do we have an optional editor parameter ? - let editor = params.cheEditor; - if (editor) { - const response = await getEditor(editor, dispatch, getState); - if (response.content) { - editorContent = response.content; - editorYamlUrl = response.editorYamlUrl; - } else { - throw new Error(response.error); - } - } else { - // do we have the custom editor in `.che/che-editor.yaml` ? - try { - editorContent = await getCustomEditor(optionalFilesContent, dispatch, getState); - if (!editorContent) { - console.warn('No custom editor found'); - } - } catch (e) { - console.warn('Failed to get custom editor', e); - } - if (!editorContent) { - const defaultsEditor = state.dwServerConfig.config.defaults.editor; - if (!defaultsEditor) { - throw new Error('Cannot define default editor'); - } - const response = await getEditor(defaultsEditor, dispatch, getState); - if (response.content) { - editorContent = response.content; - editorYamlUrl = response.editorYamlUrl; - } else { - throw new Error(response.error); - } - editor = defaultsEditor; - console.debug(`Using default editor ${defaultsEditor}`); - } - } - - try { - await dispatch({ type: Type.REQUEST_DEVWORKSPACE, check: AUTHORIZED }); - if (!(await selectAsyncIsAuthorized(getState()))) { - const error = selectSanityCheckError(getState()); - throw new Error(error); - } - editorContent = updateEditorDevfile(editorContent, params.editorImage); - const resourcesContent = await fetchResources({ - devfileContent: dump(devfile), - editorPath: undefined, - editorContent: editorContent, - }); - const resources = loadResourcesContent(resourcesContent); - devWorkspaceResource = resources.find( - resource => resource.kind === 'DevWorkspace', - ) as devfileApi.DevWorkspace; - if (devWorkspaceResource === undefined) { - throw new Error('Failed to find a DevWorkspace in the fetched resources.'); - } - if (devWorkspaceResource.metadata) { - if (!devWorkspaceResource.metadata.annotations) { - devWorkspaceResource.metadata.annotations = {}; - } - } - devWorkspaceTemplateResource = resources.find( - resource => resource.kind === 'DevWorkspaceTemplate', - ) as devfileApi.DevWorkspaceTemplate; - if (devWorkspaceTemplateResource === undefined) { - throw new Error('Failed to find a DevWorkspaceTemplate in the fetched resources.'); - } - if (editorYamlUrl && devWorkspaceTemplateResource.metadata) { - if (!devWorkspaceTemplateResource.metadata.annotations) { - devWorkspaceTemplateResource.metadata.annotations = {}; - } - devWorkspaceTemplateResource.metadata.annotations[COMPONENT_UPDATE_POLICY] = 'managed'; - devWorkspaceTemplateResource.metadata.annotations[REGISTRY_URL] = editorYamlUrl; - } - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - dispatch({ - type: Type.RECEIVE_DEVWORKSPACE_ERROR, - error: errorMessage, - }); - throw e; - } - - await dispatch( - actionCreators.createWorkspaceFromResources( - devWorkspaceResource, - devWorkspaceTemplateResource, - params, - editor ? editor : editorContent, - ), - ); - }, - - handleWebSocketMessage: - (message: api.webSocket.NotificationMessage): AppThunk> => - async (dispatch, getState): Promise => { - if (api.webSocket.isStatusMessage(message)) { - const { status } = message; - - const errorMessage = `WebSocket(DEV_WORKSPACE): status code ${status.code}, reason: ${status.message}`; - console.debug(errorMessage); - - if (status.code !== 200) { - /* in case of error status trying to fetch all devWorkspaces and re-subscribe to websocket channel */ - - const websocketClient = container.get(WebsocketClient); - - websocketClient.unsubscribeFromChannel(api.webSocket.Channel.DEV_WORKSPACE); - - await dispatch(actionCreators.requestWorkspaces()); - - const defaultKubernetesNamespace = selectDefaultNamespace(getState()); - const namespace = defaultKubernetesNamespace.name; - const getResourceVersion = () => { - const state = getState(); - return selectDevWorkspacesResourceVersion(state); - }; - websocketClient.subscribeToChannel(api.webSocket.Channel.DEV_WORKSPACE, namespace, { - getResourceVersion, - }); - } - return; - } - - if (api.webSocket.isDevWorkspaceMessage(message)) { - const { eventPhase, devWorkspace } = message; - - if (isDevWorkspace(devWorkspace) === false) { - return; - } - - const workspace = devWorkspace as devfileApi.DevWorkspace; - - // previous state of the workspace is needed for notifying about workspace status changes. - const prevWorkspace = getState().devWorkspaces.workspaces.find( - w => WorkspaceAdapter.getId(w) === WorkspaceAdapter.getId(workspace), - ); - - // update the workspace in the store - switch (eventPhase) { - case api.webSocket.EventPhase.ADDED: - dispatch({ - type: Type.ADD_DEVWORKSPACE, - workspace, - }); - break; - case api.webSocket.EventPhase.MODIFIED: - dispatch({ - type: Type.UPDATE_DEVWORKSPACE, - // update workspace only if it has newer resource version - workspace: shouldUpdateDevWorkspace(prevWorkspace, workspace) ? workspace : undefined, - }); - break; - case api.webSocket.EventPhase.DELETED: - dispatch({ - type: Type.DELETE_DEVWORKSPACE, - workspace, - }); - break; - default: - console.warn(`Unknown event phase in message: `, message); - } - if (shouldUpdateDevWorkspace(prevWorkspace, workspace)) { - // store workspace status only if the workspace has newer resource version - dispatch({ - type: Type.UPDATE_STARTED_WORKSPACES, - workspaces: [workspace], - }); - } - - // notify about workspace status changes - const devworkspaceId = workspace.status?.devworkspaceId; - const phase = workspace.status?.phase; - const prevPhase = prevWorkspace?.status?.phase; - const workspaceUID = WorkspaceAdapter.getUID(workspace); - if (shouldUpdateDevWorkspace(prevWorkspace, workspace) && phase && prevPhase !== phase) { - // notify about workspace status changes only if the workspace has newer resource version - const onStatusChangeListener = onStatusChangeCallbacks.get(workspaceUID); - - if (onStatusChangeListener) { - onStatusChangeListener(phase); - } - } - - if ( - phase === DevWorkspaceStatus.RUNNING && - phase !== prevPhase && - devworkspaceId !== undefined - ) { - try { - // inject the kube config - await injectKubeConfig(workspace.metadata.namespace, devworkspaceId); - // inject the 'podman login' - await podmanLogin(workspace.metadata.namespace, devworkspaceId); - } catch (e) { - console.error(e); - } - } - } - }, -}; - -const unloadedState: State = { - workspaces: [], - isLoading: false, - resourceVersion: '0', - startedWorkspaces: {}, - warnings: {}, -}; - -export const reducer: Reducer = ( - state: State | undefined, - incomingAction: Action, -): State => { - if (state === undefined) { - return unloadedState; - } - - const action = incomingAction as KnownAction; - switch (action.type) { - case Type.REQUEST_DEVWORKSPACE: - return createObject(state, { - isLoading: true, - error: undefined, - }); - case Type.RECEIVE_DEVWORKSPACE: - return createObject(state, { - isLoading: false, - workspaces: action.workspaces, - resourceVersion: getNewerResourceVersion(action.resourceVersion, state.resourceVersion), - }); - case Type.RECEIVE_DEVWORKSPACE_ERROR: - return createObject(state, { - isLoading: false, - error: action.error, - }); - case Type.UPDATE_DEVWORKSPACE: { - const updatedWorkspace = action.workspace; - if (updatedWorkspace === undefined) { - return createObject(state, { - isLoading: false, - }); - } - return createObject(state, { - isLoading: false, - workspaces: state.workspaces.map(workspace => - WorkspaceAdapter.getUID(workspace) === WorkspaceAdapter.getUID(updatedWorkspace) - ? updatedWorkspace - : workspace, - ), - resourceVersion: getNewerResourceVersion( - updatedWorkspace.metadata.resourceVersion, - state.resourceVersion, - ), - }); - } - case Type.ADD_DEVWORKSPACE: - return createObject(state, { - isLoading: false, - workspaces: state.workspaces - .filter( - workspace => - WorkspaceAdapter.getUID(workspace) !== WorkspaceAdapter.getUID(action.workspace), - ) - .concat([action.workspace]), - resourceVersion: getNewerResourceVersion( - action.workspace.metadata.resourceVersion, - state.resourceVersion, - ), - }); - case Type.TERMINATE_DEVWORKSPACE: - return createObject(state, { - isLoading: false, - workspaces: state.workspaces.map(workspace => { - if (WorkspaceAdapter.getUID(workspace) === action.workspaceUID) { - const targetWorkspace = Object.assign({}, workspace); - if (!targetWorkspace.status) { - targetWorkspace.status = {} as V1alpha2DevWorkspaceStatus; - } - targetWorkspace.status.phase = DevWorkspaceStatus.TERMINATING; - targetWorkspace.status.message = action.message; - return targetWorkspace; - } - return workspace; - }), - }); - case Type.DELETE_DEVWORKSPACE: - return createObject(state, { - isLoading: false, - workspaces: state.workspaces.filter( - workspace => - WorkspaceAdapter.getUID(workspace) !== WorkspaceAdapter.getUID(action.workspace), - ), - resourceVersion: getNewerResourceVersion( - action.workspace.metadata.resourceVersion, - state.resourceVersion, - ), - }); - case Type.UPDATE_STARTED_WORKSPACES: - return createObject(state, { - startedWorkspaces: action.workspaces.reduce((acc, workspace) => { - if (workspace.spec.started === false) { - delete acc[WorkspaceAdapter.getUID(workspace)]; - return acc; - } - - // workspace.spec.started === true - if (acc[WorkspaceAdapter.getUID(workspace)] !== undefined) { - // do nothing - return acc; - } - - if (workspace.metadata.resourceVersion === undefined) { - // do nothing - return acc; - } - - acc[WorkspaceAdapter.getUID(workspace)] = workspace.metadata.resourceVersion; - return acc; - }, state.startedWorkspaces), - }); - case Type.UPDATE_WARNING: - return createObject(state, { - warnings: { - [WorkspaceAdapter.getUID(action.workspace)]: action.warning, - }, - }); - default: - return state; - } -}; - -// This function was added to make it easier to mock the DevWorkspaceClient in tests -export function getDevWorkspaceClient(): DevWorkspaceClient { - return container.get(DevWorkspaceClient); -} - -// this is a helper function to prevent updating workspaces with older resource versions -export function shouldUpdateDevWorkspace( - prevDevWorkspace: devfileApi.DevWorkspace | undefined, - devWorkspace: devfileApi.DevWorkspace, -): boolean { - const prevResourceVersion = prevDevWorkspace?.metadata.resourceVersion; - const resourceVersion = devWorkspace.metadata.resourceVersion; - if (resourceVersion === undefined) { - return false; - } - - if (prevResourceVersion === undefined) { - return true; - } - if (compareStringsAsNumbers(prevResourceVersion, resourceVersion) < 0) { - return true; - } - return false; +export function throwRunningWorkspacesExceededError(runningLimit: number): never { + const message = `You can only have ${runningLimit} running workspace${ + runningLimit > 1 ? 's' : '' + } at a time.`; + throw new RunningWorkspacesExceededError(message); } diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/reducer.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/reducer.ts new file mode 100644 index 000000000..76b86c6ef --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/reducer.ts @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { V1alpha2DevWorkspaceStatus } from '@devfile/api'; +import { createReducer } from '@reduxjs/toolkit'; + +import devfileApi from '@/services/devfileApi'; +import { getNewerResourceVersion } from '@/services/helpers/resourceVersion'; +import { DevWorkspaceStatus } from '@/services/helpers/types'; +import { WorkspaceAdapter } from '@/services/workspace-adapter'; +import { + devWorkspacesAddAction, + devWorkspacesDeleteAction, + devWorkspacesErrorAction, + devWorkspacesReceiveAction, + devWorkspacesRequestAction, + devWorkspacesTerminateAction, + devWorkspacesUpdateAction, + devWorkspacesUpdateStartedAction, + devWorkspaceWarningUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export interface State { + isLoading: boolean; + workspaces: devfileApi.DevWorkspace[]; + resourceVersion: string; + error?: string; + startedWorkspaces: { + [workspaceUID: string]: string; + }; + warnings: { + [workspaceUID: string]: string; + }; +} + +export const unloadedState: State = { + workspaces: [], + isLoading: false, + resourceVersion: '0', + startedWorkspaces: {}, + warnings: {}, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(devWorkspacesRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(devWorkspacesReceiveAction, (state, action) => { + state.isLoading = false; + state.workspaces = action.payload.workspaces; + state.resourceVersion = getNewerResourceVersion( + action.payload.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(devWorkspacesErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addCase(devWorkspacesUpdateAction, (state, action) => { + state.isLoading = false; + const updatedWorkspace = action.payload; + if (updatedWorkspace === undefined) { + return; + } + state.workspaces = state.workspaces.map(workspace => + WorkspaceAdapter.getUID(workspace) === WorkspaceAdapter.getUID(updatedWorkspace) + ? updatedWorkspace + : workspace, + ); + state.resourceVersion = getNewerResourceVersion( + updatedWorkspace.metadata.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(devWorkspacesAddAction, (state, action) => { + state.isLoading = false; + state.workspaces = state.workspaces + .filter( + workspace => + WorkspaceAdapter.getUID(workspace) !== WorkspaceAdapter.getUID(action.payload), + ) + .concat([action.payload]); + state.resourceVersion = getNewerResourceVersion( + action.payload.metadata.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(devWorkspacesTerminateAction, (state, action) => { + state.isLoading = false; + state.workspaces = state.workspaces.map(workspace => { + if (WorkspaceAdapter.getUID(workspace) === action.payload.workspaceUID) { + const targetWorkspace = Object.assign({}, workspace); + if (!targetWorkspace.status) { + targetWorkspace.status = {} as V1alpha2DevWorkspaceStatus; + } + targetWorkspace.status.phase = DevWorkspaceStatus.TERMINATING; + targetWorkspace.status.message = action.payload.message; + return targetWorkspace; + } + return workspace; + }); + }) + .addCase(devWorkspacesDeleteAction, (state, action) => { + state.isLoading = false; + state.workspaces = state.workspaces.filter( + workspace => WorkspaceAdapter.getUID(workspace) !== WorkspaceAdapter.getUID(action.payload), + ); + state.resourceVersion = getNewerResourceVersion( + action.payload.metadata.resourceVersion, + state.resourceVersion, + ); + }) + .addCase(devWorkspacesUpdateStartedAction, (state, action) => { + state.startedWorkspaces = action.payload.reduce((acc, workspace) => { + if (workspace.spec.started === false) { + delete acc[WorkspaceAdapter.getUID(workspace)]; + return acc; + } + if (acc[WorkspaceAdapter.getUID(workspace)] !== undefined) { + return acc; + } + if (workspace.metadata.resourceVersion === undefined) { + return acc; + } + acc[WorkspaceAdapter.getUID(workspace)] = workspace.metadata.resourceVersion; + return acc; + }, state.startedWorkspaces); + }) + .addCase(devWorkspaceWarningUpdateAction, (state, action) => { + state.warnings = { + [WorkspaceAdapter.getUID(action.payload.workspace)]: action.payload.warning, + }; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/selectors.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/selectors.ts index d97439024..8c11529a9 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/selectors.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/selectors.ts @@ -10,13 +10,13 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; import { DevWorkspaceStatus } from '@/services/helpers/types'; -import { AppState } from '@/store'; +import { RootState } from '@/store'; import { selectRunningWorkspacesLimit } from '@/store/ClusterConfig/selectors'; -const selectState = (state: AppState) => state.devWorkspaces; +const selectState = (state: RootState) => state.devWorkspaces; export const selectDevWorkspacesState = selectState; export const selectDevWorkspacesResourceVersion = createSelector( diff --git a/packages/dashboard-frontend/src/store/Workspaces/index.ts b/packages/dashboard-frontend/src/store/Workspaces/index.ts index 6ad8d9edd..aea7a01b1 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/index.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/index.ts @@ -1,3 +1,5 @@ +/* c8 ignore start */ + /* * Copyright (c) 2018-2024 Red Hat, Inc. * This program and the accompanying materials are made @@ -10,328 +12,6 @@ * Red Hat, Inc. - initial API and implementation */ -import { Reducer } from 'redux'; - -import devfileApi from '@/services/devfileApi'; -import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import { Workspace } from '@/services/workspace-adapter'; -import { createObject } from '@/store/helpers'; -import * as DevWorkspacesStore from '@/store/Workspaces/devWorkspaces'; - -import { AppThunk } from '..'; - -// This state defines the type of data maintained in the Redux store. -export interface State { - isLoading: boolean; - - // current workspace qualified name - namespace: string; - workspaceName: string; - workspaceUID: string; - // number of recent workspaces - recentNumber: number; -} - -interface RequestWorkspacesAction { - type: 'REQUEST_WORKSPACES'; -} - -interface ReceiveErrorAction { - type: 'RECEIVE_ERROR'; -} - -interface ReceiveWorkspacesAction { - type: 'RECEIVE_WORKSPACES'; -} - -interface UpdateWorkspaceAction { - type: 'UPDATE_WORKSPACE'; -} - -interface DeleteWorkspaceLogsAction { - type: 'DELETE_WORKSPACE_LOGS'; - workspace: Workspace; -} - -interface DeleteWorkspaceAction { - type: 'DELETE_WORKSPACE'; -} - -interface AddWorkspaceAction { - type: 'ADD_WORKSPACE'; -} - -interface SetWorkspaceQualifiedName { - type: 'SET_WORKSPACE_NAME'; - namespace: string; - workspaceName: string; -} - -interface ClearWorkspaceQualifiedName { - type: 'CLEAR_WORKSPACE_NAME'; -} - -interface SetWorkspaceUID { - type: 'SET_WORKSPACE_UID'; - workspaceUID: string; -} - -interface ClearWorkspaceUID { - type: 'CLEAR_WORKSPACE_UID'; -} - -type KnownAction = - | RequestWorkspacesAction - | ReceiveErrorAction - | ReceiveWorkspacesAction - | UpdateWorkspaceAction - | DeleteWorkspaceAction - | AddWorkspaceAction - | SetWorkspaceQualifiedName - | ClearWorkspaceQualifiedName - | SetWorkspaceUID - | ClearWorkspaceUID - | DeleteWorkspaceLogsAction; - -export type ResourceQueryParams = { - 'debug-workspace-start': boolean; - [propName: string]: string | boolean | undefined; -}; -export type ActionCreators = { - requestWorkspaces: () => AppThunk>; - requestWorkspace: (workspace: Workspace) => AppThunk>; - startWorkspace: ( - workspace: Workspace, - params?: ResourceQueryParams, - ) => AppThunk>; - restartWorkspace: (workspace: Workspace) => AppThunk>; - stopWorkspace: (workspace: Workspace) => AppThunk>; - deleteWorkspace: (workspace: Workspace) => AppThunk>; - updateWorkspace: (workspace: Workspace) => AppThunk>; - updateWorkspaceWithDefaultDevfile: (workspace: Workspace) => AppThunk>; - createWorkspaceFromDevfile: ( - devfile: devfileApi.Devfile, - attributes: Partial, - optionalFilesContent?: { - [fileName: string]: string; - }, - ) => AppThunk>; - - setWorkspaceQualifiedName: ( - namespace: string, - workspaceName: string, - ) => AppThunk; - clearWorkspaceQualifiedName: () => AppThunk; - setWorkspaceUID: (workspaceUID: string) => AppThunk; - clearWorkspaceUID: () => AppThunk; -}; - -export const actionCreators: ActionCreators = { - requestWorkspaces: - (): AppThunk> => - async (dispatch): Promise => { - dispatch({ type: 'REQUEST_WORKSPACES' }); - try { - await dispatch(DevWorkspacesStore.actionCreators.requestWorkspaces()); - - dispatch({ type: 'RECEIVE_WORKSPACES' }); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - requestWorkspace: - (workspace: Workspace): AppThunk> => - async (dispatch): Promise => { - dispatch({ type: 'REQUEST_WORKSPACES' }); - try { - await dispatch(DevWorkspacesStore.actionCreators.requestWorkspace(workspace.ref)); - dispatch({ type: 'UPDATE_WORKSPACE' }); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - startWorkspace: - (workspace: Workspace, params?: ResourceQueryParams): AppThunk> => - async (dispatch): Promise => { - dispatch({ type: 'REQUEST_WORKSPACES' }); - const debugWorkspace = params && params['debug-workspace-start']; - await dispatch( - DevWorkspacesStore.actionCreators.startWorkspace(workspace.ref, debugWorkspace), - ); - dispatch({ type: 'UPDATE_WORKSPACE' }); - }, - - restartWorkspace: - (workspace: Workspace): AppThunk> => - async (dispatch): Promise => { - try { - await dispatch(DevWorkspacesStore.actionCreators.restartWorkspace(workspace.ref)); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - stopWorkspace: - (workspace: Workspace): AppThunk> => - async (dispatch): Promise => { - try { - await dispatch(DevWorkspacesStore.actionCreators.stopWorkspace(workspace.ref)); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - deleteWorkspace: - (workspace: Workspace): AppThunk> => - async (dispatch): Promise => { - try { - await dispatch(DevWorkspacesStore.actionCreators.terminateWorkspace(workspace.ref)); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - updateWorkspace: - (workspace: Workspace): AppThunk> => - async (dispatch): Promise => { - dispatch({ type: 'REQUEST_WORKSPACES' }); - try { - await dispatch( - DevWorkspacesStore.actionCreators.updateWorkspace( - workspace.ref as devfileApi.DevWorkspace, - ), - ); - dispatch({ type: 'UPDATE_WORKSPACE' }); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - updateWorkspaceWithDefaultDevfile: - (workspace: Workspace): AppThunk> => - async (dispatch): Promise => { - dispatch({ type: 'REQUEST_WORKSPACES' }); - try { - await dispatch( - DevWorkspacesStore.actionCreators.updateWorkspaceWithDefaultDevfile( - workspace.ref as devfileApi.DevWorkspace, - ), - ); - dispatch({ type: 'UPDATE_WORKSPACE' }); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - createWorkspaceFromDevfile: - ( - devfile: devfileApi.Devfile, - attributes: Partial, - optionalFilesContent?: { - [fileName: string]: string; - }, - ): AppThunk> => - async (dispatch): Promise => { - dispatch({ type: 'REQUEST_WORKSPACES' }); - try { - await dispatch( - DevWorkspacesStore.actionCreators.createWorkspaceFromDevfile( - devfile, - attributes, - optionalFilesContent || {}, - ), - ); - dispatch({ type: 'ADD_WORKSPACE' }); - } catch (e) { - dispatch({ type: 'RECEIVE_ERROR' }); - throw e; - } - }, - - setWorkspaceQualifiedName: - (namespace: string, workspaceName: string): AppThunk => - dispatch => { - dispatch({ - type: 'SET_WORKSPACE_NAME', - namespace, - workspaceName, - }); - }, - - clearWorkspaceQualifiedName: (): AppThunk => dispatch => { - dispatch({ type: 'CLEAR_WORKSPACE_NAME' }); - }, - - setWorkspaceUID: - (workspaceUID: string): AppThunk => - dispatch => { - dispatch({ - type: 'SET_WORKSPACE_UID', - workspaceUID, - }); - }, - - clearWorkspaceUID: (): AppThunk => dispatch => { - dispatch({ type: 'CLEAR_WORKSPACE_UID' }); - }, -}; - -const unloadedState: State = { - isLoading: false, - - namespace: '', - workspaceName: '', - workspaceUID: '', - - recentNumber: 5, -}; - -export const reducer: Reducer = (state: State | undefined, action: KnownAction): State => { - if (state === undefined) { - return unloadedState; - } - - switch (action.type) { - case 'REQUEST_WORKSPACES': - return createObject(state, { - isLoading: true, - }); - case 'RECEIVE_ERROR': - case 'UPDATE_WORKSPACE': - case 'ADD_WORKSPACE': - case 'DELETE_WORKSPACE': - case 'RECEIVE_WORKSPACES': - return createObject(state, { - isLoading: false, - }); - case 'SET_WORKSPACE_NAME': - return createObject(state, { - namespace: action.namespace, - workspaceName: action.workspaceName, - }); - case 'CLEAR_WORKSPACE_NAME': - return createObject(state, { - namespace: '', - workspaceName: '', - }); - case 'SET_WORKSPACE_UID': - return createObject(state, { - workspaceUID: action.workspaceUID, - }); - case 'CLEAR_WORKSPACE_UID': - return createObject(state, { - workspaceUID: '', - }); - default: - return state; - } -}; +export { actionCreators as workspacesActionCreators } from '@/store/Workspaces/actions'; +export { reducer as workspacesReducer, State as WorkspacesState } from '@/store/Workspaces/reducer'; +export * from '@/store/Workspaces/selectors'; diff --git a/packages/dashboard-frontend/src/store/Workspaces/reducer.ts b/packages/dashboard-frontend/src/store/Workspaces/reducer.ts new file mode 100644 index 000000000..7a244b3c8 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/reducer.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { + qualifiedNameClearAction, + qualifiedNameSetAction, + workspaceUIDClearAction, + workspaceUIDSetAction, +} from '@/store/Workspaces/actions'; + +export interface State { + isLoading: boolean; + + // current workspace qualified name + namespace: string; + workspaceName: string; + workspaceUID: string; + // number of recent workspaces + recentNumber: number; +} + +export const unloadedState: State = { + isLoading: false, + + namespace: '', + workspaceName: '', + workspaceUID: '', + + recentNumber: 5, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(qualifiedNameClearAction, state => { + state.namespace = ''; + state.workspaceName = ''; + }) + .addCase(qualifiedNameSetAction, (state, action) => { + state.namespace = action.payload.namespace; + state.workspaceName = action.payload.workspaceName; + }) + .addCase(workspaceUIDClearAction, state => { + state.workspaceUID = ''; + }) + .addCase(workspaceUIDSetAction, (state, action) => { + state.workspaceUID = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/Workspaces/selectors.ts b/packages/dashboard-frontend/src/store/Workspaces/selectors.ts index 50256d13d..8d09d4272 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/selectors.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/selectors.ts @@ -10,18 +10,17 @@ * Red Hat, Inc. - initial API and implementation */ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; import { selectDevWorkspacesError, selectRunningDevWorkspaces, } from '@/store/Workspaces/devWorkspaces/selectors'; -import { AppState } from '..'; - -const selectState = (state: AppState) => state.workspaces; -const selectDevWorkspacesState = (state: AppState) => state.devWorkspaces; +const selectState = (state: RootState) => state.workspaces; +const selectDevWorkspacesState = (state: RootState) => state.devWorkspaces; export const selectIsLoading = createSelector(selectDevWorkspacesState, devWorkspacesState => { return devWorkspacesState.isLoading; diff --git a/packages/dashboard-frontend/src/store/__mocks__/devWorkspaceBuilder.ts b/packages/dashboard-frontend/src/store/__mocks__/devWorkspaceBuilder.ts index ea6e6163a..0a61e61f2 100644 --- a/packages/dashboard-frontend/src/store/__mocks__/devWorkspaceBuilder.ts +++ b/packages/dashboard-frontend/src/store/__mocks__/devWorkspaceBuilder.ts @@ -19,7 +19,7 @@ import getRandomString from '@/services/helpers/random'; import { DevWorkspaceStatus } from '@/services/helpers/types'; export class DevWorkspaceBuilder { - private workspace: devfileApi.DevWorkspace = { + private workspace: any = { kind: 'DevWorkspace', apiVersion: 'workspace.devfile.io/v1alpha2', metadata: { diff --git a/packages/dashboard-frontend/src/store/__mocks__/mockActionsTestStore.ts b/packages/dashboard-frontend/src/store/__mocks__/mockActionsTestStore.ts new file mode 100644 index 000000000..90fcb8455 --- /dev/null +++ b/packages/dashboard-frontend/src/store/__mocks__/mockActionsTestStore.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; +import configureMockStore from 'redux-mock-store'; + +import { RootState } from '@/store'; +import mockThunk from '@/store/__mocks__/thunk'; + +/** + * This function creates a mock store for testing only the action-related logic. It is not intended to be used for testing the reducers as it does not update the redux store. + * https://github.com/reduxjs/redux-mock-store?tab=readme-ov-file#redux-mock-store- + */ +export function createMockStore(preloadedState: Partial = {}) { + const middlewares = [mockThunk]; + const mockStore = configureMockStore< + RootState, + ThunkDispatch + >(middlewares); + return mockStore(preloadedState as RootState); +} diff --git a/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts b/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts new file mode 100644 index 000000000..4927912b4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, ClusterConfig, ClusterInfo } from '@eclipse-che/common'; +import { CoreV1Event, V1Pod } from '@kubernetes/client-node'; +import { configureStore } from '@reduxjs/toolkit'; + +import { BrandingData } from '@/services/bootstrap/branding.constant'; +import devfileApi from '@/services/devfileApi'; +import { che } from '@/services/models'; +import { RootState } from '@/store'; +import { DevWorkspaceResources } from '@/store/DevfileRegistries'; +import { RegistryEntry } from '@/store/DockerConfig'; +import { FactoryResolverStateResolver } from '@/store/FactoryResolver'; +import { IGitOauth } from '@/store/GitOauthConfig'; +import { PluginDefinition } from '@/store/Plugins/devWorkspacePlugins'; +import { PodLogsState } from '@/store/Pods/Logs'; +import { rootReducer } from '@/store/rootReducer'; + +export class MockStoreBuilder { + private state: Partial; + + constructor(state: Partial = {}) { + this.state = { ...state }; + } + + public withDwServerConfig(config: Partial): MockStoreBuilder { + this.state = { + ...this.state, + dwServerConfig: { + isLoading: false, + config, + }, + } as RootState; + return this; + } + + public withBannerAlert(messages: string[]): MockStoreBuilder { + this.state = { + ...this.state, + bannerAlert: { + messages: [...messages], + }, + } as RootState; + return this; + } + + public withGitOauthConfig( + gitOauth: IGitOauth[], + providersWithToken: api.GitOauthProvider[], + skipOauthProviders: api.GitOauthProvider[], + isLoading = false, + error?: string, + ): MockStoreBuilder { + this.state = { + ...this.state, + gitOauthConfig: { + gitOauth, + providersWithToken, + skipOauthProviders, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withDockerConfig( + registries: RegistryEntry[], + isLoading = false, + error?: string, + ): MockStoreBuilder { + this.state = { + ...this.state, + dockerConfig: { + registries, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withClusterConfig( + clusterConfig: Partial = {}, + isLoading = false, + error?: string, + ): MockStoreBuilder { + this.state = { + ...this.state, + clusterConfig: { + clusterConfig, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withClusterInfo( + clusterInfo: Partial, + isLoading = false, + error?: string, + ): MockStoreBuilder { + this.state = { + ...this.state, + clusterInfo: { + clusterInfo, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withBranding(branding: BrandingData, isLoading = false, error?: string): MockStoreBuilder { + this.state = { + ...this.state, + branding: { + data: branding, + isLoading, + error, + }, + }; + return this; + } + + public withFactoryResolver( + options: { + resolver?: Partial; + }, + isLoading = false, + ): MockStoreBuilder { + this.state = { + ...this.state, + factoryResolver: { + resolver: options.resolver, + isLoading, + }, + } as RootState; + return this; + } + + public withInfrastructureNamespace( + namespaces: che.KubernetesNamespace[], + isLoading = false, + error?: string, + ): MockStoreBuilder { + this.state = { + ...this.state, + infrastructureNamespaces: { + namespaces, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withPlugins(plugins: che.Plugin[], isLoading = false, error?: string): MockStoreBuilder { + this.state = { + ...this.state, + plugins: { + plugins, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withUserProfile(profile: api.IUserProfile, error?: string): MockStoreBuilder { + this.state = { + ...this.state, + userProfile: { + userProfile: profile, + isLoading: false, + error, + }, + } as RootState; + return this; + } + + public withDevfileRegistries( + options: { + devfiles?: { [location: string]: { content?: string; error?: string } }; + registries?: { [location: string]: { metadata?: che.DevfileMetaData[]; error?: string } }; + devWorkspaceResources?: { + [location: string]: { resources?: DevWorkspaceResources; error?: string }; + }; + filter?: string; + }, + isLoading = false, + ): MockStoreBuilder { + this.state = { + ...this.state, + devfileRegistries: { + isLoading, + devfiles: options.devfiles || this.state.devfileRegistries?.devfiles || {}, + registries: options.registries || this.state.devfileRegistries?.registries || {}, + devWorkspaceResources: + options.devWorkspaceResources || + this.state.devfileRegistries?.devWorkspaceResources || + {}, + filter: options.filter || '', + }, + } as RootState; + return this; + } + + public withDevWorkspacesCluster( + options: { isRunningDevWorkspacesClusterLimitExceeded: boolean }, + isLoading = false, + error?: string, + ): MockStoreBuilder { + this.state = { + ...this.state, + devWorkspacesCluster: { + isRunningDevWorkspacesClusterLimitExceeded: + options.isRunningDevWorkspacesClusterLimitExceeded, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withDevWorkspaces( + options: { + workspaces?: devfileApi.DevWorkspace[]; + startedWorkspaces?: { [uid: string]: string }; + warnings?: { [uid: string]: string }; + }, + isLoading = false, + error?: string, + ): MockStoreBuilder { + this.state = { + ...this.state, + devWorkspaces: { + workspaces: options.workspaces || this.state.devWorkspaces?.workspaces || [], + startedWorkspaces: + options.startedWorkspaces || this.state.devWorkspaces?.startedWorkspaces || {}, + warnings: options.warnings || this.state.devWorkspaces?.warnings || {}, + isLoading, + error, + }, + } as RootState; + return this; + } + + public withWorkspaces( + options: { + namespace?: string; + workspaceName?: string; + workspaceUID?: string; + recentNumber?: number; + }, + isLoading = false, + ): MockStoreBuilder { + this.state = { + ...this.state, + workspaces: { + namespace: options.namespace || '', + workspaceName: options.workspaceName || '', + workspaceUID: options.workspaceUID || '', + recentNumber: options.recentNumber || 0, + isLoading, + }, + }; + return this; + } + + public withDwPlugins( + plugins: { [url: string]: PluginDefinition }, + editors: { [url: string]: PluginDefinition }, + isLoading = false, + cmEditors?: devfileApi.Devfile[], + defaultEditorError?: string, + defaultEditorName?: string, + ) { + this.state = { + ...this.state, + dwPlugins: { + defaultEditorError, + plugins, + editors, + isLoading, + defaultEditorName, + cmEditors, + }, + } as RootState; + return this; + } + + public withEvents( + options: { events: CoreV1Event[]; error?: string; resourceVersion?: string }, + isLoading = false, + ): MockStoreBuilder { + this.state = { + ...this.state, + events: { + events: options.events, + error: options.error, + resourceVersion: options.resourceVersion, + isLoading, + }, + } as RootState; + return this; + } + + public withPods( + options: { pods: V1Pod[]; error?: string; resourceVersion?: string }, + isLoading = false, + ): MockStoreBuilder { + this.state = { + ...this.state, + pods: { + pods: options.pods, + error: options.error, + resourceVersion: options.resourceVersion, + isLoading, + }, + } as RootState; + return this; + } + + public withSanityCheck(options: { authorized?: boolean; error?: string; lastFetched?: number }) { + this.state = { + ...this.state, + sanityCheck: { + authorized: options.authorized, + error: options.error, + lastFetched: options.lastFetched || Date.now(), + }, + } as RootState; + return this; + } + + public withLogs(logs: PodLogsState['logs']) { + this.state = { + ...this.state, + logs: { + logs, + }, + }; + return this; + } + + public withPersonalAccessTokens( + options: { tokens: api.PersonalAccessToken[]; error?: string }, + isLoading = false, + ) { + this.state = { + ...this.state, + personalAccessToken: { + tokens: options.tokens, + error: options.error, + isLoading, + }, + }; + return this; + } + + public withCheUserId(options: { cheUserId: string; error?: string }, isLoading = false) { + this.state = { + ...this.state, + userId: { + cheUserId: options.cheUserId, + error: options.error, + isLoading, + }, + }; + return this; + } + + public withGitConfig( + options: { + config?: api.IGitConfig; + error?: string; + }, + isLoading = false, + ) { + this.state = { + ...this.state, + gitConfig: { + ...this.state.gitConfig, + config: options.config, + error: options.error, + isLoading, + }, + }; + return this; + } + + public withSshKeys( + options: { + keys?: api.SshKey[]; + error?: string; + }, + isLoading = false, + ) { + this.state = { + ...this.state, + sshKeys: { + keys: options.keys || [], + error: options.error, + isLoading, + }, + }; + return this; + } + + public withWorkspacePreferences( + options: { + 'skip-authorisation'?: api.GitProvider[]; + 'trusted-sources'?: api.TrustedSources; + error?: string; + }, + isLoading = false, + ) { + this.state = { + ...this.state, + workspacePreferences: { + preferences: options, + error: options.error, + isLoading, + }, + } as RootState; + return this; + } + + public build() { + return configureStore({ + reducer: rootReducer, + preloadedState: this.state, + }); + } +} diff --git a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts b/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts deleted file mode 100644 index 5259b5b7d..000000000 --- a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts +++ /dev/null @@ -1,526 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { api, ClusterConfig, ClusterInfo } from '@eclipse-che/common'; -import { CoreV1Event, V1Pod } from '@kubernetes/client-node'; -import { AnyAction } from 'redux'; -import createMockStore, { MockStoreEnhanced } from 'redux-mock-store'; -import { ThunkDispatch } from 'redux-thunk'; - -import { BrandingData } from '@/services/bootstrap/branding.constant'; -import devfileApi from '@/services/devfileApi'; -import { che } from '@/services/models'; -import mockThunk from '@/store/__mocks__/thunk'; -import { State as BrandingState } from '@/store/Branding'; -import { DevWorkspaceResources, State as DevfileRegistriesState } from '@/store/DevfileRegistries'; -import { RegistryEntry } from '@/store/DockerConfig/types'; -import { FactoryResolverState, FactoryResolverStateResolver } from '@/store/FactoryResolver'; -import { IGitOauth } from '@/store/GitOauthConfig/types'; -import { State as InfrastructureNamespaceState } from '@/store/InfrastructureNamespaces'; -import { State as PluginsState } from '@/store/Plugins/chePlugins'; -import { PluginDefinition } from '@/store/Plugins/devWorkspacePlugins'; -import { State as LogsState } from '@/store/Pods/Logs'; -import { State as UserProfileState } from '@/store/User/Profile'; -import { State as WorkspacesState } from '@/store/Workspaces'; - -import { AppState } from '..'; - -export class FakeStoreBuilder { - private state: AppState = { - bannerAlert: { - messages: [], - }, - clusterConfig: { - isLoading: false, - clusterConfig: { - runningWorkspacesLimit: 1, - allWorkspacesLimit: -1, - }, - }, - events: { - isLoading: false, - events: [], - resourceVersion: '0', - }, - pods: { - isLoading: false, - pods: [], - resourceVersion: '0', - }, - dwServerConfig: { - isLoading: false, - config: { - containerBuild: {}, - defaults: { - editor: undefined, - components: [], - plugins: [], - pvcStrategy: '', - }, - pluginRegistry: { - openVSXURL: '', - }, - timeouts: { - inactivityTimeout: -1, - runTimeout: -1, - startTimeout: 300, - }, - defaultNamespace: { - autoProvision: true, - }, - cheNamespace: '', - devfileRegistry: { - disableInternalRegistry: false, - externalDevfileRegistries: [], - }, - pluginRegistryURL: '', - pluginRegistryInternalURL: '', - allowedSourceUrls: [], - } as api.IServerConfig, - }, - clusterInfo: { - isLoading: false, - clusterInfo: { - applications: [], - }, - }, - factoryResolver: { - isLoading: false, - resolver: {}, - } as FactoryResolverState, - plugins: { - isLoading: false, - plugins: [], - } as PluginsState, - sanityCheck: { - authorized: Promise.resolve(true), - lastFetched: 0, - }, - workspaces: { - isLoading: false, - namespace: '', - workspaceName: '', - workspaceUID: '', - recentNumber: 5, - } as WorkspacesState, - workspacePreferences: { - isLoading: false, - preferences: { - 'skip-authorisation': [], - }, - }, - devWorkspaces: { - isLoading: false, - workspaces: [], - resourceVersion: '0', - startedWorkspaces: {}, - warnings: {}, - }, - devWorkspacesCluster: { - isLoading: false, - isRunningDevWorkspacesClusterLimitExceeded: false, - }, - branding: { - isLoading: false, - data: {}, - } as BrandingState, - gitOauthConfig: { - isLoading: false, - gitOauth: [], - providersWithToken: [], - skipOauthProviders: [], - error: undefined, - }, - devfileRegistries: { - isLoading: false, - devfiles: {}, - filter: '', - registries: {}, - devWorkspaceResources: {}, - } as DevfileRegistriesState, - userId: { - cheUserId: '', - isLoading: false, - }, - userProfile: { - isLoading: false, - userProfile: {}, - } as UserProfileState, - infrastructureNamespaces: { - isLoading: false, - namespaces: [], - } as InfrastructureNamespaceState, - dwPlugins: { - isLoading: false, - editors: {}, - plugins: {}, - defaultPlugins: {}, - }, - dockerConfig: { - isLoading: false, - registries: [], - error: undefined, - }, - logs: { - logs: {}, - }, - personalAccessToken: { - isLoading: false, - tokens: [], - }, - gitConfig: { - config: undefined, - isLoading: false, - error: undefined, - }, - sshKeys: { - isLoading: false, - keys: [], - error: undefined, - }, - }; - - constructor(store?: MockStoreEnhanced>) { - if (store) { - this.state = store.getState(); - } - } - - public withDwServerConfig(config: Partial): FakeStoreBuilder { - this.state.dwServerConfig = { - isLoading: false, - config: { ...this.state.dwServerConfig.config, ...config }, - }; - return this; - } - - public withBannerAlert(messages: string[]): FakeStoreBuilder { - this.state.bannerAlert.messages = [...messages]; - return this; - } - - public withGitOauthConfig( - gitOauth: IGitOauth[], - providersWithToken: api.GitOauthProvider[], - skipOauthProviders: api.GitOauthProvider[], - isLoading = false, - error?: string, - ): FakeStoreBuilder { - this.state.gitOauthConfig.gitOauth = gitOauth; - this.state.gitOauthConfig.providersWithToken = providersWithToken; - this.state.gitOauthConfig.skipOauthProviders = skipOauthProviders; - this.state.gitOauthConfig.isLoading = isLoading; - this.state.gitOauthConfig.error = error; - return this; - } - - public withDockerConfig( - registries: RegistryEntry[], - isLoading = false, - error?: string, - ): FakeStoreBuilder { - this.state.dockerConfig.registries = registries; - this.state.dockerConfig.isLoading = isLoading; - this.state.dockerConfig.error = error; - return this; - } - - public withClusterConfig( - clusterConfig: Partial | undefined, - isLoading = false, - error?: string, - ): FakeStoreBuilder { - if (clusterConfig) { - this.state.clusterConfig.clusterConfig = Object.assign({}, clusterConfig as ClusterConfig); - } - this.state.clusterConfig.isLoading = isLoading; - this.state.clusterConfig.error = error; - return this; - } - - public withClusterInfo( - clusterInfo: Partial, - isLoading = false, - error?: string, - ): FakeStoreBuilder { - this.state.clusterInfo.clusterInfo = Object.assign({}, clusterInfo as ClusterInfo); - this.state.clusterInfo.isLoading = isLoading; - this.state.clusterInfo.error = error; - return this; - } - - public withBranding(branding: BrandingData, isLoading = false, error?: string): FakeStoreBuilder { - this.state.branding.data = Object.assign({}, branding); - this.state.branding.isLoading = isLoading; - this.state.branding.error = error; - return this; - } - - public withFactoryResolver( - options: { - resolver?: Partial; - }, - isLoading = false, - ): FakeStoreBuilder { - if (options.resolver) { - this.state.factoryResolver.resolver = Object.assign( - {}, - options.resolver as FactoryResolverStateResolver, - ); - } else { - delete this.state.factoryResolver.resolver; - } - this.state.factoryResolver.isLoading = isLoading; - return this; - } - - public withInfrastructureNamespace( - namespaces: che.KubernetesNamespace[], - isLoading = false, - error?: string, - ): FakeStoreBuilder { - this.state.infrastructureNamespaces.namespaces = Object.assign([], namespaces); - this.state.infrastructureNamespaces.isLoading = isLoading; - this.state.infrastructureNamespaces.error = error; - return this; - } - - public withPlugins(plugins: che.Plugin[], isLoading = false, error?: string): FakeStoreBuilder { - this.state.plugins.plugins = Object.assign([], plugins); - this.state.plugins.isLoading = isLoading; - this.state.plugins.error = error; - return this; - } - - public withUserProfile(profile: api.IUserProfile, error?: string): FakeStoreBuilder { - this.state.userProfile.userProfile = Object.assign({}, profile); - this.state.userProfile.error = error; - return this; - } - - public withDevfileRegistries( - options: { - devfiles?: { [location: string]: { content?: string; error?: string } }; - registries?: { [location: string]: { metadata?: che.DevfileMetaData[]; error?: string } }; - devWorkspaceResources?: { - [location: string]: { resources?: DevWorkspaceResources; error?: string }; - }; - filter?: string; - }, - isLoading = false, - ): FakeStoreBuilder { - if (options.devfiles) { - this.state.devfileRegistries.devfiles = Object.assign({}, options.devfiles); - } - if (options.registries) { - this.state.devfileRegistries.registries = Object.assign({}, options.registries); - } - if (options.devWorkspaceResources) { - this.state.devfileRegistries.devWorkspaceResources = Object.assign( - {}, - options.devWorkspaceResources, - ); - } - if (options.filter) { - this.state.devfileRegistries.filter = options.filter; - } - this.state.devfileRegistries.isLoading = isLoading; - return this; - } - - public withDevWorkspacesCluster( - options: { isRunningDevWorkspacesClusterLimitExceeded: boolean }, - isLoading = false, - error?: string, - ): FakeStoreBuilder { - this.state.devWorkspacesCluster.isRunningDevWorkspacesClusterLimitExceeded = - options.isRunningDevWorkspacesClusterLimitExceeded; - this.state.devWorkspacesCluster.isLoading = isLoading; - this.state.devWorkspacesCluster.error = error; - return this; - } - - public withDevWorkspaces( - options: { - workspaces?: devfileApi.DevWorkspace[]; - startedWorkspaces?: { [uid: string]: string }; - warnings?: { [uid: string]: string }; - }, - isLoading = false, - error?: string, - ): FakeStoreBuilder { - if (options.workspaces) { - this.state.devWorkspaces.workspaces = Object.assign([], options.workspaces); - } - if (options.startedWorkspaces) { - this.state.devWorkspaces.startedWorkspaces = Object.assign({}, options.startedWorkspaces); - } - if (options.warnings) { - this.state.devWorkspaces.warnings = Object.assign({}, options.warnings); - } - this.state.devWorkspaces.isLoading = isLoading; - this.state.devWorkspaces.error = error; - return this; - } - - public withWorkspaces( - options: { - namespace?: string; - workspaceName?: string; - workspaceUID?: string; - recentNumber?: number; - }, - isLoading = false, - ): FakeStoreBuilder { - if (options.namespace) { - this.state.workspaces.namespace = options.namespace; - } - if (options.workspaceName) { - this.state.workspaces.workspaceName = options.workspaceName; - } - if (options.workspaceUID) { - this.state.workspaces.workspaceUID = options.workspaceUID; - } - if (options.recentNumber) { - this.state.workspaces.recentNumber = options.recentNumber; - } - this.state.workspaces.isLoading = isLoading; - return this; - } - - public withDwPlugins( - plugins: { [url: string]: PluginDefinition }, - editors: { [url: string]: PluginDefinition }, - isLoading = false, - cmEditors?: devfileApi.Devfile[], - defaultEditorError?: string, - defaultEditorName?: string, - ) { - this.state.dwPlugins.defaultEditorError = defaultEditorError; - this.state.dwPlugins.plugins = Object.assign({}, plugins); - this.state.dwPlugins.editors = Object.assign({}, editors); - this.state.dwPlugins.isLoading = isLoading; - this.state.dwPlugins.defaultEditorName = defaultEditorName; - this.state.dwPlugins.cmEditors = cmEditors; - - return this; - } - - public withEvents( - options: { events: CoreV1Event[]; error?: string; resourceVersion?: string }, - isLoading = false, - ): FakeStoreBuilder { - this.state.events.events = Object.assign([], options.events); - this.state.events.error = options.error; - this.state.events.resourceVersion = - options.resourceVersion || this.state.events.resourceVersion; - this.state.events.isLoading = isLoading; - return this; - } - - public withPods( - options: { pods: V1Pod[]; error?: string; resourceVersion?: string }, - isLoading = false, - ): FakeStoreBuilder { - this.state.pods.pods = Object.assign([], options.pods); - this.state.pods.error = options.error; - this.state.pods.resourceVersion = options.resourceVersion || this.state.pods.resourceVersion; - this.state.pods.isLoading = isLoading; - return this; - } - - public withSanityCheck(options: { - authorized?: Promise; - error?: string; - lastFetched?: number; - }) { - this.state.sanityCheck = Object.assign({}, this.state.sanityCheck, options); - if (this.state.sanityCheck.lastFetched === undefined) { - this.state.sanityCheck.lastFetched = Date.now(); - } - return this; - } - - public withLogs(logs: LogsState['logs']) { - this.state.logs.logs = Object.assign({}, logs); - return this; - } - - public withPersonalAccessTokens( - options: { tokens: api.PersonalAccessToken[]; error?: string }, - isLoading = false, - ) { - this.state.personalAccessToken.tokens = Object.assign([], options.tokens); - this.state.personalAccessToken.error = options.error; - this.state.personalAccessToken.isLoading = isLoading; - return this; - } - - public withCheUserId(options: { cheUserId: string; error?: string }, isLoading = false) { - this.state.userId.cheUserId = options.cheUserId; - this.state.userId.error = options.error; - this.state.userId.isLoading = isLoading; - return this; - } - - public withGitConfig( - options: { - config?: api.IGitConfig; - error?: string; - }, - isLoading = false, - ) { - this.state.gitConfig.config = options.config; - this.state.gitConfig.error = options.error; - - this.state.gitConfig.isLoading = isLoading; - return this; - } - - public withSshKeys( - options: { - keys?: api.SshKey[]; - error?: string; - }, - isLoading = false, - ) { - this.state.sshKeys.keys = options.keys || []; - this.state.sshKeys.error = options.error; - - this.state.sshKeys.isLoading = isLoading; - return this; - } - - public withWorkspacePreferences( - options: { - 'skip-authorisation'?: api.GitProvider[]; - 'trusted-sources'?: api.TrustedSources; - error?: string; - }, - isLoading = false, - ) { - if (options['skip-authorisation']) { - this.state.workspacePreferences.preferences['skip-authorisation'] = - options['skip-authorisation']; - } - this.state.workspacePreferences.preferences['trusted-sources'] = options['trusted-sources']; - this.state.workspacePreferences.error = options.error; - this.state.workspacePreferences.isLoading = isLoading; - return this; - } - - public build(): MockStoreEnhanced> { - const middlewares = [mockThunk]; - const mockStore = createMockStore(middlewares); - return mockStore(this.state); - } -} diff --git a/packages/dashboard-frontend/src/store/__tests__/helpers.spec.ts b/packages/dashboard-frontend/src/store/__tests__/helpers.spec.ts deleted file mode 100644 index 3cf71eb73..000000000 --- a/packages/dashboard-frontend/src/store/__tests__/helpers.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { createObject } from '@/store/helpers'; - -describe('Store helpers', () => { - describe('Creates a new state object', () => { - it('should copy all enumerable own properties from two source objects to a new one', () => { - const source = { a: [1], b: [2, 3] }; - const newSource = { b: [3], c: [4] }; - - const target = createObject(source, newSource); - - expect(source).toEqual({ a: [1], b: [2, 3] }); - expect(newSource).toEqual({ b: [3], c: [4] }); - expect(target).toEqual({ a: [1], b: [3], c: [4] }); - }); - }); -}); diff --git a/packages/dashboard-frontend/src/store/configureStore.ts b/packages/dashboard-frontend/src/store/configureStore.ts deleted file mode 100644 index 49ec560b2..000000000 --- a/packages/dashboard-frontend/src/store/configureStore.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { History } from 'history'; -import { applyMiddleware, combineReducers, compose, createStore, Store } from 'redux'; -import thunk from 'redux-thunk'; - -import { sanityCheckMiddleware } from '@/store/sanityCheckMiddleware'; - -import { AppState, reducers } from '.'; - -export default function configureStore(history: History, initialState?: AppState): Store { - const rootReducer = combineReducers({ - ...reducers, - }); - - const middleware = [thunk, sanityCheckMiddleware]; - - const enhancers: Array<() => void> = []; - const windowIfDefined = typeof window === 'undefined' ? null : (window as Window); - if (windowIfDefined && windowIfDefined['__REDUX_DEVTOOLS_EXTENSION__']) { - enhancers.push(windowIfDefined['__REDUX_DEVTOOLS_EXTENSION__']()); - } - - return createStore( - rootReducer, - initialState, - compose(applyMiddleware(...middleware), ...enhancers), - ); -} diff --git a/packages/dashboard-frontend/src/store/helpers.ts b/packages/dashboard-frontend/src/store/helpers.ts deleted file mode 100644 index cd3ee494f..000000000 --- a/packages/dashboard-frontend/src/store/helpers.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -/** - * Creates a new object where it takes the first (target) object - * and fills it with fields from the second object(source). - * Note: it does not merge nested objects, maps or arrays but - * just overrides them. You will need to use it on nested level - * where merging is needed. Like: - * target = {"map": {"target": "value", ...}} - * source = {"map": {"source": "value", ...}} - * newObject.map = createObject(target.map, source.map); - * - * @param target an object that is usually a store state, e.g workspaces, plugins. - * @param source a slice of a target object - */ -export function createObject(target: T, source: Partial): T { - return Object.assign({}, target, source); -} diff --git a/packages/dashboard-frontend/src/store/GitOauthConfig/types.ts b/packages/dashboard-frontend/src/store/hooks.ts similarity index 56% rename from packages/dashboard-frontend/src/store/GitOauthConfig/types.ts rename to packages/dashboard-frontend/src/store/hooks.ts index 86c1e7d99..e6a443ac9 100644 --- a/packages/dashboard-frontend/src/store/GitOauthConfig/types.ts +++ b/packages/dashboard-frontend/src/store/hooks.ts @@ -10,12 +10,9 @@ * Red Hat, Inc. - initial API and implementation */ -import { api } from '@eclipse-che/common'; +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import { che } from '@/services/models'; +import { AppDispatch, RootState } from '@/store'; -export interface IGitOauth { - name: api.GitOauthProvider; - endpointUrl: string; - links?: che.api.core.rest.Link[]; -} +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/packages/dashboard-frontend/src/store/index.ts b/packages/dashboard-frontend/src/store/index.ts index 1d1dbf743..7958a6966 100644 --- a/packages/dashboard-frontend/src/store/index.ts +++ b/packages/dashboard-frontend/src/store/index.ts @@ -10,98 +10,32 @@ * Red Hat, Inc. - initial API and implementation */ -import { Action } from 'redux'; -import { ThunkAction } from 'redux-thunk'; +import { configureStore, ThunkAction, UnknownAction } from '@reduxjs/toolkit'; +import logger from 'redux-logger'; -import * as BannerAlertStore from '@/store/BannerAlert'; -import * as BrandingStore from '@/store/Branding'; -import * as ClusterConfig from '@/store/ClusterConfig'; -import * as ClusterInfo from '@/store/ClusterInfo'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; -import * as DevWorkspacesClusterStore from '@/store/DevWorkspacesCluster'; -import * as DockerConfigStore from '@/store/DockerConfig'; -import * as EventsStore from '@/store/Events'; -import { factoryResolverReducer, FactoryResolverState } from '@/store/FactoryResolver'; -import * as GitConfigStore from '@/store/GitConfig'; -import * as GitOauthConfigStore from '@/store/GitOauthConfig'; -import * as InfrastructureNamespacesStore from '@/store/InfrastructureNamespaces'; -import * as PersonalAccessToken from '@/store/PersonalAccessToken'; -import * as PluginsStore from '@/store/Plugins/chePlugins'; -import * as DwPluginsStore from '@/store/Plugins/devWorkspacePlugins'; -import * as PodsStore from '@/store/Pods'; -import * as LogsStore from '@/store/Pods/Logs'; -import * as SanityCheckStore from '@/store/SanityCheck'; -import * as DwServerConfigStore from '@/store/ServerConfig'; -import * as SshKeysStore from '@/store/SshKeys'; -import * as UserIdStore from '@/store/User/Id'; -import * as UserProfileStore from '@/store/User/Profile'; -import * as WorkspacesStore from '@/store/Workspaces'; -import * as DevWorkspacesStore from '@/store/Workspaces/devWorkspaces'; -import { - workspacePreferencesReducer, - WorkspacePreferencesState, -} from '@/store/Workspaces/Preferences'; +import { rootReducer } from '@/store/rootReducer'; -// the top-level state object -export interface AppState { - bannerAlert: BannerAlertStore.State; - branding: BrandingStore.State; - clusterConfig: ClusterConfig.State; - clusterInfo: ClusterInfo.State; - devfileRegistries: DevfileRegistriesStore.State; - devWorkspaces: DevWorkspacesStore.State; - devWorkspacesCluster: DevWorkspacesClusterStore.State; - dockerConfig: DockerConfigStore.State; - dwPlugins: DwPluginsStore.State; - dwServerConfig: DwServerConfigStore.State; - events: EventsStore.State; - factoryResolver: FactoryResolverState; - gitConfig: GitConfigStore.State; - gitOauthConfig: GitOauthConfigStore.State; - infrastructureNamespaces: InfrastructureNamespacesStore.State; - logs: LogsStore.State; - personalAccessToken: PersonalAccessToken.State; - plugins: PluginsStore.State; - pods: PodsStore.State; - sanityCheck: SanityCheckStore.State; - sshKeys: SshKeysStore.State; - userId: UserIdStore.State; - userProfile: UserProfileStore.State; - workspaces: WorkspacesStore.State; - workspacePreferences: WorkspacePreferencesState; -} +export const store = configureStore({ + reducer: rootReducer, + middleware: getDefaultMiddleware => { + const middlewares = getDefaultMiddleware({ + // serializableCheck: false, + // immutableCheck: true, + }); + if (process.env.NODE_ENV === 'development') { + middlewares.push(logger); + } + return middlewares; + }, + devTools: process.env.NODE_ENV === 'development', +}); -export const reducers = { - bannerAlert: BannerAlertStore.reducer, - branding: BrandingStore.reducer, - clusterConfig: ClusterConfig.reducer, - clusterInfo: ClusterInfo.reducer, - devfileRegistries: DevfileRegistriesStore.reducer, - devWorkspaces: DevWorkspacesStore.reducer, - devWorkspacesCluster: DevWorkspacesClusterStore.reducer, - dockerConfig: DockerConfigStore.reducer, - dwPlugins: DwPluginsStore.reducer, - dwServerConfig: DwServerConfigStore.reducer, - events: EventsStore.reducer, - factoryResolver: factoryResolverReducer, - gitConfig: GitConfigStore.reducer, - gitOauthConfig: GitOauthConfigStore.reducer, - infrastructureNamespaces: InfrastructureNamespacesStore.reducer, - logs: LogsStore.reducer, - personalAccessToken: PersonalAccessToken.reducer, - plugins: PluginsStore.reducer, - pods: PodsStore.reducer, - sanityCheck: SanityCheckStore.reducer, - sshKeys: SshKeysStore.reducer, - userId: UserIdStore.reducer, - userProfile: UserProfileStore.reducer, - workspacePreferences: workspacePreferencesReducer, - workspaces: WorkspacesStore.reducer, -}; +export type RootState = ReturnType; -export type AppThunk = ThunkAction< +export type AppDispatch = typeof store.dispatch; +export type AppThunk> = ThunkAction< ReturnType, - AppState, + RootState, unknown, - ActionType + UnknownAction >; diff --git a/packages/dashboard-frontend/src/store/rootReducer.ts b/packages/dashboard-frontend/src/store/rootReducer.ts new file mode 100644 index 000000000..9a70fc5a0 --- /dev/null +++ b/packages/dashboard-frontend/src/store/rootReducer.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { bannerAlertReducer } from '@/store/BannerAlert'; +import { brandingReducer } from '@/store/Branding'; +import { clusterConfigReducer } from '@/store/ClusterConfig'; +import { clusterInfoReducer } from '@/store/ClusterInfo'; +import { devfileRegistriesReducer } from '@/store/DevfileRegistries'; +import { devWorkspacesClusterReducer } from '@/store/DevWorkspacesCluster'; +import { dockerConfigReducer } from '@/store/DockerConfig'; +import { eventsReducer } from '@/store/Events'; +import { factoryResolverReducer } from '@/store/FactoryResolver'; +import { gitConfigReducer } from '@/store/GitConfig'; +import { gitOauthConfigReducer } from '@/store/GitOauthConfig'; +import { infrastructureNamespacesReducer } from '@/store/InfrastructureNamespaces'; +import { personalAccessTokenReducer } from '@/store/PersonalAccessTokens'; +import { chePluginsReducer } from '@/store/Plugins/chePlugins'; +import { devWorkspacePluginsReducer } from '@/store/Plugins/devWorkspacePlugins'; +import { podsReducer } from '@/store/Pods'; +import { podLogsReducer } from '@/store/Pods/Logs'; +import { sanityCheckReducer } from '@/store/SanityCheck'; +import { serverConfigReducer } from '@/store/ServerConfig'; +import { sshKeysReducer } from '@/store/SshKeys'; +import { UserIdReducer } from '@/store/User/Id'; +import { UserProfileReducer } from '@/store/User/Profile'; +import { workspacesReducer } from '@/store/Workspaces'; +import { devWorkspacesReducer } from '@/store/Workspaces/devWorkspaces'; +import { workspacePreferencesReducer } from '@/store/Workspaces/Preferences'; + +export const rootReducer = { + bannerAlert: bannerAlertReducer, + branding: brandingReducer, + clusterConfig: clusterConfigReducer, + clusterInfo: clusterInfoReducer, + devfileRegistries: devfileRegistriesReducer, + devWorkspaces: devWorkspacesReducer, + devWorkspacesCluster: devWorkspacesClusterReducer, + dockerConfig: dockerConfigReducer, + dwPlugins: devWorkspacePluginsReducer, + dwServerConfig: serverConfigReducer, + events: eventsReducer, + factoryResolver: factoryResolverReducer, + gitConfig: gitConfigReducer, + gitOauthConfig: gitOauthConfigReducer, + infrastructureNamespaces: infrastructureNamespacesReducer, + logs: podLogsReducer, + personalAccessToken: personalAccessTokenReducer, + plugins: chePluginsReducer, + pods: podsReducer, + sanityCheck: sanityCheckReducer, + sshKeys: sshKeysReducer, + userId: UserIdReducer, + userProfile: UserProfileReducer, + workspacePreferences: workspacePreferencesReducer, + workspaces: workspacesReducer, +}; diff --git a/packages/dashboard-frontend/src/store/sanityCheckMiddleware.ts b/packages/dashboard-frontend/src/store/sanityCheckMiddleware.ts deleted file mode 100644 index 78d10d0b5..000000000 --- a/packages/dashboard-frontend/src/store/sanityCheckMiddleware.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { AnyAction } from 'redux'; -import { ThunkMiddleware } from 'redux-thunk'; - -import * as AuthorizationStore from '@/store/SanityCheck'; - -import { AppState } from '.'; - -export const AUTHORIZED = Symbol('Authorized resource'); - -export interface SanityCheckAction extends AnyAction { - check: typeof AUTHORIZED; -} - -function isSanityCheck(action: AnyAction): action is SanityCheckAction { - return action.check === AUTHORIZED; -} - -export const sanityCheckMiddleware: ThunkMiddleware = - storeApi => next => async action => { - if (isSanityCheck(action)) { - try { - await AuthorizationStore.actionCreators.testBackends()( - storeApi.dispatch, - storeApi.getState, - undefined, - ); - } catch (e) { - // noop - } - } - return next(action); - }; diff --git a/yarn.lock b/yarn.lock index 62550c128..c153184af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -398,15 +398,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.15.4": - version: 7.24.5 - resolution: "@babel/runtime@npm:7.24.5" - dependencies: - regenerator-runtime: ^0.14.0 - checksum: 755383192f3ac32ba4c62bd4f1ae92aed5b82d2c6665f39eb28fa94546777cf5c63493ea92dd03f1c2e621b17e860f190c056684b7f234270fdc91e29beda063 - languageName: node - linkType: hard - "@babel/template@npm:^7.22.15, @babel/template@npm:^7.3.3": version: 7.22.15 resolution: "@babel/template@npm:7.22.15" @@ -708,6 +699,7 @@ __metadata: "@patternfly/react-icons": ^4.93.7 "@patternfly/react-table": ^4.113.3 "@react-mock/state": ^0.1.8 + "@reduxjs/toolkit": ^2.2.7 "@testing-library/dom": ^10.4.0 "@testing-library/jest-dom": ^6.5.0 "@testing-library/react": ^16.0.1 @@ -721,7 +713,8 @@ __metadata: "@types/react-dom": ^18.3.0 "@types/react-router-dom": ^5.3.3 "@types/react-test-renderer": ^18.3.0 - "@types/redux-mock-store": ^1.0.2 + "@types/redux-logger": ^3.0.13 + "@types/redux-mock-store": ^1.0.6 "@types/sanitize-html": ^2.9.0 "@types/webpack": ^5.28.5 "@typescript-eslint/eslint-plugin": ^6.4.0 @@ -770,16 +763,15 @@ __metadata: react-helmet: ^6.1.0 react-markdown: ^9.0.1 react-pluralize: ^1.6.3 - react-redux: ^7.2.9 + react-redux: ^9.1.2 react-router-dom: ^6.26.1 react-test-renderer: ^18.3.1 react-tooltip: ^4.5.1 reconnecting-websocket: ^4.4.0 - redux: ^4.2.1 + redux: ^5.0.1 + redux-logger: ^3.0.6 redux-mock-store: ^1.5.4 - redux-thunk: ^2.4.2 reflect-metadata: ^0.1.13 - reselect: ^4.1.8 sanitize-html: ^2.11.0 source-map-loader: ^4.0.1 speed-measure-webpack-plugin: ^1.5.0 @@ -1679,6 +1671,26 @@ __metadata: languageName: node linkType: hard +"@reduxjs/toolkit@npm:^2.2.7": + version: 2.2.7 + resolution: "@reduxjs/toolkit@npm:2.2.7" + dependencies: + immer: ^10.0.3 + redux: ^5.0.1 + redux-thunk: ^3.1.0 + reselect: ^5.1.0 + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 039d61d94cec25d07c7813f9cd234cbe83478f65df7622f2dafed609f2f0cbe00565999e33e7d22bf915c3c5bceb7be606ca17a9c55b51ce1841f541f63a3395 + languageName: node + linkType: hard + "@remix-run/router@npm:1.19.1": version: 1.19.1 resolution: "@remix-run/router@npm:1.19.1" @@ -1954,16 +1966,6 @@ __metadata: languageName: node linkType: hard -"@types/hoist-non-react-statics@npm:^3.3.0": - version: 3.3.5 - resolution: "@types/hoist-non-react-statics@npm:3.3.5" - dependencies: - "@types/react": "*" - hoist-non-react-statics: ^3.3.0 - checksum: b645b062a20cce6ab1245ada8274051d8e2e0b2ee5c6bd58215281d0ec6dae2f26631af4e2e7c8abe238cdcee73fcaededc429eef569e70908f82d0cc0ea31d7 - languageName: node - linkType: hard - "@types/html-minifier-terser@npm:^6.0.0": version: 6.1.0 resolution: "@types/html-minifier-terser@npm:6.1.0" @@ -2137,18 +2139,6 @@ __metadata: languageName: node linkType: hard -"@types/react-redux@npm:^7.1.20": - version: 7.1.33 - resolution: "@types/react-redux@npm:7.1.33" - dependencies: - "@types/hoist-non-react-statics": ^3.3.0 - "@types/react": "*" - hoist-non-react-statics: ^3.3.0 - redux: ^4.0.0 - checksum: 063e98c0d8cdc7cc2da1663716260ffb8d504b2f8be2d92cabb630cae31eb05aa0e389175265caa9a160bb7c4b66646d4a4171d4aa2dc292722088dcf593cdc3 - languageName: node - linkType: hard - "@types/react-router-dom@npm:^5.3.3": version: 5.3.3 resolution: "@types/react-router-dom@npm:5.3.3" @@ -2190,12 +2180,21 @@ __metadata: languageName: node linkType: hard -"@types/redux-mock-store@npm:^1.0.2": - version: 1.0.5 - resolution: "@types/redux-mock-store@npm:1.0.5" +"@types/redux-logger@npm:^3.0.13": + version: 3.0.13 + resolution: "@types/redux-logger@npm:3.0.13" + dependencies: + redux: ^5.0.0 + checksum: fb8386c6a0940a3b3864039b5ceb420c5f75186e9ad2b166820e27201b3b0ca6a387bcb766d778343f69b1af768c624911e62e6bca19392e9d5b89c524ef176c + languageName: node + linkType: hard + +"@types/redux-mock-store@npm:^1.0.6": + version: 1.0.6 + resolution: "@types/redux-mock-store@npm:1.0.6" dependencies: redux: ^4.0.5 - checksum: 0ecae2d503f388c0e51e0b0eff10bedf3bfe331943c57b43efc66b84b1c1755079c736e8e099ab5be6336ff20d17a3377847c6d7abe39244cd78a69d0db3063d + checksum: 5c799d2fc5b3f0f84bfcd6243c56b1ac98be6707057f570b5e51e9d305f446978cc2958c7e0b629ebf73293f15a79765517e6d0a1df0371fd27551f7128325c7 languageName: node linkType: hard @@ -2274,6 +2273,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.3": + version: 0.0.3 + resolution: "@types/use-sync-external-store@npm:0.0.3" + checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e + languageName: node + linkType: hard + "@types/webpack@npm:^5.28.5": version: 5.28.5 resolution: "@types/webpack@npm:5.28.5" @@ -4451,6 +4457,13 @@ __metadata: languageName: node linkType: hard +"deep-diff@npm:^0.3.5": + version: 0.3.8 + resolution: "deep-diff@npm:0.3.8" + checksum: 8a0fb6cbe468e50211836f8daa1c14798b2d7436bfbcb7d8eb0902e0d61bf1dfd48d5b9edd46a10596182b90ad25f87461b8e55111ff9257b6067ad0676f79c9 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -6428,15 +6441,6 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": - version: 3.3.2 - resolution: "hoist-non-react-statics@npm:3.3.2" - dependencies: - react-is: ^16.7.0 - checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8 - languageName: node - linkType: hard - "hosted-git-info@npm:^4.0.1": version: 4.1.0 resolution: "hosted-git-info@npm:4.1.0" @@ -6679,6 +6683,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.0.3": + version: 10.1.1 + resolution: "immer@npm:10.1.1" + checksum: 07c67970b7d22aded73607193d84861bf786f07d47f7d7c98bb10016c7a88f6654ad78ae1e220b3c623695b133aabbf24f5eb8d9e8060cff11e89ccd81c9c10b + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" @@ -10728,14 +10739,14 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1, react-is@npm:^16.3.2, react-is@npm:^16.7.0": +"react-is@npm:^16.13.1, react-is@npm:^16.3.2": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f languageName: node linkType: hard -"react-is@npm:^17.0.1, react-is@npm:^17.0.2": +"react-is@npm:^17.0.1": version: 17.0.2 resolution: "react-is@npm:17.0.2" checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 @@ -10780,24 +10791,22 @@ __metadata: languageName: node linkType: hard -"react-redux@npm:^7.2.9": - version: 7.2.9 - resolution: "react-redux@npm:7.2.9" +"react-redux@npm:^9.1.2": + version: 9.1.2 + resolution: "react-redux@npm:9.1.2" dependencies: - "@babel/runtime": ^7.15.4 - "@types/react-redux": ^7.1.20 - hoist-non-react-statics: ^3.3.2 - loose-envify: ^1.4.0 - prop-types: ^15.7.2 - react-is: ^17.0.2 + "@types/use-sync-external-store": ^0.0.3 + use-sync-external-store: ^1.0.0 peerDependencies: - react: ^16.8.3 || ^17 || ^18 + "@types/react": ^18.2.25 + react: ^18.0 + redux: ^5.0.0 peerDependenciesMeta: - react-dom: + "@types/react": optional: true - react-native: + redux: optional: true - checksum: 369a2bdcf87915659af9e5c55abfd9f52a84e43e0d12dcc108ed17dbe6933558b7b7fc12caa9c10c1a10a8be7df89454b6c96989d8573fedec1a772c94a1f145 + checksum: 1ee9cf41f29f68267320b4fc3bcf6a76a3825c82441612582678ddd827a2b60834f687d2a8b755c905885dfce476a1eb41af42b36f4dd71f8ee9991296a1e515 languageName: node linkType: hard @@ -10980,6 +10989,15 @@ __metadata: languageName: node linkType: hard +"redux-logger@npm:^3.0.6": + version: 3.0.6 + resolution: "redux-logger@npm:3.0.6" + dependencies: + deep-diff: ^0.3.5 + checksum: c40f63c44c6475cf6374ae0eaa810d913f142614cb80692a0beacaf135c5dc3eb3e2cdd4296f01446ba48cb69b82e81363b84d829f1f6659382c991022a814ac + languageName: node + linkType: hard + "redux-mock-store@npm:^1.5.4": version: 1.5.4 resolution: "redux-mock-store@npm:1.5.4" @@ -10989,16 +11007,16 @@ __metadata: languageName: node linkType: hard -"redux-thunk@npm:^2.4.2": - version: 2.4.2 - resolution: "redux-thunk@npm:2.4.2" +"redux-thunk@npm:^3.1.0": + version: 3.1.0 + resolution: "redux-thunk@npm:3.1.0" peerDependencies: - redux: ^4 - checksum: c7f757f6c383b8ec26152c113e20087818d18ed3edf438aaad43539e9a6b77b427ade755c9595c4a163b6ad3063adf3497e5fe6a36c68884eb1f1cfb6f049a5c + redux: ^5.0.0 + checksum: bea96f8233975aad4c9f24ca1ffd08ac7ec91eaefc26e7ba9935544dc55d7f09ba2aa726676dab53dc79d0c91e8071f9729cddfea927f4c41839757d2ade0f50 languageName: node linkType: hard -"redux@npm:^4.0.0, redux@npm:^4.0.5, redux@npm:^4.2.1": +"redux@npm:^4.0.5": version: 4.2.1 resolution: "redux@npm:4.2.1" dependencies: @@ -11007,6 +11025,13 @@ __metadata: languageName: node linkType: hard +"redux@npm:^5.0.0, redux@npm:^5.0.1": + version: 5.0.1 + resolution: "redux@npm:5.0.1" + checksum: e74affa9009dd5d994878b9a1ce30d6569d986117175056edb003de2651c05b10fe7819d6fa94aea1a94de9a82f252f986547f007a2fbeb35c317a2e5f5ecf2c + languageName: node + linkType: hard + "reflect-metadata@npm:^0.1.13": version: 0.1.13 resolution: "reflect-metadata@npm:0.1.13" @@ -11154,10 +11179,10 @@ __metadata: languageName: node linkType: hard -"reselect@npm:^4.1.8": - version: 4.1.8 - resolution: "reselect@npm:4.1.8" - checksum: a4ac87cedab198769a29be92bc221c32da76cfdad6911eda67b4d3e7136dca86208c3b210e31632eae31ebd2cded18596f0dd230d3ccc9e978df22f233b5583e +"reselect@npm:^5.1.0": + version: 5.1.1 + resolution: "reselect@npm:5.1.1" + checksum: 5d32d48be29071ddda21a775945c2210cf4ca3fccde1c4a0e1582ac3bf99c431c6c2330ef7ca34eae4c06feea617e7cb2c275c4b33ccf9a930836dfc98b49b13 languageName: node linkType: hard @@ -13067,6 +13092,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.0.0": + version: 1.2.2 + resolution: "use-sync-external-store@npm:1.2.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: fe07c071c4da3645f112c38c0e57beb479a8838616ff4e92598256ecce527f2888c08febc7f9b2f0ce2f0e18540ba3cde41eb2035e4fafcb4f52955037098a81 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2"