@react-spa-scaffold/mcp 2.2.0 → 2.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/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/database.d.ts +3 -0
- package/dist/features/definitions/database.d.ts.map +1 -0
- package/dist/features/definitions/database.js +45 -0
- package/dist/features/definitions/database.js.map +1 -0
- package/dist/features/definitions/deployment.d.ts +3 -0
- package/dist/features/definitions/deployment.d.ts.map +1 -0
- package/dist/features/definitions/deployment.js +14 -0
- package/dist/features/definitions/deployment.js.map +1 -0
- package/dist/features/definitions/index.d.ts +2 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +2 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +3 -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/resources/docs.d.ts.map +1 -1
- package/dist/resources/docs.js +5 -0
- package/dist/resources/docs.js.map +1 -1
- package/dist/tools/add-features.js +1 -1
- package/dist/tools/add-features.js.map +1 -1
- package/dist/utils/docs.d.ts.map +1 -1
- package/dist/utils/docs.js +2 -0
- package/dist/utils/docs.js.map +1 -1
- package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/index.js +3 -1
- package/dist/utils/scaffold/claude-md/index.js.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
- package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.js +132 -2
- package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
- package/dist/utils/scaffold/compute.js +1 -1
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts +2 -2
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +57 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +40 -12
- package/templates/.github/workflows/ci.yml +4 -1
- package/templates/.github/workflows/deploy.yml +59 -0
- package/templates/CLAUDE.md +177 -1
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/DEPLOYMENT.md +268 -0
- package/templates/docs/E2E_TESTING.md +81 -4
- package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
- package/templates/docs/TESTING.md +195 -77
- package/templates/e2e/auth/auth.setup.ts +60 -0
- package/templates/e2e/fixtures/index.ts +11 -0
- package/templates/e2e/tests/profile.auth.spec.ts +103 -0
- package/templates/e2e/tests/profile.spec.ts +64 -0
- package/templates/e2e/tests/register-form.spec.ts +38 -0
- package/templates/gitignore +5 -0
- package/templates/package.json +8 -0
- package/templates/playwright.config.ts +33 -3
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +11 -0
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +3 -3
- package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
- package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
- package/templates/src/components/shared/ProfileSync/index.ts +1 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +3 -3
- package/templates/src/components/shared/index.ts +1 -0
- package/templates/src/contexts/performanceContext.tsx +3 -3
- package/templates/src/contexts/supabaseContext.test.tsx +59 -0
- package/templates/src/contexts/supabaseContext.tsx +87 -0
- package/templates/src/hooks/index.ts +17 -0
- package/templates/src/hooks/supabase/index.ts +12 -0
- package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
- package/templates/src/hooks/supabase/useProfiles.ts +213 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
- package/templates/src/lib/api.test.ts +30 -38
- package/templates/src/lib/api.ts +1 -7
- package/templates/src/lib/config.ts +54 -4
- package/templates/src/lib/env.ts +36 -14
- package/templates/src/lib/index.ts +4 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +13 -10
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.tsx +17 -39
- package/templates/src/mocks/constants.ts +31 -0
- package/templates/src/mocks/fixtures/index.ts +3 -1
- package/templates/src/mocks/fixtures/profiles.ts +55 -0
- package/templates/src/mocks/fixtures/users.ts +91 -0
- package/templates/src/mocks/handlers/index.ts +2 -1
- package/templates/src/mocks/handlers/supabase.ts +64 -0
- package/templates/src/mocks/handlers/todos.ts +1 -1
- package/templates/src/mocks/index.ts +6 -0
- package/templates/src/pages/Profile.test.tsx +263 -0
- package/templates/src/pages/Profile.tsx +171 -0
- package/templates/src/pages/index.ts +1 -0
- package/templates/src/stores/preferencesStore.ts +2 -1
- package/templates/src/test/clerkMock.tsx +49 -9
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +49 -3
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +7 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +26 -0
- package/templates/src/types/database.ts +46 -0
- package/templates/src/types/index.ts +1 -0
- package/templates/src/types/supabase.ts +167 -0
- package/templates/src/vite-env.d.ts +6 -0
- package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Trans, useLingui } from '@lingui/react/macro';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { SEO } from '@/components/shared';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { Input } from '@/components/ui/input';
|
|
8
|
+
import { Label } from '@/components/ui/label';
|
|
9
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
10
|
+
import { useProfile, useSyncedState, useUpdateProfile } from '@/hooks';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Profile page demonstrating Supabase database integration.
|
|
14
|
+
* Shows current user's profile with ability to edit the name.
|
|
15
|
+
*/
|
|
16
|
+
export function ProfilePage() {
|
|
17
|
+
const { t } = useLingui();
|
|
18
|
+
const { profile, isLoading, error, refetch } = useProfile();
|
|
19
|
+
const updateProfile = useUpdateProfile();
|
|
20
|
+
|
|
21
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
22
|
+
// Sync name with profile, blocking sync while editing
|
|
23
|
+
const [name, setName] = useSyncedState(profile?.full_name ?? '', isEditing);
|
|
24
|
+
|
|
25
|
+
// Extract error messages for i18n (avoids object property access in Trans)
|
|
26
|
+
const fetchErrorMessage = error?.message;
|
|
27
|
+
const updateErrorMessage = updateProfile.error?.message;
|
|
28
|
+
|
|
29
|
+
const handleEdit = () => {
|
|
30
|
+
setIsEditing(true);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleCancel = () => {
|
|
34
|
+
setName(profile?.full_name ?? '');
|
|
35
|
+
setIsEditing(false);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleSave = () => {
|
|
39
|
+
updateProfile.mutate(
|
|
40
|
+
{ full_name: name },
|
|
41
|
+
{
|
|
42
|
+
onSuccess: () => {
|
|
43
|
+
setIsEditing(false);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
handleSave();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="container mx-auto max-w-lg px-4 py-8">
|
|
56
|
+
<SEO
|
|
57
|
+
title={t({ message: 'Profile', comment: 'Profile page title for SEO' })}
|
|
58
|
+
description={t({
|
|
59
|
+
message: 'Manage your profile information',
|
|
60
|
+
comment: 'Profile page meta description for SEO',
|
|
61
|
+
})}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<Card>
|
|
65
|
+
<CardHeader>
|
|
66
|
+
<CardTitle>
|
|
67
|
+
<Trans comment="Profile page card title">Your Profile</Trans>
|
|
68
|
+
</CardTitle>
|
|
69
|
+
<CardDescription>
|
|
70
|
+
<Trans comment="Profile page card description">Manage your profile information stored in Supabase</Trans>
|
|
71
|
+
</CardDescription>
|
|
72
|
+
</CardHeader>
|
|
73
|
+
<CardContent>
|
|
74
|
+
{isLoading && (
|
|
75
|
+
<div className="space-y-4">
|
|
76
|
+
<div className="flex items-center gap-4">
|
|
77
|
+
<Skeleton className="size-16 rounded-full" />
|
|
78
|
+
<div className="space-y-2">
|
|
79
|
+
<Skeleton className="h-4 w-32" />
|
|
80
|
+
<Skeleton className="h-4 w-48" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<Skeleton className="h-10 w-full" />
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{error && (
|
|
88
|
+
<div className="space-y-4">
|
|
89
|
+
<p className="text-destructive text-sm" role="alert">
|
|
90
|
+
<Trans comment="Error message when profile fails to load">
|
|
91
|
+
Failed to load profile: {fetchErrorMessage}
|
|
92
|
+
</Trans>
|
|
93
|
+
</p>
|
|
94
|
+
<Button variant="outline" onClick={() => refetch()}>
|
|
95
|
+
<Trans comment="Retry button label">Try Again</Trans>
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{profile && (
|
|
101
|
+
<div className="space-y-6">
|
|
102
|
+
{/* Avatar and Email */}
|
|
103
|
+
<div className="flex items-center gap-4">
|
|
104
|
+
{profile.avatar_url ? (
|
|
105
|
+
<img
|
|
106
|
+
src={profile.avatar_url}
|
|
107
|
+
alt={t({ message: 'Profile avatar', comment: 'Alt text for profile avatar image' })}
|
|
108
|
+
className="size-16 rounded-full object-cover"
|
|
109
|
+
/>
|
|
110
|
+
) : (
|
|
111
|
+
<div className="bg-muted flex size-16 items-center justify-center rounded-full">
|
|
112
|
+
<span className="text-muted-foreground text-xl">
|
|
113
|
+
{profile.full_name?.[0]?.toUpperCase() ?? profile.email[0].toUpperCase()}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
<div>
|
|
118
|
+
<p className="font-medium">{profile.full_name ?? profile.email}</p>
|
|
119
|
+
<p className="text-muted-foreground text-sm">{profile.email}</p>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Name Editor */}
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<Label htmlFor="full-name">
|
|
126
|
+
<Trans comment="Full name field label">Full Name</Trans>
|
|
127
|
+
</Label>
|
|
128
|
+
{isEditing ? (
|
|
129
|
+
<form onSubmit={handleSubmit} className="space-y-2">
|
|
130
|
+
<Input
|
|
131
|
+
id="full-name"
|
|
132
|
+
value={name}
|
|
133
|
+
onChange={(e) => setName(e.target.value)}
|
|
134
|
+
placeholder={t({ message: 'Enter your name', comment: 'Name input placeholder' })}
|
|
135
|
+
/>
|
|
136
|
+
<div className="flex gap-2">
|
|
137
|
+
<Button type="submit" disabled={updateProfile.isPending}>
|
|
138
|
+
{updateProfile.isPending ? (
|
|
139
|
+
<Trans comment="Save button loading state">Saving...</Trans>
|
|
140
|
+
) : (
|
|
141
|
+
<Trans comment="Save button label">Save</Trans>
|
|
142
|
+
)}
|
|
143
|
+
</Button>
|
|
144
|
+
<Button type="button" variant="outline" onClick={handleCancel} disabled={updateProfile.isPending}>
|
|
145
|
+
<Trans comment="Cancel button label">Cancel</Trans>
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
{updateProfile.error && (
|
|
149
|
+
<p className="text-destructive text-sm" role="alert">
|
|
150
|
+
<Trans comment="Error message when update fails">Failed to update: {updateErrorMessage}</Trans>
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
</form>
|
|
154
|
+
) : (
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<p className="text-muted-foreground">
|
|
157
|
+
{profile.full_name || <Trans comment="Placeholder when no name is set">Not set</Trans>}
|
|
158
|
+
</p>
|
|
159
|
+
<Button variant="ghost" size="sm" onClick={handleEdit}>
|
|
160
|
+
<Trans comment="Edit button label">Edit</Trans>
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</CardContent>
|
|
168
|
+
</Card>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|
|
2
2
|
import { devtools, persist } from 'zustand/middleware';
|
|
3
3
|
|
|
4
4
|
import { createSelectors } from '@/lib/createSelectors';
|
|
5
|
+
import { env } from '@/lib/env';
|
|
5
6
|
import { STORAGE_KEYS } from '@/lib/storageKeys';
|
|
6
7
|
|
|
7
8
|
export type Theme = 'light' | 'dark' | 'system';
|
|
@@ -75,7 +76,7 @@ const usePreferencesStoreBase = create<PreferencesState>()(
|
|
|
75
76
|
},
|
|
76
77
|
},
|
|
77
78
|
),
|
|
78
|
-
{ name: 'preferences', enabled:
|
|
79
|
+
{ name: 'preferences', enabled: env.DEV },
|
|
79
80
|
),
|
|
80
81
|
);
|
|
81
82
|
|
|
@@ -1,39 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clerk authentication mocks for testing.
|
|
3
|
+
*
|
|
4
|
+
* Provides mock implementations of Clerk components and hooks,
|
|
5
|
+
* with state controls for testing different auth scenarios.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import type { ReactNode } from 'react';
|
|
2
9
|
|
|
10
|
+
import { MOCK_AUTH_TOKEN, MOCK_SESSION_ID } from '@/mocks/constants';
|
|
11
|
+
import { defaultUser, type MockUser } from '@/mocks/fixtures/users';
|
|
12
|
+
|
|
13
|
+
// Re-export user fixtures for test convenience
|
|
14
|
+
export { createUser, createUsers, defaultUser, mockUsers, type MockUser } from '@/mocks/fixtures/users';
|
|
15
|
+
|
|
3
16
|
// =============================================================================
|
|
4
|
-
//
|
|
17
|
+
// Types
|
|
5
18
|
// =============================================================================
|
|
6
19
|
|
|
7
20
|
interface ClerkMockState {
|
|
8
21
|
isSignedIn: boolean;
|
|
9
22
|
isLoaded: boolean;
|
|
23
|
+
user: MockUser;
|
|
10
24
|
}
|
|
11
25
|
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Default State
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
12
30
|
const defaultState: ClerkMockState = {
|
|
13
31
|
isSignedIn: true,
|
|
14
32
|
isLoaded: true,
|
|
33
|
+
user: defaultUser,
|
|
15
34
|
};
|
|
16
35
|
|
|
17
36
|
let mockState: ClerkMockState = { ...defaultState };
|
|
18
37
|
|
|
19
38
|
// =============================================================================
|
|
20
|
-
//
|
|
39
|
+
// State Controls
|
|
21
40
|
// =============================================================================
|
|
22
41
|
|
|
23
|
-
|
|
42
|
+
/** Set whether the user is signed in */
|
|
43
|
+
export function setMockClerkSignedIn(value: boolean) {
|
|
24
44
|
mockState.isSignedIn = value;
|
|
25
45
|
}
|
|
26
46
|
|
|
27
|
-
|
|
47
|
+
/** Set whether Clerk has finished loading */
|
|
48
|
+
export function setMockClerkLoaded(value: boolean) {
|
|
28
49
|
mockState.isLoaded = value;
|
|
29
50
|
}
|
|
30
51
|
|
|
52
|
+
/** Set multiple Clerk state values at once */
|
|
31
53
|
export function setMockClerkState(state: Partial<ClerkMockState>) {
|
|
32
54
|
mockState = { ...mockState, ...state };
|
|
33
55
|
}
|
|
34
56
|
|
|
57
|
+
/** Set mock user properties */
|
|
58
|
+
export function setMockClerkUser(user: Partial<MockUser>) {
|
|
59
|
+
mockState.user = { ...mockState.user, ...user };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reset all Clerk mocks to default state */
|
|
35
63
|
export function resetClerkMocks() {
|
|
36
|
-
mockState = { ...defaultState };
|
|
64
|
+
mockState = { ...defaultState, user: { ...defaultUser } };
|
|
37
65
|
}
|
|
38
66
|
|
|
39
67
|
// =============================================================================
|
|
@@ -83,15 +111,27 @@ export function useAuth() {
|
|
|
83
111
|
return {
|
|
84
112
|
isLoaded: mockState.isLoaded,
|
|
85
113
|
isSignedIn: mockState.isSignedIn,
|
|
86
|
-
userId: mockState.isSignedIn ?
|
|
87
|
-
sessionId: mockState.isSignedIn ?
|
|
88
|
-
getToken: async () => (mockState.isSignedIn ?
|
|
114
|
+
userId: mockState.isSignedIn ? mockState.user.id : null,
|
|
115
|
+
sessionId: mockState.isSignedIn ? MOCK_SESSION_ID : null,
|
|
116
|
+
getToken: async () => (mockState.isSignedIn ? MOCK_AUTH_TOKEN : null),
|
|
89
117
|
};
|
|
90
118
|
}
|
|
91
119
|
|
|
92
120
|
export function useUser() {
|
|
93
121
|
return {
|
|
94
122
|
isLoaded: mockState.isLoaded,
|
|
95
|
-
user: mockState.isSignedIn ?
|
|
123
|
+
user: mockState.isSignedIn ? mockState.user : null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function useSession() {
|
|
128
|
+
return {
|
|
129
|
+
isLoaded: mockState.isLoaded,
|
|
130
|
+
session: mockState.isSignedIn
|
|
131
|
+
? {
|
|
132
|
+
id: MOCK_SESSION_ID,
|
|
133
|
+
getToken: async () => MOCK_AUTH_TOKEN,
|
|
134
|
+
}
|
|
135
|
+
: null,
|
|
96
136
|
};
|
|
97
137
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mock a successful fetch response with JSON data.
|
|
5
|
+
*
|
|
6
|
+
* @remarks Requires `vi.spyOn(global, 'fetch')` in beforeEach
|
|
7
|
+
*/
|
|
8
|
+
export function mockFetchSuccess<T>(data: T, status = 200): void {
|
|
9
|
+
vi.mocked(global.fetch).mockResolvedValueOnce({
|
|
10
|
+
ok: status >= 200 && status < 300,
|
|
11
|
+
status,
|
|
12
|
+
json: () => Promise.resolve(data),
|
|
13
|
+
} as Response);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mock a 204 No Content response.
|
|
18
|
+
*
|
|
19
|
+
* @remarks Requires `vi.spyOn(global, 'fetch')` in beforeEach
|
|
20
|
+
*/
|
|
21
|
+
export function mockFetchNoContent(): void {
|
|
22
|
+
vi.mocked(global.fetch).mockResolvedValueOnce({
|
|
23
|
+
ok: true,
|
|
24
|
+
status: 204,
|
|
25
|
+
} as Response);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mock an error response (4xx/5xx).
|
|
30
|
+
*
|
|
31
|
+
* @remarks Requires `vi.spyOn(global, 'fetch')` in beforeEach
|
|
32
|
+
*/
|
|
33
|
+
export function mockFetchError(status: number, message: string, hasJson = true): void {
|
|
34
|
+
vi.mocked(global.fetch).mockResolvedValueOnce({
|
|
35
|
+
ok: false,
|
|
36
|
+
status,
|
|
37
|
+
statusText: message,
|
|
38
|
+
json: hasJson ? () => Promise.resolve({ message }) : () => Promise.reject(new Error('No JSON')),
|
|
39
|
+
} as Response);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mock a network error (fetch rejection).
|
|
44
|
+
*
|
|
45
|
+
* @remarks Requires `vi.spyOn(global, 'fetch')` in beforeEach
|
|
46
|
+
*/
|
|
47
|
+
export function mockFetchNetworkError(message = 'Network failure'): void {
|
|
48
|
+
vi.mocked(global.fetch).mockRejectedValueOnce(new Error(message));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Mock an unknown error (non-Error rejection).
|
|
53
|
+
*
|
|
54
|
+
* @remarks Requires `vi.spyOn(global, 'fetch')` in beforeEach
|
|
55
|
+
*/
|
|
56
|
+
export function mockFetchUnknownError(rejection: unknown = 'string error'): void {
|
|
57
|
+
vi.mocked(global.fetch).mockRejectedValueOnce(rejection);
|
|
58
|
+
}
|
|
@@ -1,11 +1,57 @@
|
|
|
1
1
|
// Custom render with all providers
|
|
2
2
|
export { createTestQueryClient, render } from './providers';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
export {
|
|
4
|
+
// Browser API mocks
|
|
5
|
+
export {
|
|
6
|
+
mockAnimationFrame,
|
|
7
|
+
mockMatchMedia,
|
|
8
|
+
mockScrollTo,
|
|
9
|
+
mockStorageRemoveItemError,
|
|
10
|
+
mockStorageSetItemError,
|
|
11
|
+
silenceConsoleError,
|
|
12
|
+
silenceConsoleLog,
|
|
13
|
+
silenceConsoleWarn,
|
|
14
|
+
} from './mocks';
|
|
6
15
|
|
|
7
16
|
// Clerk test utilities
|
|
8
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
createUser,
|
|
19
|
+
createUsers,
|
|
20
|
+
mockUsers,
|
|
21
|
+
resetClerkMocks,
|
|
22
|
+
setMockClerkLoaded,
|
|
23
|
+
setMockClerkSignedIn,
|
|
24
|
+
setMockClerkState,
|
|
25
|
+
setMockClerkUser,
|
|
26
|
+
type MockUser,
|
|
27
|
+
} from './clerkMock';
|
|
28
|
+
|
|
29
|
+
// Supabase test utilities
|
|
30
|
+
export {
|
|
31
|
+
createMockSupabaseClient,
|
|
32
|
+
createProfile,
|
|
33
|
+
createProfiles,
|
|
34
|
+
mockProfiles,
|
|
35
|
+
resetSupabaseMocks,
|
|
36
|
+
setMockSupabaseData,
|
|
37
|
+
setMockSupabaseError,
|
|
38
|
+
type Profile,
|
|
39
|
+
} from './supabaseMock';
|
|
40
|
+
|
|
41
|
+
// Fetch mock utilities
|
|
42
|
+
export {
|
|
43
|
+
mockFetchError,
|
|
44
|
+
mockFetchNetworkError,
|
|
45
|
+
mockFetchNoContent,
|
|
46
|
+
mockFetchSuccess,
|
|
47
|
+
mockFetchUnknownError,
|
|
48
|
+
} from './fetchMock';
|
|
9
49
|
|
|
10
50
|
// MSW server instance
|
|
11
51
|
export { server } from '@/mocks/node';
|
|
52
|
+
|
|
53
|
+
// Todo fixtures
|
|
54
|
+
export { createTodo, createTodos, mockTodos, type Todo } from '@/mocks/fixtures/todos';
|
|
55
|
+
|
|
56
|
+
// Shared test constants
|
|
57
|
+
export { MOCK_AUTH_TOKEN, MOCK_SESSION_ID, MOCK_SUPABASE_URL, MOCK_TIMESTAMPS, MOCK_USER } from '@/mocks/constants';
|
|
@@ -1,8 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser API mocks for testing.
|
|
3
|
+
*
|
|
4
|
+
* Provides reusable mock implementations for browser APIs
|
|
5
|
+
* that are commonly needed across tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import { vi } from 'vitest';
|
|
2
9
|
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Media Query Mocks
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
3
14
|
/**
|
|
4
15
|
* Creates a mock for window.matchMedia.
|
|
5
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* beforeEach(() => {
|
|
20
|
+
* window.matchMedia = mockMatchMedia(true); // matches query
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
6
23
|
*/
|
|
7
24
|
export const mockMatchMedia = (matches: boolean) =>
|
|
8
25
|
vi.fn().mockImplementation((query: string) => ({
|
|
@@ -15,3 +32,113 @@ export const mockMatchMedia = (matches: boolean) =>
|
|
|
15
32
|
removeListener: vi.fn(),
|
|
16
33
|
dispatchEvent: vi.fn(),
|
|
17
34
|
}));
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Console Mocks
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Silence console.error during a test.
|
|
42
|
+
* Returns a spy that can be restored with `.mockRestore()`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* it('handles error gracefully', () => {
|
|
47
|
+
* const spy = silenceConsoleError();
|
|
48
|
+
* // ... test that triggers console.error
|
|
49
|
+
* spy.mockRestore();
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function silenceConsoleError() {
|
|
54
|
+
return vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Silence console.warn during a test.
|
|
59
|
+
*/
|
|
60
|
+
export function silenceConsoleWarn() {
|
|
61
|
+
return vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Silence console.log during a test.
|
|
66
|
+
*/
|
|
67
|
+
export function silenceConsoleLog() {
|
|
68
|
+
return vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Animation Frame Mocks
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates mocks for requestAnimationFrame and cancelAnimationFrame.
|
|
77
|
+
* Returns the callback capture for manual triggering.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* let rafCallback: FrameRequestCallback | null = null;
|
|
82
|
+
* beforeEach(() => {
|
|
83
|
+
* rafCallback = mockAnimationFrame();
|
|
84
|
+
* });
|
|
85
|
+
*
|
|
86
|
+
* it('updates on animation frame', () => {
|
|
87
|
+
* // trigger state change
|
|
88
|
+
* act(() => rafCallback?.(0));
|
|
89
|
+
* // assert new state
|
|
90
|
+
* });
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function mockAnimationFrame() {
|
|
94
|
+
let callback: FrameRequestCallback | null = null;
|
|
95
|
+
|
|
96
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
|
97
|
+
callback = cb;
|
|
98
|
+
return 1;
|
|
99
|
+
});
|
|
100
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
|
101
|
+
|
|
102
|
+
return () => callback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// Scroll Mocks
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mock window.scrollTo for tests.
|
|
111
|
+
* Returns a spy for assertions.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* const scrollSpy = mockScrollTo();
|
|
116
|
+
* // trigger scroll
|
|
117
|
+
* expect(scrollSpy).toHaveBeenCalledWith(0, 0);
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function mockScrollTo() {
|
|
121
|
+
return vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// =============================================================================
|
|
125
|
+
// Storage Mocks
|
|
126
|
+
// =============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mock localStorage.setItem to throw (simulate quota exceeded).
|
|
130
|
+
*/
|
|
131
|
+
export function mockStorageSetItemError() {
|
|
132
|
+
return vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
133
|
+
throw new Error('QuotaExceeded');
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Mock localStorage.removeItem to throw.
|
|
139
|
+
*/
|
|
140
|
+
export function mockStorageRemoveItemError() {
|
|
141
|
+
return vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
|
142
|
+
throw new Error('Storage error');
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -8,6 +8,7 @@ import { MemoryRouter } from 'react-router';
|
|
|
8
8
|
import { ClerkThemeProvider } from '@/contexts/clerkContext';
|
|
9
9
|
import { MobileProvider } from '@/contexts/mobileContext';
|
|
10
10
|
import { PerformanceProviderWrapper } from '@/contexts/performanceContext';
|
|
11
|
+
import { SupabaseProvider } from '@/contexts/supabaseContext';
|
|
11
12
|
|
|
12
13
|
// Setup empty English catalog for tests
|
|
13
14
|
i18n.loadAndActivate({ locale: 'en', messages: {} });
|
|
@@ -38,15 +39,17 @@ interface WrapperProps {
|
|
|
38
39
|
function AllProviders({ children }: WrapperProps) {
|
|
39
40
|
const queryClient = createTestQueryClient();
|
|
40
41
|
|
|
41
|
-
// Provider order matches main.tsx: Query > I18n > Router > Clerk > Mobile > Performance
|
|
42
|
+
// Provider order matches main.tsx: Query > I18n > Router > Clerk > Supabase > Mobile > Performance
|
|
42
43
|
return (
|
|
43
44
|
<QueryClientProvider client={queryClient}>
|
|
44
45
|
<I18nProvider i18n={i18n}>
|
|
45
46
|
<MemoryRouter>
|
|
46
47
|
<ClerkThemeProvider publishableKey="test_key">
|
|
47
|
-
<
|
|
48
|
-
<
|
|
49
|
-
|
|
48
|
+
<SupabaseProvider>
|
|
49
|
+
<MobileProvider>
|
|
50
|
+
<PerformanceProviderWrapper>{children}</PerformanceProviderWrapper>
|
|
51
|
+
</MobileProvider>
|
|
52
|
+
</SupabaseProvider>
|
|
50
53
|
</ClerkThemeProvider>
|
|
51
54
|
</MemoryRouter>
|
|
52
55
|
</I18nProvider>
|