Merge pull request #766 from ajnart/tests/add-tests

 Add vitest and initial tests
This commit is contained in:
Thomas Camlong
2023-03-29 20:16:08 +09:00
committed by GitHub
35 changed files with 2007 additions and 2083 deletions

View File

@@ -2,12 +2,12 @@ module.exports = {
extends: [
'mantine',
'plugin:@next/next/recommended',
'plugin:jest/recommended',
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vitest/recommended',
],
plugins: ['testing-library', 'jest', 'react-hooks', 'react', 'unused-imports'],
plugins: ['testing-library', 'react-hooks', 'react', 'unused-imports', 'vitest'],
overrides: [
{
files: ['**/?(*.)+(spec|test).[jt]s?(x)'],
@@ -31,5 +31,12 @@ module.exports = {
'@typescript-eslint/no-non-null-assertion': 'off',
'no-continue': 'off',
'linebreak-style': 0,
'vitest/max-nested-describe': [
'error',
{
max: 3,
},
],
'testing-library/no-node-access': ['error', { allowContainerFirstChild: true }],
},
};

View File

@@ -104,3 +104,11 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/ajnart
Homarr uses [GitMoji](https://gitmoji.dev/).
We would appreciate it if everyone keeps their commit messages withing these rulings.
### Tests
> Components should be tested using unit tests. A unit is the smallest isolated part of the component. Unit tests must not have any dependencies and must be isolated.
- Place testfiles directly at the root of the unit
- Only test a single unit of work inside a unit test
- You may test multiple units inside one test file
- Testnames do not begin with ``should`` or the unit name

View File

@@ -28,12 +28,12 @@ module.exports = {
'sk',
'no',
],
localePath: path.resolve('./public/locales'),
fallbackLng: 'en',
localeDetection: true,
returnEmptyString: false,
debug: false,
appendNamespaceToCIMode: true,
reloadOnPrerender: process.env.NODE_ENV === 'development',
},
returnEmptyString: false,
appendNamespaceToCIMode: true,
reloadOnPrerender: process.env.NODE_ENV === 'development',
fallbackLng: 'en',
localePath: path.resolve('./public/locales'),
};

View File

@@ -10,18 +10,18 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"turbo" : "turbo run build",
"analyze": "ANALYZE=true next build",
"turbo": "turbo run build",
"start": "next start",
"typecheck": "tsc --noEmit",
"export": "next build && next export",
"lint": "next lint",
"jest": "jest",
"jest:watch": "jest --watch",
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@ctrl/deluge": "^4.1.0",
@@ -44,6 +44,7 @@
"@tabler/icons": "^1.106.0",
"@tanstack/react-query": "^4.2.1",
"@tanstack/react-query-devtools": "^4.24.4",
"@vitejs/plugin-react": "^3.1.0",
"axios": "^0.27.2",
"consola": "^2.15.3",
"cookies-next": "^2.1.1",
@@ -71,6 +72,8 @@
"devDependencies": {
"@next/bundle-analyzer": "^12.1.4",
"@next/eslint-plugin-next": "^12.1.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/dockerode": "^3.3.9",
"@types/node": "17.0.1",
"@types/prismjs": "^1.26.0",
@@ -79,23 +82,27 @@
"@types/video.js": "^7.3.51",
"@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^5.30.7",
"@vitest/coverage-c8": "^0.29.3",
"@vitest/ui": "^0.29.3",
"eslint": "^8.20.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-mantine": "^2.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.6.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-testing-library": "^5.5.1",
"eslint-plugin-unused-imports": "^2.0.0",
"jest": "^28.1.3",
"eslint-plugin-vitest": "^0.0.54",
"happy-dom": "^8.9.0",
"prettier": "^2.7.1",
"sass": "^1.56.1",
"turbo": "^1.8.3",
"typescript": "^4.7.4",
"video.js": "^8.0.3"
"video.js": "^8.0.3",
"vitest": "^0.29.3",
"vitest-fetch-mock": "^0.2.2"
},
"resolutions": {
"@types/react": "17.0.2",

View File

@@ -0,0 +1,13 @@
import { render, screen, cleanup } from '@testing-library/react';
import { describe, expect, it, afterEach } from 'vitest';
import { AppAvatar } from './AppAvatar';
describe('AppAvatar', () => {
afterEach(cleanup);
it('display placeholder when no url', () => {
render(<AppAvatar iconUrl="" color="blue" />);
expect(screen.getByTestId('app-avatar')).toBeDefined();
});
});

View File

@@ -11,6 +11,7 @@ export const AppAvatar = ({
return (
<Avatar
data-testid="app-avatar"
src={iconUrl}
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
size="sm"

View File

@@ -1,4 +1,3 @@
import { useMantineTheme } from '@mantine/core';
import { create } from 'zustand';
import { useConfigContext } from '../../../../config/provider';
import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints';

View File

@@ -11,7 +11,7 @@ import type {
} from '../../../pages/api/modules/usenet/history';
import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../../pages/api/modules/usenet';
import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause';
import { queryClient } from '../../../tools/queryClient';
import { queryClient } from '../../../tools/server/configurations/tanstack/queryClient.tool';
import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume';
const POLLING_INTERVAL = 2000;

View File

@@ -17,8 +17,8 @@ import { useState } from 'react';
import { TFunction } from 'react-i18next';
import { v4 as uuidv4 } from 'uuid';
import { useConfigContext } from '../../config/provider';
import { tryMatchService } from '../../tools/addToHomarr';
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types';
import { AppType } from '../../types/app';
let t: TFunction<'modules/docker', undefined>;
@@ -206,3 +206,36 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
</Group>
);
}
/**
* @deprecated legacy code
*/
function tryMatchType(imageName: string): ServiceType {
const match = MatchingImages.find(({ image }) => imageName.includes(image));
if (match) {
return match.type;
}
// TODO: Remove this legacy code
return 'Other';
}
/**
* @deprecated
* @param container the container to match
* @returns a new service
*/
const tryMatchService = (container: Dockerode.ContainerInfo | undefined) => {
if (container === undefined) return {};
const name = container.Names[0].substring(1);
const type = tryMatchType(container.Image);
const port = tryMatchPort(type.toLowerCase())?.value ?? container.Ports[0]?.PublicPort;
return {
name,
id: container.Id,
type: tryMatchType(container.Image),
url: `localhost${port ? `:${port}` : ''}`,
icon: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`,
};
};

View File

@@ -1,13 +1,12 @@
import {
Table,
Checkbox,
Group,
Badge,
Checkbox,
createStyles,
Group,
ScrollArea,
TextInput,
useMantineTheme,
Table,
Text,
TextInput,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons';

View File

@@ -9,8 +9,9 @@ import {
} from '@mantine/core';
import React from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { GetServerSidePropsContext } from 'next';
import Link from 'next/link';
import { getServerSideTranslations } from '../tools/server/getServerSideTranslations';
const useStyles = createStyles((theme) => ({
root: {
@@ -94,12 +95,11 @@ export default function Custom404() {
</Container>
);
}
export async function getStaticProps({ locale }: { locale: string }) {
export async function getStaticProps({ req, res, locale }: GetServerSidePropsContext) {
const translations = await getServerSideTranslations(['common'], locale, undefined, undefined);
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
// Will be passed to the page component as props
...translations,
},
};
}

View File

@@ -23,7 +23,7 @@ export async function getServerSideProps({
const configPath = path.join(process.cwd(), 'data/configs', `${configName}.json`);
const configExists = fs.existsSync(configPath);
const translations = await getServerSideTranslations(req, res, dashboardNamespaces, locale);
const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res);
if (!configExists) {
// Redirect to 404

View File

@@ -22,12 +22,12 @@ import { CategoryEditModal } from '../components/Dashboard/Wrappers/Category/Cat
import { ConfigProvider } from '../config/provider';
import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore';
import { ColorTheme } from '../tools/color';
import { queryClient } from '../tools/queryClient';
import { queryClient } from '../tools/server/configurations/tanstack/queryClient.tool';
import {
getServiceSidePackageAttributes,
ServerSidePackageAttributesType,
} from '../tools/server/getPackageVersion';
import { theme } from '../tools/theme';
import { theme } from '../tools/server/theme/theme';
import { useEditModeInformationStore } from '../hooks/useEditModeInformation';
import '../styles/global.scss';

View File

@@ -9,8 +9,8 @@ import { NextApiRequest, NextApiResponse } from 'next';
import Parser from 'rss-parser';
import { getConfig } from '../../../../tools/config/getConfig';
import { Stopwatch } from '../../../../tools/shared/stopwatch';
import { IRssWidget } from '../../../../widgets/rss/RssWidgetTile';
import { Stopwatch } from '../../../../tools/shared/time/stopwatch.tool';
type CustomItem = {
'media:content': string;

View File

@@ -46,7 +46,7 @@ export async function getServerSideProps({
configName = 'default';
}
const translations = await getServerSideTranslations(req, res, dashboardNamespaces, locale);
const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res);
const config = getFrontendConfig(configName as string);
return {

View File

@@ -1,29 +0,0 @@
import Dockerode from 'dockerode';
import { MatchingImages, ServiceType, tryMatchPort } from './types';
function tryMatchType(imageName: string): ServiceType {
// Try to find imageName inside MatchingImages
const match = MatchingImages.find(({ image }) => imageName.includes(image));
if (match) {
return match.type;
}
return 'Other';
}
export function tryMatchService(container: Dockerode.ContainerInfo | undefined) {
if (container === undefined) return {};
const name = container.Names[0].substring(1);
const type = tryMatchType(container.Image);
const port = tryMatchPort(type.toLowerCase())?.value ?? container.Ports[0]?.PublicPort;
return {
name,
id: container.Id,
type: tryMatchType(container.Image),
url: `localhost${port ? `:${port}` : ''}`,
icon: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`,
};
}

View File

@@ -3,17 +3,16 @@ import { IncomingMessage, ServerResponse } from 'http';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export const getServerSideTranslations = async (
req: IncomingMessage,
res: ServerResponse,
namespaces: string[],
requestLocale?: string
requestLocale?: string,
req?: IncomingMessage,
res?: ServerResponse
) => {
if (!req || !res) {
return serverSideTranslations(requestLocale ?? 'en', namespaces);
}
const configLocale = getCookie('config-locale', { req, res });
const translations = await serverSideTranslations(
(configLocale ?? requestLocale ?? 'en') as string,
namespaces
);
return translations;
return serverSideTranslations((configLocale ?? requestLocale ?? 'en') as string, namespaces);
};

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import 'vitest-fetch-mock';
import { PlexClient } from './plexClient';
const mockResponse = `<MediaContainer size="1">
<Video addedAt="0000000" art="/library/metadata/2/art/00000000" audienceRating="0.0" audienceRatingImage="niceImage" chapterSource="media" contentRating="TV-PG" duration="6262249" guid="plex://movie/0000000000000000" key="/library/metadata/2" lastViewedAt="0000000" librarySectionID="1" librarySectionKey="/library/sections/1" librarySectionTitle="Movies" originalTitle="00000000000000" originallyAvailableAt="0000-00-00" rating="0.0" ratingImage="ratingimage" ratingKey="2" sessionKey="1" studio="Example Studio" summary="Lorem Ispum dolor sit amet" tagline="Yep" thumb="/library/metadata/2/thumb/0000000" title="A long title" titleSort="A short title" type="movie" updatedAt="000000" viewOffset="0" year="0000">
<Media audioProfile="ma" id="2" videoProfile="high" audioChannels="2" audioCodec="aac" bitrate="20231" container="mp4" duration="6262249" height="1080" optimizedForStreaming="1" protocol="dash" videoCodec="h264" videoFrameRate="24p" videoResolution="1080p" width="1920" selected="1">
<Part audioProfile="ma" hasThumbnail="1" id="2" videoProfile="high" bitrate="20231" container="mp4" duration="6262249" height="1080" optimizedForStreaming="1" protocol="dash" width="1920" decision="transcode" selected="1">
<Stream bitDepth="8" bitrate="19975" chromaLocation="left" chromaSubsampling="4:2:0" codec="h264" codedHeight="1088" codedWidth="1920" default="1" displayTitle="XXXX" extendedDisplayTitle="Yes" frameRate="23.975999832153320" hasScalingMatrix="0" height="1080" id="4" level="41" profile="high" refFrames="4" scanType="progressive" streamType="1" title="Example" width="1920" decision="copy" location="segments-video"/>
<Stream bitrate="256" bitrateMode="cbr" channels="2" codec="aac" default="1" displayTitle="Not Existing" extendedDisplayTitle="Yes, really" id="5" language="Yep" languageCode="jpn" languageTag="ch" selected="1" streamType="2" decision="transcode" location="segments-audio"/>
</Part>
</Media>
<Genre count="13" filter="genre=48" id="48" tag="Drama"/>
<Genre count="8" filter="genre=104" id="104" tag="Adventure"/>
<User id="1" thumb="https://google.com" title="example_usr"/>
<Player address="0.0.0.0" device="Windows" machineIdentifier="72483785378573857385" model="bundled" platform="Chrome" platformVersion="111.0" product="Plex Web" profile="Web" state="paused" title="Chrome" version="0.000.0" local="1" relayed="0" secure="1" userID="1"/>
<Session id="2894294r2jf2038fj3098jgf3gt" bandwidth="21560" location="lan"/>
<TranscodeSession key="/transcode/sessions/example-session" throttled="0" complete="0" progress="0" size="-22" speed="18.600000381469727" error="0" duration="100" remaining="70" context="streaming" sourceVideoCodec="h264" sourceAudioCodec="dca" videoDecision="copy" audioDecision="transcode" protocol="dash" container="mp4" videoCodec="h264" audioCodec="aac" audioChannels="2" width="1920" height="1080" transcodeHwRequested="0" transcodeHwFullPipeline="0" timeStamp="1679349635.2791338" maxOffsetAvailable="104.27" minOffsetAvailable="84.166999816894531"/>
</Video>
</MediaContainer>`;
describe('Plex SDK', () => {
it('abc', async () => {
// arrange
const client = new PlexClient('https://plex.local', 'MY_TOKEN');
fetchMock.mockResponseOnce(mockResponse);
// act
const response = await client.getSessions();
// assert
expect(fetchMock.requests().length).toBe(1);
expect(fetchMock.requests()[0].url).toBe(
'https://plex.local/status/sessions?X-Plex-Token=MY_TOKEN'
);
expect(response).not.toBeNull();
expect(response.length).toBe(1);
expect(response[0].id).toBe('2894294r2jf2038fj3098jgf3gt');
expect(response[0].username).toBe('example_usr');
expect(response[0].userProfilePicture).toBe('https://google.com');
expect(response[0].sessionName).toBe('Plex Web (Chrome)');
expect(response[0].currentlyPlaying).toMatchObject({
name: 'A long title',
type: 'movie',
metadata: {
video: {
bitrate: '20231',
height: '1080',
videoCodec: 'h264',
videoFrameRate: '24p',
width: '1920',
},
audio: { audioChannels: '2', audioCodec: 'aac' },
transcoding: {
audioChannels: '2',
audioCodec: 'aac',
audioDecision: 'transcode',
container: 'mp4',
context: 'streaming',
duration: '100',
error: false,
height: '1080',
sourceAudioCodec: 'dca',
sourceVideoCodec: 'h264',
timeStamp: '1679349635.2791338',
transcodeHwRequested: false,
videoCodec: 'h264',
videoDecision: 'copy',
width: '1920',
},
},
});
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { percentage } from './percentage.tool';
describe('percentage', () => {
it.concurrent('be fixed value', () => {
// arrange
const value = 62;
// act
const fixedPercentage = percentage(value, 100);
// assert
expect(fixedPercentage).toBe('62.0');
});
it.concurrent('be fixed value when decimal places', () => {
// arrange
const value = 42.69696969;
// act
const fixedPercentage = percentage(value, 100);
// assert
expect(fixedPercentage).toBe('42.7');
});
});

View File

@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isToday } from './date.tool';
describe('isToday', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it.concurrent('should return true if date is today', () => {
// arrange
const date = new Date(2022, 3, 17);
vi.setSystemTime(date);
// act
const today = isToday(date);
// assert
expect(today).toBe(true);
});
it.concurrent("should return true if date is today and time doesn't match", () => {
// arrange
vi.setSystemTime(new Date(2022, 3, 17, 16, 25, 11));
// act
const today = isToday(new Date(2022, 3, 17));
// assert
expect(today).toBe(true);
});
it.concurrent("should be false if date doesn't match", () => {
// arrange
vi.setSystemTime(new Date(2022, 3, 17, 16));
// act
const today = isToday(new Date(2022, 3, 15));
// assert
expect(today).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Stopwatch } from './stopwatch.tool';
describe('stopwatch', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it.concurrent('should be elapsed time between start and current', () => {
// arrange
vi.setSystemTime(new Date(2023, 2, 26, 0, 0, 0));
const stopwatch = new Stopwatch();
// act
vi.setSystemTime(new Date(2023, 2, 26, 0, 0, 2));
const milliseconds = stopwatch.getEllapsedMilliseconds();
// assert
expect(milliseconds).toBe(2000);
});
});

View File

@@ -1,4 +1,5 @@
import { MantineTheme } from '@mantine/core';
import { OptionValues } from '../modules/ModuleTypes';
export interface Settings {
@@ -74,6 +75,12 @@ export type ServiceType =
| 'Sabnzbd'
| 'NZBGet';
/**
* @deprecated
* @param name the name to match
* @param form the form
* @returns the port from the map
*/
export function tryMatchPort(name: string | undefined, form?: any) {
if (!name) {
return undefined;

View File

@@ -1,6 +1,6 @@
import { Box, Indicator, IndicatorProps, Popover, useMantineTheme } from '@mantine/core';
import { Box, Indicator, IndicatorProps, Popover } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { isToday } from '../../tools/isToday';
import { isToday } from '../../tools/shared/time/date.tool';
import { MediaList } from './MediaList';
import { MediasType } from './type';

View File

@@ -6,7 +6,6 @@ import { i18n } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../config/provider';
import { useColorTheme } from '../../tools/color';
import { isToday } from '../../tools/isToday';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { CalendarDay } from './CalendarDay';

View File

@@ -4,7 +4,7 @@ import axios from 'axios';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../config/provider';
import { bytes } from '../../tools/bytesHelper';
import { percentage } from '../../tools/percentage';
import { percentage } from '../../tools/shared/math/percentage.tool';
import { DashDotInfo } from './DashDotCompactNetwork';
interface DashDotCompactStorageProps {

View File

@@ -9,7 +9,6 @@ import {
Table,
Text,
Title,
useMantineTheme,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconFileDownload } from '@tabler/icons';

View File

@@ -1,14 +1,4 @@
import {
Badge,
Button,
Group,
Select,
Stack,
Tabs,
Text,
Title,
useMantineTheme,
} from '@mantine/core';
import { Badge, Button, Group, Select, Stack, Tabs, Text, Title } from '@mantine/core';
import { IconFileDownload, IconPlayerPause, IconPlayerPlay } from '@tabler/icons';
import { useEffect, useState } from 'react';
@@ -17,6 +7,7 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../config/provider';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
import {
useGetUsenetInfo,
usePauseUsenetQueue,
@@ -28,7 +19,6 @@ import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { UsenetHistoryList } from './UsenetHistoryList';
import { UsenetQueueList } from './UsenetQueueList';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
dayjs.extend(duration);

8
tests/setupVitest.ts Normal file
View File

@@ -0,0 +1,8 @@
//setupVitest.js or similar file
import createFetchMock from 'vitest-fetch-mock';
import { vi } from 'vitest';
const fetchMocker = createFetchMock(vi);
// sets globalThis.fetch and globalThis.fetchMock to our mocked version
fetchMocker.enableMocks();

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
coverage: {
provider: 'c8',
reporter: ['html'],
all: true,
exclude: ['.next/', '.yarn/', 'data/'],
},
setupFiles: ['./tests/setupVitest.ts'],
},
});

3677
yarn.lock

File diff suppressed because it is too large Load Diff