@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.
Files changed (112) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +3 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/features/definitions/database.d.ts +3 -0
  6. package/dist/features/definitions/database.d.ts.map +1 -0
  7. package/dist/features/definitions/database.js +45 -0
  8. package/dist/features/definitions/database.js.map +1 -0
  9. package/dist/features/definitions/deployment.d.ts +3 -0
  10. package/dist/features/definitions/deployment.d.ts.map +1 -0
  11. package/dist/features/definitions/deployment.js +14 -0
  12. package/dist/features/definitions/deployment.js.map +1 -0
  13. package/dist/features/definitions/index.d.ts +2 -0
  14. package/dist/features/definitions/index.d.ts.map +1 -1
  15. package/dist/features/definitions/index.js +2 -0
  16. package/dist/features/definitions/index.js.map +1 -1
  17. package/dist/features/registry.d.ts.map +1 -1
  18. package/dist/features/registry.js +3 -1
  19. package/dist/features/registry.js.map +1 -1
  20. package/dist/features/types.test.js +4 -2
  21. package/dist/features/types.test.js.map +1 -1
  22. package/dist/resources/docs.d.ts.map +1 -1
  23. package/dist/resources/docs.js +5 -0
  24. package/dist/resources/docs.js.map +1 -1
  25. package/dist/tools/add-features.js +1 -1
  26. package/dist/tools/add-features.js.map +1 -1
  27. package/dist/utils/docs.d.ts.map +1 -1
  28. package/dist/utils/docs.js +2 -0
  29. package/dist/utils/docs.js.map +1 -1
  30. package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
  31. package/dist/utils/scaffold/claude-md/index.js +3 -1
  32. package/dist/utils/scaffold/claude-md/index.js.map +1 -1
  33. package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
  34. package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
  35. package/dist/utils/scaffold/claude-md/sections.js +132 -2
  36. package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
  37. package/dist/utils/scaffold/compute.js +1 -1
  38. package/dist/utils/scaffold/compute.js.map +1 -1
  39. package/dist/utils/scaffold/generators.d.ts +2 -2
  40. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  41. package/dist/utils/scaffold/generators.js +57 -22
  42. package/dist/utils/scaffold/generators.js.map +1 -1
  43. package/package.json +1 -1
  44. package/templates/.env.example +40 -12
  45. package/templates/.github/workflows/ci.yml +4 -1
  46. package/templates/.github/workflows/deploy.yml +59 -0
  47. package/templates/CLAUDE.md +177 -1
  48. package/templates/docs/AUTHENTICATION.md +325 -0
  49. package/templates/docs/DEPLOYMENT.md +268 -0
  50. package/templates/docs/E2E_TESTING.md +81 -4
  51. package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
  52. package/templates/docs/TESTING.md +195 -77
  53. package/templates/e2e/auth/auth.setup.ts +60 -0
  54. package/templates/e2e/fixtures/index.ts +11 -0
  55. package/templates/e2e/tests/profile.auth.spec.ts +103 -0
  56. package/templates/e2e/tests/profile.spec.ts +64 -0
  57. package/templates/e2e/tests/register-form.spec.ts +38 -0
  58. package/templates/gitignore +5 -0
  59. package/templates/package.json +8 -0
  60. package/templates/playwright.config.ts +33 -3
  61. package/templates/src/App.tsx +32 -19
  62. package/templates/src/components/layout/Header.test.tsx +17 -1
  63. package/templates/src/components/layout/Header.tsx +11 -0
  64. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +3 -3
  65. package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
  66. package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
  67. package/templates/src/components/shared/ProfileSync/index.ts +1 -0
  68. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +3 -3
  69. package/templates/src/components/shared/index.ts +1 -0
  70. package/templates/src/contexts/performanceContext.tsx +3 -3
  71. package/templates/src/contexts/supabaseContext.test.tsx +59 -0
  72. package/templates/src/contexts/supabaseContext.tsx +87 -0
  73. package/templates/src/hooks/index.ts +17 -0
  74. package/templates/src/hooks/supabase/index.ts +12 -0
  75. package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
  76. package/templates/src/hooks/supabase/useProfiles.ts +213 -0
  77. package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
  78. package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
  79. package/templates/src/lib/api.test.ts +30 -38
  80. package/templates/src/lib/api.ts +1 -7
  81. package/templates/src/lib/config.ts +54 -4
  82. package/templates/src/lib/env.ts +36 -14
  83. package/templates/src/lib/index.ts +4 -2
  84. package/templates/src/lib/routes.ts +1 -0
  85. package/templates/src/lib/sentry.ts +13 -10
  86. package/templates/src/lib/supabase/client.ts +58 -0
  87. package/templates/src/lib/supabase/index.ts +5 -0
  88. package/templates/src/main.tsx +17 -39
  89. package/templates/src/mocks/constants.ts +31 -0
  90. package/templates/src/mocks/fixtures/index.ts +3 -1
  91. package/templates/src/mocks/fixtures/profiles.ts +55 -0
  92. package/templates/src/mocks/fixtures/users.ts +91 -0
  93. package/templates/src/mocks/handlers/index.ts +2 -1
  94. package/templates/src/mocks/handlers/supabase.ts +64 -0
  95. package/templates/src/mocks/handlers/todos.ts +1 -1
  96. package/templates/src/mocks/index.ts +6 -0
  97. package/templates/src/pages/Profile.test.tsx +263 -0
  98. package/templates/src/pages/Profile.tsx +171 -0
  99. package/templates/src/pages/index.ts +1 -0
  100. package/templates/src/stores/preferencesStore.ts +2 -1
  101. package/templates/src/test/clerkMock.tsx +49 -9
  102. package/templates/src/test/fetchMock.ts +58 -0
  103. package/templates/src/test/index.ts +49 -3
  104. package/templates/src/test/mocks.ts +128 -1
  105. package/templates/src/test/providers.tsx +7 -4
  106. package/templates/src/test/supabaseMock.ts +112 -0
  107. package/templates/src/test-setup.ts +26 -0
  108. package/templates/src/types/database.ts +46 -0
  109. package/templates/src/types/index.ts +1 -0
  110. package/templates/src/types/supabase.ts +167 -0
  111. package/templates/src/vite-env.d.ts +6 -0
  112. package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
