Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
180 changes: 180 additions & 0 deletions tests/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createRoot>;

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(
<MemoryRouter initialEntries={[route]}>
<App />
</MemoryRouter>
);
});
}

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();
});
});
114 changes: 114 additions & 0 deletions tests/components/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createRoot>;

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(
<MemoryRouter>
<CommandPalette open={open} onClose={onClose} />
</MemoryRouter>
);
});
// 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/components/ToolLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createRoot>;

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(
<MemoryRouter initialEntries={[`/tools/${slug}`]}>
<Routes>
<Route path="/tools/:slug" element={<ToolLayout><div>Test Child Content</div></ToolLayout>} />
</Routes>
</MemoryRouter>
);
});
// 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('');
});
});
Loading