From 6104d88d5a52c7b2d9a5c5c7d92045158d1f9c2e Mon Sep 17 00:00:00 2001 From: Raghunandan Balachandran Date: Wed, 5 Feb 2020 01:02:00 +0100 Subject: [PATCH] more deps --- frontend/packages/app/src/App.test.tsx | 1 - .../core/src/layout/Header/Header.test.js | 4 +- .../HeaderActionMenu/HeaderActionMenu.test.js | 2 +- .../layout/HeaderLabel/HeaderLabel.test.js | 4 +- .../HeaderLabel/OwnerHeaderLabel.test.js | 4 +- .../src/layout/InfoCard/BottomLink.test.js | 3 +- .../core/src/layout/InfoCard/InfoCard.test.js | 2 +- .../layout/Navigation/GcpProjectByTools.tsx | 14 + .../src/layout/Navigation/InfoSquared.tsx | 11 + .../src/layout/Navigation/NavItem.test.js | 5 +- .../src/layout/Navigation/Navigation.test.js | 32 +- .../SupportButton/SupportButton.test.js | 2 +- .../packages/core/src/testUtils/Keyboard.js | 188 ++++++++++++ .../core/src/testUtils/Keyboard.test.js | 79 +++++ frontend/packages/core/src/testUtils/index.js | 281 ++++++++++++++++++ .../core/src/testUtils/mockBreakpoint.ts | 73 +++++ 16 files changed, 683 insertions(+), 22 deletions(-) create mode 100644 frontend/packages/core/src/layout/Navigation/GcpProjectByTools.tsx create mode 100644 frontend/packages/core/src/layout/Navigation/InfoSquared.tsx create mode 100644 frontend/packages/core/src/testUtils/Keyboard.js create mode 100644 frontend/packages/core/src/testUtils/Keyboard.test.js create mode 100644 frontend/packages/core/src/testUtils/index.js create mode 100644 frontend/packages/core/src/testUtils/mockBreakpoint.ts 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( +
+ typed1.push(value)} /> + typed2.push(value)} autoFocus /> + typed3.push(value)} /> +
, + ); + + 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( +
+ typed1.push(value)} /> + typed2.push(value)} /> + typed3.push(value)} /> +
, + ); + + 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; + }, + }; +}