Add JS tests. Cleanup code and add github workflow to test.

This commit is contained in:
Sudhanshu Gautam 2021-01-12 23:40:38 +05:30
parent f3133a38a0
commit 3f8de60160
20 changed files with 533 additions and 264 deletions

View file

@ -1,9 +1,18 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
changeLanguage: (l: string) => {},
language: 'en',
},
}),
}));
test('renders the app container', () => {
const { container } = render(<App />);
expect(container.querySelector('div.App')).toBeTruthy();
});

View file

@ -5,10 +5,10 @@ import './App.scss';
import { createMuiTheme } from '@material-ui/core/styles';
import { ThemeProvider } from '@material-ui/styles';
import LinearProgress from '@material-ui/core/LinearProgress';
import { Paper, Toolbar } from '@material-ui/core';
import Header from './components/Header';
import Home from './containers/home/home';
import NotFound from './containers/not-found/not-found';
import Footer from './components/Footer';
const theme = createMuiTheme({
palette: {
@ -33,17 +33,7 @@ const App: FunctionComponent = () => {
<Route default component={NotFound} />
</Switch>
</Router>
<Toolbar hidden />
<Paper elevation={4} className="report-problem-container">
<span>
If you come across any issue, feel free to report{' '}
<a href="https://github.com/aparcar/attendedsysupgrade-server/issues">here</a>.
</span>
<span className="report-link">
For contributions, go to{' '}
<a href="https://github.com/sudhanshu16/openwrt-firmware-selector/">Github</a>
</span>
</Paper>
<Footer />
</div>
</React.Suspense>
</ThemeProvider>

View file

@ -1,57 +0,0 @@
import React, { FunctionComponent } from 'react';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@material-ui/core';
type Props = {
open: boolean;
cancelHandler: () => void;
acceptHandler: () => void;
body: React.ReactElement;
title: React.ReactElement;
cancelComponent: React.ReactElement;
acceptComponent: React.ReactElement;
};
const AlertDialog: FunctionComponent<Props> = ({
open,
cancelHandler,
acceptHandler,
body,
title,
cancelComponent,
acceptComponent,
}) => {
return (
<Dialog
open={open}
onClose={cancelHandler}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{title}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">{body}</DialogContentText>
</DialogContent>
<DialogActions>
{acceptHandler && (
<Button onClick={acceptHandler} color="primary">
{acceptComponent}
</Button>
)}
{cancelHandler && (
<Button onClick={cancelHandler} color="secondary" variant="contained" autoFocus>
{cancelComponent}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default AlertDialog;

View file

@ -1,60 +0,0 @@
import React, { FunctionComponent } from 'react';
import { IconButton, makeStyles, Snackbar, SnackbarContent } from '@material-ui/core';
import ErrorIcon from '@material-ui/icons/Error';
import CloseIcon from '@material-ui/icons/Close';
const SnackBarStyles = makeStyles((theme) => ({
error: {
backgroundColor: theme.palette.error.dark,
},
message: {
display: 'flex',
alignItems: 'center',
},
icon: {
marginRight: '20px',
fontSize: 20,
},
}));
type Props = {
open: boolean;
closeHandle: () => void;
errorMessage: string;
};
const ErrorSnackbar: FunctionComponent<Props> = ({ open, closeHandle, errorMessage }) => {
const classes = SnackBarStyles();
return (
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={open}
autoHideDuration={6000}
onClose={closeHandle}
ContentProps={{
'aria-describedby': 'message-id',
}}
>
<SnackbarContent
className={classes.error}
aria-describedby="client-snackbar"
message={
<span id="client-snackbar" className={classes.message}>
<ErrorIcon className={classes.icon} />
{errorMessage || 'An unexpected error occurred. Please try again'}
</span>
}
action={[
<IconButton key="close" aria-label="Close" color="inherit" onClick={closeHandle}>
<CloseIcon />
</IconButton>,
]}
/>
</Snackbar>
);
};
export default ErrorSnackbar;

22
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,22 @@
import { Paper, Toolbar } from '@material-ui/core';
import React, { FunctionComponent } from 'react';
const Footer: FunctionComponent = () => {
return (
<>
<Toolbar hidden />
<Paper elevation={4} className="report-problem-container">
<span>
If you come across any issue, feel free to report{' '}
<a href="https://github.com/aparcar/attendedsysupgrade-server/issues">here</a>.
</span>
<span className="report-link">
For contributions, go to{' '}
<a href="https://github.com/sudhanshu16/openwrt-firmware-selector/">Github</a>
</span>
</Paper>
</>
);
};
export default Footer;

View file

@ -0,0 +1,149 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import ProfileDetails from './ProfileDetails';
import { Profile } from '../../../types/profile';
const mockAxios = new MockAdapter(axios);
const testVersion = 'TEST_VERSION';
const testProfile1: Profile = {
build_at: '2020-12-08 13:51:01',
target: 'TEST_TARGET',
version_code: 'TEST_VERSION_CODE',
version_number: 'TEST_VERSION_NUMBER',
id: 'TEST_ID',
titles: [
{
title: 'TEST_TITLE1',
},
{
model: 'TEST_MODEL',
vendor: 'TEST_VENDOR',
},
],
images: [
{
name: 'testimage',
type: 'sysupgrade',
sha256: 'sha256',
},
],
};
const testProfile2: Profile = {
build_at: '2020-12-08 13:51:01',
target: 'TEST_TARGET',
version_code: 'TEST_VERSION_CODE',
version_number: 'TEST_VERSION_NUMBER',
id: 'TEST_ID',
titles: [
{
title: 'TEST_TITLE2',
},
],
};
const testProfiles = [testProfile1, testProfile2];
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
changeLanguage: (l: string) => {},
language: 'en',
},
}),
}));
describe('Profile Details', () => {
afterEach(() => {
mockAxios.reset();
});
it('renders the component, sends a get request and displays and shows the profile target', async () => {
render(
<ProfileDetails
selectedProfile={{
id: 'TEST_ID1',
target: 'TEST_TARGET',
}}
selectedVersion={testVersion}
/>
);
mockAxios.onGet().replyOnce(200);
await waitFor(() => {
expect(mockAxios.history.get).toHaveLength(1);
});
});
it('renders titles correctly', async () => {
let { container } = render(
<ProfileDetails
selectedProfile={{
id: 'TEST_ID1',
target: 'TEST_TARGET',
}}
selectedVersion={testVersion}
/>
);
mockAxios.onGet().replyOnce(200, testProfile1);
await waitFor(() => {
expect(container.querySelector('#title')).toHaveTextContent(
/^TEST_TITLE1, TEST_VENDOR TEST_MODEL$/
);
});
({ container } = render(
<ProfileDetails
selectedProfile={{
id: 'TEST_ID2',
target: 'TEST_TARGET',
}}
selectedVersion={testVersion}
/>
));
mockAxios.onGet().replyOnce(200, testProfile2);
await waitFor(() => {
expect(container.querySelector('#title')).toHaveTextContent(/^TEST_TITLE2$/);
});
});
it('renders download links correctly', async () => {
for (const p of testProfiles) {
render(
<ProfileDetails
selectedProfile={{
id: 'TEST_ID1',
target: 'TEST_TARGET',
}}
selectedVersion={testVersion}
/>
);
mockAxios.onGet().replyOnce(200, p);
await waitFor(() => {
const downloadLinks = screen.getAllByTestId('download_link');
let expectedItems = p.images?.map((i) => i.type) || [];
downloadLinks.forEach((downloadLink) => {
expectedItems = expectedItems.filter((i) => i !== downloadLink.textContent);
});
expect(expectedItems).toHaveLength(0);
});
}
});
});

View file

@ -1,4 +1,4 @@
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import React, { FunctionComponent, ReactNode, useCallback, useEffect, useState } from 'react';
import {
Box,
Button,
@ -34,7 +34,6 @@ const profilesData: { [key: string]: Profile } = {};
const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedProfile }) => {
const [profile, setProfileData] = useState<Profile>();
const [working, toggleWorking] = useState<boolean>(true);
const { t } = useTranslation();
const getHelpKey = (type: string) => {
@ -63,8 +62,6 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
const getProfileData = useCallback(async () => {
let profileData = profilesData[selectedProfile.id];
toggleWorking(true);
if (!profileData) {
const response = await Axios.get<Profile>(
`${process.env.PUBLIC_URL}/data/${selectedVersion}/${selectedProfile.target}/${selectedProfile.id}.json`
@ -73,20 +70,23 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
profilesData[selectedProfile.id] = profileData;
}
toggleWorking(false);
return profileData;
}, [selectedVersion, selectedProfile]);
useEffect(() => {
let mounted = true;
if (selectedVersion && selectedProfile) {
getProfileData().then((_profileData) => {
if (!isEqual(profile, _profileData)) setProfileData(_profileData);
if (mounted && !isEqual(profile, _profileData)) setProfileData(_profileData);
});
}
return () => {
mounted = false;
};
}, [selectedVersion, selectedProfile, getProfileData, profile]);
if (working || !profile) return <CircularProgress />;
if (!profile) return <CircularProgress />;
const buildAt = new Date(profile.build_at);
@ -103,11 +103,13 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
<TableBody>
<TableRow>
<TableCell>{t('tr-model')}</TableCell>
<TableCell>{profile.titles?.map((title) => getTitle(title)).join(', ')}</TableCell>
<TableCell id="title">
{profile.titles?.map((title) => getTitle(title)).join(', ')}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t('tr-target')}</TableCell>
<TableCell>{profile.target}</TableCell>
<TableCell id="target">{profile.target}</TableCell>
</TableRow>
<TableRow>
<TableCell>{t('tr-version')}</TableCell>
@ -121,39 +123,37 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
</TableRow>
<TableRow>
<TableCell>Info</TableCell>
{profile.titles && (
<TableCell>
{profile.titles
.map<React.ReactNode>((title) => {
const titleString = getTitle(title);
const infoUrl = config.info_url
.replace('{title}', encodeURI(titleString))
.replace('{target}', profile.target)
.replace('{id}', profile.id)
.replace('{version}', profile.version_number);
return (
<Link href={infoUrl}>
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{profile.titles!.length > 1 && (
<Typography component="span">{titleString}</Typography>
)}
<Launch
style={{
marginLeft: 10,
verticalAlign: 'middle',
}}
/>
</Link>
);
})
.reduce((prev, curr) => [
prev,
<Box display="inline-block" marginRight={2} />,
curr,
])}
</TableCell>
)}
<TableCell>
{profile.titles
?.map<React.ReactNode>((title: TitlesEntity) => {
const titleString = getTitle(title);
const infoUrl = config.info_url.replace('{title}', encodeURI(titleString));
return (
<Link href={infoUrl} key={titleString}>
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{profile.titles!.length > 1 && (
<Typography component="span">{titleString}</Typography>
)}
<Launch
style={{
marginLeft: 10,
verticalAlign: 'middle',
}}
/>
</Link>
);
})
.reduce((acc: ReactNode, curr: ReactNode) => [
acc,
// eslint-disable-next-line react/no-array-index-key
<Box
display="inline-block"
marginRight={2}
key={(acc?.toString() ?? '') + (curr?.toString() ?? '')}
/>,
curr,
])}
</TableCell>
</TableRow>
</TableBody>
</Table>
@ -177,9 +177,9 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
.replace('{target}', profile.target)
.replace('{version}', profile.version_number)}/${i.name}`;
return (
<TableRow>
<TableRow key={downloadURL}>
<TableCell>
<Link href={downloadURL} target="_blank">
<Link href={downloadURL} target="_blank" data-testid="download_link">
<Button endIcon={<CloudDownload />} variant="contained" color="primary">
{i.type}
</Button>

1
src/global.d.ts vendored Normal file
View file

@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';

View file

@ -4,7 +4,7 @@ import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import './i18n';
import './locales/i18n';
ReactDOM.render(
<React.StrictMode>

View file

@ -1,7 +1,7 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import translations from './locales/translations';
import translations from './translations';
const resources = {
ca: {
@ -39,7 +39,7 @@ i18n
.init({
resources,
fallbackLng: 'en',
debug: (process.env.REACT_APP_I18N_DEBUG || '0') === '1',
debug: !!process.env.REACT_APP_I18N_DEBUG,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},

View file

@ -1,42 +0,0 @@
import axios from 'axios';
import config from '../config';
class DataService {
// getVersions = versionsPath => axios.get(versionsPath);
//
// getOverview = overviewPath => axios.get(overviewPath);
//
// getDeviceData = devicePath => axios.get(devicePath);
//
// getDeviceManifest = async manifest_path => {
// const manifest = await axios.get(manifest_path);
// return manifest.data.split('\n');
// };
//
// getDevicePackages = (version, target, profile) =>
// axios.get(
// base_api +
// 'packages_image?distro=openwrt&version=' +
// version.toLowerCase() +
// '&target=' +
// target +
// '&profile=' +
// profile.toLowerCase()
// );
//
// buildImage = (board, packages, target, version, uciDefaults) =>
// axios.post(base_api + 'build-request', {
// profile: board,
// board,
// defaults: uciDefaults,
// distro: 'openwrt',
// packages,
// target,
// version,
// });
//
// buildStatusCheck = request_hash =>
// axios.get(base_api + 'build-request/' + request_hash);
}
export default DataService;

16
src/tests/utils.ts Normal file
View file

@ -0,0 +1,16 @@
export const mocki18n = (): void => {
jest.mock('react-i18next', () => ({
// this mock makes sure any components using the translate hook can use it without a warning being shown
useTranslation: () => {
return {
t: (str: string) => str,
i18n: {
changeLanguage: () => new Promise(() => {}),
language: 'en',
},
};
},
}));
};
export default { mocki18n };

View file

@ -1,12 +1,12 @@
export interface Profile {
arch_packages: string;
arch_packages?: string;
build_at: string;
default_packages?: string[] | null;
device_packages?: string[] | null;
id: string;
image_prefix: string;
image_prefix?: string;
images?: ImagesEntity[] | null;
metadata_version: number;
metadata_version?: number;
target: string;
titles?: TitlesEntity[] | null;
version_code: string;