@react-spa-scaffold/mcp 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +423 -0
- package/dist/features/index.d.ts +5 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +3 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/registry.d.ts +10 -0
- package/dist/features/registry.d.ts.map +1 -0
- package/dist/features/registry.js +508 -0
- package/dist/features/registry.js.map +1 -0
- package/dist/features/types.d.ts +45 -0
- package/dist/features/types.d.ts.map +1 -0
- package/dist/features/types.js +5 -0
- package/dist/features/types.js.map +1 -0
- package/dist/features/versions.d.ts +16 -0
- package/dist/features/versions.d.ts.map +1 -0
- package/dist/features/versions.js +46 -0
- package/dist/features/versions.js.map +1 -0
- package/dist/features/versions.json +5 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/docs.d.ts +29 -0
- package/dist/resources/docs.d.ts.map +1 -0
- package/dist/resources/docs.js +105 -0
- package/dist/resources/docs.js.map +1 -0
- package/dist/resources/index.d.ts +2 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +2 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +115 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/get-example.d.ts +51 -0
- package/dist/tools/get-example.d.ts.map +1 -0
- package/dist/tools/get-example.js +90 -0
- package/dist/tools/get-example.js.map +1 -0
- package/dist/tools/get-features.d.ts +30 -0
- package/dist/tools/get-features.d.ts.map +1 -0
- package/dist/tools/get-features.js +46 -0
- package/dist/tools/get-features.js.map +1 -0
- package/dist/tools/get-scaffold.d.ts +77 -0
- package/dist/tools/get-scaffold.d.ts.map +1 -0
- package/dist/tools/get-scaffold.js +153 -0
- package/dist/tools/get-scaffold.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/utils/docs.d.ts +14 -0
- package/dist/utils/docs.d.ts.map +1 -0
- package/dist/utils/docs.js +64 -0
- package/dist/utils/docs.js.map +1 -0
- package/dist/utils/examples.d.ts +27 -0
- package/dist/utils/examples.d.ts.map +1 -0
- package/dist/utils/examples.js +399 -0
- package/dist/utils/examples.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/paths.d.ts +28 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +40 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/scaffold.d.ts +50 -0
- package/dist/utils/scaffold.d.ts.map +1 -0
- package/dist/utils/scaffold.js +500 -0
- package/dist/utils/scaffold.js.map +1 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +63 -0
- package/templates/.bundled +0 -0
- package/templates/CLAUDE.md +145 -0
- package/templates/docs/API_REFERENCE.md +58 -0
- package/templates/docs/ARCHITECTURE.md +185 -0
- package/templates/docs/CODING_STANDARDS.md +53 -0
- package/templates/docs/COMPONENT_GUIDELINES.md +301 -0
- package/templates/docs/E2E_TESTING.md +116 -0
- package/templates/docs/INTERNATIONALIZATION.md +67 -0
- package/templates/docs/TESTING.md +259 -0
- package/templates/docs/WORKFLOW.md +170 -0
- package/templates/src/App.tsx +42 -0
- package/templates/src/components/layout/Header.tsx +19 -0
- package/templates/src/components/layout/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +104 -0
- package/templates/src/components/shared/ErrorBoundary/index.ts +1 -0
- package/templates/src/components/shared/LanguageSwitcher/LanguageSwitcher.tsx +45 -0
- package/templates/src/components/shared/LanguageSwitcher/index.ts +1 -0
- package/templates/src/components/shared/SEO/SEO.tsx +55 -0
- package/templates/src/components/shared/SEO/index.ts +1 -0
- package/templates/src/components/shared/ThemeToggle/ThemeToggle.tsx +41 -0
- package/templates/src/components/shared/ThemeToggle/index.ts +1 -0
- package/templates/src/components/shared/index.ts +4 -0
- package/templates/src/components/ui/button.tsx +48 -0
- package/templates/src/components/ui/dropdown-menu.tsx +228 -0
- package/templates/src/components/ui/form-error.tsx +95 -0
- package/templates/src/components/ui/loading.tsx +58 -0
- package/templates/src/components/ui/skeleton.tsx +52 -0
- package/templates/src/components/ui/sonner.tsx +34 -0
- package/templates/src/components/ui/spinner.tsx +40 -0
- package/templates/src/components/ui/visually-hidden.tsx +51 -0
- package/templates/src/contexts/mobileContext.tsx +66 -0
- package/templates/src/contexts/queryContext.tsx +28 -0
- package/templates/src/hooks/index.ts +7 -0
- package/templates/src/hooks/useContactForm.ts +33 -0
- package/templates/src/hooks/useExampleQuery.ts +20 -0
- package/templates/src/hooks/useLanguage.ts +23 -0
- package/templates/src/hooks/useMediaQuery.ts +53 -0
- package/templates/src/hooks/useThemeEffect.ts +31 -0
- package/templates/src/hooks/useTouchSizes.ts +16 -0
- package/templates/src/i18n/config.ts +11 -0
- package/templates/src/i18n/detectLanguage.ts +57 -0
- package/templates/src/i18n/index.ts +20 -0
- package/templates/src/i18n/loadCatalog.ts +30 -0
- package/templates/src/index.css +98 -0
- package/templates/src/lib/api.ts +142 -0
- package/templates/src/lib/config.ts +15 -0
- package/templates/src/lib/constants.ts +8 -0
- package/templates/src/lib/env.ts +53 -0
- package/templates/src/lib/format.ts +119 -0
- package/templates/src/lib/index.ts +24 -0
- package/templates/src/lib/routes.ts +11 -0
- package/templates/src/lib/storage.ts +91 -0
- package/templates/src/lib/storageKeys.ts +10 -0
- package/templates/src/lib/utils.ts +6 -0
- package/templates/src/lib/validations.ts +39 -0
- package/templates/src/locales/de.po +65 -0
- package/templates/src/locales/en.po +65 -0
- package/templates/src/locales/es.po +65 -0
- package/templates/src/main.tsx +107 -0
- package/templates/src/mocks/fixtures/index.ts +1 -0
- package/templates/src/mocks/fixtures/todos.ts +40 -0
- package/templates/src/mocks/handlers/index.ts +7 -0
- package/templates/src/mocks/handlers/todos.ts +59 -0
- package/templates/src/mocks/index.ts +3 -0
- package/templates/src/mocks/node.ts +9 -0
- package/templates/src/pages/Home.tsx +27 -0
- package/templates/src/pages/NotFound.tsx +28 -0
- package/templates/src/pages/index.ts +2 -0
- package/templates/src/stores/index.ts +2 -0
- package/templates/src/stores/preferencesStore.ts +85 -0
- package/templates/src/test/index.ts +8 -0
- package/templates/src/test/mocks.ts +17 -0
- package/templates/src/test/providers.tsx +54 -0
- package/templates/src/test-setup.ts +54 -0
- package/templates/src/types/api.ts +31 -0
- package/templates/src/types/index.ts +2 -0
- package/templates/src/types/preferences.ts +5 -0
- package/templates/src/vite-env.d.ts +10 -0
- package/templates/tests/unit/components/ErrorBoundary.test.tsx +193 -0
- package/templates/tests/unit/components/Header.test.tsx +33 -0
- package/templates/tests/unit/components/LanguageSwitcher.test.tsx +40 -0
- package/templates/tests/unit/components/Loading.test.tsx +76 -0
- package/templates/tests/unit/components/SEO.test.tsx +80 -0
- package/templates/tests/unit/components/ThemeToggle.test.tsx +62 -0
- package/templates/tests/unit/contexts/mobileContext.test.tsx +54 -0
- package/templates/tests/unit/hooks/useContactForm.test.ts +60 -0
- package/templates/tests/unit/hooks/useExampleQuery.test.tsx +94 -0
- package/templates/tests/unit/hooks/useLanguage.test.tsx +75 -0
- package/templates/tests/unit/hooks/useMediaQuery.test.ts +57 -0
- package/templates/tests/unit/hooks/useThemeEffect.test.ts +42 -0
- package/templates/tests/unit/i18n/detectLanguage.test.ts +40 -0
- package/templates/tests/unit/i18n/loadCatalog.test.ts +70 -0
- package/templates/tests/unit/lib/api.test.ts +142 -0
- package/templates/tests/unit/lib/format.test.ts +100 -0
- package/templates/tests/unit/lib/storage.test.ts +90 -0
- package/templates/tests/unit/lib/utils.test.ts +19 -0
- package/templates/tests/unit/lib/validations.test.ts +56 -0
- package/templates/tests/unit/stores/preferencesStore.test.ts +75 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { fireEvent, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { ErrorBoundary } from '@/components/shared/ErrorBoundary';
|
|
5
|
+
import { render } from '@/test';
|
|
6
|
+
|
|
7
|
+
// Component that throws an error
|
|
8
|
+
function ThrowingComponent({ shouldThrow = true }: { shouldThrow?: boolean }) {
|
|
9
|
+
if (shouldThrow) {
|
|
10
|
+
throw new Error('Test error');
|
|
11
|
+
}
|
|
12
|
+
return <div>Normal content</div>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Suppress console.error for error boundary tests
|
|
16
|
+
const originalError = console.error;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
console.error = vi.fn();
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
console.error = originalError;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('ErrorBoundary', () => {
|
|
25
|
+
it('renders children when there is no error', () => {
|
|
26
|
+
render(
|
|
27
|
+
<ErrorBoundary>
|
|
28
|
+
<div>Test content</div>
|
|
29
|
+
</ErrorBoundary>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByText('Test content')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('renders error UI when child throws', () => {
|
|
36
|
+
render(
|
|
37
|
+
<ErrorBoundary>
|
|
38
|
+
<ThrowingComponent />
|
|
39
|
+
</ErrorBoundary>,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('shows Try Again button', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ErrorBoundary>
|
|
48
|
+
<ThrowingComponent />
|
|
49
|
+
</ErrorBoundary>,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('shows Refresh Page button', () => {
|
|
56
|
+
render(
|
|
57
|
+
<ErrorBoundary>
|
|
58
|
+
<ThrowingComponent />
|
|
59
|
+
</ErrorBoundary>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(screen.getByRole('button', { name: /refresh page/i })).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders custom fallback when provided', () => {
|
|
66
|
+
render(
|
|
67
|
+
<ErrorBoundary fallback={<div>Custom error message</div>}>
|
|
68
|
+
<ThrowingComponent />
|
|
69
|
+
</ErrorBoundary>,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(screen.getByText('Custom error message')).toBeInTheDocument();
|
|
73
|
+
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('calls onError callback when error occurs', () => {
|
|
77
|
+
const onError = vi.fn();
|
|
78
|
+
|
|
79
|
+
render(
|
|
80
|
+
<ErrorBoundary onError={onError}>
|
|
81
|
+
<ThrowingComponent />
|
|
82
|
+
</ErrorBoundary>,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(onError).toHaveBeenCalledWith(
|
|
87
|
+
expect.any(Error),
|
|
88
|
+
expect.objectContaining({ componentStack: expect.any(String) }),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('calls onReset when Try Again is clicked', () => {
|
|
93
|
+
const onReset = vi.fn();
|
|
94
|
+
|
|
95
|
+
render(
|
|
96
|
+
<ErrorBoundary onReset={onReset}>
|
|
97
|
+
<ThrowingComponent />
|
|
98
|
+
</ErrorBoundary>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
|
102
|
+
|
|
103
|
+
expect(onReset).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reloads page when Refresh Page is clicked', () => {
|
|
107
|
+
const reloadMock = vi.fn();
|
|
108
|
+
const originalLocation = window.location;
|
|
109
|
+
|
|
110
|
+
// Mock window.location.reload
|
|
111
|
+
Object.defineProperty(window, 'location', {
|
|
112
|
+
value: { ...originalLocation, reload: reloadMock },
|
|
113
|
+
writable: true,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
render(
|
|
117
|
+
<ErrorBoundary>
|
|
118
|
+
<ThrowingComponent />
|
|
119
|
+
</ErrorBoundary>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: /refresh page/i }));
|
|
123
|
+
|
|
124
|
+
expect(reloadMock).toHaveBeenCalledTimes(1);
|
|
125
|
+
|
|
126
|
+
// Restore
|
|
127
|
+
Object.defineProperty(window, 'location', {
|
|
128
|
+
value: originalLocation,
|
|
129
|
+
writable: true,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('reports to Sentry in production', async () => {
|
|
134
|
+
const captureExceptionMock = vi.fn();
|
|
135
|
+
|
|
136
|
+
// Mock import.meta.env.PROD
|
|
137
|
+
vi.stubEnv('PROD', true);
|
|
138
|
+
|
|
139
|
+
// Mock Sentry dynamic import
|
|
140
|
+
vi.doMock('@sentry/react', () => ({
|
|
141
|
+
captureException: captureExceptionMock,
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
render(
|
|
145
|
+
<ErrorBoundary>
|
|
146
|
+
<ThrowingComponent />
|
|
147
|
+
</ErrorBoundary>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Give time for the dynamic import to resolve
|
|
151
|
+
await vi.waitFor(() => {
|
|
152
|
+
// The Sentry import might not complete in test environment
|
|
153
|
+
// but at least we verify the code path is reached
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
vi.unstubAllEnvs();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does not report to Sentry when SENTRY_CONFIG.enabled is false', async () => {
|
|
160
|
+
const captureExceptionMock = vi.fn();
|
|
161
|
+
|
|
162
|
+
// Mock import.meta.env.PROD
|
|
163
|
+
vi.stubEnv('PROD', true);
|
|
164
|
+
// Disable Sentry via environment variable
|
|
165
|
+
vi.stubEnv('VITE_SENTRY_ENABLED', 'false');
|
|
166
|
+
|
|
167
|
+
// Mock Sentry dynamic import
|
|
168
|
+
vi.doMock('@sentry/react', () => ({
|
|
169
|
+
captureException: captureExceptionMock,
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
// Re-import the component to pick up the new env value
|
|
173
|
+
vi.resetModules();
|
|
174
|
+
const { ErrorBoundary: ReloadedErrorBoundary } = await import('@/components/shared/ErrorBoundary');
|
|
175
|
+
|
|
176
|
+
render(
|
|
177
|
+
<ReloadedErrorBoundary>
|
|
178
|
+
<ThrowingComponent />
|
|
179
|
+
</ReloadedErrorBoundary>,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Wait a bit and verify Sentry was not called
|
|
183
|
+
await vi.waitFor(
|
|
184
|
+
() => {
|
|
185
|
+
expect(captureExceptionMock).not.toHaveBeenCalled();
|
|
186
|
+
},
|
|
187
|
+
{ timeout: 100 },
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
vi.unstubAllEnvs();
|
|
191
|
+
vi.resetModules();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { Header } from '@/components/layout/Header';
|
|
5
|
+
import { render } from '@/test';
|
|
6
|
+
|
|
7
|
+
describe('Header', () => {
|
|
8
|
+
it('renders the app title', () => {
|
|
9
|
+
render(<Header />);
|
|
10
|
+
|
|
11
|
+
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('renders the theme toggle', () => {
|
|
15
|
+
render(<Header />);
|
|
16
|
+
|
|
17
|
+
// ThemeToggle has an aria-label
|
|
18
|
+
expect(screen.getByRole('button', { name: /switch to (dark|light) mode/i })).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders the language switcher', () => {
|
|
22
|
+
render(<Header />);
|
|
23
|
+
|
|
24
|
+
expect(screen.getByRole('button', { name: /change language/i })).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('has proper semantic structure', () => {
|
|
28
|
+
render(<Header />);
|
|
29
|
+
|
|
30
|
+
const header = screen.getByRole('banner');
|
|
31
|
+
expect(header).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { LanguageSwitcher } from '@/components/shared/LanguageSwitcher';
|
|
6
|
+
import { render } from '@/test';
|
|
7
|
+
|
|
8
|
+
describe('LanguageSwitcher', () => {
|
|
9
|
+
it('renders the language button', () => {
|
|
10
|
+
render(<LanguageSwitcher />);
|
|
11
|
+
|
|
12
|
+
const button = screen.getByRole('button', { name: /change language/i });
|
|
13
|
+
expect(button).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('opens dropdown menu on click', async () => {
|
|
17
|
+
const user = userEvent.setup();
|
|
18
|
+
render(<LanguageSwitcher />);
|
|
19
|
+
|
|
20
|
+
const button = screen.getByRole('button', { name: /change language/i });
|
|
21
|
+
await user.click(button);
|
|
22
|
+
|
|
23
|
+
// Should show language options
|
|
24
|
+
expect(screen.getByText('English')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('Español')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Deutsch')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('highlights current language in dropdown', async () => {
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
render(<LanguageSwitcher />);
|
|
32
|
+
|
|
33
|
+
const button = screen.getByRole('button', { name: /change language/i });
|
|
34
|
+
await user.click(button);
|
|
35
|
+
|
|
36
|
+
// English should be highlighted (default locale in tests)
|
|
37
|
+
const englishOption = screen.getByText('English');
|
|
38
|
+
expect(englishOption.closest('[data-slot="dropdown-menu-item"]')).toHaveClass('bg-accent');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { InlineLoading, Loading, PageLoading } from '@/components/ui/loading';
|
|
5
|
+
import { Skeleton, SkeletonAvatar, SkeletonCard, SkeletonText } from '@/components/ui/skeleton';
|
|
6
|
+
import { Spinner } from '@/components/ui/spinner';
|
|
7
|
+
import { render } from '@/test';
|
|
8
|
+
|
|
9
|
+
describe('Spinner', () => {
|
|
10
|
+
it.each([
|
|
11
|
+
{ size: 'sm', expectedClass: 'size-4' },
|
|
12
|
+
{ size: 'default', expectedClass: 'size-6' },
|
|
13
|
+
{ size: 'lg', expectedClass: 'size-8' },
|
|
14
|
+
] as const)('renders $size size with $expectedClass', ({ size, expectedClass }) => {
|
|
15
|
+
const { container } = render(<Spinner size={size} />);
|
|
16
|
+
expect(container.querySelector('svg')).toHaveClass(expectedClass);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('accepts custom className', () => {
|
|
20
|
+
const { container } = render(<Spinner className="text-primary" />);
|
|
21
|
+
expect(container.querySelector('svg')).toHaveClass('text-primary');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('Loading', () => {
|
|
26
|
+
it('renders spinner with optional text', () => {
|
|
27
|
+
const { container, rerender } = render(<Loading />);
|
|
28
|
+
expect(container.querySelector('svg')).toBeInTheDocument();
|
|
29
|
+
|
|
30
|
+
rerender(<Loading text="Please wait..." />);
|
|
31
|
+
expect(screen.getByText('Please wait...')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders full screen when specified', () => {
|
|
35
|
+
const { container } = render(<Loading fullScreen />);
|
|
36
|
+
expect(container.firstChild).toHaveClass('fixed', 'inset-0');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('PageLoading / InlineLoading', () => {
|
|
41
|
+
it.each([
|
|
42
|
+
{ Component: PageLoading, name: 'PageLoading' },
|
|
43
|
+
{ Component: InlineLoading, name: 'InlineLoading' },
|
|
44
|
+
])('$name renders loading text', ({ Component }) => {
|
|
45
|
+
render(<Component />);
|
|
46
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Skeleton', () => {
|
|
51
|
+
it('renders with animate-pulse', () => {
|
|
52
|
+
const { container } = render(<Skeleton className="h-10 w-full" />);
|
|
53
|
+
expect(container.firstChild).toHaveClass('animate-pulse');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders SkeletonText with specified lines', () => {
|
|
57
|
+
const { container } = render(<SkeletonText lines={3} />);
|
|
58
|
+
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders SkeletonCard', () => {
|
|
62
|
+
const { container } = render(<SkeletonCard />);
|
|
63
|
+
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('SkeletonAvatar', () => {
|
|
68
|
+
it.each([
|
|
69
|
+
{ size: 'sm', expectedClass: 'size-8' },
|
|
70
|
+
{ size: 'default', expectedClass: 'size-10' },
|
|
71
|
+
{ size: 'lg', expectedClass: 'size-12' },
|
|
72
|
+
] as const)('renders $size with $expectedClass', ({ size, expectedClass }) => {
|
|
73
|
+
const { container } = render(<SkeletonAvatar size={size} />);
|
|
74
|
+
expect(container.firstChild).toHaveClass(expectedClass);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { SEO } from '@/components/shared/SEO';
|
|
5
|
+
|
|
6
|
+
describe('SEO', () => {
|
|
7
|
+
describe('title', () => {
|
|
8
|
+
it.each([
|
|
9
|
+
{ title: 'Test Page', expected: 'Test Page | My App' },
|
|
10
|
+
{ title: undefined, expected: 'My App' },
|
|
11
|
+
])('renders "$expected"', ({ title, expected }) => {
|
|
12
|
+
render(<SEO title={title} />);
|
|
13
|
+
expect(document.title).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('meta tags', () => {
|
|
18
|
+
it.each([
|
|
19
|
+
{ prop: 'description', selector: 'meta[name="description"]', value: 'Test desc', attr: 'content' },
|
|
20
|
+
{ prop: 'ogImage', selector: 'meta[property="og:image"]', value: 'https://example.com/img.jpg', attr: 'content' },
|
|
21
|
+
{ prop: 'canonical', selector: 'link[rel="canonical"]', value: 'https://example.com/page', attr: 'href' },
|
|
22
|
+
{ prop: 'ogType', selector: 'meta[property="og:type"]', value: 'article', attr: 'content' },
|
|
23
|
+
])('renders $prop correctly', ({ prop, selector, value, attr }) => {
|
|
24
|
+
render(<SEO {...{ [prop]: value }} />);
|
|
25
|
+
expect(document.querySelector(selector)).toHaveAttribute(attr, value);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders default description', () => {
|
|
29
|
+
render(<SEO />);
|
|
30
|
+
expect(document.querySelector('meta[name="description"]')).toHaveAttribute(
|
|
31
|
+
'content',
|
|
32
|
+
'A modern React application',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('keywords', () => {
|
|
38
|
+
it('renders keywords when provided', () => {
|
|
39
|
+
render(<SEO keywords={['react', 'typescript']} />);
|
|
40
|
+
expect(document.querySelector('meta[name="keywords"]')).toHaveAttribute('content', 'react, typescript');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('omits keywords when empty', () => {
|
|
44
|
+
render(<SEO keywords={[]} />);
|
|
45
|
+
expect(document.querySelector('meta[name="keywords"]')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('Open Graph / Twitter', () => {
|
|
50
|
+
it('renders OG and Twitter tags', () => {
|
|
51
|
+
render(<SEO title="Test" description="Desc" />);
|
|
52
|
+
|
|
53
|
+
expect(document.querySelector('meta[property="og:title"]')).toHaveAttribute('content', 'Test | My App');
|
|
54
|
+
expect(document.querySelector('meta[property="og:description"]')).toHaveAttribute('content', 'Desc');
|
|
55
|
+
expect(document.querySelector('meta[name="twitter:card"]')).toHaveAttribute('content', 'summary_large_image');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('robots', () => {
|
|
60
|
+
it.each([
|
|
61
|
+
{ noIndex: true, expected: 'noindex, nofollow' },
|
|
62
|
+
{ noIndex: false, expected: null },
|
|
63
|
+
])('noIndex=$noIndex renders correctly', ({ noIndex, expected }) => {
|
|
64
|
+
render(<SEO noIndex={noIndex} />);
|
|
65
|
+
const robots = document.querySelector('meta[name="robots"]');
|
|
66
|
+
if (expected) {
|
|
67
|
+
expect(robots).toHaveAttribute('content', expected);
|
|
68
|
+
} else {
|
|
69
|
+
expect(robots).toBeNull();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('optional elements', () => {
|
|
75
|
+
it('omits canonical when not provided', () => {
|
|
76
|
+
render(<SEO />);
|
|
77
|
+
expect(document.querySelector('link[rel="canonical"]')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ThemeToggle } from '@/components/shared/ThemeToggle';
|
|
6
|
+
import { usePreferencesStore } from '@/stores/preferencesStore';
|
|
7
|
+
import { render } from '@/test';
|
|
8
|
+
|
|
9
|
+
// Reset store state before each test
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('ThemeToggle', () => {
|
|
15
|
+
it('renders a button', () => {
|
|
16
|
+
render(<ThemeToggle />);
|
|
17
|
+
|
|
18
|
+
const button = screen.getByRole('button');
|
|
19
|
+
expect(button).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('has accessible label for light theme', () => {
|
|
23
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
24
|
+
render(<ThemeToggle />);
|
|
25
|
+
|
|
26
|
+
const button = screen.getByRole('button');
|
|
27
|
+
expect(button).toHaveAccessibleName(/switch to dark mode/i);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('has accessible label for dark theme', () => {
|
|
31
|
+
usePreferencesStore.setState({ theme: 'dark' });
|
|
32
|
+
render(<ThemeToggle />);
|
|
33
|
+
|
|
34
|
+
const button = screen.getByRole('button');
|
|
35
|
+
expect(button).toHaveAccessibleName(/switch to light mode/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('toggles theme when clicked', async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
41
|
+
|
|
42
|
+
render(<ThemeToggle />);
|
|
43
|
+
|
|
44
|
+
const button = screen.getByRole('button');
|
|
45
|
+
await user.click(button);
|
|
46
|
+
|
|
47
|
+
// After click, theme should be dark
|
|
48
|
+
expect(usePreferencesStore.getState().theme).toBe('dark');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('toggles from dark to light', async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
usePreferencesStore.setState({ theme: 'dark' });
|
|
54
|
+
|
|
55
|
+
render(<ThemeToggle />);
|
|
56
|
+
|
|
57
|
+
const button = screen.getByRole('button');
|
|
58
|
+
await user.click(button);
|
|
59
|
+
|
|
60
|
+
expect(usePreferencesStore.getState().theme).toBe('light');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { MobileProvider, useMobileContext } from '@/contexts/mobileContext';
|
|
6
|
+
|
|
7
|
+
const wrapper = ({ children }: { children: ReactNode }) => <MobileProvider>{children}</MobileProvider>;
|
|
8
|
+
|
|
9
|
+
describe('MobileProvider', () => {
|
|
10
|
+
let rafCallback: FrameRequestCallback | null = null;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
|
14
|
+
rafCallback = cb;
|
|
15
|
+
return 1;
|
|
16
|
+
});
|
|
17
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rafCallback = null;
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it.each([
|
|
26
|
+
{ width: 500, isMobile: true, isTablet: false, isDesktop: false },
|
|
27
|
+
{ width: 800, isMobile: false, isTablet: true, isDesktop: false },
|
|
28
|
+
{ width: 1200, isMobile: false, isTablet: false, isDesktop: true },
|
|
29
|
+
])('detects viewport at $width px', ({ width, isMobile, isTablet, isDesktop }) => {
|
|
30
|
+
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true });
|
|
31
|
+
const { result } = renderHook(() => useMobileContext(), { wrapper });
|
|
32
|
+
expect(result.current).toMatchObject({ isMobile, isTablet, isDesktop, width });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('updates on resize', () => {
|
|
36
|
+
Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true, configurable: true });
|
|
37
|
+
const { result } = renderHook(() => useMobileContext(), { wrapper });
|
|
38
|
+
|
|
39
|
+
expect(result.current.isDesktop).toBe(true);
|
|
40
|
+
|
|
41
|
+
act(() => {
|
|
42
|
+
Object.defineProperty(window, 'innerWidth', { value: 500, writable: true, configurable: true });
|
|
43
|
+
window.dispatchEvent(new Event('resize'));
|
|
44
|
+
rafCallback?.(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.current.isMobile).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('throws when used outside provider', () => {
|
|
51
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
52
|
+
expect(() => renderHook(() => useMobileContext())).toThrow('useMobileContext must be used within MobileProvider');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useContactForm } from '@/hooks/useContactForm';
|
|
5
|
+
|
|
6
|
+
describe('useContactForm', () => {
|
|
7
|
+
describe('initial state', () => {
|
|
8
|
+
it('initializes with empty values and no errors', () => {
|
|
9
|
+
const { result } = renderHook(() => useContactForm());
|
|
10
|
+
|
|
11
|
+
expect(result.current.form.getValues()).toEqual({ name: '', email: '', message: '' });
|
|
12
|
+
expect(result.current.errors).toEqual({});
|
|
13
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
14
|
+
expect(typeof result.current.onSubmit).toBe('function');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('validation', () => {
|
|
19
|
+
it.each([
|
|
20
|
+
{ field: 'name', value: 'J', valid: { email: 'test@example.com', message: 'Valid message here' } },
|
|
21
|
+
{ field: 'email', value: 'invalid', valid: { name: 'John', message: 'Valid message here' } },
|
|
22
|
+
{ field: 'message', value: 'Short', valid: { name: 'John', email: 'test@example.com' } },
|
|
23
|
+
])('rejects invalid $field', async ({ field, value, valid }) => {
|
|
24
|
+
const { result } = renderHook(() => useContactForm());
|
|
25
|
+
|
|
26
|
+
act(() => {
|
|
27
|
+
result.current.form.setValue(field as 'name' | 'email' | 'message', value);
|
|
28
|
+
Object.entries(valid).forEach(([k, v]) => {
|
|
29
|
+
result.current.form.setValue(k as 'name' | 'email' | 'message', v);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await act(async () => {
|
|
34
|
+
await result.current.form.trigger();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(result.current.errors[field as keyof typeof result.current.errors]).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('passes with valid data', async () => {
|
|
43
|
+
const { result } = renderHook(() => useContactForm());
|
|
44
|
+
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.form.setValue('name', 'John Doe');
|
|
47
|
+
result.current.form.setValue('email', 'test@example.com');
|
|
48
|
+
result.current.form.setValue('message', 'This is a valid message that is long enough.');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await act(async () => {
|
|
52
|
+
await result.current.form.trigger();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await waitFor(() => {
|
|
56
|
+
expect(result.current.errors).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|