@react-spa-scaffold/mcp 2.1.1 → 2.2.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 +2 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/auth.d.ts +3 -0
- package/dist/features/definitions/auth.d.ts.map +1 -0
- package/dist/features/definitions/auth.js +17 -0
- package/dist/features/definitions/auth.js.map +1 -0
- package/dist/features/definitions/core.d.ts.map +1 -1
- package/dist/features/definitions/core.js +16 -1
- package/dist/features/definitions/core.js.map +1 -1
- package/dist/features/definitions/forms.d.ts.map +1 -1
- package/dist/features/definitions/forms.js +4 -0
- package/dist/features/definitions/forms.js.map +1 -1
- package/dist/features/definitions/index.d.ts +1 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +1 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/definitions/mobile.d.ts.map +1 -1
- package/dist/features/definitions/mobile.js +11 -2
- package/dist/features/definitions/mobile.js.map +1 -1
- package/dist/features/definitions/observability.js +1 -1
- package/dist/features/definitions/observability.js.map +1 -1
- package/dist/features/definitions/routing.d.ts.map +1 -1
- package/dist/features/definitions/routing.js +2 -1
- package/dist/features/definitions/routing.js.map +1 -1
- package/dist/features/definitions/state.d.ts.map +1 -1
- package/dist/features/definitions/state.js +9 -2
- package/dist/features/definitions/state.js.map +1 -1
- package/dist/features/definitions/testing.d.ts.map +1 -1
- package/dist/features/definitions/testing.js +4 -2
- package/dist/features/definitions/testing.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +2 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.test.js +4 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +7 -0
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +6 -0
- package/templates/.github/workflows/ci.yml +8 -3
- package/templates/CLAUDE.md +74 -1
- package/templates/docs/ARCHITECTURE.md +13 -12
- package/templates/docs/CODING_STANDARDS.md +65 -0
- package/templates/docs/E2E_TESTING.md +52 -7
- package/templates/e2e/fixtures/index.ts +13 -2
- package/templates/package.json +7 -3
- package/templates/playwright.config.ts +6 -1
- package/templates/src/components/layout/Header.tsx +2 -1
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
- package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
- package/templates/src/components/shared/AccountButton/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
- package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
- package/templates/src/components/shared/index.ts +4 -2
- package/templates/src/contexts/clerkContext.tsx +45 -0
- package/templates/src/hooks/index.ts +23 -2
- package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
- package/templates/src/hooks/useCopyFeedback.ts +41 -0
- package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
- package/templates/src/hooks/useDebouncedCallback.ts +47 -0
- package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
- package/templates/src/hooks/useDocumentTitle.ts +31 -0
- package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
- package/templates/src/hooks/useIOSViewportReset.ts +18 -0
- package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
- package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
- package/templates/src/hooks/useLocalStorage.test.ts +111 -0
- package/templates/src/hooks/useLocalStorage.ts +77 -0
- package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
- package/templates/src/hooks/useSyncedFormData.ts +21 -0
- package/templates/src/hooks/useSyncedState.test.ts +119 -0
- package/templates/src/hooks/useSyncedState.ts +30 -0
- package/templates/src/index.css +1 -0
- package/templates/src/lib/constants.ts +10 -0
- package/templates/src/lib/createSelectors.test.ts +136 -0
- package/templates/src/lib/createSelectors.ts +31 -0
- package/templates/src/lib/index.ts +1 -0
- package/templates/src/lib/sentry.ts +55 -0
- package/templates/src/lib/storage.ts +6 -2
- package/templates/src/main.tsx +18 -8
- package/templates/src/stores/preferencesStore.ts +34 -9
- package/templates/src/test/clerkMock.tsx +97 -0
- package/templates/src/test/index.ts +3 -0
- package/templates/src/test/providers.tsx +7 -4
- package/templates/src/test-setup.ts +16 -2
- package/templates/vitest.config.ts +9 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
import { createSelectors } from './createSelectors';
|
|
6
|
+
|
|
7
|
+
interface TestState {
|
|
8
|
+
count: number;
|
|
9
|
+
name: string;
|
|
10
|
+
increment: () => void;
|
|
11
|
+
setName: (name: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('createSelectors', () => {
|
|
15
|
+
it('creates auto-generated selectors for all state properties', () => {
|
|
16
|
+
const useStoreBase = create<TestState>()((set) => ({
|
|
17
|
+
count: 0,
|
|
18
|
+
name: 'test',
|
|
19
|
+
increment: () => set((state) => ({ count: state.count + 1 })),
|
|
20
|
+
setName: (name) => set({ name }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const useStore = createSelectors(useStoreBase);
|
|
24
|
+
|
|
25
|
+
// Verify .use namespace exists
|
|
26
|
+
expect(useStore.use).toBeDefined();
|
|
27
|
+
expect(typeof useStore.use.count).toBe('function');
|
|
28
|
+
expect(typeof useStore.use.name).toBe('function');
|
|
29
|
+
expect(typeof useStore.use.increment).toBe('function');
|
|
30
|
+
expect(typeof useStore.use.setName).toBe('function');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('selectors return correct state values', () => {
|
|
34
|
+
const useStoreBase = create<TestState>()((set) => ({
|
|
35
|
+
count: 42,
|
|
36
|
+
name: 'hello',
|
|
37
|
+
increment: () => set((state) => ({ count: state.count + 1 })),
|
|
38
|
+
setName: (name) => set({ name }),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const useStore = createSelectors(useStoreBase);
|
|
42
|
+
|
|
43
|
+
const { result: countResult } = renderHook(() => useStore.use.count());
|
|
44
|
+
const { result: nameResult } = renderHook(() => useStore.use.name());
|
|
45
|
+
|
|
46
|
+
expect(countResult.current).toBe(42);
|
|
47
|
+
expect(nameResult.current).toBe('hello');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('selectors return action functions', () => {
|
|
51
|
+
const useStoreBase = create<TestState>()((set) => ({
|
|
52
|
+
count: 0,
|
|
53
|
+
name: 'test',
|
|
54
|
+
increment: () => set((state) => ({ count: state.count + 1 })),
|
|
55
|
+
setName: (name) => set({ name }),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const useStore = createSelectors(useStoreBase);
|
|
59
|
+
|
|
60
|
+
const { result: incrementResult } = renderHook(() => useStore.use.increment());
|
|
61
|
+
const { result: setNameResult } = renderHook(() => useStore.use.setName());
|
|
62
|
+
|
|
63
|
+
expect(typeof incrementResult.current).toBe('function');
|
|
64
|
+
expect(typeof setNameResult.current).toBe('function');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('actions from selectors update state correctly', () => {
|
|
68
|
+
const useStoreBase = create<TestState>()((set) => ({
|
|
69
|
+
count: 0,
|
|
70
|
+
name: 'initial',
|
|
71
|
+
increment: () => set((state) => ({ count: state.count + 1 })),
|
|
72
|
+
setName: (name) => set({ name }),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const useStore = createSelectors(useStoreBase);
|
|
76
|
+
|
|
77
|
+
// Get initial state
|
|
78
|
+
expect(useStore.getState().count).toBe(0);
|
|
79
|
+
expect(useStore.getState().name).toBe('initial');
|
|
80
|
+
|
|
81
|
+
// Use action from selector
|
|
82
|
+
const { result: incrementResult } = renderHook(() => useStore.use.increment());
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
incrementResult.current();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(useStore.getState().count).toBe(1);
|
|
89
|
+
|
|
90
|
+
// Use another action
|
|
91
|
+
const { result: setNameResult } = renderHook(() => useStore.use.setName());
|
|
92
|
+
|
|
93
|
+
act(() => {
|
|
94
|
+
setNameResult.current('updated');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(useStore.getState().name).toBe('updated');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('preserves original store functionality', () => {
|
|
101
|
+
const useStoreBase = create<TestState>()((set) => ({
|
|
102
|
+
count: 0,
|
|
103
|
+
name: 'test',
|
|
104
|
+
increment: () => set((state) => ({ count: state.count + 1 })),
|
|
105
|
+
setName: (name) => set({ name }),
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const useStore = createSelectors(useStoreBase);
|
|
109
|
+
|
|
110
|
+
// Original hook still works
|
|
111
|
+
const { result } = renderHook(() => useStore((state) => state.count));
|
|
112
|
+
expect(result.current).toBe(0);
|
|
113
|
+
|
|
114
|
+
// getState still works
|
|
115
|
+
expect(useStore.getState().count).toBe(0);
|
|
116
|
+
|
|
117
|
+
// setState still works
|
|
118
|
+
act(() => {
|
|
119
|
+
useStore.setState({ count: 100 });
|
|
120
|
+
});
|
|
121
|
+
expect(useStore.getState().count).toBe(100);
|
|
122
|
+
|
|
123
|
+
// subscribe still works
|
|
124
|
+
let subscribedValue = 0;
|
|
125
|
+
const unsub = useStore.subscribe((state) => {
|
|
126
|
+
subscribedValue = state.count;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
useStore.setState({ count: 200 });
|
|
131
|
+
});
|
|
132
|
+
expect(subscribedValue).toBe(200);
|
|
133
|
+
|
|
134
|
+
unsub();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StoreApi, UseBoundStore } from 'zustand';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type that extends a Zustand store with auto-generated selectors.
|
|
5
|
+
* Adds a `use` namespace with selector functions for each store property.
|
|
6
|
+
*/
|
|
7
|
+
type WithSelectors<S> = S extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wraps a Zustand store and automatically generates selector hooks for all properties.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const useStoreBase = create<State>()((set) => ({ count: 0 }));
|
|
15
|
+
* export const useStore = createSelectors(useStoreBase);
|
|
16
|
+
*
|
|
17
|
+
* // Usage in components:
|
|
18
|
+
* const count = useStore.use.count();
|
|
19
|
+
* const increment = useStore.use.increment();
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function createSelectors<S extends UseBoundStore<StoreApi<object>>>(_store: S): WithSelectors<S> {
|
|
23
|
+
const store = _store as WithSelectors<typeof _store>;
|
|
24
|
+
store.use = {} as WithSelectors<S>['use'];
|
|
25
|
+
|
|
26
|
+
for (const key of Object.keys(store.getState())) {
|
|
27
|
+
(store.use as Record<string, () => unknown>)[key] = () => store((state) => state[key as keyof typeof state]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return store;
|
|
31
|
+
}
|
|
@@ -12,3 +12,4 @@ export { env, validateEnv, type Env } from './env';
|
|
|
12
12
|
export { api, ApiClientError } from './api';
|
|
13
13
|
export { getStorageItem, setStorageItem, removeStorageItem, clearAppStorage } from './storage';
|
|
14
14
|
export { registerFormSchema, type RegisterFormData } from './validations';
|
|
15
|
+
export { createSelectors } from './createSelectors';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type * as SentryType from '@sentry/react';
|
|
2
|
+
|
|
3
|
+
let sentryInstance: typeof SentryType | null = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Initialize Sentry error tracking.
|
|
7
|
+
* Only runs in production when VITE_SENTRY_DSN is configured.
|
|
8
|
+
*/
|
|
9
|
+
export async function initSentry(): Promise<void> {
|
|
10
|
+
// Skip in development or if already initialized
|
|
11
|
+
if (import.meta.env.DEV || sentryInstance) return;
|
|
12
|
+
|
|
13
|
+
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
|
14
|
+
if (!dsn) return;
|
|
15
|
+
|
|
16
|
+
const sentry = await import('@sentry/react');
|
|
17
|
+
|
|
18
|
+
sentry.init({
|
|
19
|
+
dsn,
|
|
20
|
+
environment: import.meta.env.MODE,
|
|
21
|
+
sendDefaultPii: true,
|
|
22
|
+
integrations: [sentry.browserTracingIntegration()],
|
|
23
|
+
tracesSampleRate: 0.1,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
sentryInstance = sentry;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lazily initialize Sentry after the app is interactive.
|
|
31
|
+
* Uses requestIdleCallback with fallback for older browsers.
|
|
32
|
+
*/
|
|
33
|
+
export function lazySentryInit(): void {
|
|
34
|
+
const init = () => void initSentry();
|
|
35
|
+
|
|
36
|
+
if ('requestIdleCallback' in window) {
|
|
37
|
+
window.requestIdleCallback(init, { timeout: 2000 });
|
|
38
|
+
} else {
|
|
39
|
+
setTimeout(init, 1000);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CaptureContext {
|
|
44
|
+
componentStack?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Capture an exception to Sentry.
|
|
49
|
+
* No-op if Sentry is not initialized.
|
|
50
|
+
*/
|
|
51
|
+
export function captureException(error: unknown, context?: CaptureContext): void {
|
|
52
|
+
sentryInstance?.captureException(error, {
|
|
53
|
+
extra: context?.componentStack ? { componentStack: context.componentStack } : undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { STORAGE_KEYS } from './storageKeys';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Extract only string keys (exclude functions if any are added later)
|
|
8
|
+
type StorageKey = Extract<(typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS], string>;
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Check if we're in a browser environment
|
|
@@ -81,7 +82,10 @@ export function clearAppStorage(): boolean {
|
|
|
81
82
|
|
|
82
83
|
try {
|
|
83
84
|
Object.values(STORAGE_KEYS).forEach((key) => {
|
|
84
|
-
|
|
85
|
+
// Skip function keys (like prompt()) if any exist
|
|
86
|
+
if (typeof key === 'string') {
|
|
87
|
+
localStorage.removeItem(key);
|
|
88
|
+
}
|
|
85
89
|
});
|
|
86
90
|
return true;
|
|
87
91
|
} catch (error) {
|
package/templates/src/main.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { BrowserRouter } from 'react-router';
|
|
|
6
6
|
import './index.css';
|
|
7
7
|
import { ErrorBoundary } from '@/components/shared';
|
|
8
8
|
import { Toaster } from '@/components/ui/sonner';
|
|
9
|
+
import { ClerkThemeProvider } from '@/contexts/clerkContext';
|
|
9
10
|
import { MobileProvider } from '@/contexts/mobileContext';
|
|
10
11
|
import { PerformanceProviderWrapper } from '@/contexts/performanceContext';
|
|
11
12
|
import { QueryProvider } from '@/contexts/queryContext';
|
|
@@ -15,6 +16,13 @@ import { initPreferencesSync } from '@/stores/preferencesStore';
|
|
|
15
16
|
|
|
16
17
|
import App from './App';
|
|
17
18
|
|
|
19
|
+
// Import Clerk Publishable Key (per official docs)
|
|
20
|
+
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
|
|
21
|
+
|
|
22
|
+
if (!PUBLISHABLE_KEY) {
|
|
23
|
+
throw new Error('Add your Clerk Publishable Key to the .env.local file');
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* Lazy load Sentry after initial render to avoid blocking web vitals.
|
|
20
28
|
* Returns the Sentry module for use in global error handlers.
|
|
@@ -74,14 +82,16 @@ initI18n().then(() => {
|
|
|
74
82
|
<QueryProvider>
|
|
75
83
|
<I18nProvider i18n={i18n}>
|
|
76
84
|
<BrowserRouter>
|
|
77
|
-
<
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
<ClerkThemeProvider publishableKey={PUBLISHABLE_KEY}>
|
|
86
|
+
<MobileProvider>
|
|
87
|
+
<ErrorBoundary>
|
|
88
|
+
<PerformanceProviderWrapper>
|
|
89
|
+
<App />
|
|
90
|
+
<Toaster />
|
|
91
|
+
</PerformanceProviderWrapper>
|
|
92
|
+
</ErrorBoundary>
|
|
93
|
+
</MobileProvider>
|
|
94
|
+
</ClerkThemeProvider>
|
|
85
95
|
</BrowserRouter>
|
|
86
96
|
</I18nProvider>
|
|
87
97
|
</QueryProvider>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import { devtools, persist } from 'zustand/middleware';
|
|
3
3
|
|
|
4
|
+
import { createSelectors } from '@/lib/createSelectors';
|
|
4
5
|
import { STORAGE_KEYS } from '@/lib/storageKeys';
|
|
5
6
|
|
|
6
7
|
export type Theme = 'light' | 'dark' | 'system';
|
|
@@ -31,18 +32,25 @@ function getSystemTheme(): 'light' | 'dark' {
|
|
|
31
32
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
/** Current schema version for preferences store persistence */
|
|
36
|
+
const PREFERENCES_STORE_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
const usePreferencesStoreBase = create<PreferencesState>()(
|
|
35
39
|
devtools(
|
|
36
40
|
persist(
|
|
37
41
|
(set, get) => ({
|
|
38
42
|
...initialState,
|
|
39
|
-
setTheme: (theme) => set({ theme }),
|
|
43
|
+
setTheme: (theme) => set({ theme }, undefined, 'preferences/setTheme'),
|
|
40
44
|
toggleTheme: () =>
|
|
41
|
-
set(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
set(
|
|
46
|
+
(state) => {
|
|
47
|
+
const resolved = state.theme === 'system' ? getSystemTheme() : state.theme;
|
|
48
|
+
return { theme: resolved === 'light' ? 'dark' : 'light' };
|
|
49
|
+
},
|
|
50
|
+
undefined,
|
|
51
|
+
'preferences/toggleTheme',
|
|
52
|
+
),
|
|
53
|
+
reset: () => set(initialState, undefined, 'preferences/reset'),
|
|
46
54
|
getResolvedTheme: () => {
|
|
47
55
|
const { theme } = get();
|
|
48
56
|
return theme === 'system' ? getSystemTheme() : theme;
|
|
@@ -50,13 +58,30 @@ export const usePreferencesStore = create<PreferencesState>()(
|
|
|
50
58
|
}),
|
|
51
59
|
{
|
|
52
60
|
name: STORAGE_KEYS.preferences,
|
|
53
|
-
|
|
61
|
+
version: PREFERENCES_STORE_VERSION,
|
|
62
|
+
partialize: (state): Preferences => ({ theme: state.theme }),
|
|
63
|
+
migrate: (persisted, version) => {
|
|
64
|
+
const state = persisted as Preferences;
|
|
65
|
+
if (version === 0) {
|
|
66
|
+
// v0 → v1: No changes needed, establishes baseline for future migrations
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
return state;
|
|
70
|
+
},
|
|
71
|
+
onRehydrateStorage: () => (_state, error) => {
|
|
72
|
+
if (error) {
|
|
73
|
+
console.error('Failed to hydrate preferences store:', error);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
54
76
|
},
|
|
55
77
|
),
|
|
56
|
-
{ name: 'preferences' },
|
|
78
|
+
{ name: 'preferences', enabled: process.env.NODE_ENV === 'development' },
|
|
57
79
|
),
|
|
58
80
|
);
|
|
59
81
|
|
|
82
|
+
/** Preferences store with auto-generated selectors */
|
|
83
|
+
export const usePreferencesStore = createSelectors(usePreferencesStoreBase);
|
|
84
|
+
|
|
60
85
|
/**
|
|
61
86
|
* Initialize multi-tab sync for preferences.
|
|
62
87
|
* Call this once at app startup.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Mock State
|
|
5
|
+
// =============================================================================
|
|
6
|
+
|
|
7
|
+
interface ClerkMockState {
|
|
8
|
+
isSignedIn: boolean;
|
|
9
|
+
isLoaded: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const defaultState: ClerkMockState = {
|
|
13
|
+
isSignedIn: true,
|
|
14
|
+
isLoaded: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let mockState: ClerkMockState = { ...defaultState };
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Test Utilities
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
export function setMockSignedIn(value: boolean) {
|
|
24
|
+
mockState.isSignedIn = value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function setMockLoaded(value: boolean) {
|
|
28
|
+
mockState.isLoaded = value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setMockClerkState(state: Partial<ClerkMockState>) {
|
|
32
|
+
mockState = { ...mockState, ...state };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetClerkMocks() {
|
|
36
|
+
mockState = { ...defaultState };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Mock Components
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export function SignedIn({ children }: { children: ReactNode }) {
|
|
44
|
+
return mockState.isLoaded && mockState.isSignedIn ? <>{children}</> : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function SignedOut({ children }: { children: ReactNode }) {
|
|
48
|
+
return mockState.isLoaded && !mockState.isSignedIn ? <>{children}</> : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function SignInButton({ children }: { children?: ReactNode; mode?: string }) {
|
|
52
|
+
return <div data-testid="sign-in-button">{children}</div>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function SignUpButton({ children }: { children?: ReactNode; mode?: string }) {
|
|
56
|
+
return <div data-testid="sign-up-button">{children}</div>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function UserButton() {
|
|
60
|
+
return <button data-testid="user-button">User</button>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function RedirectToSignIn() {
|
|
64
|
+
return <div data-testid="redirect-to-sign-in" />;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ClerkProvider({
|
|
68
|
+
children,
|
|
69
|
+
}: {
|
|
70
|
+
children: ReactNode;
|
|
71
|
+
publishableKey?: string;
|
|
72
|
+
afterSignOutUrl?: string;
|
|
73
|
+
appearance?: unknown;
|
|
74
|
+
}) {
|
|
75
|
+
return <>{children}</>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Mock Hooks
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
export function useAuth() {
|
|
83
|
+
return {
|
|
84
|
+
isLoaded: mockState.isLoaded,
|
|
85
|
+
isSignedIn: mockState.isSignedIn,
|
|
86
|
+
userId: mockState.isSignedIn ? 'user_123' : null,
|
|
87
|
+
sessionId: mockState.isSignedIn ? 'sess_123' : null,
|
|
88
|
+
getToken: async () => (mockState.isSignedIn ? 'mock-token' : null),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useUser() {
|
|
93
|
+
return {
|
|
94
|
+
isLoaded: mockState.isLoaded,
|
|
95
|
+
user: mockState.isSignedIn ? { id: 'user_123', firstName: 'Test', lastName: 'User' } : null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -4,5 +4,8 @@ export { createTestQueryClient, render } from './providers';
|
|
|
4
4
|
// Mock utilities
|
|
5
5
|
export { mockMatchMedia } from './mocks';
|
|
6
6
|
|
|
7
|
+
// Clerk test utilities
|
|
8
|
+
export { resetClerkMocks, setMockClerkState, setMockLoaded, setMockSignedIn } from './clerkMock';
|
|
9
|
+
|
|
7
10
|
// MSW server instance
|
|
8
11
|
export { server } from '@/mocks/node';
|
|
@@ -5,6 +5,7 @@ import { render, type RenderOptions } from '@testing-library/react';
|
|
|
5
5
|
import { type ReactElement, type ReactNode } from 'react';
|
|
6
6
|
import { MemoryRouter } from 'react-router';
|
|
7
7
|
|
|
8
|
+
import { ClerkThemeProvider } from '@/contexts/clerkContext';
|
|
8
9
|
import { MobileProvider } from '@/contexts/mobileContext';
|
|
9
10
|
import { PerformanceProviderWrapper } from '@/contexts/performanceContext';
|
|
10
11
|
|
|
@@ -37,14 +38,16 @@ interface WrapperProps {
|
|
|
37
38
|
function AllProviders({ children }: WrapperProps) {
|
|
38
39
|
const queryClient = createTestQueryClient();
|
|
39
40
|
|
|
40
|
-
// Provider order matches main.tsx: Query > I18n > Router > Mobile > Performance
|
|
41
|
+
// Provider order matches main.tsx: Query > I18n > Router > Clerk > Mobile > Performance
|
|
41
42
|
return (
|
|
42
43
|
<QueryClientProvider client={queryClient}>
|
|
43
44
|
<I18nProvider i18n={i18n}>
|
|
44
45
|
<MemoryRouter>
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
46
|
+
<ClerkThemeProvider publishableKey="test_key">
|
|
47
|
+
<MobileProvider>
|
|
48
|
+
<PerformanceProviderWrapper>{children}</PerformanceProviderWrapper>
|
|
49
|
+
</MobileProvider>
|
|
50
|
+
</ClerkThemeProvider>
|
|
48
51
|
</MemoryRouter>
|
|
49
52
|
</I18nProvider>
|
|
50
53
|
</QueryClientProvider>
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import '@testing-library/jest-dom/vitest';
|
|
2
|
-
import { afterAll, afterEach, beforeAll } from 'vitest';
|
|
2
|
+
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { server } from '@/mocks/node';
|
|
5
|
+
import { resetClerkMocks } from '@/test/clerkMock';
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Module Mocks
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
// Mock @clerk/react-router for testing
|
|
12
|
+
vi.mock('@clerk/react-router', async () => import('@/test/clerkMock'));
|
|
13
|
+
|
|
14
|
+
// Mock @clerk/themes for testing
|
|
15
|
+
vi.mock('@clerk/themes', () => ({
|
|
16
|
+
shadcn: { baseTheme: 'shadcn' },
|
|
17
|
+
}));
|
|
5
18
|
|
|
6
19
|
// =============================================================================
|
|
7
20
|
// MSW Server Setup
|
|
@@ -14,9 +27,10 @@ beforeAll(() => {
|
|
|
14
27
|
});
|
|
15
28
|
});
|
|
16
29
|
|
|
17
|
-
// Reset handlers after each test to ensure test isolation
|
|
30
|
+
// Reset handlers and mocks after each test to ensure test isolation
|
|
18
31
|
afterEach(() => {
|
|
19
32
|
server.resetHandlers();
|
|
33
|
+
resetClerkMocks();
|
|
20
34
|
});
|
|
21
35
|
|
|
22
36
|
// Close MSW server after all tests complete
|
|
@@ -21,7 +21,15 @@ export default defineConfig({
|
|
|
21
21
|
coverage: {
|
|
22
22
|
provider: 'v8',
|
|
23
23
|
reporter: ['text', 'json', 'html', 'lcov'],
|
|
24
|
-
exclude: [
|
|
24
|
+
exclude: [
|
|
25
|
+
'**/*.test.{ts,tsx}',
|
|
26
|
+
'**/index.ts',
|
|
27
|
+
'src/types/**',
|
|
28
|
+
'src/components/ui/**',
|
|
29
|
+
'src/mocks/**',
|
|
30
|
+
'src/test/**', // Test utilities and mocks
|
|
31
|
+
'src/lib/sentry.ts', // Production-only, lazily loaded
|
|
32
|
+
],
|
|
25
33
|
thresholds: {
|
|
26
34
|
lines: 80,
|
|
27
35
|
functions: 80,
|