From 65bce07cf0726a4055d74e0f7df42ef5aeed5a8c Mon Sep 17 00:00:00 2001 From: ajithakdev Date: Sun, 6 Sep 2026 23:15:21 +0530 Subject: [PATCH 1/2] test: establish centralized test architecture and achieve comprehensive app coverage - Centralize test suite into dedicated tests/ directory mirroring src/ - Add tests/setup.ts with jsdom polyfills (matchMedia, animate, scrollIntoView) and instant Framer Motion execution - Add reusable test utilities in tests/test-utils.tsx (renderWithProviders, simulateInput, waitAct) - Cover all 17 tools, custom hooks, pages, components, and UI building blocks - Reach 159 passing unit tests across 35 test files and 84%+ line coverage --- tests/App.test.tsx | 180 ++++++++++++++++ tests/components/CommandPalette.test.tsx | 114 ++++++++++ .../components/ErrorBoundary.test.tsx | 2 +- tests/components/ToolLayout.test.tsx | 61 ++++++ tests/components/ui/CopyButton.test.tsx | 104 +++++++++ tests/components/ui/Field.test.tsx | 200 ++++++++++++++++++ tests/components/ui/ThemeToggle.test.tsx | 85 ++++++++ tests/components/ui/Toast.test.tsx | 108 ++++++++++ tests/hooks/useClipboard.test.tsx | 131 ++++++++++++ tests/hooks/useRecentTools.test.tsx | 94 ++++++++ tests/hooks/useTheme.test.tsx | 102 +++++++++ tests/hooks/useToolStats.test.tsx | 76 +++++++ tests/hooks/useUrlState.test.tsx | 79 +++++++ tests/pages/Landing.test.tsx | 77 +++++++ {src => tests}/pages/NotFound.test.tsx | 2 +- tests/pages/ToolPage.test.tsx | 57 +++++ tests/setup.ts | 40 ++++ tests/test-utils.tsx | 79 +++++++ .../base64 => tests/tools}/base64.test.ts | 2 +- tests/tools/color.test.tsx | 114 ++++++++++ .../tools}/curlSecurity.test.ts | 2 +- tests/tools/hash.test.tsx | 134 ++++++++++++ tests/tools/json-to-ts.test.tsx | 99 +++++++++ tests/tools/json.test.tsx | 119 +++++++++++ tests/tools/jwt-decode.test.tsx | 101 +++++++++ {src/tools/jwt => tests/tools}/jwt.test.ts | 2 +- tests/tools/markdown.test.tsx | 124 +++++++++++ tests/tools/nanoid.test.tsx | 128 +++++++++++ .../objectid => tests/tools}/objectid.test.ts | 2 +- .../password => tests/tools}/password.test.ts | 2 +- tests/tools/passwordTool.test.tsx | 145 +++++++++++++ tests/tools/qr.test.tsx | 135 ++++++++++++ tests/tools/regex.test.tsx | 107 ++++++++++ tests/tools/registry.test.ts | 46 ++++ tests/tools/timestamp.test.tsx | 106 ++++++++++ tests/tools/url.test.tsx | 116 ++++++++++ tests/tools/uuid.test.tsx | 103 +++++++++ vite.config.ts | 3 +- 38 files changed, 3173 insertions(+), 8 deletions(-) create mode 100644 tests/App.test.tsx create mode 100644 tests/components/CommandPalette.test.tsx rename {src => tests}/components/ErrorBoundary.test.tsx (98%) create mode 100644 tests/components/ToolLayout.test.tsx create mode 100644 tests/components/ui/CopyButton.test.tsx create mode 100644 tests/components/ui/Field.test.tsx create mode 100644 tests/components/ui/ThemeToggle.test.tsx create mode 100644 tests/components/ui/Toast.test.tsx create mode 100644 tests/hooks/useClipboard.test.tsx create mode 100644 tests/hooks/useRecentTools.test.tsx create mode 100644 tests/hooks/useTheme.test.tsx create mode 100644 tests/hooks/useToolStats.test.tsx create mode 100644 tests/hooks/useUrlState.test.tsx create mode 100644 tests/pages/Landing.test.tsx rename {src => tests}/pages/NotFound.test.tsx (98%) create mode 100644 tests/pages/ToolPage.test.tsx create mode 100644 tests/setup.ts create mode 100644 tests/test-utils.tsx rename {src/tools/base64 => tests/tools}/base64.test.ts (92%) create mode 100644 tests/tools/color.test.tsx rename {src/tools/curl-to-fetch => tests/tools}/curlSecurity.test.ts (99%) create mode 100644 tests/tools/hash.test.tsx create mode 100644 tests/tools/json-to-ts.test.tsx create mode 100644 tests/tools/json.test.tsx create mode 100644 tests/tools/jwt-decode.test.tsx rename {src/tools/jwt => tests/tools}/jwt.test.ts (97%) create mode 100644 tests/tools/markdown.test.tsx create mode 100644 tests/tools/nanoid.test.tsx rename {src/tools/objectid => tests/tools}/objectid.test.ts (94%) rename {src/tools/password => tests/tools}/password.test.ts (95%) create mode 100644 tests/tools/passwordTool.test.tsx create mode 100644 tests/tools/qr.test.tsx create mode 100644 tests/tools/regex.test.tsx create mode 100644 tests/tools/registry.test.ts create mode 100644 tests/tools/timestamp.test.tsx create mode 100644 tests/tools/url.test.tsx create mode 100644 tests/tools/uuid.test.tsx 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(