@@ -0,0 +1,310 @@
1
+ # Supabase Integration
2
+
3
+ Clerk-Supabase integration architecture, RLS patterns, and database operations.
4
+ For quick-start usage and hooks, see [CLAUDE.md](../CLAUDE.md#database-supabase).
5
+
6
+ ---
7
+
8
+ ## Architecture
9
+
10
+ ```
11
+ ClerkProvider
12
+
13
+
14
+ useSession().getToken() ──► Clerk JWT (role: authenticated)
15
+
16
+
17
+ SupabaseProvider
18
+
19
+
20
+ createClient({ accessToken: getToken }) ──► Supabase API
21
+
22
+
23
+ PostgreSQL + Row Level Security (RLS)
24
+ └── auth.uid() = Clerk user_id (JWT sub claim)
25
+ ```
26
+
27
+ **Key Design Decisions:**
28
+
29
+ - **No Supabase Auth** - Clerk handles authentication; Supabase validates JWTs via third-party provider
30
+ - **Modern `accessToken` pattern** - No JWT templates needed (post April 2025)
31
+ - **Automatic `role` claim** - Clerk integration adds `"role": "authenticated"` to JWTs, enabling RLS `TO authenticated` policies
32
+ - **Session-based client** - Client recreates only on `session?.id` change, not every render
33
+ - **TanStack Query integration** - All data fetching uses React Query for caching/invalidation
34
+ - **RLS enforcement** - Security at database level via `auth.uid()` = Clerk `user_id`
35
+
36
+ ---
37
+
38
+ ## Setup
39
+
40
+ ### 1. Enable Clerk as Third-Party Auth Provider
41
+
42
+ 1. **Clerk Dashboard** → Integrations → Supabase → Activate
43
+ 2. Copy your **Clerk domain** (e.g., `your-app.clerk.accounts.dev`)
44
+ 3. **Supabase Dashboard** → Authentication → Sign In/Up → Add provider → Clerk
45
+ 4. Paste your Clerk domain
46
+
47
+ ### 2. Environment Variables
48
+
49
+ | Variable | Description |
50
+ | ---------------------------- | ------------------------------------------- |
51
+ | `VITE_SUPABASE_DATABASE_URL` | `https://your-project.supabase.co` |
52
+ | `VITE_SUPABASE_ANON_KEY` | Client-side API key (respects RLS) |
53
+ | `SUPABASE_PROJECT_ID` | For `npm run db:types` (subdomain from URL) |
54
+
55
+ Both URL and key must be set together. Find them in Supabase Dashboard → Project Settings → Data API.
56
+
57
+ ### 3. Provider Hierarchy
58
+
59
+ ```tsx
60
+ // main.tsx - SupabaseProvider MUST be inside ClerkProvider
61
+ <ClerkProvider>
62
+ <SupabaseProvider>
63
+ <App />
64
+ </SupabaseProvider>
65
+ </ClerkProvider>
66
+ ```
67
+
68
+ ---
69
+
70
+ ## File Structure
71
+
72
+ ```
73
+ src/
74
+ ├── lib/supabase/
75
+ │ └── client.ts # createSupabaseClient(getToken)
76
+ ├── contexts/
77
+ │ └── supabaseContext.tsx # SupabaseProvider + useSupabase hook
78
+ ├── hooks/supabase/
79
+ │ ├── useSupabaseQuery.ts # Generic SELECT with TanStack Query
80
+ │ └── useProfiles.ts # Profile CRUD hooks
81
+ ├── types/
82
+ │ ├── supabase.ts # Auto-generated (npm run db:types)
83
+ │ └── database.ts # Convenience aliases (Profile, etc.)
84
+ └── components/shared/
85
+ └── ProfileSync/ # Auto-sync Clerk user to Supabase
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Database & RLS
91
+
92
+ ### Complete Table Example
93
+
94
+ ```sql
95
+ -- Create table with user_id linked to Clerk
96
+ CREATE TABLE profiles (
97
+ id TEXT PRIMARY KEY, -- Clerk user_id (auth.uid())
98
+ email TEXT NOT NULL,
99
+ full_name TEXT,
100
+ avatar_url TEXT,
101
+ created_at TIMESTAMPTZ DEFAULT NOW(),
102
+ updated_at TIMESTAMPTZ DEFAULT NOW()
103
+ );
104
+
105
+ -- Enable Row Level Security
106
+ ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
107
+
108
+ -- RLS Policies (all four CRUD operations)
109
+ CREATE POLICY "Users can view own profile"
110
+ ON profiles FOR SELECT TO authenticated
111
+ USING (id = (auth.uid())::text);
112
+
113
+ CREATE POLICY "Users can insert own profile"
114
+ ON profiles FOR INSERT TO authenticated
115
+ WITH CHECK (id = (auth.uid())::text);
116
+
117
+ CREATE POLICY "Users can update own profile"
118
+ ON profiles FOR UPDATE TO authenticated
119
+ USING (id = (auth.uid())::text)
120
+ WITH CHECK (id = (auth.uid())::text);
121
+
122
+ CREATE POLICY "Users can delete own profile"
123
+ ON profiles FOR DELETE TO authenticated
124
+ USING (id = (auth.uid())::text);
125
+
126
+ -- Auto-update timestamp trigger
127
+ CREATE OR REPLACE FUNCTION update_updated_at()
128
+ RETURNS TRIGGER AS $$
129
+ BEGIN
130
+ NEW.updated_at = NOW();
131
+ RETURN NEW;
132
+ END;
133
+ $$ LANGUAGE plpgsql;
134
+
135
+ CREATE TRIGGER profiles_updated_at
136
+ BEFORE UPDATE ON profiles
137
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at();
138
+ ```
139
+
140
+ ### RLS Policy Template
141
+
142
+ For tables where `user_id` is NOT the primary key:
143
+
144
+ ```sql
145
+ CREATE TABLE tasks (
146
+ id SERIAL PRIMARY KEY,
147
+ name TEXT NOT NULL,
148
+ user_id TEXT NOT NULL DEFAULT auth.jwt()->>'sub', -- Auto-set from Clerk
149
+ created_at TIMESTAMPTZ DEFAULT NOW()
150
+ );
151
+
152
+ ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
153
+
154
+ -- Apply this pattern for each operation (SELECT, INSERT, UPDATE, DELETE)
155
+ CREATE POLICY "Users can select own tasks"
156
+ ON tasks FOR SELECT TO authenticated
157
+ USING (user_id = (auth.uid())::text);
158
+
159
+ CREATE POLICY "Users can insert own tasks"
160
+ ON tasks FOR INSERT TO authenticated
161
+ WITH CHECK (user_id = (auth.uid())::text);
162
+
163
+ -- UPDATE needs both USING (existing rows) and WITH CHECK (new values)
164
+ -- DELETE only needs USING
165
+ ```
166
+
167
+ ### SQL Functions Reference
168
+
169
+ | Function | Returns | Use Case |
170
+ | --------------------- | ------- | ---------------------------------------------- |
171
+ | `auth.uid()` | UUID | Primary comparison (`::text` for TEXT columns) |
172
+ | `auth.jwt()` | JSON | Access full JWT claims |
173
+ | `auth.jwt()->>'sub'` | TEXT | User ID directly as text |
174
+ | `(select auth.uid())` | UUID | Performance optimization in RLS |
175
+
176
+ ### MCP Tools for Database Operations
177
+
178
+ | Tool | Purpose |
179
+ | ------------------------------------------ | ------------------------------------ |
180
+ | `mcp__supabase__apply_migration` | Apply DDL (CREATE, ALTER, DROP) |
181
+ | `mcp__supabase__execute_sql` | Run queries (SELECT, INSERT, UPDATE) |
182
+ | `mcp__supabase__list_tables` | List all tables in schemas |
183
+ | `mcp__supabase__list_migrations` | View applied migrations |
184
+ | `mcp__supabase__get_advisors` | Check security/performance issues |
185
+ | `mcp__supabase__generate_typescript_types` | Generate types from schema |
186
+
187
+ ### Type Generation
188
+
189
+ After schema changes:
190
+
191
+ ```bash
192
+ npm run db:types # Regenerates src/types/supabase.ts
193
+ ```
194
+
195
+ Add convenience aliases in `src/types/database.ts`:
196
+
197
+ ```typescript
198
+ export type Profile = Tables<'profiles'>;
199
+ export type ProfileInsert = TablesInsert<'profiles'>;
200
+ export type ProfileUpdate = TablesUpdate<'profiles'>;
201
+ ```
202
+
203
+ ---
204
+
205
+ ## React Integration
206
+
207
+ ### Hooks Reference
208
+
209
+ | Hook | Purpose | Returns |
210
+ | --------------------------- | --------------------------- | -------------------------------- |
211
+ | `useSupabase()` | Direct client access | `TypedSupabaseClient` |
212
+ | `useSupabaseQuery(options)` | Generic SELECT with caching | `UseQueryResult<T[]>` |
213
+ | `useProfile()` | Current user's profile | `{ profile, exists, isLoading }` |
214
+ | `useCurrentProfile()` | Raw profile query | `UseQueryResult<Profile[]>` |
215
+ | `useUpsertProfile()` | Create/update profile | `UseMutationResult` |
216
+ | `useUpdateProfile()` | Update current profile | `UseMutationResult` |
217
+ | `useDeleteProfile()` | Delete current profile | `UseMutationResult` |
218
+
219
+ **`useSupabaseQuery` options:** `{ table, queryKey, select?, filter?, queryOptions? }`
220
+
221
+ **Query key pattern:** All queries use `['supabase', table, ...queryKey]` for cache invalidation.
222
+
223
+ For usage examples, see [CLAUDE.md](../CLAUDE.md#database-supabase).
224
+
225
+ ### ProfileSync Component
226
+
227
+ Automatically syncs Clerk user data to Supabase on sign-in:
228
+
229
+ ```tsx
230
+ import { ProfileSync } from '@/components/shared';
231
+
232
+ function App() {
233
+ return (
234
+ <>
235
+ <ProfileSync /> {/* Optional: onSyncComplete, onSyncError callbacks */}
236
+ <Routes>...</Routes>
237
+ </>
238
+ );
239
+ }
240
+ ```
241
+
242
+ **Behavior:** Creates profile on first login, updates if email changed, runs once per mount.
243
+
244
+ ---
245
+
246
+ ## Testing
247
+
248
+ ### Mock Utilities
249
+
250
+ Import from `@/test`:
251
+
252
+ | Utility | Purpose |
253
+ | ----------------------------------------- | ---------------------------------------- |
254
+ | `setMockSupabaseData(data[])` | Set data to return |
255
+ | `setMockSupabaseError({ message, code })` | Simulate error |
256
+ | `resetSupabaseMocks()` | Reset to defaults (call in `beforeEach`) |
257
+ | `createProfile(overrides?)` | Create mock profile |
258
+ | `mockProfiles` | Pre-built profile array |
259
+
260
+ For test examples, see [CLAUDE.md](../CLAUDE.md#database-supabase).
261
+
262
+ ---
263
+
264
+ ## Security
265
+
266
+ - **Always enable RLS** on every table
267
+ - **Use `anon` key** in client code (respects RLS)
268
+ - **Never expose `service_role` key** (bypasses RLS)
269
+ - **Cast `auth.uid()` to text** when comparing with TEXT columns: `(auth.uid())::text`
270
+ - **Use `(select auth.uid())`** in policies for performance (prevents re-evaluation per row)
271
+ - **Test with multiple accounts** to verify users can't access each other's data
272
+
273
+ ---
274
+
275
+ ## Deployment
276
+
277
+ For Netlify deployment with Supabase integration, see [DEPLOYMENT.md](./DEPLOYMENT.md).
278
+
279
+ ---
280
+
281
+ ## Troubleshooting
282
+
283
+ | Issue | Cause | Fix |
284
+ | -------------------------------------------------- | --------------------------------- | ---------------------------------------------------- |
285
+ | "useSupabase must be used within SupabaseProvider" | Component outside provider | Check `main.tsx` provider order |
286
+ | 403 RLS Policy Violation | JWT missing `role: authenticated` | Verify Clerk-Supabase integration enabled |
287
+ | Empty arrays returned | RLS blocking access | Check policies use `auth.uid()` correctly |
288
+ | Stale data after mutations | Missing invalidation | Ensure `invalidateQueries(['supabase', 'profiles'])` |
289
+ | Missing env vars error | Incomplete config | Both URL and key must be set |
290
+
291
+ ### Debug Token Claims
292
+
293
+ ```typescript
294
+ const { session } = useSession();
295
+ const token = await session?.getToken();
296
+ if (token) {
297
+ const payload = JSON.parse(atob(token.split('.')[1]));
298
+ console.log(payload); // { sub: "user_xxx", role: "authenticated", ... }
299
+ }
300
+ ```
301
+
302
+ ---
303
+
304
+ ## Resources
305
+
306
+ - [AUTHENTICATION.md](./AUTHENTICATION.md) - Clerk authentication setup and configuration
307
+ - [Supabase + Clerk Integration Guide](https://supabase.com/docs/guides/auth/third-party/clerk)
308
+ - [Row Level Security Docs](https://supabase.com/docs/guides/auth/row-level-security)
309
+ - [Clerk Supabase Docs](https://clerk.com/docs/integrations/databases/supabase)
310
+ - [Supabase MCP Server](https://supabase.com/docs/guides/getting-started/mcp)
@@ -30,7 +30,180 @@ import { screen, renderHook, act, waitFor, fireEvent } from '@testing-library/re
30
30
  import userEvent from '@testing-library/user-event';
31
31
 
32
32
  // Custom utilities from @/test
33
- import { render, mockMatchMedia, createTestQueryClient, server } from '@/test';
33
+ import { render, server, mockMatchMedia, silenceConsoleError } from '@/test';
34
+ ```
35
+
36
+ ## Mock Infrastructure
37
+
38
+ The codebase uses a unified mocking approach with shared constants and reusable utilities.
39
+
40
+ ### Directory Structure
41
+
42
+ ```
43
+ src/test/ # Test utilities
44
+ ├── index.ts # Public API - import from '@/test'
45
+ ├── clerkMock.tsx # Clerk auth mocks
46
+ ├── supabaseMock.ts # Supabase mocks
47
+ ├── mocks.ts # Browser API mocks
48
+ └── providers.tsx # Test render wrapper
49
+
50
+ src/mocks/ # MSW infrastructure
51
+ ├── constants.ts # Shared test values (MOCK_USER, etc.)
52
+ ├── fixtures/ # Test data factories
53
+ │ ├── profiles.ts # createProfile(), mockProfiles
54
+ │ └── todos.ts # createTodo(), mockTodos
55
+ ├── handlers/ # MSW request handlers
56
+ │ ├── supabase.ts # Supabase API mocks
57
+ │ └── todos.ts # JSONPlaceholder mocks
58
+ └── node.ts # MSW server setup
59
+ ```
60
+
61
+ ### Shared Constants
62
+
63
+ All mocks use shared constants from `@/mocks/constants.ts` to ensure consistency:
64
+
65
+ ```typescript
66
+ import { MOCK_USER, MOCK_SESSION_ID } from '@/test';
67
+
68
+ // MOCK_USER.id = 'user_123'
69
+ // MOCK_USER.email = 'test@example.com'
70
+ // MOCK_USER.fullName = 'Test User'
71
+ ```
72
+
73
+ ### Clerk Mocks
74
+
75
+ Control authentication state in tests:
76
+
77
+ ```typescript
78
+ import { setMockClerkSignedIn, setMockClerkLoaded, resetClerkMocks } from '@/test';
79
+
80
+ beforeEach(() => resetClerkMocks());
81
+
82
+ it('shows sign-in when not authenticated', () => {
83
+ setMockClerkSignedIn(false);
84
+ render(<ProtectedRoute />);
85
+ expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
86
+ });
87
+ ```
88
+
89
+ ### Supabase Mocks
90
+
91
+ Control Supabase query responses:
92
+
93
+ ```typescript
94
+ import { setMockSupabaseData, setMockSupabaseError, createProfile, resetSupabaseMocks } from '@/test';
95
+
96
+ beforeEach(() => resetSupabaseMocks());
97
+
98
+ it('displays profile data', async () => {
99
+ setMockSupabaseData([createProfile({ full_name: 'John Doe' })]);
100
+ render(<ProfileCard />);
101
+ await waitFor(() => expect(screen.getByText('John Doe')).toBeInTheDocument());
102
+ });
103
+
104
+ it('handles database error', async () => {
105
+ setMockSupabaseError({ message: 'Connection failed', code: 'NETWORK_ERROR' });
106
+ render(<ProfileCard />);
107
+ await waitFor(() => expect(screen.getByText(/error/i)).toBeInTheDocument());
108
+ });
109
+ ```
110
+
111
+ ### Browser API Mocks
112
+
113
+ Reusable mocks for common browser APIs:
114
+
115
+ ```typescript
116
+ import { mockMatchMedia, mockScrollTo, mockAnimationFrame, silenceConsoleError, silenceConsoleWarn } from '@/test';
117
+
118
+ // Media queries
119
+ beforeEach(() => {
120
+ window.matchMedia = mockMatchMedia(true); // matches
121
+ });
122
+
123
+ // Scroll behavior
124
+ it('scrolls to top', () => {
125
+ const scrollSpy = mockScrollTo();
126
+ triggerScroll();
127
+ expect(scrollSpy).toHaveBeenCalledWith(0, 0);
128
+ });
129
+
130
+ // Animation frames
131
+ let getCallback: () => FrameRequestCallback | null;
132
+ beforeEach(() => {
133
+ getCallback = mockAnimationFrame();
134
+ });
135
+
136
+ it('updates on animation frame', () => {
137
+ const callback = getCallback();
138
+ act(() => callback?.(0));
139
+ // assert state change
140
+ });
141
+
142
+ // Silence console during error tests
143
+ it('handles error gracefully', () => {
144
+ const spy = silenceConsoleError();
145
+ triggerError();
146
+ expect(handleError).toHaveBeenCalled();
147
+ spy.mockRestore();
148
+ });
149
+ ```
150
+
151
+ ### Fetch Mocking
152
+
153
+ Use `vi.spyOn` for type-safe fetch mocking:
154
+
155
+ ```typescript
156
+ beforeEach(() => {
157
+ vi.spyOn(global, 'fetch');
158
+ });
159
+
160
+ afterEach(() => {
161
+ vi.restoreAllMocks();
162
+ });
163
+
164
+ it('handles API response', async () => {
165
+ vi.mocked(global.fetch).mockResolvedValueOnce({
166
+ ok: true,
167
+ status: 200,
168
+ json: () => Promise.resolve({ data: 'test' }),
169
+ } as Response);
170
+
171
+ const result = await fetchData();
172
+ expect(result.data).toBe('test');
173
+ });
174
+ ```
175
+
176
+ ### MSW (Mock Service Worker)
177
+
178
+ MSW handlers intercept HTTP requests. Override per-test:
179
+
180
+ ```typescript
181
+ import { http, HttpResponse } from 'msw';
182
+ import { server } from '@/test';
183
+
184
+ it('handles API error', async () => {
185
+ server.use(http.get('/api/todos', () => new HttpResponse(null, { status: 500 })));
186
+ // Test error handling...
187
+ });
188
+ ```
189
+
190
+ MSW handlers auto-reset after each test via `src/test-setup.ts`.
191
+
192
+ ### Test Data Factories
193
+
194
+ Use factories to create test data with sensible defaults:
195
+
196
+ ```typescript
197
+ import { createProfile, createTodo } from '@/test';
198
+
199
+ // Override specific fields
200
+ const profile = createProfile({ full_name: 'Custom Name' });
201
+ const todo = createTodo({ completed: true });
202
+
203
+ // Create multiple
204
+ import { createProfiles, createTodos } from '@/mocks/fixtures';
205
+ const profiles = createProfiles(5);
206
+ const todos = createTodos(10, { userId: 1 });
34
207
  ```
35
208
 
36
209
  ## Core Patterns
@@ -50,25 +223,6 @@ it.each([
50
223
  // ❌ Avoid - repetitive individual tests
51
224
  it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 Bytes'));
52
225
  it('formats 1024 bytes', () => expect(formatBytes(1024)).toBe('1 KB'));
53
- it('formats 1MB', () => expect(formatBytes(1024 * 1024)).toBe('1 MB'));
54
- ```
55
-
56
- ### Use Shared Mocks
57
-
58
- ```typescript
59
- import { mockMatchMedia } from '@/test';
60
-
61
- describe('useMediaQuery', () => {
62
- beforeEach(() => {
63
- window.matchMedia = mockMatchMedia(false);
64
- });
65
-
66
- it('detects desktop viewport', () => {
67
- window.matchMedia = mockMatchMedia(true); // matches min-width query
68
- const { result } = renderHook(() => useIsDesktop());
69
- expect(result.current).toBe(true);
70
- });
71
- });
72
226
  ```
73
227
 
74
228
  ### Component Testing
@@ -120,61 +274,6 @@ it('updates after async action', async () => {
120
274
  });
121
275
  ```
122
276
 
123
- ### Mocking
124
-
125
- ```typescript
126
- import { mockMatchMedia } from '@/test';
127
-
128
- // Mock modules at top of file
129
- vi.mock('@/lib/storage', () => ({
130
- setStorageItem: vi.fn(() => true),
131
- }));
132
-
133
- // Mock browser APIs using shared utilities
134
- beforeEach(() => {
135
- window.matchMedia = mockMatchMedia(false);
136
- });
137
-
138
- afterEach(() => {
139
- vi.restoreAllMocks();
140
- });
141
-
142
- // Mock fetch for API tests
143
- const mockFetch = vi.fn();
144
- beforeEach(() => {
145
- global.fetch = mockFetch;
146
- });
147
- mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
148
- ```
149
-
150
- ### MSW (Mock Service Worker)
151
-
152
- MSW handlers are organized in `src/mocks/`:
153
-
154
- ```
155
- src/mocks/
156
- ├── handlers/
157
- │ ├── index.ts # Combines all handlers
158
- │ └── todos.ts # Example domain handlers
159
- ├── fixtures/
160
- │ └── todos.ts # Response data
161
- ├── browser.ts # Browser worker setup
162
- └── node.ts # Node server for tests
163
- ```
164
-
165
- Override handlers per-test:
166
-
167
- ```typescript
168
- import { http, HttpResponse, server } from '@/test';
169
-
170
- it('handles API error', async () => {
171
- server.use(http.get('/api/todos', () => new HttpResponse(null, { status: 500 })));
172
- // Test error handling...
173
- });
174
- ```
175
-
176
- MSW handlers auto-reset after each test via `src/test-setup.ts`.
177
-
178
277
  ### Store Testing (Zustand)
179
278
 
180
279
  ```typescript
@@ -192,6 +291,24 @@ describe('preferencesStore', () => {
192
291
  });
193
292
  ```
194
293
 
294
+ ## Mock Best Practices
295
+
296
+ ### DO
297
+
298
+ - ✅ Use shared constants (`MOCK_USER`, etc.) for consistency
299
+ - ✅ Use `vi.spyOn()` for type-safe mocking
300
+ - ✅ Use `vi.mocked()` wrapper for TypeScript support
301
+ - ✅ Reset mocks in `beforeEach`/`afterEach`
302
+ - ✅ Restore mocks after silencing console
303
+ - ✅ Use factory functions for test data
304
+
305
+ ### DON'T
306
+
307
+ - ❌ Hardcode user IDs or emails across files
308
+ - ❌ Use `global.fetch = mockFn` (use `vi.spyOn` instead)
309
+ - ❌ Create duplicate mock utilities in test files
310
+ - ❌ Forget to reset stateful mocks between tests
311
+
195
312
  ## Test Organization
196
313
 
197
314
  ### Structure Within Test Files
@@ -239,9 +356,10 @@ npm run test:ui # Visual UI
239
356
  ## Checklist for New Tests
240
357
 
241
358
  - [ ] File follows `[name].test.ts(x)` naming
359
+ - [ ] Uses shared mocks from `@/test`
360
+ - [ ] Uses shared constants (`MOCK_USER`, etc.)
242
361
  - [ ] Uses `it.each` for parameterized cases
243
- - [ ] Mocks are cleared in `beforeEach`/`afterEach`
244
- - [ ] No duplicate helper functions
362
+ - [ ] Mocks are reset in `beforeEach`/`afterEach`
245
363
  - [ ] Tests behavior, not implementation
246
364
  - [ ] Async tests use `await`/`waitFor` properly
247
365
  - [ ] Coverage threshold maintained (80%)
@@ -0,0 +1,60 @@
1
+ import { clerk, clerkSetup } from '@clerk/testing/playwright';
2
+ import { expect, test as setup } from '@playwright/test';
3
+
4
+ import { ASYNC_CONTENT_TIMEOUT, AUTH_STATE_FILE } from '../fixtures';
5
+
6
+ // Setup must be run serially for Clerk initialization
7
+ setup.describe.configure({ mode: 'serial' });
8
+
9
+ /**
10
+ * Configure Playwright with Clerk testing token.
11
+ * This obtains a Testing Token when the test suite starts.
12
+ *
13
+ * Required: CLERK_SECRET_KEY environment variable
14
+ */
15
+ setup('global setup', async () => {
16
+ // Skip if CLERK_SECRET_KEY is not set
17
+ if (!process.env.CLERK_SECRET_KEY) {
18
+ setup.skip(true, 'CLERK_SECRET_KEY required for authenticated tests');
19
+ return;
20
+ }
21
+ await clerkSetup();
22
+ });
23
+
24
+ /**
25
+ * Authenticate and save state to storage.
26
+ * This creates a reusable auth state for authenticated tests.
27
+ *
28
+ * Required environment variables:
29
+ * - E2E_CLERK_USER_USERNAME: Test user email
30
+ * - E2E_CLERK_USER_PASSWORD: Test user password
31
+ */
32
+ setup('authenticate and save state', async ({ page }) => {
33
+ const username = process.env.E2E_CLERK_USER_USERNAME;
34
+ const password = process.env.E2E_CLERK_USER_PASSWORD;
35
+
36
+ // Skip auth setup if credentials are not provided
37
+ if (!username || !password) {
38
+ setup.skip(true, 'E2E_CLERK_USER_USERNAME and E2E_CLERK_USER_PASSWORD required for authenticated tests');
39
+ return;
40
+ }
41
+
42
+ // Navigate to app and sign in
43
+ await page.goto('/');
44
+
45
+ await clerk.signIn({
46
+ page,
47
+ signInParams: {
48
+ strategy: 'password',
49
+ identifier: username,
50
+ password: password,
51
+ },
52
+ });
53
+
54
+ // Verify authentication succeeded by checking for authenticated UI
55
+ await page.goto('/profile');
56
+ await expect(page.getByText('Your Profile')).toBeVisible({ timeout: ASYNC_CONTENT_TIMEOUT });
57
+
58
+ // Save authentication state for reuse in other tests
59
+ await page.context().storageState({ path: AUTH_STATE_FILE });
60
+ });
@@ -1,4 +1,15 @@
1
1
  import type { Page } from '@playwright/test';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ // ESM-compatible path resolution
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ /** Path to Clerk auth state file for authenticated tests */
9
+ export const AUTH_STATE_FILE = join(__dirname, '../.clerk/user.json');
10
+
11
+ /** Default timeout for waiting on profile/async content to load */
12
+ export const ASYNC_CONTENT_TIMEOUT = 10000;
2
13
 
3
14
  /**
4
15
  * Navigate to page with clean state (clears localStorage).