@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.
Files changed (173) hide show
  1. package/README.md +423 -0
  2. package/dist/features/index.d.ts +5 -0
  3. package/dist/features/index.d.ts.map +1 -0
  4. package/dist/features/index.js +3 -0
  5. package/dist/features/index.js.map +1 -0
  6. package/dist/features/registry.d.ts +10 -0
  7. package/dist/features/registry.d.ts.map +1 -0
  8. package/dist/features/registry.js +508 -0
  9. package/dist/features/registry.js.map +1 -0
  10. package/dist/features/types.d.ts +45 -0
  11. package/dist/features/types.d.ts.map +1 -0
  12. package/dist/features/types.js +5 -0
  13. package/dist/features/types.js.map +1 -0
  14. package/dist/features/versions.d.ts +16 -0
  15. package/dist/features/versions.d.ts.map +1 -0
  16. package/dist/features/versions.js +46 -0
  17. package/dist/features/versions.js.map +1 -0
  18. package/dist/features/versions.json +5 -0
  19. package/dist/index.d.ts +22 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +43 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/resources/docs.d.ts +29 -0
  24. package/dist/resources/docs.d.ts.map +1 -0
  25. package/dist/resources/docs.js +105 -0
  26. package/dist/resources/docs.js.map +1 -0
  27. package/dist/resources/index.d.ts +2 -0
  28. package/dist/resources/index.d.ts.map +1 -0
  29. package/dist/resources/index.js +2 -0
  30. package/dist/resources/index.js.map +1 -0
  31. package/dist/server.d.ts +12 -0
  32. package/dist/server.d.ts.map +1 -0
  33. package/dist/server.js +115 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/tools/get-example.d.ts +51 -0
  36. package/dist/tools/get-example.d.ts.map +1 -0
  37. package/dist/tools/get-example.js +90 -0
  38. package/dist/tools/get-example.js.map +1 -0
  39. package/dist/tools/get-features.d.ts +30 -0
  40. package/dist/tools/get-features.d.ts.map +1 -0
  41. package/dist/tools/get-features.js +46 -0
  42. package/dist/tools/get-features.js.map +1 -0
  43. package/dist/tools/get-scaffold.d.ts +77 -0
  44. package/dist/tools/get-scaffold.d.ts.map +1 -0
  45. package/dist/tools/get-scaffold.js +153 -0
  46. package/dist/tools/get-scaffold.js.map +1 -0
  47. package/dist/tools/index.d.ts +4 -0
  48. package/dist/tools/index.d.ts.map +1 -0
  49. package/dist/tools/index.js +4 -0
  50. package/dist/tools/index.js.map +1 -0
  51. package/dist/utils/docs.d.ts +14 -0
  52. package/dist/utils/docs.d.ts.map +1 -0
  53. package/dist/utils/docs.js +64 -0
  54. package/dist/utils/docs.js.map +1 -0
  55. package/dist/utils/examples.d.ts +27 -0
  56. package/dist/utils/examples.d.ts.map +1 -0
  57. package/dist/utils/examples.js +399 -0
  58. package/dist/utils/examples.js.map +1 -0
  59. package/dist/utils/index.d.ts +5 -0
  60. package/dist/utils/index.d.ts.map +1 -0
  61. package/dist/utils/index.js +5 -0
  62. package/dist/utils/index.js.map +1 -0
  63. package/dist/utils/paths.d.ts +28 -0
  64. package/dist/utils/paths.d.ts.map +1 -0
  65. package/dist/utils/paths.js +40 -0
  66. package/dist/utils/paths.js.map +1 -0
  67. package/dist/utils/scaffold.d.ts +50 -0
  68. package/dist/utils/scaffold.d.ts.map +1 -0
  69. package/dist/utils/scaffold.js +500 -0
  70. package/dist/utils/scaffold.js.map +1 -0
  71. package/dist/version.d.ts +5 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +19 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +63 -0
  76. package/templates/.bundled +0 -0
  77. package/templates/CLAUDE.md +145 -0
  78. package/templates/docs/API_REFERENCE.md +58 -0
  79. package/templates/docs/ARCHITECTURE.md +185 -0
  80. package/templates/docs/CODING_STANDARDS.md +53 -0
  81. package/templates/docs/COMPONENT_GUIDELINES.md +301 -0
  82. package/templates/docs/E2E_TESTING.md +116 -0
  83. package/templates/docs/INTERNATIONALIZATION.md +67 -0
  84. package/templates/docs/TESTING.md +259 -0
  85. package/templates/docs/WORKFLOW.md +170 -0
  86. package/templates/src/App.tsx +42 -0
  87. package/templates/src/components/layout/Header.tsx +19 -0
  88. package/templates/src/components/layout/index.ts +1 -0
  89. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +104 -0
  90. package/templates/src/components/shared/ErrorBoundary/index.ts +1 -0
  91. package/templates/src/components/shared/LanguageSwitcher/LanguageSwitcher.tsx +45 -0
  92. package/templates/src/components/shared/LanguageSwitcher/index.ts +1 -0
  93. package/templates/src/components/shared/SEO/SEO.tsx +55 -0
  94. package/templates/src/components/shared/SEO/index.ts +1 -0
  95. package/templates/src/components/shared/ThemeToggle/ThemeToggle.tsx +41 -0
  96. package/templates/src/components/shared/ThemeToggle/index.ts +1 -0
  97. package/templates/src/components/shared/index.ts +4 -0
  98. package/templates/src/components/ui/button.tsx +48 -0
  99. package/templates/src/components/ui/dropdown-menu.tsx +228 -0
  100. package/templates/src/components/ui/form-error.tsx +95 -0
  101. package/templates/src/components/ui/loading.tsx +58 -0
  102. package/templates/src/components/ui/skeleton.tsx +52 -0
  103. package/templates/src/components/ui/sonner.tsx +34 -0
  104. package/templates/src/components/ui/spinner.tsx +40 -0
  105. package/templates/src/components/ui/visually-hidden.tsx +51 -0
  106. package/templates/src/contexts/mobileContext.tsx +66 -0
  107. package/templates/src/contexts/queryContext.tsx +28 -0
  108. package/templates/src/hooks/index.ts +7 -0
  109. package/templates/src/hooks/useContactForm.ts +33 -0
  110. package/templates/src/hooks/useExampleQuery.ts +20 -0
  111. package/templates/src/hooks/useLanguage.ts +23 -0
  112. package/templates/src/hooks/useMediaQuery.ts +53 -0
  113. package/templates/src/hooks/useThemeEffect.ts +31 -0
  114. package/templates/src/hooks/useTouchSizes.ts +16 -0
  115. package/templates/src/i18n/config.ts +11 -0
  116. package/templates/src/i18n/detectLanguage.ts +57 -0
  117. package/templates/src/i18n/index.ts +20 -0
  118. package/templates/src/i18n/loadCatalog.ts +30 -0
  119. package/templates/src/index.css +98 -0
  120. package/templates/src/lib/api.ts +142 -0
  121. package/templates/src/lib/config.ts +15 -0
  122. package/templates/src/lib/constants.ts +8 -0
  123. package/templates/src/lib/env.ts +53 -0
  124. package/templates/src/lib/format.ts +119 -0
  125. package/templates/src/lib/index.ts +24 -0
  126. package/templates/src/lib/routes.ts +11 -0
  127. package/templates/src/lib/storage.ts +91 -0
  128. package/templates/src/lib/storageKeys.ts +10 -0
  129. package/templates/src/lib/utils.ts +6 -0
  130. package/templates/src/lib/validations.ts +39 -0
  131. package/templates/src/locales/de.po +65 -0
  132. package/templates/src/locales/en.po +65 -0
  133. package/templates/src/locales/es.po +65 -0
  134. package/templates/src/main.tsx +107 -0
  135. package/templates/src/mocks/fixtures/index.ts +1 -0
  136. package/templates/src/mocks/fixtures/todos.ts +40 -0
  137. package/templates/src/mocks/handlers/index.ts +7 -0
  138. package/templates/src/mocks/handlers/todos.ts +59 -0
  139. package/templates/src/mocks/index.ts +3 -0
  140. package/templates/src/mocks/node.ts +9 -0
  141. package/templates/src/pages/Home.tsx +27 -0
  142. package/templates/src/pages/NotFound.tsx +28 -0
  143. package/templates/src/pages/index.ts +2 -0
  144. package/templates/src/stores/index.ts +2 -0
  145. package/templates/src/stores/preferencesStore.ts +85 -0
  146. package/templates/src/test/index.ts +8 -0
  147. package/templates/src/test/mocks.ts +17 -0
  148. package/templates/src/test/providers.tsx +54 -0
  149. package/templates/src/test-setup.ts +54 -0
  150. package/templates/src/types/api.ts +31 -0
  151. package/templates/src/types/index.ts +2 -0
  152. package/templates/src/types/preferences.ts +5 -0
  153. package/templates/src/vite-env.d.ts +10 -0
  154. package/templates/tests/unit/components/ErrorBoundary.test.tsx +193 -0
  155. package/templates/tests/unit/components/Header.test.tsx +33 -0
  156. package/templates/tests/unit/components/LanguageSwitcher.test.tsx +40 -0
  157. package/templates/tests/unit/components/Loading.test.tsx +76 -0
  158. package/templates/tests/unit/components/SEO.test.tsx +80 -0
  159. package/templates/tests/unit/components/ThemeToggle.test.tsx +62 -0
  160. package/templates/tests/unit/contexts/mobileContext.test.tsx +54 -0
  161. package/templates/tests/unit/hooks/useContactForm.test.ts +60 -0
  162. package/templates/tests/unit/hooks/useExampleQuery.test.tsx +94 -0
  163. package/templates/tests/unit/hooks/useLanguage.test.tsx +75 -0
  164. package/templates/tests/unit/hooks/useMediaQuery.test.ts +57 -0
  165. package/templates/tests/unit/hooks/useThemeEffect.test.ts +42 -0
  166. package/templates/tests/unit/i18n/detectLanguage.test.ts +40 -0
  167. package/templates/tests/unit/i18n/loadCatalog.test.ts +70 -0
  168. package/templates/tests/unit/lib/api.test.ts +142 -0
  169. package/templates/tests/unit/lib/format.test.ts +100 -0
  170. package/templates/tests/unit/lib/storage.test.ts +90 -0
  171. package/templates/tests/unit/lib/utils.test.ts +19 -0
  172. package/templates/tests/unit/lib/validations.test.ts +56 -0
  173. 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
+ });