From 68ff28c35fd3697536eec530dfeea57604cf7ed7 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Sun, 1 Feb 2026 21:21:56 +0200 Subject: [PATCH 01/11] Add Vitest test suite for React components - Set up Vitest with React Testing Library and jsdom - Create comprehensive mocks for Docusaurus modules - Add 318 tests across 20 test files covering: - All custom components (Grid, Callout, Install, OneLineInstall, etc.) - Theme overrides (ColorModeToggle, Details, EditThisPage, DocItem) - Data and pages (News.js, index.js redirect) - Add snapshot tests for regression detection - Configure coverage reporting with v8 provider - Add test scripts: test, test:run, test:coverage, test:ui Coverage for non-AskNetdata components: 100% statements, 100% lines --- .gitignore | 5 +- package.json | 17 +- src/__mocks__/@docusaurus/Link.js | 15 + src/__mocks__/@docusaurus/Translate.js | 21 + .../@docusaurus/plugin-content-docs/client.js | 49 + src/__mocks__/@docusaurus/router.js | 72 + src/__mocks__/@docusaurus/theme-common.js | 56 + .../@docusaurus/theme-common/Details.js | 13 + src/__mocks__/@docusaurus/useIsBrowser.js | 12 + .../@theme-original/DocItem/Footer.js | 12 + .../NavbarItem/ComponentTypes.js | 12 + src/__mocks__/@theme/CodeBlock.js | 18 + src/__mocks__/@theme/Heading.js | 12 + src/__mocks__/@theme/Icon/DarkMode.js | 12 + src/__mocks__/@theme/Icon/Edit.js | 12 + src/__mocks__/@theme/Icon/LightMode.js | 12 + src/__mocks__/@theme/MDXComponents/A.js | 12 + src/__mocks__/@theme/MDXComponents/Code.js | 12 + src/__mocks__/@theme/MDXComponents/Heading.js | 12 + src/__mocks__/@theme/MDXComponents/Img.js | 11 + src/__mocks__/@theme/MDXComponents/Li.js | 12 + src/__mocks__/@theme/MDXComponents/Pre.js | 12 + src/__mocks__/@theme/MDXComponents/Ul.js | 12 + src/__mocks__/@theme/MDXContent.js | 8 + src/__mocks__/@theme/Mermaid.js | 12 + src/__mocks__/mermaid.js | 17 + src/__mocks__/react-inlinesvg.js | 12 + .../theme-original/DocItem/Footer.js | 12 + .../NavbarItem/ComponentTypes.js | 12 + src/components/AskNetdata/colors.test.js | 178 +++ src/components/AskNetdata/index.test.js | 132 ++ .../__snapshots__/index.test.js.snap | 287 ++++ src/components/AskNetdataWidget/index.test.js | 118 ++ .../AskNetdataWidgetNavbarItem.test.js | 146 ++ src/components/Callout.test.js | 109 ++ .../_collector-components.test.js.snap | 168 +++ .../Collectors/_collector-components.test.js | 196 +++ src/components/Grid.test.js | 248 ++++ src/components/Grid_integrations.test.js | 138 ++ .../Install/__snapshots__/index.test.js.snap | 127 ++ src/components/Install/index.test.js | 119 ++ .../__snapshots__/index.test.js.snap | 87 ++ src/components/InstallRegexLink/index.test.js | 119 ++ .../__snapshots__/index.test.js.snap | 347 +++++ src/components/OneLineInstall/index.test.js | 323 +++++ .../AskNetdataWidgetNavbarItem.test.js.snap | 30 + .../__snapshots__/Callout.test.js.snap | 57 + .../__snapshots__/Grid.test.js.snap | 127 ++ .../Grid_integrations.test.js.snap | 122 ++ .../dbCalc/__snapshots__/index.test.js.snap | 319 +++++ src/components/agent/dbCalc/index.test.js | 281 ++++ src/data/News.test.js | 73 + src/pages/__snapshots__/index.test.js.snap | 12 + src/pages/index.test.js | 28 + src/test/setup.js | 78 ++ .../__snapshots__/index.test.js.snap | 107 ++ src/theme/ColorModeToggle/index.test.js | 156 +++ .../Details/__snapshots__/index.test.js.snap | 56 + src/theme/Details/index.test.js | 118 ++ .../Content/__snapshots__/index.test.js.snap | 113 ++ src/theme/DocItem/Content/index.test.js | 146 ++ .../Footer/__snapshots__/index.test.js.snap | 23 + src/theme/DocItem/Footer/index.test.js | 61 + .../__snapshots__/index.test.js.snap | 20 + src/theme/EditThisPage/index.test.js | 86 ++ src/theme/NavbarItem/ComponentTypes.test.js | 32 + .../__snapshots__/ComponentTypes.test.js.snap | 16 + vitest.config.js | 116 ++ yarn.lock | 1227 ++++++++++++++++- 69 files changed, 6736 insertions(+), 16 deletions(-) create mode 100644 src/__mocks__/@docusaurus/Link.js create mode 100644 src/__mocks__/@docusaurus/Translate.js create mode 100644 src/__mocks__/@docusaurus/plugin-content-docs/client.js create mode 100644 src/__mocks__/@docusaurus/router.js create mode 100644 src/__mocks__/@docusaurus/theme-common.js create mode 100644 src/__mocks__/@docusaurus/theme-common/Details.js create mode 100644 src/__mocks__/@docusaurus/useIsBrowser.js create mode 100644 src/__mocks__/@theme-original/DocItem/Footer.js create mode 100644 src/__mocks__/@theme-original/NavbarItem/ComponentTypes.js create mode 100644 src/__mocks__/@theme/CodeBlock.js create mode 100644 src/__mocks__/@theme/Heading.js create mode 100644 src/__mocks__/@theme/Icon/DarkMode.js create mode 100644 src/__mocks__/@theme/Icon/Edit.js create mode 100644 src/__mocks__/@theme/Icon/LightMode.js create mode 100644 src/__mocks__/@theme/MDXComponents/A.js create mode 100644 src/__mocks__/@theme/MDXComponents/Code.js create mode 100644 src/__mocks__/@theme/MDXComponents/Heading.js create mode 100644 src/__mocks__/@theme/MDXComponents/Img.js create mode 100644 src/__mocks__/@theme/MDXComponents/Li.js create mode 100644 src/__mocks__/@theme/MDXComponents/Pre.js create mode 100644 src/__mocks__/@theme/MDXComponents/Ul.js create mode 100644 src/__mocks__/@theme/MDXContent.js create mode 100644 src/__mocks__/@theme/Mermaid.js create mode 100644 src/__mocks__/mermaid.js create mode 100644 src/__mocks__/react-inlinesvg.js create mode 100644 src/__mocks__/theme-original/DocItem/Footer.js create mode 100644 src/__mocks__/theme-original/NavbarItem/ComponentTypes.js create mode 100644 src/components/AskNetdata/colors.test.js create mode 100644 src/components/AskNetdata/index.test.js create mode 100644 src/components/AskNetdataWidget/__snapshots__/index.test.js.snap create mode 100644 src/components/AskNetdataWidget/index.test.js create mode 100644 src/components/AskNetdataWidgetNavbarItem.test.js create mode 100644 src/components/Callout.test.js create mode 100644 src/components/Collectors/__snapshots__/_collector-components.test.js.snap create mode 100644 src/components/Collectors/_collector-components.test.js create mode 100644 src/components/Grid.test.js create mode 100644 src/components/Grid_integrations.test.js create mode 100644 src/components/Install/__snapshots__/index.test.js.snap create mode 100644 src/components/Install/index.test.js create mode 100644 src/components/InstallRegexLink/__snapshots__/index.test.js.snap create mode 100644 src/components/InstallRegexLink/index.test.js create mode 100644 src/components/OneLineInstall/__snapshots__/index.test.js.snap create mode 100644 src/components/OneLineInstall/index.test.js create mode 100644 src/components/__snapshots__/AskNetdataWidgetNavbarItem.test.js.snap create mode 100644 src/components/__snapshots__/Callout.test.js.snap create mode 100644 src/components/__snapshots__/Grid.test.js.snap create mode 100644 src/components/__snapshots__/Grid_integrations.test.js.snap create mode 100644 src/components/agent/dbCalc/__snapshots__/index.test.js.snap create mode 100644 src/components/agent/dbCalc/index.test.js create mode 100644 src/data/News.test.js create mode 100644 src/pages/__snapshots__/index.test.js.snap create mode 100644 src/pages/index.test.js create mode 100644 src/test/setup.js create mode 100644 src/theme/ColorModeToggle/__snapshots__/index.test.js.snap create mode 100644 src/theme/ColorModeToggle/index.test.js create mode 100644 src/theme/Details/__snapshots__/index.test.js.snap create mode 100644 src/theme/Details/index.test.js create mode 100644 src/theme/DocItem/Content/__snapshots__/index.test.js.snap create mode 100644 src/theme/DocItem/Content/index.test.js create mode 100644 src/theme/DocItem/Footer/__snapshots__/index.test.js.snap create mode 100644 src/theme/DocItem/Footer/index.test.js create mode 100644 src/theme/EditThisPage/__snapshots__/index.test.js.snap create mode 100644 src/theme/EditThisPage/index.test.js create mode 100644 src/theme/NavbarItem/ComponentTypes.test.js create mode 100644 src/theme/NavbarItem/__snapshots__/ComponentTypes.test.js.snap create mode 100644 vitest.config.js diff --git a/.gitignore b/.gitignore index 1dce148f7c..ab5c9a8bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,7 @@ yarn-error.log* /ingest/__pycache__/ /myenv/ venv -.python-version \ No newline at end of file +.python-version + +# Test coverage reports +/coverage/ \ No newline at end of file diff --git a/package.json b/package.json index 0be9b6a59c..35d4bf6c3d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" + "write-heading-ids": "docusaurus write-heading-ids", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "test:ui": "vitest --ui" }, "dependencies": { "@babel/core": "^7.26.10", @@ -47,10 +51,19 @@ "webpack": "^5.94.0" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/coverage-v8": "^4.0.18", "autoprefixer": "^10.2.5", + "happy-dom": "^20.4.0", + "jsdom": "^27.4.0", "postcss-loader": "^5.2.0", "postcss-preset-env": "^7.8.0", - "tailwindcss": "^2.1.2" + "tailwindcss": "^2.1.2", + "vitest": "^4.0.18" }, "browserslist": { "production": [ diff --git a/src/__mocks__/@docusaurus/Link.js b/src/__mocks__/@docusaurus/Link.js new file mode 100644 index 0000000000..299442e415 --- /dev/null +++ b/src/__mocks__/@docusaurus/Link.js @@ -0,0 +1,15 @@ +import React from 'react'; + +// Mock Docusaurus Link component +const Link = React.forwardRef(function Link({ to, href, children, ...props }, ref) { + const destination = to || href || '#'; + return ( + + {children} + + ); +}); + +Link.displayName = 'MockLink'; + +export default Link; diff --git a/src/__mocks__/@docusaurus/Translate.js b/src/__mocks__/@docusaurus/Translate.js new file mode 100644 index 0000000000..45f7a814eb --- /dev/null +++ b/src/__mocks__/@docusaurus/Translate.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { vi } from 'vitest'; + +// Mock Translate component +const Translate = ({ children, id, description }) => { + return React.createElement('span', { 'data-testid': 'translate', 'data-id': id }, children); +}; + +// Mock translate function +export const translate = vi.fn((config, values) => { + if (typeof config === 'string') return config; + let message = config.message || ''; + if (values) { + Object.entries(values).forEach(([key, value]) => { + message = message.replace(`{${key}}`, value); + }); + } + return message; +}); + +export default Translate; diff --git a/src/__mocks__/@docusaurus/plugin-content-docs/client.js b/src/__mocks__/@docusaurus/plugin-content-docs/client.js new file mode 100644 index 0000000000..13551a3443 --- /dev/null +++ b/src/__mocks__/@docusaurus/plugin-content-docs/client.js @@ -0,0 +1,49 @@ +import { vi } from 'vitest'; + +// Default mock doc data +const mockDocData = { + metadata: { + title: 'Test Doc Title', + description: 'Test doc description', + editUrl: 'https://github.com/netdata/learn/edit/master/docs/test.md', + lastUpdatedAt: 1640000000000, + lastUpdatedBy: 'test-user', + formattedLastUpdatedAt: 'Dec 20, 2021', + tags: [], + permalink: '/docs/test', + slug: '/test', + }, + frontMatter: { + id: 'test-doc', + title: 'Test Doc', + hide_title: false, + }, + contentTitle: undefined, // undefined triggers synthetic title + toc: [], +}; + +// Clone to allow modification +let currentMockDoc = { ...mockDocData }; + +export const useDoc = vi.fn(() => currentMockDoc); + +// Export for test manipulation +export const __setMockDoc = (docOverrides) => { + currentMockDoc = { + ...mockDocData, + ...docOverrides, + metadata: { ...mockDocData.metadata, ...(docOverrides?.metadata || {}) }, + frontMatter: { ...mockDocData.frontMatter, ...(docOverrides?.frontMatter || {}) }, + }; +}; + +export const __resetMockDoc = () => { + currentMockDoc = { ...mockDocData }; +}; + +export const useDocById = vi.fn(() => mockDocData); + +export const useActiveDocContext = vi.fn(() => ({ + activeVersion: { name: 'current', path: '/docs' }, + activeDoc: mockDocData, +})); diff --git a/src/__mocks__/@docusaurus/router.js b/src/__mocks__/@docusaurus/router.js new file mode 100644 index 0000000000..9dc311c035 --- /dev/null +++ b/src/__mocks__/@docusaurus/router.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { vi } from 'vitest'; + +// Mock history object +const mockHistory = { + push: vi.fn(), + replace: vi.fn(), + go: vi.fn(), + goBack: vi.fn(), + goForward: vi.fn(), + listen: vi.fn(() => vi.fn()), + location: { + pathname: '/docs/test', + search: '', + hash: '', + state: null, + }, +}; + +// Mock location object +const mockLocation = { + pathname: '/docs/test', + search: '', + hash: '', + state: null, + key: 'test-key', +}; + +export const useHistory = vi.fn(() => mockHistory); +export const useLocation = vi.fn(() => mockLocation); +export const useParams = vi.fn(() => ({})); + +export const Redirect = ({ to }) => { + return React.createElement('div', { 'data-testid': 'redirect', 'data-to': to }, `Redirect to ${to}`); +}; + +export const withRouter = (Component) => { + return function WrappedComponent(props) { + return React.createElement(Component, { + ...props, + history: mockHistory, + location: mockLocation, + }); + }; +}; + +// Default location state +const defaultLocation = { + pathname: '/docs/test', + search: '', + hash: '', + state: null, + key: 'test-key', +}; + +// Export mocks for test manipulation +export const __mockHistory = mockHistory; +export const __mockLocation = mockLocation; +export const __setMockPathname = (pathname) => { + mockLocation.pathname = pathname; + mockHistory.location.pathname = pathname; +}; + +export const __setMockLocation = (locationOverrides) => { + Object.assign(mockLocation, locationOverrides); + Object.assign(mockHistory.location, locationOverrides); +}; + +export const __resetMockLocation = () => { + Object.assign(mockLocation, defaultLocation); + Object.assign(mockHistory.location, defaultLocation); +}; diff --git a/src/__mocks__/@docusaurus/theme-common.js b/src/__mocks__/@docusaurus/theme-common.js new file mode 100644 index 0000000000..a0b91747a6 --- /dev/null +++ b/src/__mocks__/@docusaurus/theme-common.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { vi } from 'vitest'; + +// Mock color mode context +let mockColorMode = 'light'; + +export const useColorMode = vi.fn(() => ({ + colorMode: mockColorMode, + setColorMode: vi.fn((mode) => { + mockColorMode = mode; + }), + isDarkTheme: mockColorMode === 'dark', +})); + +// Export for test manipulation +export const __setMockColorMode = (mode) => { + mockColorMode = mode; +}; + +// Mock ThemeClassNames +export const ThemeClassNames = { + docs: { + docMarkdown: 'theme-doc-markdown', + docFooterEditMetaRow: 'theme-doc-footer-edit-meta-row', + }, + common: { + editThisPage: 'theme-edit-this-page', + }, +}; + +// Mock Details component +export const Details = React.forwardRef(function Details({ children, summary, ...props }, ref) { + return React.createElement( + 'details', + { ref, 'data-testid': 'docusaurus-details', ...props }, + React.createElement('summary', null, summary), + children + ); +}); + +// Mock usePrismTheme +export const usePrismTheme = vi.fn(() => ({ + plain: { color: '#000', backgroundColor: '#fff' }, + styles: [], +})); + +// Mock useDocsSidebar +export const useDocsSidebar = vi.fn(() => ({ + items: [], +})); + +// Mock useWindowSize +export const useWindowSize = vi.fn(() => ({ + width: 1024, + height: 768, +})); diff --git a/src/__mocks__/@docusaurus/theme-common/Details.js b/src/__mocks__/@docusaurus/theme-common/Details.js new file mode 100644 index 0000000000..39591f94d3 --- /dev/null +++ b/src/__mocks__/@docusaurus/theme-common/Details.js @@ -0,0 +1,13 @@ +import React from 'react'; + +// Mock Details component from theme-common/Details +export const Details = React.forwardRef(function Details({ children, summary, className, ...props }, ref) { + return React.createElement( + 'details', + { ref, className, 'data-testid': 'docusaurus-details-generic', ...props }, + summary && React.createElement('summary', null, summary), + children + ); +}); + +export default { Details }; diff --git a/src/__mocks__/@docusaurus/useIsBrowser.js b/src/__mocks__/@docusaurus/useIsBrowser.js new file mode 100644 index 0000000000..21286186b4 --- /dev/null +++ b/src/__mocks__/@docusaurus/useIsBrowser.js @@ -0,0 +1,12 @@ +import { vi } from 'vitest'; + +let isBrowser = true; + +const useIsBrowser = vi.fn(() => isBrowser); + +// Export for test manipulation +export const __setIsBrowser = (value) => { + isBrowser = value; +}; + +export default useIsBrowser; diff --git a/src/__mocks__/@theme-original/DocItem/Footer.js b/src/__mocks__/@theme-original/DocItem/Footer.js new file mode 100644 index 0000000000..5af4845141 --- /dev/null +++ b/src/__mocks__/@theme-original/DocItem/Footer.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock original DocItem/Footer +const Footer = (props) => { + return React.createElement( + 'footer', + { 'data-testid': 'original-doc-footer', ...props }, + 'Original Footer' + ); +}; + +export default Footer; diff --git a/src/__mocks__/@theme-original/NavbarItem/ComponentTypes.js b/src/__mocks__/@theme-original/NavbarItem/ComponentTypes.js new file mode 100644 index 0000000000..c780d6a07a --- /dev/null +++ b/src/__mocks__/@theme-original/NavbarItem/ComponentTypes.js @@ -0,0 +1,12 @@ +// Mock original NavbarItem ComponentTypes +export default { + default: () => null, + localeDropdown: () => null, + search: () => null, + dropdown: () => null, + html: () => null, + doc: () => null, + docSidebar: () => null, + docsVersion: () => null, + docsVersionDropdown: () => null, +}; diff --git a/src/__mocks__/@theme/CodeBlock.js b/src/__mocks__/@theme/CodeBlock.js new file mode 100644 index 0000000000..2834f193ea --- /dev/null +++ b/src/__mocks__/@theme/CodeBlock.js @@ -0,0 +1,18 @@ +import React from 'react'; + +// Mock CodeBlock component +const CodeBlock = ({ children, className, language, title, ...props }) => { + const lang = language || (className ? className.replace('language-', '') : 'text'); + return React.createElement( + 'pre', + { + 'data-testid': 'code-block', + 'data-language': lang, + className: `language-${lang}`, + ...props + }, + React.createElement('code', null, children) + ); +}; + +export default CodeBlock; diff --git a/src/__mocks__/@theme/Heading.js b/src/__mocks__/@theme/Heading.js new file mode 100644 index 0000000000..eb7191225d --- /dev/null +++ b/src/__mocks__/@theme/Heading.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock Heading component +const Heading = ({ as: Tag = 'h1', children, id, ...props }) => { + return React.createElement( + Tag, + { 'data-testid': 'heading', id, ...props }, + children + ); +}; + +export default Heading; diff --git a/src/__mocks__/@theme/Icon/DarkMode.js b/src/__mocks__/@theme/Icon/DarkMode.js new file mode 100644 index 0000000000..6da2a8c4dd --- /dev/null +++ b/src/__mocks__/@theme/Icon/DarkMode.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock DarkMode icon +const IconDarkMode = (props) => { + return React.createElement( + 'svg', + { 'data-testid': 'icon-dark-mode', ...props }, + React.createElement('path', { d: 'M21 12.79A9 9 0 1 1 11.21 3' }) + ); +}; + +export default IconDarkMode; diff --git a/src/__mocks__/@theme/Icon/Edit.js b/src/__mocks__/@theme/Icon/Edit.js new file mode 100644 index 0000000000..d068704974 --- /dev/null +++ b/src/__mocks__/@theme/Icon/Edit.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock Edit icon +const IconEdit = (props) => { + return React.createElement( + 'svg', + { 'data-testid': 'icon-edit', ...props }, + React.createElement('path', { d: 'M0 0h24v24H0z' }) + ); +}; + +export default IconEdit; diff --git a/src/__mocks__/@theme/Icon/LightMode.js b/src/__mocks__/@theme/Icon/LightMode.js new file mode 100644 index 0000000000..be65daf55d --- /dev/null +++ b/src/__mocks__/@theme/Icon/LightMode.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock LightMode icon +const IconLightMode = (props) => { + return React.createElement( + 'svg', + { 'data-testid': 'icon-light-mode', ...props }, + React.createElement('circle', { cx: 12, cy: 12, r: 5 }) + ); +}; + +export default IconLightMode; diff --git a/src/__mocks__/@theme/MDXComponents/A.js b/src/__mocks__/@theme/MDXComponents/A.js new file mode 100644 index 0000000000..76f94312f7 --- /dev/null +++ b/src/__mocks__/@theme/MDXComponents/A.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock MDXComponents/A (anchor) +const MDXA = ({ href, children, ...props }) => { + return React.createElement( + 'a', + { 'data-testid': 'mdx-link', href, ...props }, + children + ); +}; + +export default MDXA; diff --git a/src/__mocks__/@theme/MDXComponents/Code.js b/src/__mocks__/@theme/MDXComponents/Code.js new file mode 100644 index 0000000000..56b2de34bf --- /dev/null +++ b/src/__mocks__/@theme/MDXComponents/Code.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock MDXComponents/Code +const MDXCode = ({ children, className, ...props }) => { + return React.createElement( + 'code', + { 'data-testid': 'mdx-code', className, ...props }, + children + ); +}; + +export default MDXCode; diff --git a/src/__mocks__/@theme/MDXComponents/Heading.js b/src/__mocks__/@theme/MDXComponents/Heading.js new file mode 100644 index 0000000000..6f44fb8d38 --- /dev/null +++ b/src/__mocks__/@theme/MDXComponents/Heading.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock MDXComponents/Heading +const MDXHeading = ({ as: Tag = 'h1', children, ...props }) => { + return React.createElement( + Tag, + { 'data-testid': 'mdx-heading', ...props }, + children + ); +}; + +export default MDXHeading; diff --git a/src/__mocks__/@theme/MDXComponents/Img.js b/src/__mocks__/@theme/MDXComponents/Img.js new file mode 100644 index 0000000000..077c8487e1 --- /dev/null +++ b/src/__mocks__/@theme/MDXComponents/Img.js @@ -0,0 +1,11 @@ +import React from 'react'; + +// Mock MDXComponents/Img +const MDXImg = ({ src, alt, ...props }) => { + return React.createElement( + 'img', + { 'data-testid': 'mdx-img', src, alt, ...props } + ); +}; + +export default MDXImg; diff --git a/src/__mocks__/@theme/MDXComponents/Li.js b/src/__mocks__/@theme/MDXComponents/Li.js new file mode 100644 index 0000000000..9083996955 --- /dev/null +++ b/src/__mocks__/@theme/MDXComponents/Li.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock MDXComponents/Li +const MDXLi = ({ children, ...props }) => { + return React.createElement( + 'li', + { 'data-testid': 'mdx-li', ...props }, + children + ); +}; + +export default MDXLi; diff --git a/src/__mocks__/@theme/MDXComponents/Pre.js b/src/__mocks__/@theme/MDXComponents/Pre.js new file mode 100644 index 0000000000..4bb202ba2d --- /dev/null +++ b/src/__mocks__/@theme/MDXComponents/Pre.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock MDXComponents/Pre +const MDXPre = ({ children, ...props }) => { + return React.createElement( + 'pre', + { 'data-testid': 'mdx-pre', ...props }, + children + ); +}; + +export default MDXPre; diff --git a/src/__mocks__/@theme/MDXComponents/Ul.js b/src/__mocks__/@theme/MDXComponents/Ul.js new file mode 100644 index 0000000000..0896f7982e --- /dev/null +++ b/src/__mocks__/@theme/MDXComponents/Ul.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock MDXComponents/Ul +const MDXUl = ({ children, ...props }) => { + return React.createElement( + 'ul', + { 'data-testid': 'mdx-ul', ...props }, + children + ); +}; + +export default MDXUl; diff --git a/src/__mocks__/@theme/MDXContent.js b/src/__mocks__/@theme/MDXContent.js new file mode 100644 index 0000000000..79df45cc49 --- /dev/null +++ b/src/__mocks__/@theme/MDXContent.js @@ -0,0 +1,8 @@ +import React from 'react'; + +// Mock MDXContent component - just renders children +const MDXContent = ({ children }) => { + return React.createElement('div', { 'data-testid': 'mdx-content' }, children); +}; + +export default MDXContent; diff --git a/src/__mocks__/@theme/Mermaid.js b/src/__mocks__/@theme/Mermaid.js new file mode 100644 index 0000000000..862f5aa914 --- /dev/null +++ b/src/__mocks__/@theme/Mermaid.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock Mermaid component +const Mermaid = ({ value, ...props }) => { + return React.createElement( + 'div', + { 'data-testid': 'mermaid', 'data-value': value, ...props }, + 'Mermaid Diagram' + ); +}; + +export default Mermaid; diff --git a/src/__mocks__/mermaid.js b/src/__mocks__/mermaid.js new file mode 100644 index 0000000000..265f683b02 --- /dev/null +++ b/src/__mocks__/mermaid.js @@ -0,0 +1,17 @@ +import { vi } from 'vitest'; + +// Mock mermaid library +const mermaid = { + initialize: vi.fn(), + init: vi.fn(), + run: vi.fn().mockResolvedValue(undefined), + render: vi.fn().mockResolvedValue({ svg: '' }), + parse: vi.fn().mockResolvedValue(true), + contentLoaded: vi.fn(), + mermaidAPI: { + initialize: vi.fn(), + render: vi.fn().mockResolvedValue({ svg: '' }), + }, +}; + +export default mermaid; diff --git a/src/__mocks__/react-inlinesvg.js b/src/__mocks__/react-inlinesvg.js new file mode 100644 index 0000000000..b70c15a38b --- /dev/null +++ b/src/__mocks__/react-inlinesvg.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock react-inlinesvg +const SVG = ({ src, alt, ...props }) => { + return React.createElement( + 'svg', + { 'data-testid': 'inline-svg', 'data-src': src, 'aria-label': alt, ...props }, + React.createElement('title', null, alt || 'SVG') + ); +}; + +export default SVG; diff --git a/src/__mocks__/theme-original/DocItem/Footer.js b/src/__mocks__/theme-original/DocItem/Footer.js new file mode 100644 index 0000000000..ff6a84791a --- /dev/null +++ b/src/__mocks__/theme-original/DocItem/Footer.js @@ -0,0 +1,12 @@ +import React from 'react'; + +// Mock original DocItem/Footer +const Footer = (props) => { + return React.createElement( + 'footer', + { 'data-testid': 'doc-item-footer', ...props }, + 'Original Footer' + ); +}; + +export default Footer; diff --git a/src/__mocks__/theme-original/NavbarItem/ComponentTypes.js b/src/__mocks__/theme-original/NavbarItem/ComponentTypes.js new file mode 100644 index 0000000000..c780d6a07a --- /dev/null +++ b/src/__mocks__/theme-original/NavbarItem/ComponentTypes.js @@ -0,0 +1,12 @@ +// Mock original NavbarItem ComponentTypes +export default { + default: () => null, + localeDropdown: () => null, + search: () => null, + dropdown: () => null, + html: () => null, + doc: () => null, + docSidebar: () => null, + docsVersion: () => null, + docsVersionDropdown: () => null, +}; diff --git a/src/components/AskNetdata/colors.test.js b/src/components/AskNetdata/colors.test.js new file mode 100644 index 0000000000..79e7c4c322 --- /dev/null +++ b/src/components/AskNetdata/colors.test.js @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import { + ASKNET_PRIMARY, + ASKNET_SECOND, + hexToRgbTuple, + rgbString, + rgba, + OPACITY, +} from './colors'; + +describe('AskNetdata colors', () => { + describe('color constants', () => { + it('should export ASKNET_PRIMARY as a valid hex color', () => { + expect(ASKNET_PRIMARY).toBeDefined(); + expect(ASKNET_PRIMARY).toMatch(/^#[0-9a-fA-F]{6,8}$/); + }); + + it('should export ASKNET_SECOND as a valid hex color', () => { + expect(ASKNET_SECOND).toBeDefined(); + expect(ASKNET_SECOND).toMatch(/^#[0-9a-fA-F]{6,8}$/); + }); + + it('should export OPACITY object with all required keys', () => { + expect(OPACITY).toBeDefined(); + expect(OPACITY).toHaveProperty('dimLight'); + expect(OPACITY).toHaveProperty('dimDark'); + expect(OPACITY).toHaveProperty('focusRing'); + expect(OPACITY).toHaveProperty('glowStrong'); + expect(OPACITY).toHaveProperty('glowSoft'); + }); + + it('should have opacity values between 0 and 1', () => { + Object.values(OPACITY).forEach((value) => { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + }); + }); + }); + + describe('hexToRgbTuple', () => { + it('should convert 6-digit hex to RGB tuple', () => { + expect(hexToRgbTuple('#ff0000')).toEqual([255, 0, 0]); + expect(hexToRgbTuple('#00ff00')).toEqual([0, 255, 0]); + expect(hexToRgbTuple('#0000ff')).toEqual([0, 0, 255]); + expect(hexToRgbTuple('#ffffff')).toEqual([255, 255, 255]); + expect(hexToRgbTuple('#000000')).toEqual([0, 0, 0]); + }); + + it('should convert 3-digit hex (short form) to RGB tuple', () => { + expect(hexToRgbTuple('#f00')).toEqual([255, 0, 0]); + expect(hexToRgbTuple('#0f0')).toEqual([0, 255, 0]); + expect(hexToRgbTuple('#00f')).toEqual([0, 0, 255]); + expect(hexToRgbTuple('#fff')).toEqual([255, 255, 255]); + expect(hexToRgbTuple('#000')).toEqual([0, 0, 0]); + }); + + it('should convert 8-digit hex (with alpha) to RGB tuple, ignoring alpha', () => { + expect(hexToRgbTuple('#ff0000ff')).toEqual([255, 0, 0]); + expect(hexToRgbTuple('#00ff0080')).toEqual([0, 255, 0]); + expect(hexToRgbTuple('#0000ff00')).toEqual([0, 0, 255]); + }); + + it('should handle hex without # prefix', () => { + expect(hexToRgbTuple('ff0000')).toEqual([255, 0, 0]); + expect(hexToRgbTuple('fff')).toEqual([255, 255, 255]); + }); + + it('should handle hex with leading/trailing whitespace', () => { + expect(hexToRgbTuple(' #ff0000 ')).toEqual([255, 0, 0]); + expect(hexToRgbTuple(' fff ')).toEqual([255, 255, 255]); + }); + + it('should return [0,0,0] for invalid input', () => { + expect(hexToRgbTuple(null)).toEqual([0, 0, 0]); + expect(hexToRgbTuple(undefined)).toEqual([0, 0, 0]); + expect(hexToRgbTuple('')).toEqual([0, 0, 0]); + expect(hexToRgbTuple('#')).toEqual([0, 0, 0]); + expect(hexToRgbTuple('#gg0000')).toEqual([0, 0, 0]); // invalid hex chars + expect(hexToRgbTuple('#ff00')).toEqual([0, 0, 0]); // invalid length + expect(hexToRgbTuple('#ff0000000')).toEqual([0, 0, 0]); // too long + }); + + it('should handle uppercase hex values', () => { + expect(hexToRgbTuple('#FF0000')).toEqual([255, 0, 0]); + expect(hexToRgbTuple('#AABBCC')).toEqual([170, 187, 204]); + }); + + it('should handle mixed case hex values', () => { + expect(hexToRgbTuple('#AaBbCc')).toEqual([170, 187, 204]); + }); + + it('should convert the actual ASKNET_PRIMARY color', () => { + const result = hexToRgbTuple(ASKNET_PRIMARY); + expect(result).toHaveLength(3); + result.forEach((val) => { + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThanOrEqual(255); + expect(Number.isInteger(val)).toBe(true); + }); + }); + + it('should convert the actual ASKNET_SECOND color', () => { + const result = hexToRgbTuple(ASKNET_SECOND); + expect(result).toHaveLength(3); + result.forEach((val) => { + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThanOrEqual(255); + expect(Number.isInteger(val)).toBe(true); + }); + }); + }); + + describe('rgbString', () => { + it('should convert hex to RGB string format', () => { + expect(rgbString('#ff0000')).toBe('255,0,0'); + expect(rgbString('#00ff00')).toBe('0,255,0'); + expect(rgbString('#0000ff')).toBe('0,0,255'); + }); + + it('should handle short hex format', () => { + expect(rgbString('#f00')).toBe('255,0,0'); + expect(rgbString('#fff')).toBe('255,255,255'); + }); + + it('should return "0,0,0" for invalid input', () => { + expect(rgbString(null)).toBe('0,0,0'); + expect(rgbString(undefined)).toBe('0,0,0'); + expect(rgbString('')).toBe('0,0,0'); + }); + + it('should work with actual color constants', () => { + const primaryRgb = rgbString(ASKNET_PRIMARY); + const secondRgb = rgbString(ASKNET_SECOND); + expect(primaryRgb).toMatch(/^\d{1,3},\d{1,3},\d{1,3}$/); + expect(secondRgb).toMatch(/^\d{1,3},\d{1,3},\d{1,3}$/); + }); + }); + + describe('rgba', () => { + it('should convert hex to rgba string with default alpha', () => { + expect(rgba('#ff0000')).toBe('rgba(255,0,0,1)'); + expect(rgba('#00ff00')).toBe('rgba(0,255,0,1)'); + }); + + it('should convert hex to rgba string with custom alpha', () => { + expect(rgba('#ff0000', 0.5)).toBe('rgba(255,0,0,0.5)'); + expect(rgba('#00ff00', 0)).toBe('rgba(0,255,0,0)'); + expect(rgba('#0000ff', 0.75)).toBe('rgba(0,0,255,0.75)'); + }); + + it('should handle alpha edge cases', () => { + expect(rgba('#ffffff', 0)).toBe('rgba(255,255,255,0)'); + expect(rgba('#ffffff', 1)).toBe('rgba(255,255,255,1)'); + }); + + it('should return black for invalid hex input', () => { + expect(rgba(null, 0.5)).toBe('rgba(0,0,0,0.5)'); + expect(rgba(undefined, 0.5)).toBe('rgba(0,0,0,0.5)'); + }); + + it('should work with actual color constants and opacity values', () => { + const result = rgba(ASKNET_PRIMARY, OPACITY.focusRing); + expect(result).toMatch(/^rgba\(\d{1,3},\d{1,3},\d{1,3},[\d.]+\)$/); + }); + }); + + describe('default export', () => { + it('should export all utilities as default', async () => { + const defaultExport = (await import('./colors')).default; + expect(defaultExport).toHaveProperty('ASKNET_PRIMARY'); + expect(defaultExport).toHaveProperty('ASKNET_SECOND'); + expect(defaultExport).toHaveProperty('hexToRgbTuple'); + expect(defaultExport).toHaveProperty('rgbString'); + expect(defaultExport).toHaveProperty('rgba'); + expect(defaultExport).toHaveProperty('OPACITY'); + }); + }); +}); diff --git a/src/components/AskNetdata/index.test.js b/src/components/AskNetdata/index.test.js new file mode 100644 index 0000000000..727279fb8d --- /dev/null +++ b/src/components/AskNetdata/index.test.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Mock react-markdown +vi.mock('react-markdown', () => ({ + default: ({ children }) =>
{children}
, +})); + +// Mock remark-gfm +vi.mock('remark-gfm', () => ({ + default: () => {}, +})); + +// Import the exported SUGGESTION_GROUPS for testing +import { SUGGESTION_GROUPS } from './index'; + +// Import after mocks +import AskNetdata from './index'; + +describe('AskNetdata component', () => { + const originalLocation = window.location; + const originalFetch = global.fetch; + + beforeEach(() => { + // Mock window.location + delete window.location; + window.location = { + hostname: 'learn.netdata.cloud', + href: 'https://learn.netdata.cloud/docs/ask-netdata', + }; + + // Mock fetch API for streaming + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + }), + }, + json: () => Promise.resolve({ results: [] }), + }) + ); + }); + + afterEach(() => { + window.location = originalLocation; + global.fetch = originalFetch; + }); + + describe('smoke tests', () => { + it('should render without crashing', () => { + render(); + // Component should render + expect(document.body.innerHTML).toBeTruthy(); + }); + + it('should render suggestion categories', () => { + render(); + // Should have rendered something + const container = document.body.innerHTML; + expect(container.length).toBeGreaterThan(0); + }); + }); + + describe('SUGGESTION_GROUPS export', () => { + it('should export SUGGESTION_GROUPS array', () => { + expect(Array.isArray(SUGGESTION_GROUPS)).toBe(true); + }); + + it('should have at least one suggestion group', () => { + expect(SUGGESTION_GROUPS.length).toBeGreaterThan(0); + }); + + it('should have about group', () => { + const aboutGroup = SUGGESTION_GROUPS.find(g => g.key === 'about'); + expect(aboutGroup).toBeDefined(); + expect(aboutGroup.title).toBe('About Netdata'); + }); + + it('should have deployment group', () => { + const deploymentGroup = SUGGESTION_GROUPS.find(g => g.key === 'deployment'); + expect(deploymentGroup).toBeDefined(); + expect(deploymentGroup.title).toBe('Deployment'); + }); + + it('should have operations group', () => { + const opsGroup = SUGGESTION_GROUPS.find(g => g.key === 'operations'); + expect(opsGroup).toBeDefined(); + expect(opsGroup.title).toBe('Operations'); + }); + + it('should have ai group', () => { + const aiGroup = SUGGESTION_GROUPS.find(g => g.key === 'ai'); + expect(aiGroup).toBeDefined(); + expect(aiGroup.title).toBe('AI & Machine Learning'); + }); + + it('each group should have items array', () => { + SUGGESTION_GROUPS.forEach(group => { + expect(Array.isArray(group.items)).toBe(true); + expect(group.items.length).toBeGreaterThan(0); + }); + }); + + it('each group should have key and title', () => { + SUGGESTION_GROUPS.forEach(group => { + expect(typeof group.key).toBe('string'); + expect(typeof group.title).toBe('string'); + expect(group.key.length).toBeGreaterThan(0); + expect(group.title.length).toBeGreaterThan(0); + }); + }); + }); + + describe('structure', () => { + it('should render container with proper class', () => { + const { container } = render(); + // Note: We avoid snapshot testing here because the component cycles through + // random suggestions, making snapshots unstable + expect(container.firstChild).toBeTruthy(); + }); + + it('should render main container element', () => { + const { container } = render(); + // Component should render its main container + const mainContainer = container.querySelector('[style*="flex"]'); + expect(mainContainer).toBeTruthy(); + }); + }); +}); diff --git a/src/components/AskNetdataWidget/__snapshots__/index.test.js.snap b/src/components/AskNetdataWidget/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..f1c13cb17d --- /dev/null +++ b/src/components/AskNetdataWidget/__snapshots__/index.test.js.snap @@ -0,0 +1,287 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AskNetdataWidget component > snapshots > should match initial snapshot 1`] = ` +
+ +
+
+
+
+ Ask Netdata + + Ctrl + K + +
+