diff --git a/frontend/packages/app/src/App.test.tsx b/frontend/packages/app/src/App.test.tsx
index a4b304af28..b67d9918cb 100644
--- a/frontend/packages/app/src/App.test.tsx
+++ b/frontend/packages/app/src/App.test.tsx
@@ -6,7 +6,6 @@ describe('App', () => {
it('renders learn react link', () => {
const rendered = render();
rendered.getByText('This is Backstage!');
- rendered.getByText('Backstage is an open platform for building developer portals');
rendered.getByText('…with plugin hello-world:');
});
});
diff --git a/frontend/packages/core/src/layout/Header/Header.test.js b/frontend/packages/core/src/layout/Header/Header.test.js
index 15d5f3f4b4..0609cbbc17 100644
--- a/frontend/packages/core/src/layout/Header/Header.test.js
+++ b/frontend/packages/core/src/layout/Header/Header.test.js
@@ -1,7 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
-import { Header } from 'shared/components/layout';
-import { wrapInThemedTestApp } from 'testUtils';
+import Header from './Header';
+import { wrapInThemedTestApp } from '../../testUtils';
jest.mock('react-helmet', () => {
return ({ defaultTitle }) =>
defaultTitle: {defaultTitle}
;
diff --git a/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js
index dcfecf5e3f..5212c0c2df 100644
--- a/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js
+++ b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js
@@ -1,6 +1,6 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
-import { wrapInThemedTestApp, Keyboard } from 'testUtils';
+import { wrapInThemedTestApp, Keyboard } from '../../testUtils';
import HeaderActionMenu from './HeaderActionMenu';
describe('', () => {
diff --git a/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js
index 06eccae61c..5c35cc1d4e 100644
--- a/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js
+++ b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js
@@ -1,7 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
-import { HeaderLabel } from 'shared/components/layout';
-import { wrapInThemedTestApp } from 'testUtils';
+import HeaderLabel from './HeaderLabel';
+import { wrapInThemedTestApp } from '../../testUtils';
describe('', () => {
it('should have a label', () => {
diff --git a/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js
index 11ae8f3a89..b4e64f64b0 100644
--- a/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js
+++ b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js
@@ -1,7 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
-import { OwnerHeaderLabel } from 'shared/components/layout';
-import { wrapInThemedTestApp } from 'testUtils';
+import OwnerHeaderLabel from './OwnerHeaderLabel';
+import { wrapInThemedTestApp } from '../../testUtils';
const properOwner = { id: 'tools', name: 'tools', type: 'squad' };
const badOwner = { id: 'tools-xxx', name: 'tools-xxx' };
diff --git a/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js b/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js
index 3941c53b53..740f9fd835 100644
--- a/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js
+++ b/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js
@@ -1,7 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
-import { wrapInTestApp } from 'testUtils';
-
+import { wrapInTestApp } from '../../testUtils';
import BottomLink from './BottomLink';
const minProps = {
diff --git a/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js b/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js
index 88c0e08fa9..ef02efa8a2 100644
--- a/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js
+++ b/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js
@@ -1,6 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
-import { wrapInTestApp } from 'testUtils';
+import { wrapInTestApp } from '../../testUtils';
import InfoCard from './InfoCard';
const minProps = {
diff --git a/frontend/packages/core/src/layout/Navigation/GcpProjectByTools.tsx b/frontend/packages/core/src/layout/Navigation/GcpProjectByTools.tsx
new file mode 100644
index 0000000000..104fcee7d3
--- /dev/null
+++ b/frontend/packages/core/src/layout/Navigation/GcpProjectByTools.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
+
+const SvgGcpProjectByTools = (props: SvgIconProps) =>
+ React.createElement(
+ SvgIcon,
+ props,
+ ,
+ );
+
+export default SvgGcpProjectByTools;
diff --git a/frontend/packages/core/src/layout/Navigation/InfoSquared.tsx b/frontend/packages/core/src/layout/Navigation/InfoSquared.tsx
new file mode 100644
index 0000000000..0f51727069
--- /dev/null
+++ b/frontend/packages/core/src/layout/Navigation/InfoSquared.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
+
+const SvgInfoSquared = (props: SvgIconProps) =>
+ React.createElement(
+ SvgIcon,
+ props,
+ ,
+ );
+
+export default SvgInfoSquared;
diff --git a/frontend/packages/core/src/layout/Navigation/NavItem.test.js b/frontend/packages/core/src/layout/Navigation/NavItem.test.js
index 997b765a12..3cc95b635b 100644
--- a/frontend/packages/core/src/layout/Navigation/NavItem.test.js
+++ b/frontend/packages/core/src/layout/Navigation/NavItem.test.js
@@ -1,9 +1,8 @@
import React from 'react';
import { matchPath } from 'react-router';
import { fireEvent, render } from '@testing-library/react';
-
-import NavItem from 'shared/components/layout/NavItem';
-import { wrapInThemedTestApp } from 'testUtils';
+import NavItem from './NavItem';
+import { wrapInThemedTestApp } from '../../testUtils';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
diff --git a/frontend/packages/core/src/layout/Navigation/Navigation.test.js b/frontend/packages/core/src/layout/Navigation/Navigation.test.js
index d20fa0ddf2..ed18457637 100644
--- a/frontend/packages/core/src/layout/Navigation/Navigation.test.js
+++ b/frontend/packages/core/src/layout/Navigation/Navigation.test.js
@@ -1,17 +1,23 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
-import { Navigation, NavItem } from 'shared/components/layout';
-import { wrapInThemedTestApp } from 'testUtils';
+import Navigation from './Navigation';
+import NavItem from './NavItem';
+import { wrapInThemedTestApp } from '../../testUtils';
-import { GcpProjectIcon, OverviewIcon } from 'shared/icons';
+import { default as GcpProjectIcon } from './GcpProjectByTools';
+import { default as OverviewIcon } from './InfoSquared';
describe('', () => {
it('renders without exploding', () => {
const nav = (
} href={'/'} />
- } href={'/projects'} />
+ }
+ href={'/projects'}
+ />
);
const rendered = render(wrapInThemedTestApp(nav));
@@ -23,7 +29,11 @@ describe('', () => {
const nav = (
} href={'/'} />
- } href={'/projects'} />
+ }
+ href={'/projects'}
+ />
);
const rendered = render(wrapInThemedTestApp(nav));
@@ -35,7 +45,11 @@ describe('', () => {
const nav = (
} href={'/'} />
- } href={'/projects'} />
+ }
+ href={'/projects'}
+ />
);
const rendered = render(wrapInThemedTestApp(nav));
@@ -47,7 +61,11 @@ describe('', () => {
it('should have an anchor with correct href', () => {
const nav = (
- } href={'/projects'} />
+ }
+ href={'/projects'}
+ />
);
const rendered = render(wrapInThemedTestApp(nav));
diff --git a/frontend/packages/core/src/layout/SupportButton/SupportButton.test.js b/frontend/packages/core/src/layout/SupportButton/SupportButton.test.js
index b2289542f1..d008fc42b6 100644
--- a/frontend/packages/core/src/layout/SupportButton/SupportButton.test.js
+++ b/frontend/packages/core/src/layout/SupportButton/SupportButton.test.js
@@ -2,7 +2,7 @@ import React from 'react';
import { render, fireEvent, waitForElement } from '@testing-library/react';
import SupportButton from './SupportButton';
-import { wrapInThemedTestApp, Keyboard } from 'testUtils';
+import { wrapInThemedTestApp, Keyboard } from '../../testUtils';
describe('', () => {
it('renders without exploding', () => {
diff --git a/frontend/packages/core/src/testUtils/Keyboard.js b/frontend/packages/core/src/testUtils/Keyboard.js
new file mode 100644
index 0000000000..6e91a6b562
--- /dev/null
+++ b/frontend/packages/core/src/testUtils/Keyboard.js
@@ -0,0 +1,188 @@
+import { act, fireEvent } from '@testing-library/react';
+
+const codes = {
+ Tab: 9,
+ Enter: 10,
+ Click: 17 /* This keyboard can click, deal with it */,
+ Esc: 27,
+};
+
+export default class Keyboard {
+ static async type(target, input) {
+ await new Keyboard(target).type(input);
+ }
+
+ static async typeDebug(target, input) {
+ await new Keyboard(target, { debug: true }).type(input);
+ }
+
+ static toReadableInput(chars) {
+ return chars.split('').map(char => {
+ switch (char.charCodeAt(0)) {
+ case codes.Tab:
+ return '';
+ case codes.Enter:
+ return '';
+ case codes.Click:
+ return '';
+ case codes.Esc:
+ return '';
+ default:
+ return char;
+ }
+ });
+ }
+
+ static fromReadableInput(input) {
+ return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => {
+ if (name in codes) {
+ return String.fromCharCode(codes[name]);
+ }
+ throw new Error(`Unknown char name: '${name}'`);
+ });
+ }
+
+ constructor(target, { debug = false } = {}) {
+ this.debug = debug;
+
+ if (target.ownerDocument) {
+ this.document = target.ownerDocument;
+ } else if (target.baseElement) {
+ this.document = target.baseElement.ownerDocument;
+ } else {
+ throw TypeError('Keyboard(target): target must be DOM node or react-testing-library render() output');
+ }
+ }
+
+ toString() {
+ return `Keyboard{document=${this.document}, debug=${this.debug}}`;
+ }
+
+ _log(message, ...args) {
+ if (this.debug) {
+ console.log(`[Keyboard] ${message}`, ...args);
+ }
+ }
+
+ _pretty(element) {
+ const attrs = [...element.attributes].map(attr => `${attr.name}="${attr.value}"`).join(' ');
+ return `<${element.nodeName.toLowerCase()} ${attrs}>`;
+ }
+
+ get focused() {
+ return this.document.activeElement;
+ }
+
+ async type(input) {
+ this._log(`sending sequence '${input}' with initial focus ${this._pretty(this.focused)}`);
+ await this.send(Keyboard.fromReadableInput(input));
+ }
+
+ async send(chars) {
+ for (const key of chars.split('')) {
+ const charCode = key.charCodeAt(0);
+
+ if (charCode === codes.Tab) {
+ await this.tab();
+ continue;
+ }
+
+ const focused = this.focused;
+ if (!focused || focused === this.document.body) {
+ throw Error(`No element focused in document while trying to type '${Keyboard.toReadableInput(chars)}'`);
+ }
+ const nextValue = (focused.value || '') + key;
+
+ if (charCode >= 32) {
+ await this._sendKey(key, charCode, () => {
+ this._log(`sending +${key} = '${nextValue}' to ${this._pretty(focused)}`);
+ fireEvent.change(focused, {
+ target: { value: nextValue },
+ bubbles: true,
+ cancelable: true,
+ });
+ });
+ } else if (charCode === codes.Enter) {
+ await this.enter(focused.value || '');
+ } else if (charCode === codes.Esc) {
+ await this.escape();
+ } else if (charCode === codes.Click) {
+ await this.click();
+ } else {
+ throw new Error(`Unsupported char code, ${charCode}`);
+ }
+ }
+ }
+
+ async click() {
+ this._log(`clicking ${this._pretty(this.focused)}`);
+ await act(async () => fireEvent.click(this.focused));
+ }
+
+ async tab() {
+ await this._sendKey('Tab', codes.Tab, () => {
+ const focusable = this.document.querySelectorAll(
+ [
+ 'a[href]',
+ 'area[href]',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ 'button:not([disabled])',
+ 'iframe',
+ 'object',
+ 'embed',
+ '*[tabindex]',
+ '*[contenteditable]',
+ ].join(','),
+ );
+
+ const tabbable = [...focusable].filter(el => {
+ return el.tabIndex >= 0;
+ });
+
+ const focused = this.document.activeElement;
+ const focusedIndex = tabbable.indexOf(focused);
+ const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)];
+
+ this._log(`tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`);
+ nextFocus.focus();
+ });
+ }
+
+ async enter(value) {
+ this._log(`submitting '${value}' via ${this._pretty(this.focused)}`);
+ await act(() =>
+ this._sendKey('Enter', codes.Enter, () => {
+ if (this.focused.type === 'button') {
+ fireEvent.click(this.focused, { target: { value } });
+ } else {
+ fireEvent.submit(this.focused, {
+ target: { value },
+ bubbles: true,
+ cancelable: true,
+ });
+ }
+ }),
+ );
+ }
+
+ async escape() {
+ this._log(`escape from ${this._pretty(this.focused)}`);
+ await act(async () => this._sendKey('Escape', codes.Esc));
+ }
+
+ async _sendKey(key, charCode, action) {
+ const event = { key, charCode, keyCode: charCode, which: charCode };
+ const focused = this.focused;
+
+ if (fireEvent.keyDown(focused, event)) {
+ if (fireEvent.keyPress(focused, event)) {
+ if (action) {
+ action();
+ }
+ }
+ }
+ fireEvent.keyUp(focused, event);
+ }
+}
diff --git a/frontend/packages/core/src/testUtils/Keyboard.test.js b/frontend/packages/core/src/testUtils/Keyboard.test.js
new file mode 100644
index 0000000000..5ea108d8dc
--- /dev/null
+++ b/frontend/packages/core/src/testUtils/Keyboard.test.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import Keyboard from './Keyboard';
+import { render } from '@testing-library/react';
+
+describe('testUtils.Keyboard', () => {
+ it('types into some inputs with focus and submits a form', async () => {
+ const typed1 = [];
+ const typed2 = [];
+ const typed3 = [];
+
+ let submitted = false;
+ const handleSubmit = event => {
+ event.preventDefault();
+ submitted = true;
+ };
+
+ const rendered = render(
+ ,
+ );
+
+ const keyboard = new Keyboard(rendered);
+ await keyboard.send('xy');
+ await keyboard.tab();
+ await keyboard.send('abc');
+ await keyboard.enter();
+
+ expect(typed1).toEqual([]);
+ expect(typed2).toEqual(['x', 'xy']);
+ expect(typed3).toEqual(['a', 'ab', 'abc']);
+ expect(submitted).toBe(true);
+ });
+
+ it('can use Keyboard.type to send readable input', async () => {
+ const typed1 = [];
+ const typed2 = [];
+ const typed3 = [];
+
+ let submitted = false;
+ const handleSubmit = event => {
+ event.preventDefault();
+ submitted = true;
+ };
+
+ const rendered = render(
+ ,
+ );
+
+ await Keyboard.type(rendered, ' a b c ');
+
+ expect(typed1).toEqual(['1a']);
+ expect(typed2).toEqual(['2b']);
+ expect(typed3).toEqual(['3c']);
+ expect(submitted).toBe(true);
+ });
+
+ it('should be able to navigate a radio input with click', async () => {
+ const selections = [];
+
+ const rendered = render(
+ selections.push(value)}>
+
+
+
+
,
+ );
+
+ await Keyboard.type(rendered, ' ');
+
+ expect(selections).toEqual(['a', 'c']);
+ });
+});
diff --git a/frontend/packages/core/src/testUtils/index.js b/frontend/packages/core/src/testUtils/index.js
new file mode 100644
index 0000000000..5c5ed40741
--- /dev/null
+++ b/frontend/packages/core/src/testUtils/index.js
@@ -0,0 +1,281 @@
+/**
+ * Helpers for testing redux components and containers
+ */
+import { MuiThemeProvider } from '@material-ui/core';
+import { ThemeProvider } from '@material-ui/styles';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { ApolloProvider } from '@apollo/react-hooks';
+import { Provider } from 'react-redux';
+import { MemoryRouter } from 'react-router';
+import { Route } from 'react-router-dom';
+import configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+import { V1 } from 'core/app/Themes';
+import defaultAppState from 'core/defaultAppState';
+import store from 'core/store';
+import ErrorBoundary from 'shared/components/ErrorBoundary';
+import { SearchProvider } from 'plugins/searchPage/SearchContext';
+
+import { render } from '@testing-library/react';
+import { FirestoreProvider } from 'shared/apis/firestore';
+import MockFirestoreStorage from 'shared/apis/firestore/MockFirestoreStorage';
+import { UserInfoProvider, defaultMockUser } from 'shared/apis/user';
+
+export { default as Keyboard } from './Keyboard';
+export { default as mockBreakpoint } from './mockBreakpoint';
+
+export function wrapInTestApp(Component, initialRouterEntries, user = defaultMockUser) {
+ const Wrapper = Component instanceof Function ? Component : () => Component;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function wrapInThemedTestApp(component, initialRouterEntries) {
+ const themed = (
+
+ {component}
+
+ );
+ return wrapInTestApp(themed, initialRouterEntries);
+}
+
+export function wrapInSearchTestApp(component) {
+ const search = {component};
+ return wrapInThemedTestApp(search);
+}
+
+export function getTestStore(state) {
+ const appState = state ? state : defaultAppState;
+ const mockStore = configureStore([thunk]);
+ return mockStore(appState);
+}
+
+export function getGoogleProfile() {
+ return {
+ getName: () => 'Niklas',
+ getGivenName: () => 'Niklas',
+ getEmail: () => 'niklas@spotify.com',
+ getImageUrl: () => 'http://img.url',
+ getId: () => '123456',
+ };
+}
+
+const DEFAULT_APOLLO_STATE = {
+ data: { loading: true },
+};
+
+export const ApolloMock = ({ children, state = DEFAULT_APOLLO_STATE }) => {
+ // Hideous mocking of the ApolloClient to use the ApolloProvider
+ // The test do not have information about the GraphQL queries made by the components
+ // and hence we cant use MockedProvider and use the one below.
+ const mockClient = {
+ query: async () => state,
+ watchQuery: () => {
+ return {
+ refetch: () => {},
+ fetchMore: () => {},
+ updateQuery: () => {},
+ startPolling: () => {},
+ stopPolling: () => {},
+ subscribeToMore: () => {},
+ options: {
+ fetchPolicy: {},
+ },
+ getLastResult: () => {},
+ currentResult: () => state,
+ getCurrentResult: () => state,
+ resetQueryStoreErrors: () => {},
+ subscribe: () => {},
+ setOptions: () => Promise.resolve({}),
+ };
+ },
+ };
+ return {children};
+};
+
+/**
+ * Return a builder for a given Component which will permit fluent immutable
+ * configuration and access to @testing-library/react rendering functions.
+ *
+ * A full example will make the utility of this more clear:
+ *
+ * const builder = buildComponentInApp(MyComponent)
+ * .withProps({myProp: 'myValue'})
+ *
+ * // MuiThemeProvider will wrap MyComponent when this is called:
+ * .withTheme()
+ *
+ * // MemoryRouter can be configured with initialEntries:
+ * .withRouterEntries(['/'])
+ *
+ * // ApolloProvider wraps MyComponent with a mock client setup to
+ * // provide the given data:
+ * .withApolloData({
+ * component: {id: 'my-component', system: 'my-system'}
+ * });
+ *
+ * // Test 1: Use the defaults from above when mounting
+ * const { getByText } = builder.render();
+ * expect(getByText('Display Text').querySelectorAll('td')).toHaveLength(1);
+
+ * // Test 2: ApolloRouter provides loading=true state instead of valid data
+ * const { queryByText } = builder.withApolloLoading().render();
+ * queryByText('Content').toBeNull();
+ */
+export function buildComponentInApp(Component) {
+ function createBuilder(opt = {}) {
+ const builder = {
+ withProps: (props = {}) =>
+ createBuilder({
+ ...opt,
+ props: Object.assign(opt.props || {}, props),
+ }),
+ withTheme: (theme = V1) =>
+ createBuilder({
+ ...opt,
+ theme,
+ }),
+ withRouterEntries: routerEntries =>
+ createBuilder({
+ ...opt,
+ routerEntries,
+ }),
+ withApolloLoading: () =>
+ // Support `data.loading` (errorOrLoading) or just `loading` (Query)
+ createBuilder({
+ ...opt,
+ apolloState: { data: { loading: true }, loading: true },
+ }),
+ withApolloData: data =>
+ createBuilder({
+ ...opt,
+ apolloState: { data: { loading: false, ...data } },
+ }),
+ withApolloError: error =>
+ createBuilder({
+ ...opt,
+ apolloState: { error: error },
+ }),
+ withUser: user =>
+ createBuilder({
+ ...opt,
+ user,
+ }),
+ build: (argProps = {}) => {
+ const props = { ...(opt.props || {}), ...argProps };
+ const component = ;
+ const routed = wrapInTestApp(component, opt.routerEntries, opt.user);
+ const themed = opt.theme ? {routed} : routed;
+ return opt.apolloState ? {themed} : themed;
+ },
+ render: props => {
+ return render(builder.build(props));
+ },
+ renderWithEffects: async props => {
+ return renderWithEffects(builder.build(props));
+ },
+ };
+ return builder;
+ }
+ return createBuilder();
+}
+
+// Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async
+// act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit
+// cleaner, since act doesn't return the result of the evaluated function.
+// https://github.com/testing-library/react-testing-library/issues/281
+// https://github.com/facebook/react/pull/14853
+export async function renderWithEffects(nodes) {
+ let value;
+ await act(async () => {
+ value = await render(nodes);
+ });
+ return value;
+}
+
+export function logBoundaryErrors() {
+ ErrorBoundary.onError = error => console.error('ErrorBoundary received error', error);
+}
+
+// Runs a function with all error logging captured and returned as an array.
+// If the callback function is async this one will be too.
+export function withLogCollector(logsToCollect, callback) {
+ if (typeof logsToCollect === 'function') {
+ callback = logsToCollect;
+ logsToCollect = ['log', 'warn', 'error'];
+ }
+ const logs = {
+ log: [],
+ warn: [],
+ error: [],
+ };
+
+ const origLog = console.log;
+ const origWarn = console.warn;
+ const origError = console.error;
+
+ if (logsToCollect.includes('log')) {
+ console.log = message => {
+ logs.log.push(message);
+ };
+ }
+ if (logsToCollect.includes('warn')) {
+ console.warn = message => {
+ logs.warn.push(message);
+ };
+ }
+ if (logsToCollect.includes('error')) {
+ console.error = message => {
+ logs.error.push(message);
+ };
+ }
+
+ const restore = () => {
+ console.log = origLog;
+ console.warn = origWarn;
+ console.error = origError;
+ };
+
+ try {
+ const ret = callback();
+
+ if (!ret || !ret.then) {
+ restore();
+ return logs;
+ }
+
+ return ret.then(
+ () => {
+ restore();
+ return logs;
+ },
+ error => {
+ restore();
+ throw error;
+ },
+ );
+ } catch (error) {
+ restore();
+ throw error;
+ }
+}
+
+export const wrapInTheme = (component, theme = V1) => (
+
+ {component}
+
+);
diff --git a/frontend/packages/core/src/testUtils/mockBreakpoint.ts b/frontend/packages/core/src/testUtils/mockBreakpoint.ts
new file mode 100644
index 0000000000..388c71494c
--- /dev/null
+++ b/frontend/packages/core/src/testUtils/mockBreakpoint.ts
@@ -0,0 +1,73 @@
+import { act } from '@testing-library/react';
+
+type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+
+const queryToBreakpoint = {
+ '(min-width:1920px)': 'xl',
+ '(min-width:1280px)': 'lg',
+ '(min-width:960px)': 'md',
+ '(min-width:600px)': 'sm',
+ '(min-width:0px)': 'xs',
+} as Record;
+
+function toBreakpoint(query: string) {
+ const breakpoint = queryToBreakpoint[query];
+ if (!breakpoint) {
+ throw new Error(`received unknown media query in breakpoint mock: '${query}'`);
+ }
+ return breakpoint;
+}
+
+type Listener = (event: { matches: boolean }) => void;
+
+interface QueryList {
+ addListener(listener: Listener): void;
+ removeListener(listener: Listener): void;
+ matches: boolean;
+}
+
+interface Query {
+ query: string;
+ queryList: QueryList;
+ listeners: Set;
+}
+
+export default function mockBreakpoint(initialBreakpoint: Breakpoint = 'xl') {
+ let currentBreakpoint = initialBreakpoint;
+ const queries = Array();
+
+ (window as any).matchMedia = (query: string): QueryList => {
+ const listeners = new Set();
+
+ const queryList: QueryList = {
+ addListener(listener) {
+ listeners.add(listener);
+ },
+ removeListener(listener) {
+ listeners.delete(listener);
+ },
+ matches: toBreakpoint(query) === currentBreakpoint,
+ };
+
+ queries.push({ query, queryList, listeners });
+
+ return queryList;
+ };
+
+ return {
+ set(breakpoint: Breakpoint) {
+ currentBreakpoint = breakpoint;
+
+ act(() => {
+ queries.forEach(({ query, queryList, listeners }) => {
+ const matches = toBreakpoint(query) === breakpoint;
+ queryList.matches = matches;
+ listeners.forEach(listener => listener({ matches }));
+ });
+ });
+ },
+ remove() {
+ delete window.matchMedia;
+ },
+ };
+}