22 ErrorBoundary. Обработка React ошибок
В React обработка ошибок происходит за счёт классового компонента ErrorBoundary, который отображает экран ошибки при возникновении любой ошибки в приложении
Сам компонент выглядит следующим образом. Нужно сказать, что в componentDidCatch
можно поместить отправку ошибок пользователя на сервер.
Так же нужно упомянуть, что при получении ошибки мы будем выводить заранее определённую страницу с ошибкой. Эту страницу с ошибкой нужно обернуть в Suspense
src / app / providers / ErrorBoundary / ui / ErrorBoundary.tsx
import React, { ErrorInfo, ReactNode, Suspense } from 'react';
import { ErrorPage } from '@/widgets/ErrorPage';
import { Skeleton } from '@/widgets/Skeleton';
/* пропсы компонента */
interface ErrorBoundaryProps {
children: ReactNode;
}
/* стейт компонента */
interface ErrorBoundaryState {
hasError: boolean;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
/* todo реализовать сервис для логирования ошибок на странице */
console.log(error, errorInfo);
}
render() {
const { hasError } = this.state;
const { children } = this.props;
if (hasError) {
return (
<Suspense fallback={<Skeleton />}>
<ErrorPage />
</Suspense>
);
}
return children;
}
}
Далее так уже будет выглядеть страница с ошибкой
src / widgets / ErrorPage / ui / ErrorPage.tsx
import React from 'react';
import styles from './ErrorPage.module.scss';
import { useTranslation } from 'react-i18next';
import { Button, HTag, HTagType } from '@/shared/ui';
import { TRANSLATIONS_API } from '@/shared/const';
export const ErrorPage = () => {
const { t } = useTranslation(TRANSLATIONS_API.error.translate);
const handleReloadPage = () => {
location.reload();
};
return (
<div className={styles.error}>
<HTag tag={HTagType.H1}>{t(TRANSLATIONS_API.error.components.error_page_title)}</HTag>
<Button onClick={handleReloadPage}>
{t(TRANSLATIONS_API.error.components.error_page_button)}
</Button>
</div>
);
};
И всё приложение так же нужно обернуть в ErrorBoundary
src / index.tsx
root.render(
<BrowserRouter>
<ErrorBoundary>
<ThemeProvider>
<StrictMode>
<Suspense fallback={<Skeleton />}>
<App />
</Suspense>
</StrictMode>
</ThemeProvider>
</ErrorBoundary>
</BrowserRouter>,
);
Ну и так же можно заранее реализовать компонент кнопки ошибки, который будет выводить нам кастомную ошибку на странице
src / app / providers / ErrorBoundary / ui / ErrorButton.tsx
import React, { useEffect, useState } from 'react';
import { Button } from '@/shared/ui';
import { useTranslation } from 'react-i18next';
import { TRANSLATIONS_API } from '@/shared/const';
export const ErrorButton = () => {
const { t } = useTranslation(TRANSLATIONS_API.error.translate);
const [error, setError] = useState<boolean>(false);
const handleCastError = () => setError((prevState) => !prevState);
useEffect(() => {
if (error) {
throw new Error(t(TRANSLATIONS_API.error.components.error_bug_message));
}
}, [error]);
return (
<Button onClick={handleCastError}>{t(TRANSLATIONS_API.error.components.error_bug)}</Button>
);
};
23 Анализ размера банда. BundleAnalyzer
Первым делом нужно установить зависимости
npm install --save-dev webpack-bundle-analyzer @types/webpack-bundle-analyzer
Далее нам нужно будет добавить плагин для анализа бандла в билд
config / build / buildPlugins.ts
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
const plugins = [
// то плагин, который будет показывать прогресс сборки
new ProgressPlugin(),
// это плагин, который будет добавлять самостоятельно скрипт в наш index.html
new HTMLWebpackPlugin({
// указываем путь до базового шаблона той вёрстки, которая нужна в нашем проекте
template: paths.html,
}),
// этот плагин будет отвечать за отделение чанков с css от файлов JS
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].css',
}),
// этот плагин позволяет прокидывать глобальные переменные в приложение
new DefinePlugin({
__IS_DEV__: JSON.stringify(isDev),
__API__: JSON.stringify('https://' /* api_path */),
}),
/* данный плагин анализирует размеры собираемых пакетов */
new BundleAnalyzerPlugin({
/*
* отключаем автоматические открытие анализатора
* он будет открываться по ссылке из терминала * */
* openAnalyzer: false,
}),
];
24 React Testing Library. Тесты на компоненты метка
Чтобы подготовить тесты в приложении, нужно установить следующие зависимости
npm install --save-dev
jest
@testing-library/react
@testing-library/jest-dom
@babel/preset-react
identity-obj-proxy
regenerator-runtime
Далее нам нужно создать такой сетап файл, который будет импортировать данные зависимости во все тесты
config / jest / jest.setup.ts
/* виртуальный дом, который будет собираться в тестах */
import '@testing-library/jest-dom';
/* рантайм для работы джеста с асинхронностью */
import 'regenerator-runtime/runtime';
И так же нужно реализовать заглушку, которая будет вставляться вместо svg изображений
config / jest / jestEmptyComponent.tsx
import React from 'react';
const jestEmptyComponent = function () {
return <div />;
};
export default jestEmptyComponent;
Собираем такой конфиг джеста:
config / jest / jest.config.ts
import type { Config } from 'jest';
const config: Config = {
/* устанавливаем сюда глобальные переменные */
globals: {
__IS_DEV__: true,
__API__: '/test/api',
__PROJECT__: 'jest',
},
/* очищаем моковые данные */
clearMocks: true,
/*
* корневая точка * мы её настраиваем так как * */ rootDir: '../../',
modulePaths: ['<rootDir>src'],
/* разворачиваемся в браузере */
testEnvironment: 'jsdom',
/* настройки для запуска тестов с ипользованием
* - абсолютных импортов * - стилей * */ moduleNameMapper: {
/* эта настройка нужна для поддержки абсолютных импортов */
'^@/(.*)$': '<rootDir>/src/$1',
'\\.s?css$': 'identity-obj-proxy',
/* чтобы работали svg, их нужно заменить на моковый компонент */
'\\.(svg|png|jpg)': '<rootDir>/config/jest/jestEmptyComponent.tsx',
},
moduleDirectories: ['node_modules', '<rootDir>/'],
/* эту директорию не трогаем */
coveragePathIgnorePatterns: ['\\\\node_modules\\\\'],
/* доступные расширения файлов */
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'],
/* регулярка, по которой находим файлы с тестами */
testMatch: ['<rootDir>src/**/*(*.)@(spec|test).[tj]s?(x)'],
/* тут мы определяем путь до файла с сетапмами джеста */
setupFilesAfterEnv: ['<rootDir>config/jest/jest.setup.ts'],
/* тут настроен репорт для проходок тестов */
reporters: [
'default',
[
'jest-html-reporters',
{
publicPath: '<rootDir>/reports/unit',
filename: 'report.html',
// openReport: true,
inlineSource: true,
},
],
],
};
export default config;
В тс конфиг добавляем путь до сетапа джеста, который будет прокидываться в джест-тесты. Вместе с сетапом нужно будет добавить и все остальные ts-файлы, так как в них не будут работать обычные неабсолютные импорты
tsconfig.json
"include": [
"./config/jest/setupTests.ts",
"./src/**/*.ts",
"./src/**/*.tsx"
],
В бейбел добавляем пресет для работы реакта в тестах
babel.config.js
module.exports = {
presets: [
'@babel/preset-env',
'@babel/preset-typescript',
['@babel/preset-react', { runtime: 'automatic' }],
],
};
Ну и так же нужно отключить для определённых файлов правила. Конкретно тут стоит начать с того, что можно отключить правила тех же переводов для тестов
.eslintrc.cjs
overrides: [
{
'env': {
'node': true,
},
'files': [
'.eslintrc.{js,cjs}',
'**/src/**/*.test.{ts,tsx}',
],
'parserOptions': {
'sourceType': 'script',
},
rules: {
'i18next/no-literal-string': 'off',
},
},
],
Далее нужно написать конфиг i18n
для тестов
src / shared / config / i18n / i18n.tests.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
i18n.use(initReactI18next).init({
lng: 'ru',
fallbackLng: 'ru',
debug: false,
resources: { ru: { translations: {} } },
});
export default i18n;
Далее подготовим хелпер-провайдер, в который будем оборачивать компоненты для тестирования. Этот хелпер уже будет иметь моковый браузер-роутер, редакс-стейт, провайдер переводов и темы
src / shared / lib / tests / testRenderComponent.tsx
import { ReducersMapObject } from '@reduxjs/toolkit';
import { render } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { I18nextProvider } from 'react-i18next';
import { MemoryRouter } from 'react-router-dom';
import { StoreProvider } from '@/app/providers/StoreProvider';
import { Theme, ThemeProvider } from '@/app/providers/ThemeProvider';
import i18n from '../../config/i18n/i18n.tests';
import { IDivAttributes } from '../types/baseProps.type';
import { StateSchema } from '../types/state.schema';
/** необязательные опции, которые нужны для запуска провайдеров тестов */
export interface IRenderOptions {
route: string;
initialState: DeepPartial<StateSchema>;
asyncReducers: DeepPartial<ReducersMapObject<StateSchema>>;
theme: Theme;
}
export interface ITestRenderProviderProps extends IDivAttributes, DeepPartial<IRenderOptions> {}
/** провайдер для рендера компонентов из тестов */
export const TestRenderProvider = ({
children,
route = '/',
theme = Theme.LIGHT,
asyncReducers,
initialState,
}: ITestRenderProviderProps) => {
return (
<MemoryRouter initialEntries={[route]}>
<StoreProvider initialState={initialState} asyncReducers={asyncReducers}>
<I18nextProvider i18n={i18n}>
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
</I18nextProvider>
</StoreProvider>
</MemoryRouter>
);
};
/** функция рендера компонентов тестов */
export function testComponent(component: ReactNode, options?: IRenderOptions) {
return render(<TestRenderProvider {...options}>{component}</TestRenderProvider>);
}
Для тестирования самих компонентов нужно будет им навесить data-testid
атрибуты
export const Sidebar = ({ className }: ISidebarProps) => {
const { t } = useTranslation('ui');
const [collapsed, setCollapsed] = useState<boolean>(false);
const onToggle = () => setCollapsed((prev) => !prev);
return (
<div
data-testid={'sidebar'}
className={cn(styles.sidebar, className, { [styles.collapsed]: collapsed })}
>
<button data-testid={'sidebar-toggle'} onClick={onToggle}>
{t('toggle')}
</button>
<div className={styles.switchers}>
<ThemeSwitcher />
<LanguageSwitcher />
</div>
</div>
);
};
И пишем самый простой тест для проверки работы тестов
src / shared / ui / Button / ui / Button.test.tsx
import { render, screen } from '@testing-library/react';
import { Button, EButtonType } from '@/shared/ui';
describe('Button', () => {
test('button text', () => {
render(<Button>TEST</Button>);
expect(screen.getByText('TEST')).toBeInTheDocument();
});
test('button classname', () => {
render(<Button appearance={EButtonType.PRIMARY}>TEST</Button>);
expect(screen.getByText('TEST')).toHaveClass('button appearance__primary size__m');
});
});
И вот так уже выглядит сам тестовый сьют, который включает в себя обёртку для приложений с переводами, браузером, редаксом и остальными провайдерами
src / widgets / Sidebar / ui / Sidebar.test.tsx
import { fireEvent, screen } from '@testing-library/react';
import { Sidebar } from '@/widgets/Sidebar';
import { testComponent } from '@/shared/lib';
describe('Sidebar', () => {
/** проверяем, отрендерен ли сайдбар */
test('render sidebar', () => {
/* компоненты, которые используют перевод нужно обернуть в хок withTranslation или обернуть в провайдер, как тут */
testComponent(<Sidebar />);
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});
/** проверяем, свёрнут ли сайдбар */
test('toggle sidebar', () => {
testComponent(<Sidebar />);
const toggleBtn = screen.getByTestId('sidebar-toggle');
const sidebar = screen.getByTestId('sidebar');
fireEvent.click(toggleBtn);
expect(sidebar).toHaveClass('collapsed');
});
});
Так же нужно упомянуть, чтобы тесты прогонялись внутри вебшторма, нужно так же настроить его раннер тестов
25 Настраиваем Storybook. Декораторы. Стори кейсы на компоненты
Мы можем вручную установить множество пакетов
npm i
@storybook/addon-actions
@storybook/addon-essentials
@storybook/addon-interactions
@storybook/addon-links
@storybook/react
@storybook/react-webpack5
@storybook/testing-library
storybook-addon-mock
storybook-addon-themes
Или просто запустить установщик:
npx sb init --builder webpack5
Если у нас уже в проекте есть старый сторибук, то можно будет запустить данную команду и сторибук обновится
npx storybook@next automigrate
Далее нам нужно будет сконфигурировать
config / storybook / main.ts
import { Configuration, DefinePlugin, RuleSetRule } from 'webpack';
import path from 'path';
import { buildStyleLoader } from '../build/loader/style.loader';
import { buildFileLoader } from '../build/loader/file.loader';
/*
* конфиг был прогнан через команду:
* npx storybook@next automigrate
* */
const config = {
stories: ['../../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'storybook-addon-mock',
'storybook-addon-themes',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
core: {},
docs: {
autodocs: true,
},
webpackFinal: async (config: Configuration) => {
const paths = {
build: '',
html: '',
entry: '',
src: path.resolve(__dirname, '..', '..', 'src'),
locales: '',
buildLocales: '',
};
config!.resolve!.modules!.push(paths.src);
config!.resolve!.extensions!.push('.ts', '.tsx');
config!.resolve!.alias = {
...config!.resolve!.alias,
'@': paths.src,
};
/* если в каком-либо правиле есть svg, то мы вернём старый объект и заэксклюдим svg в правиле */
config!.module!.rules = config!.module!.rules!.map(
// @ts-ignore
(rule: RuleSetRule) => {
if (/svg/.test(rule.test as string)) {
return { ...rule, exclude: /\.svg$/i };
}
return rule;
},
);
config!.module!.rules.push(buildStyleLoader(true));
config!.module!.rules.push(buildFileLoader());
config!.plugins!.push(
new DefinePlugin({
__IS_DEV__: JSON.stringify(true),
__API__: JSON.stringify('https://testapi.ru'),
__PROJECT__: JSON.stringify('storybook'),
}),
);
return config;
},
};
export default config;
Далее нам нужно написать декораторы, которые будут обогащать наши вложенные компоненты сторибука
src / shared / lib / storybook / decorators / Router.decorator.tsx
import { StoryFn } from '@storybook/react';
import { BrowserRouter } from 'react-router-dom';
export const withRouterDecorator = (StoryComponent: StoryFn) => (
<BrowserRouter>
<StoryComponent />
</BrowserRouter>
);
src / shared / lib / storybook / decorators / Style.decorator.tsx
import { StoryFn } from '@storybook/react';
import '../../../../app/styles/index.scss';
export const withStyleDecorator = (Story: StoryFn) => <Story />;
src / shared / lib / storybook / decorators / Suspense.decorator.tsx
import { StoryFn } from '@storybook/react';
import { Suspense } from 'react';
export const withSuspenseDecorator = (StoryComponent: StoryFn) => (
<Suspense>
<StoryComponent />
</Suspense>
);
src / shared / lib / storybook / decorators / Theme.decorator.tsx
import { StoryFn } from '@storybook/react';
import { Theme, ThemeProvider } from '../../../../app/providers/ThemeProvider';
export const withThemeDecorator = (theme: Theme) => (StoryComponent: StoryFn) => (
<ThemeProvider>
<div className={`app ${theme}`}>
<StoryComponent />
</div>
</ThemeProvider>
);
Так же мы можем настроить то превью, которое будет находиться у нас во вьюпорту при отображении компонента
config / storybook / preview.tsx
import { Theme } from '../../src/app/providers/ThemeProvider';
import {
withRouterDecorator,
withStyleDecorator,
withSuspenseDecorator,
withThemeDecorator,
} from '../../src/shared/lib';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
layout: 'centered',
themes: {
default: Theme.LIGHT,
list: [
{ name: Theme.LIGHT, class: Theme.LIGHT, color: '#aeaeae' },
{ name: Theme.DARK, class: Theme.DARK, color: '#2a2a2a' },
],
},
/* сюда вставляем декораторы */
decorators: [
withStyleDecorator,
withSuspenseDecorator,
withRouterDecorator,
withThemeDecorator(Theme.DARK),
],
};
И так же можно написать общий декоратор, в который можно обернуть отдельные компоненты
src / shared / lib / storybook / withDecorators.tsx
import { StoryFn } from '@storybook/react';
import { BrowserRouter } from 'react-router-dom';
import React, { Suspense } from 'react';
import { Theme, ThemeProvider } from '@/app/providers/ThemeProvider';
export const withDecorators =
(theme: Theme = Theme.LIGHT) =>
(StoryComponent: StoryFn) => (
<BrowserRouter>
<Suspense>
<ThemeProvider>
<div className={`app ${theme}`}>
<StoryComponent />
</div>
</ThemeProvider>
</Suspense>
</BrowserRouter>
);
И далее нам нужно будет написать сторис-кейсы, которые будут отображать наши компоненты
src / shared / ui / Button / ui / Button.stories.ts
import { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { EButtonType } from '../model';
import { withDecorators } from '@/shared/lib';
import { Theme } from '@/app/providers/ThemeProvider';
const meta: Meta<typeof Button> = {
title: 'Components/UI/Button',
component: Button,
/* а сюда уже можно будет добавить декораторы */
decorators: [withDecorators(Theme.LIGHT)],
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
render: (args) => <Button {...args} />,
args: {
children: 'Кнопка основная',
appearance: EButtonType.PRIMARY,
},
};
Так же стоит упомянуть, что стоит оставлять JSDoc комментарии для компонента и его пропсов
src / shared / ui / Button / ui / Button.tsx
/** Основная кнопка приложения */
export const Button: FC<IButtonProps> = ({
className,
children,
appearance = EButtonType.PRIMARY,
size = 'm',
...props
}: IButtonProps) => {
return (
<button
className={cn(
styles.button,
className,
styles[`appearance__${appearance}`],
styles[`size__${size}`],
)}
{...props}
> {children}
</button>
);
};
src / shared / ui / Button / ui / Button.props.ts
import { EButtonType } from '../model';
import { ButtonProps } from '@/shared/lib';
export interface IButtonProps extends ButtonProps {
/** Тема кнопки */
appearance?: EButtonType;
/** Размер кнопки */
size: 's' | 'm' | 'l';
}
И далее запускаем сторибук данными командами
package.json
"storybook": "storybook dev -p 6006 -c ./config/storybook"
"storybook:build": "storybook build -c ./config/storybook"
26 Скриншотные тесты. Loki. Регрессионное UI тестирование
Первым делом нужно установить локи и указать ему путь до сторибука
npm i -D loki
npx loki init --config ./config/storybook/
После чего в пакейдже появится такая конфигурация
package.json
"loki": {
"configurations": {
"chrome.laptop": {
"target": "chrome.docker",
"width": 1366,
"height": 768,
"deviceScaleFactor": 1,
"mobile": false
},
"chrome.iphone7": {
"target": "chrome.docker",
"preset": "iPhone 7"
}
}
}
Сразу забиндим старт тестов и аппрув изменний в тестировании (первая команда прогонит тексты, а вторая подтвердит, что все отличия нам подходят в проекте нам подходят)
package.json
"test:ui": "npx loki test",
"test:ui:ok": "npx loki approve",
Далее нужно запустить сторибук (на основе которого и будут прогоняться скриншот-тесты локи) и докер (в котором поднимется браузер), и можно будет стартануть локи-тексты
npm run storybook
npm run test:ui
Теперь в папке current
будут находиться скриншоты того ui, который мы прогнали в первый раз. Если ui изменится в каком-либо компоненте, то тест упадёт с ошибкой в определённом компоненте и скриншоты изменений попадут в папку differences
.
27 CI pipeline. Автоматизация прогона тестов метка
Перед тем, как сделать коммит, нам нужно прогнать нужные проверки на нашем компьютере и для этого нужно будет husky
npm i -D husky
Далее нужно добавить две команды:
Установка хаски для триггера перед коммитом
Запуск локи на статичном билде сторибука
package.json
"prepare": "husky install",
"test:ui:ci": "npx loki --requireReference --reactUri file:./storybook-static",
Далее нужно будет в хаски добавить линтеры, которые будут прогоняться перед коммитом
.husky / pre-commit
#!/usr/bin/env sh
. "$( dirname -- " $0 ")/_/husky.sh"
npm run lint:fix:all
npm run test:unit
npm run storybook:build
npm run test:ui:ci
И далее нужно будет добавить Github Actions, которые уже будут прогоняться на серверах гитхаба.
Тут нужно будет сделать джоб checks
, который будет выполнять определённую последовательность действий.
Так как для работы локи нужно поднять сторибук, то перед запуском CI Loki, нужно будет сбилдить сторибук
Так же нужно будет прописать условие if: always()
, которое заставит прогонять полностью все тесты даже тогда, когда у нас что-то в последовательности упало
.github / workflows / main.yml
name: building project
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
# Джоб проверки проекта
checks:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [ 20.x ]
steps:
- uses: actions/checkout@v2
- name: Staring Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: install deps
run: npm i
- name: build project
run: npm run build:prod
- name: up storybook
run: npm run storybook:build
if: always()
- name: lint stylelint
run: npm run lint:stylelint:fix
if: always()
- name: lint eslint
run: npm run lint:eslint:fix
if: always()
- name: unit tests
run: npm run test:unit
if: always()
- name: unit ui
run: npm run test:ui:ci
if: always()
28 UI Screenshot test report
Первым делом нам нужно установить пакет, который генерирует сайт из json
npm i -D reg-cli
Далее нам нужно будет триггерить скрипт, который соберёт все локи скриншоты в json-файл. А уже тот собранный json
будет переведён через reg-cli
в index.html
, который уже и можно будет открыть в браузере
scripts / generate-visual-json-report.js
const { readdir, writeFile } = require('fs');
const { join: joinPath, relative } = require('path');
const { promisify } = require('util');
const asyncReaddir = promisify(readdir);
const writeFileAsync = promisify(writeFile);
/** путь до локи скришотов */
const lokiDir = joinPath(__dirname, '..', '.loki');
/** текущие скришоты */
const actualDir = joinPath(lokiDir, 'current');
/** референсы скришотов */
const expectedDir = joinPath(lokiDir, 'reference');
/** отличия между скриншотами */
const diffDir = joinPath(lokiDir, 'difference');
(async function main() {
const diffs = await asyncReaddir(diffDir);
await writeFileAsync(
joinPath(lokiDir, 'report.json'),
JSON.stringify({
newItems: [],
deletedItems: [],
passedItems: [],
failedItems: diffs,
expectedItems: diffs,
actualItems: diffs,
diffItems: diffs,
actualDir: relative(lokiDir, actualDir),
expectedDir: relative(lokiDir, expectedDir),
diffDir: relative(lokiDir, diffDir),
}),
);
})();
Далее нам нужно будет собрать test:ui:report
репорт по тестам локи
package.json
"test:ui": "npx loki test",
"test:ui:approve": "npx loki approve",
"test:ui:ci": "npx loki --requireReference --reactUri file:./storybook-static",
"test:ui:json": "node scripts/generate-visual-json-report.js",
"test:ui:html": "reg-cli --from .loki/report.json --report .loki/report.html",
"test:ui:report": "npm run test:ui:json && npm run test:ui:html",