diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ebb8779..bf3aaa0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -37,11 +37,20 @@ This project uses **strict TypeScript** (`strict: true` in `tsconfig.json`). All ## Testing -Tests use **Vitest** with `jsdom` environment. Test files live alongside source as `*.test.ts` / `*.test.tsx`. +Tests use **Vitest** with `jsdom` environment. The test suite is organized in the `tests/` directory mirroring `src/`: +- `tests/tools/` — tool-specific unit and UI tests (e.g. `tests/tools/passwordTool.test.tsx`, `tests/tools/uuid.test.tsx`) +- `tests/components/` — UI primitives (`tests/components/ui/`) and layout component tests +- `tests/hooks/` — custom React hook tests (`useUrlState`, `useClipboard`, etc.) +- `tests/pages/` — page routing and navigation integration tests +- `tests/setup.ts` — centralized test environment setup and DOM polyfills +- `tests/test-utils.tsx` — reusable helpers (`renderWithProviders`, `simulateInput`, `waitFor`) + +Source-adjacent test files (`src/**/*.test.ts` / `src/**/*.test.tsx`) are also automatically detected and run by Vitest. ```bash npm test # run all tests once -npm test -- tests/crypto # run a specific test file or path +npm test -- tests/tools/ # run tool tests +npm test -- tests/hooks/ # run hook tests npm run test:coverage # coverage report ``` diff --git a/tests/App.test.tsx b/tests/App.test.tsx new file mode 100644 index 0000000..51d3fdb --- /dev/null +++ b/tests/App.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import App from '../src/App'; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom doesn't implement matchMedia — stub it for useTheme +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + +describe('App Shell & Routing Integration', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + localStorage.clear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + localStorage.clear(); + }); + + function renderApp(route = '/') { + act(() => { + root.render( + + + + ); + }); + } + + it('renders navbar with Toolglass brand name and GitHub link', async () => { + renderApp(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(container.textContent).toContain('Toolglass'); + expect(container.textContent).toContain('GitHub'); + + const githubLink = container.querySelector('a[href*="github.com"]') as HTMLAnchorElement; + expect(githubLink).not.toBeNull(); + expect(githubLink.href).toContain('ajithakdev/toolglass'); + }); + + it('renders "Search tools" button with Ctrl K shortcut in nav', async () => { + renderApp(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + const searchBtn = container.querySelector('#open-command-palette'); + expect(searchBtn).not.toBeNull(); + expect(searchBtn?.textContent).toContain('Search tools'); + expect(searchBtn?.textContent).toContain('Ctrl K'); + }); + + it('renders footer with privacy notice and Local Usage Stats toggle', async () => { + renderApp(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(container.textContent).toContain('No data leaves your browser'); + expect(container.textContent).toContain('Local Usage Stats'); + + const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox).not.toBeNull(); + expect(checkbox.checked).toBe(true); // enabled by default + }); + + it('renders Landing page at root route /', async () => { + renderApp('/'); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + // Landing page shows the tagline + expect(container.textContent).toContain('Frosted developer utilities'); + expect(container.textContent).toContain('All Utilities'); + }); + + it('renders 404 page for unknown route /some/random/page', async () => { + renderApp('/some/random/page'); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(container.textContent).toContain('404'); + }); + + it('renders tool page for valid tool slug /tools/password', async () => { + renderApp('/tools/password'); + + await act(async () => { + await new Promise((r) => setTimeout(r, 200)); + }); + + expect(container.textContent).toContain('Password Generator'); + }); + + it('renders tool-not-found for invalid slug /tools/doesntexist', async () => { + renderApp('/tools/doesntexist'); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(container.textContent).toContain('404'); + expect(container.textContent).toContain('doesntexist'); + }); + + it('opens command palette when Ctrl+K is pressed', async () => { + renderApp(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + // Press Ctrl+K + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }) + ); + await new Promise((r) => setTimeout(r, 100)); + }); + + // Command palette should be open — look for the dialog role + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + const input = dialog?.querySelector('input'); + expect(input?.placeholder).toBe('Search tools…'); + expect(dialog?.textContent).toContain('Password Generator'); + }); + + it('opens command palette via search button click', async () => { + renderApp(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + const searchBtn = container.querySelector('#open-command-palette') as HTMLButtonElement; + expect(searchBtn).not.toBeNull(); + + await act(async () => { + searchBtn.click(); + await new Promise((r) => setTimeout(r, 100)); + }); + + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + }); +}); diff --git a/tests/components/CommandPalette.test.tsx b/tests/components/CommandPalette.test.tsx new file mode 100644 index 0000000..2192b43 --- /dev/null +++ b/tests/components/CommandPalette.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { CommandPalette } from '../../src/components/CommandPalette'; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe('CommandPalette', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + localStorage.clear(); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + async function renderComponent(open: boolean, onClose = vi.fn()) { + await act(async () => { + root.render( + + + + ); + }); + // Wait for animations + await act(async () => { await new Promise(r => setTimeout(r, 150)); }); + } + + const setInputValue = async (input: HTMLInputElement, value: string) => { + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + )?.set; + nativeSetter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + it('renders nothing when open=false', async () => { + await renderComponent(false); + expect(container.querySelector('.palette-input')).toBeNull(); + }); + + it('shows search input with placeholder when open', async () => { + await renderComponent(true); + const input = container.querySelector('.palette-input') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.placeholder).toBe('Search tools…'); + }); + + it('shows all tools initially', async () => { + await renderComponent(true); + const items = container.querySelectorAll('[role="option"]'); + expect(items.length).toBe(17); + }); + + it('filters tools as user types', async () => { + await renderComponent(true); + const input = container.querySelector('.palette-input') as HTMLInputElement; + await setInputValue(input, 'uuid'); + + const items = container.querySelectorAll('[role="option"]'); + expect(items.length).toBe(1); + expect(items[0].textContent).toContain('UUID'); + }); + + it('shows "No tools match" when query matches nothing', async () => { + await renderComponent(true); + const input = container.querySelector('.palette-input') as HTMLInputElement; + await setInputValue(input, 'thiswillnevermatchanything'); + + const items = container.querySelectorAll('[role="option"]'); + expect(items.length).toBe(0); + expect(container.textContent).toContain('No tools match'); + }); + + it('handles keyboard navigation and calls onClose on Enter', async () => { + const onClose = vi.fn(); + await renderComponent(true, onClose); + + await act(async () => { + const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }); + container.querySelector('[role="dialog"]')?.dispatchEvent(downEvent); + }); + + await act(async () => { + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); + container.querySelector('[role="dialog"]')?.dispatchEvent(enterEvent); + }); + + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when Escape is pressed', async () => { + const onClose = vi.fn(); + await renderComponent(true, onClose); + + await act(async () => { + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + container.querySelector('[role="dialog"]')?.dispatchEvent(escapeEvent); + }); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/components/ErrorBoundary.test.tsx b/tests/components/ErrorBoundary.test.tsx similarity index 98% rename from src/components/ErrorBoundary.test.tsx rename to tests/components/ErrorBoundary.test.tsx index 8572163..77ede42 100644 --- a/src/components/ErrorBoundary.test.tsx +++ b/tests/components/ErrorBoundary.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter } from 'react-router-dom'; -import { ErrorBoundary } from './ErrorBoundary'; +import { ErrorBoundary } from '../../src/components/ErrorBoundary'; // Configure act environment for React 19 // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/tests/components/ToolLayout.test.tsx b/tests/components/ToolLayout.test.tsx new file mode 100644 index 0000000..b24698f --- /dev/null +++ b/tests/components/ToolLayout.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { ToolLayout } from '../../src/components/ToolLayout'; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe('ToolLayout', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + localStorage.clear(); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + async function renderComponent(slug: string, usageCount: number = 0) { + if (usageCount > 0) { + localStorage.setItem('toolglass_stats', JSON.stringify({ [slug]: usageCount })); + } + + await act(async () => { + root.render( + + +
Test Child Content
} /> +
+
+ ); + }); + // Wait for animations + await act(async () => { await new Promise(r => setTimeout(r, 150)); }); + } + + it('renders tool info correctly', async () => { + await renderComponent('uuid'); + + expect(container.textContent).toContain('Back'); + expect(container.textContent).toContain('UUID'); // Title + expect(container.textContent).toContain('Test Child Content'); + }); + + it('shows usage count from stats', async () => { + await renderComponent('uuid', 5); + + expect(container.textContent).toContain("You've used this tool 5 times"); + }); + + it('returns null if tool not found', async () => { + await renderComponent('unknown-tool-slug'); + expect(container.textContent).toBe(''); + }); +}); diff --git a/tests/components/ui/CopyButton.test.tsx b/tests/components/ui/CopyButton.test.tsx new file mode 100644 index 0000000..ddca82b --- /dev/null +++ b/tests/components/ui/CopyButton.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { CopyButton } from '../../../src/components/ui/CopyButton'; +import { ToastProvider } from '../../../src/components/ui/Toast'; + +describe('CopyButton', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + function renderButton(value: string, label = 'Copy', disabled = false) { + act(() => { + root.render( + + + + } + /> + + + + ); + }); + } + + it('renders default label and copies text to clipboard on click', async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { + writeText: writeMock, + }, + }); + + renderButton('test-value-123', 'Copy All'); + expect(container.textContent).toContain('Copy All'); + + const button = container.querySelector('button') as HTMLButtonElement; + expect(button).not.toBeNull(); + expect(button.disabled).toBe(false); + + await act(async () => { + button.click(); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(writeMock).toHaveBeenCalledWith('test-value-123'); + expect(container.textContent).toContain('Copied'); + }); + + it('handles empty value by disabling or not triggering copy', async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { + writeText: writeMock, + }, + }); + + renderButton(''); + const button = container.querySelector('button') as HTMLButtonElement; + expect(button.disabled).toBe(true); + + await act(async () => { + button.click(); + }); + + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('handles copy failure gracefully', async () => { + const writeMock = vi.fn().mockRejectedValue(new Error('Permission denied')); + Object.assign(navigator, { + clipboard: { + writeText: writeMock, + }, + }); + + renderButton('failure-value'); + const button = container.querySelector('button') as HTMLButtonElement; + + await act(async () => { + button.click(); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(writeMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/components/ui/Field.test.tsx b/tests/components/ui/Field.test.tsx new file mode 100644 index 0000000..48cd13b --- /dev/null +++ b/tests/components/ui/Field.test.tsx @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + Field, + TextInput, + TextArea, + Select, + Dropdown, + Toggle, + Slider, +} from '../../../src/components/ui/Field'; + +describe('Field Components', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + it('renders Field with label and optional hint', () => { + act(() => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain('Username'); + expect(container.textContent).toContain('Required field'); + expect(container.querySelector('[data-testid="inp"]')).not.toBeNull(); + }); + + it('renders TextInput and handles user typing', () => { + const onChange = vi.fn(); + act(() => { + root.render(); + }); + + const input = container.querySelector('input')!; + expect(input.placeholder).toBe('Type here'); + + act(() => { + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set; + nativeSetter?.call(input, 'hello'); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalled(); + }); + + it('renders TextArea with monospaced style', () => { + act(() => { + root.render(