diff --git a/.drone.yml b/.drone.yml index 3dd34c1..d05de72 100644 --- a/.drone.yml +++ b/.drone.yml @@ -27,6 +27,14 @@ steps: commands: - yarn build + - name: unit-tests + image: node:25 + environment: + NODE_OPTIONS: --no-webstorage + commands: + - yarn test:coverage + - test -f coverage/lcov.info + - name: code-analysis when: event: diff --git a/eslint.config.mjs b/eslint.config.mjs index e428a80..d46578e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,4 +45,14 @@ export default tseslint.config( 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, }, + { + files: ['tests/**/*.{ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + ...globals.vitest, + }, + }, + }, ); diff --git a/package.json b/package.json index 96ddb95..0b81a79 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "scripts": { "clean": "rm -rf dist", "build": "yarn clean && vite build && tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/base.css dist/styles/base.css && tailwindcss -c tailwind.build.config.cjs -i src/styles/components.css -o dist/styles/components.css --minify && tailwindcss -c tailwind.build.config.cjs -i src/styles/utilities.css -o dist/styles/utilities.css --minify && cp tailwind-preset.cjs dist/tailwind-preset.cjs", + "test": "vitest run", + "test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary", + "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier . --write", @@ -64,13 +67,17 @@ "@storybook/react": "^10.2.10", "@storybook/react-vite": "^10.2.10", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^10", "eslint-plugin-react-hooks": "^7.1.0-canary-ab18f33d-20260220", "eslint-plugin-react-refresh": "^0.5.1", "globals": "^17.3.0", + "jsdom": "^28.1.0", "postcss": "^8.4.49", "prettier": "^3.8.1", "react": "^19.0.0", @@ -81,6 +88,7 @@ "typescript": "^5.6.2", "typescript-eslint": "^8.56.0", "vite": "^7.0.0", + "vitest": "^4.0.18", "yjs": "^13.6.24" } } diff --git a/tests/components/Button.test.tsx b/tests/components/Button.test.tsx new file mode 100644 index 0000000..e68f847 --- /dev/null +++ b/tests/components/Button.test.tsx @@ -0,0 +1,71 @@ +import { HomeIcon } from '@heroicons/react/24/solid'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../../src/components/Button'; +import { renderWithRouter } from '../helpers/renderWithRouter'; + +describe('Button', () => { + it('renders native button with expected type and disabled state', () => { + render(}> +
Form child
+ , + ); + + expect(screen.getByText('User Details')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument(); + expect(screen.getByText('Form child')).toBeInTheDocument(); + }); + + it('supports custom class names and optional title actions', () => { + const { container } = render( +
+
Child
+
, + ); + + expect(container.firstElementChild).toHaveClass('form-custom'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/components/InputField.test.tsx b/tests/components/InputField.test.tsx new file mode 100644 index 0000000..43f3dc4 --- /dev/null +++ b/tests/components/InputField.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { InputField } from '../../src/components/InputField'; + +describe('InputField', () => { + it('supports email type and emits change/blur callbacks', () => { + const onChange = vi.fn(); + const onBlur = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText('Email') as HTMLInputElement; + expect(input.type).toBe('email'); + expect(input).toBeRequired(); + + fireEvent.change(input, { target: { value: 'new@example.com' } }); + fireEvent.blur(input); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('passes refs to input element', () => { + const inputRef = createRef(); + render( {}} inputRef={inputRef} />); + + expect(inputRef.current).toBeInstanceOf(HTMLInputElement); + expect(inputRef.current?.value).toBe('john'); + }); + + it('toggles password visibility', () => { + render( {}} />); + + expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password'); + fireEvent.click(screen.getByRole('button', { name: 'Show password' })); + expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('text'); + fireEvent.click(screen.getByRole('button', { name: 'Hide password' })); + expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password'); + }); + + it('renders rightIcon for non-password input and displays errors', () => { + const { container } = render( + {}} + rightIcon={R} + error="Invalid username" + inputClassName="custom-input" + />, + ); + + const input = container.querySelector('input'); + expect(input).toBeInstanceOf(HTMLInputElement); + expect(input).toHaveClass('pr-10'); + expect(input).toHaveClass('custom-input'); + expect(screen.getByTestId('right-icon')).toBeInTheDocument(); + expect(screen.getByText('Invalid username')).toBeInTheDocument(); + }); + + it('disables password toggle when input is disabled', () => { + render( + {}} + disabled + />, + ); + + expect(screen.getByRole('button', { name: 'Show password' })).toBeDisabled(); + }); + + it('supports inline layout classes', () => { + const { container } = render( + {}} layout="inline" />, + ); + + expect(container.querySelector('label')).toHaveClass('inline-flex'); + expect(container.querySelector('label > div')).not.toHaveClass('mt-1'); + }); +}); diff --git a/tests/components/Label.test.tsx b/tests/components/Label.test.tsx new file mode 100644 index 0000000..039bd33 --- /dev/null +++ b/tests/components/Label.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Label } from '../../src/components/Label'; + +describe('Label', () => { + it('uses default and variant-specific tags/classes', () => { + const { rerender } = render(); + expect(screen.getByText('Body').tagName).toBe('P'); + expect(screen.getByText('Body')).toHaveClass('ui-body-primary'); + + rerender(); + expect(screen.getByText('Title').tagName).toBe('H1'); + expect(screen.getByText('Title')).toHaveClass('ui-title'); + + rerender(); + expect(screen.getByText('Section').tagName).toBe('H3'); + + rerender( + , + ); + expect(screen.getByText('Custom').tagName).toBe('SPAN'); + }); +}); diff --git a/tests/components/MDXEditorField.test.tsx b/tests/components/MDXEditorField.test.tsx new file mode 100644 index 0000000..e24d8b7 --- /dev/null +++ b/tests/components/MDXEditorField.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { MDXEditorField } from '../../src/components/MDXEditorField'; + +describe('MDXEditorField', () => { + it('renders label and change handler in editable mode', () => { + const onChange = vi.fn(); + + render( + , + ); + + expect(screen.getByText('Content')).toHaveClass('ui-label'); + fireEvent.change(screen.getByLabelText('Markdown Editor'), { + target: { value: '# Hello' }, + }); + expect(onChange).toHaveBeenCalledWith('# Hello'); + expect(screen.getByLabelText('Markdown Editor')).toHaveAttribute( + 'data-class-name', + 'light-theme extra-editor', + ); + }); + + it('renders preview and disabled classes when disabled', () => { + render( + , + ); + + const label = screen.getByText('Content'); + expect(label).toHaveClass('ui-label-disabled'); + expect(screen.getByTestId('md-preview')).toHaveTextContent('Disabled content'); + expect(screen.getByTestId('md-preview')).toHaveAttribute('data-class-name', 'dark-theme'); + expect(screen.queryByLabelText('Markdown Editor')).not.toBeInTheDocument(); + expect(document.querySelector('.post-mdx-editor--disabled')).toBeTruthy(); + }); + + it('renders read-only preview without label when label is omitted', () => { + render( + , + ); + + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + expect(screen.getByTestId('md-preview')).toHaveTextContent('Read only content'); + }); + + it('supports wrapper style/class overrides and error rendering', () => { + render( + , + ); + + const wrapper = document.querySelector('.editor-wrapper'); + expect(wrapper).toHaveClass('post-mdx-editor--enabled'); + expect(wrapper).toHaveStyle({ borderWidth: '2px' }); + expect(screen.getByText('Content is required')).toHaveClass('ui-error'); + }); +}); diff --git a/tests/components/SidebarNavItem.test.tsx b/tests/components/SidebarNavItem.test.tsx new file mode 100644 index 0000000..7d266be --- /dev/null +++ b/tests/components/SidebarNavItem.test.tsx @@ -0,0 +1,55 @@ +import { UserCircleIcon } from '@heroicons/react/24/outline'; +import { fireEvent, screen } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { SidebarNavItem } from '../../src/components/SidebarNavItem'; +import { renderWithRouter } from '../helpers/renderWithRouter'; + +describe('SidebarNavItem', () => { + it('renders active style and collapsed label behavior', () => { + renderWithRouter( + + } + /> + , + { route: '/users' }, + ); + + const link = screen.getByRole('link', { name: 'Users' }); + expect(link.className).toContain('ui-accent-active'); + expect(link.className).toContain('mx-auto'); + expect(screen.getByText('Users').className).toContain('lg:hidden'); + }); + + it('renders inactive style and triggers onClick', () => { + const onClick = vi.fn(); + renderWithRouter( + + Users page} /> + + } + /> + , + { route: '/profile' }, + ); + + const link = screen.getByRole('link', { name: 'Users' }); + expect(link.className).toContain('hover:bg-zinc-500/15'); + expect(link.className).toContain('lg:w-full'); + expect(screen.getByText('Users').className).toContain('truncate'); + + fireEvent.click(link); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/Table.test.tsx b/tests/components/Table.test.tsx new file mode 100644 index 0000000..313d0a2 --- /dev/null +++ b/tests/components/Table.test.tsx @@ -0,0 +1,199 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Table, type TableHeader } from '../../src/components/Table'; +import type { SortState } from '../../src/types/sort'; + +type Row = { + id: string; + name: string; + email: string; +}; + +const headers: TableHeader[] = [ + { label: 'Name', id: 'name', value: (row) => row.name, headerClassName: 'head-name' }, + { label: 'Email', id: 'email', value: (row) => row.email, cellClassName: 'cell-email' }, + { label: 'Static', id: 'static', value: 'Always', sortable: false }, +]; + +const data: Row[] = [{ id: '1', name: 'Jane', email: 'jane@example.com' }]; + +describe('Table', () => { + it('renders loading state', () => { + render( row.id} />); + + expect(document.querySelector('.animate-spin')).toBeTruthy(); + }); + + it('renders empty state message when no rows are available', () => { + render( +
row.id} + />, + ); + + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + }); + + it('renders row values from function and static header values', () => { + const rowKey = vi.fn((row: Row) => row.id); + render(
); + + expect(screen.getByText('Jane')).toBeInTheDocument(); + expect(screen.getByText('jane@example.com')).toBeInTheDocument(); + expect(screen.getByText('Always')).toBeInTheDocument(); + expect(screen.getByText('Name').closest('th')).toHaveClass('head-name'); + expect(screen.getByText('jane@example.com').closest('td')).toHaveClass('cell-email'); + expect(rowKey).toHaveBeenCalledWith(data[0], 0); + }); + + it('shows sortable buttons only when sort config is complete', () => { + const onSortChange = vi.fn(); + const sortableHeaders: TableHeader[] = [ + { label: 'No field', id: 'a', sortable: true, value: (row) => row.name }, + { label: 'Empty field', id: 'b', sortable: true, sortField: '', value: (row) => row.name }, + { label: 'Name', id: 'c', sortable: true, sortField: 'name', value: (row) => row.name }, + ]; + + const { rerender } = render( +
row.id} onSortChange={onSortChange} />, + ); + + expect(screen.queryByRole('button', { name: 'Sort by No field' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sort by Empty field' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sort by Name' })).toBeInTheDocument(); + + rerender(
row.id} />); + expect(screen.queryByRole('button', { name: 'Sort by Name' })).not.toBeInTheDocument(); + }); + + it('renders sort states and notifies callback on header click', () => { + const onSortChange = vi.fn(); + const sortableHeaders: TableHeader[] = [ + { label: 'Name', id: 'name', sortable: true, sortField: 'name', value: (row) => row.name }, + { label: 'Email', id: 'email', sortable: true, sortField: 'email', value: (row) => row.email }, + ]; + const sorting: SortState = { field: 'name', direction: 'asc' }; + + const { rerender } = render( +
row.id} + sorting={sorting} + onSortChange={onSortChange} + />, + ); + + const nameSort = screen.getByRole('button', { name: 'Sort by Name' }); + const emailSort = screen.getByRole('button', { name: 'Sort by Email' }); + expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'asc'); + expect(emailSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'none'); + + fireEvent.click(nameSort); + expect(onSortChange).toHaveBeenCalledWith('name'); + + rerender( +
row.id} + sorting={{ field: 'name', direction: 'desc' }} + onSortChange={onSortChange} + />, + ); + expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'desc'); + }); + + it('supports pagination controls and page-size changes', () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + + render( +
row.id} + pagination={{ + page: 2, + pageSize: 10, + total: 21, + totalPages: 3, + onPageChange, + onPageSizeChange, + }} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Previous page' })); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + fireEvent.change(screen.getByLabelText('Rows'), { target: { value: '20' } }); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1); + expect(onPageChange).toHaveBeenNthCalledWith(2, 3); + expect(onPageSizeChange).toHaveBeenCalledWith(20); + }); + + it('hides rows selector when onPageSizeChange is absent and clamps page count display', () => { + render( +
row.id} + pagination={{ + page: 1, + pageSize: 10, + total: 1, + totalPages: 0, + onPageChange: vi.fn(), + }} + />, + ); + + expect(screen.queryByLabelText('Rows')).not.toBeInTheDocument(); + expect(screen.getByText('Page 1 of 1')).toBeInTheDocument(); + }); + + it('disables prev/next at bounds or while loading', () => { + const { rerender } = render( +
row.id} + pagination={{ + page: 1, + pageSize: 10, + total: 10, + totalPages: 1, + onPageChange: vi.fn(), + }} + />, + ); + + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + + rerender( +
row.id} + isLoading + pagination={{ + page: 2, + pageSize: 10, + total: 100, + totalPages: 10, + onPageChange: vi.fn(), + onPageSizeChange: vi.fn(), + }} + />, + ); + + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + expect(screen.getByLabelText('Rows')).toBeDisabled(); + }); +}); diff --git a/tests/helpers/renderWithRouter.tsx b/tests/helpers/renderWithRouter.tsx new file mode 100644 index 0000000..c9ae14b --- /dev/null +++ b/tests/helpers/renderWithRouter.tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render } from '@testing-library/react'; + +type RenderWithRouterOptions = { + route?: string; +}; + +export function renderWithRouter(ui: ReactElement, options: RenderWithRouterOptions = {}) { + const { route = '/' } = options; + return render({ui}); +} diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..7b03f49 --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import * as webUi from '../src/index'; + +describe('index exports', () => { + it('exposes runtime component exports', () => { + expect(typeof webUi.Button).toBe('function'); + expect(typeof webUi.Chip).toBe('function'); + expect(typeof webUi.DatePicker).toBe('function'); + expect(typeof webUi.Dropdown).toBe('function'); + expect(typeof webUi.Form).toBe('function'); + expect(typeof webUi.InputField).toBe('function'); + expect(typeof webUi.Label).toBe('function'); + expect(typeof webUi.SidebarNavItem).toBe('function'); + expect(typeof webUi.Table).toBe('function'); + }); +}); diff --git a/tests/mocks/mdxeditor.tsx b/tests/mocks/mdxeditor.tsx new file mode 100644 index 0000000..206d735 --- /dev/null +++ b/tests/mocks/mdxeditor.tsx @@ -0,0 +1,74 @@ +import { forwardRef, useImperativeHandle, type ReactNode } from 'react'; + +export type MDXEditorMethods = { + setMarkdown: (value: string) => void; +}; + +export type MDXEditorProps = { + markdown: string; + onChange?: (value: string) => void; + readOnly?: boolean; + className?: string; + contentEditableClassName?: string; + plugins?: unknown[]; +}; + +export const MDXEditor = forwardRef(function MDXEditor( + { markdown, onChange, readOnly, className }, + ref, +) { + useImperativeHandle( + ref, + () => ({ + setMarkdown: () => undefined, + }), + [], + ); + + if (readOnly) { + return ( +
+ {markdown} +
+ ); + } + + return ( +