@react-spa-scaffold/mcp 2.1.1 → 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 (168) hide show
  1. package/README.md +2 -1
  2. package/dist/constants.d.ts +4 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +4 -0
  5. package/dist/constants.js.map +1 -1
  6. package/dist/features/definitions/auth.d.ts +3 -0
  7. package/dist/features/definitions/auth.d.ts.map +1 -0
  8. package/dist/features/definitions/auth.js +17 -0
  9. package/dist/features/definitions/auth.js.map +1 -0
  10. package/dist/features/definitions/core.d.ts.map +1 -1
  11. package/dist/features/definitions/core.js +16 -1
  12. package/dist/features/definitions/core.js.map +1 -1
  13. package/dist/features/definitions/database.d.ts +3 -0
  14. package/dist/features/definitions/database.d.ts.map +1 -0
  15. package/dist/features/definitions/database.js +45 -0
  16. package/dist/features/definitions/database.js.map +1 -0
  17. package/dist/features/definitions/deployment.d.ts +3 -0
  18. package/dist/features/definitions/deployment.d.ts.map +1 -0
  19. package/dist/features/definitions/deployment.js +14 -0
  20. package/dist/features/definitions/deployment.js.map +1 -0
  21. package/dist/features/definitions/forms.d.ts.map +1 -1
  22. package/dist/features/definitions/forms.js +4 -0
  23. package/dist/features/definitions/forms.js.map +1 -1
  24. package/dist/features/definitions/index.d.ts +3 -0
  25. package/dist/features/definitions/index.d.ts.map +1 -1
  26. package/dist/features/definitions/index.js +3 -0
  27. package/dist/features/definitions/index.js.map +1 -1
  28. package/dist/features/definitions/mobile.d.ts.map +1 -1
  29. package/dist/features/definitions/mobile.js +11 -2
  30. package/dist/features/definitions/mobile.js.map +1 -1
  31. package/dist/features/definitions/observability.js +1 -1
  32. package/dist/features/definitions/observability.js.map +1 -1
  33. package/dist/features/definitions/routing.d.ts.map +1 -1
  34. package/dist/features/definitions/routing.js +2 -1
  35. package/dist/features/definitions/routing.js.map +1 -1
  36. package/dist/features/definitions/state.d.ts.map +1 -1
  37. package/dist/features/definitions/state.js +9 -2
  38. package/dist/features/definitions/state.js.map +1 -1
  39. package/dist/features/definitions/testing.d.ts.map +1 -1
  40. package/dist/features/definitions/testing.js +4 -2
  41. package/dist/features/definitions/testing.js.map +1 -1
  42. package/dist/features/registry.d.ts.map +1 -1
  43. package/dist/features/registry.js +4 -1
  44. package/dist/features/registry.js.map +1 -1
  45. package/dist/features/types.test.js +6 -2
  46. package/dist/features/types.test.js.map +1 -1
  47. package/dist/resources/docs.d.ts.map +1 -1
  48. package/dist/resources/docs.js +5 -0
  49. package/dist/resources/docs.js.map +1 -1
  50. package/dist/tools/add-features.js +1 -1
  51. package/dist/tools/add-features.js.map +1 -1
  52. package/dist/utils/docs.d.ts.map +1 -1
  53. package/dist/utils/docs.js +2 -0
  54. package/dist/utils/docs.js.map +1 -1
  55. package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
  56. package/dist/utils/scaffold/claude-md/index.js +3 -1
  57. package/dist/utils/scaffold/claude-md/index.js.map +1 -1
  58. package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
  59. package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
  60. package/dist/utils/scaffold/claude-md/sections.js +132 -2
  61. package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
  62. package/dist/utils/scaffold/compute.js +1 -1
  63. package/dist/utils/scaffold/compute.js.map +1 -1
  64. package/dist/utils/scaffold/generators.d.ts +2 -2
  65. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  66. package/dist/utils/scaffold/generators.js +64 -22
  67. package/dist/utils/scaffold/generators.js.map +1 -1
  68. package/package.json +1 -1
  69. package/templates/.env.example +44 -10
  70. package/templates/.github/workflows/ci.yml +12 -4
  71. package/templates/.github/workflows/deploy.yml +59 -0
  72. package/templates/CLAUDE.md +251 -2
  73. package/templates/docs/ARCHITECTURE.md +13 -12
  74. package/templates/docs/AUTHENTICATION.md +325 -0
  75. package/templates/docs/CODING_STANDARDS.md +65 -0
  76. package/templates/docs/DEPLOYMENT.md +268 -0
  77. package/templates/docs/E2E_TESTING.md +133 -11
  78. package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
  79. package/templates/docs/TESTING.md +195 -77
  80. package/templates/e2e/auth/auth.setup.ts +60 -0
  81. package/templates/e2e/fixtures/index.ts +24 -2
  82. package/templates/e2e/tests/profile.auth.spec.ts +103 -0
  83. package/templates/e2e/tests/profile.spec.ts +64 -0
  84. package/templates/e2e/tests/register-form.spec.ts +38 -0
  85. package/templates/gitignore +5 -0
  86. package/templates/package.json +15 -3
  87. package/templates/playwright.config.ts +39 -4
  88. package/templates/src/App.tsx +32 -19
  89. package/templates/src/components/layout/Header.test.tsx +17 -1
  90. package/templates/src/components/layout/Header.tsx +13 -1
  91. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
  92. package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
  93. package/templates/src/components/shared/AccountButton/index.ts +1 -0
  94. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
  95. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
  96. package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
  97. package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
  98. package/templates/src/components/shared/ProfileSync/index.ts +1 -0
  99. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
  100. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
  101. package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
  102. package/templates/src/components/shared/index.ts +5 -2
  103. package/templates/src/contexts/clerkContext.tsx +45 -0
  104. package/templates/src/contexts/performanceContext.tsx +3 -3
  105. package/templates/src/contexts/supabaseContext.test.tsx +59 -0
  106. package/templates/src/contexts/supabaseContext.tsx +87 -0
  107. package/templates/src/hooks/index.ts +40 -2
  108. package/templates/src/hooks/supabase/index.ts +12 -0
  109. package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
  110. package/templates/src/hooks/supabase/useProfiles.ts +213 -0
  111. package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
  112. package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
  113. package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
  114. package/templates/src/hooks/useCopyFeedback.ts +41 -0
  115. package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
  116. package/templates/src/hooks/useDebouncedCallback.ts +47 -0
  117. package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
  118. package/templates/src/hooks/useDocumentTitle.ts +31 -0
  119. package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
  120. package/templates/src/hooks/useIOSViewportReset.ts +18 -0
  121. package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
  122. package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
  123. package/templates/src/hooks/useLocalStorage.test.ts +111 -0
  124. package/templates/src/hooks/useLocalStorage.ts +77 -0
  125. package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
  126. package/templates/src/hooks/useSyncedFormData.ts +21 -0
  127. package/templates/src/hooks/useSyncedState.test.ts +119 -0
  128. package/templates/src/hooks/useSyncedState.ts +30 -0
  129. package/templates/src/index.css +1 -0
  130. package/templates/src/lib/api.test.ts +30 -38
  131. package/templates/src/lib/api.ts +1 -7
  132. package/templates/src/lib/config.ts +54 -4
  133. package/templates/src/lib/constants.ts +10 -0
  134. package/templates/src/lib/createSelectors.test.ts +136 -0
  135. package/templates/src/lib/createSelectors.ts +31 -0
  136. package/templates/src/lib/env.ts +36 -14
  137. package/templates/src/lib/index.ts +5 -2
  138. package/templates/src/lib/routes.ts +1 -0
  139. package/templates/src/lib/sentry.ts +58 -0
  140. package/templates/src/lib/storage.ts +6 -2
  141. package/templates/src/lib/supabase/client.ts +58 -0
  142. package/templates/src/lib/supabase/index.ts +5 -0
  143. package/templates/src/main.tsx +19 -31
  144. package/templates/src/mocks/constants.ts +31 -0
  145. package/templates/src/mocks/fixtures/index.ts +3 -1
  146. package/templates/src/mocks/fixtures/profiles.ts +55 -0
  147. package/templates/src/mocks/fixtures/users.ts +91 -0
  148. package/templates/src/mocks/handlers/index.ts +2 -1
  149. package/templates/src/mocks/handlers/supabase.ts +64 -0
  150. package/templates/src/mocks/handlers/todos.ts +1 -1
  151. package/templates/src/mocks/index.ts +6 -0
  152. package/templates/src/pages/Profile.test.tsx +263 -0
  153. package/templates/src/pages/Profile.tsx +171 -0
  154. package/templates/src/pages/index.ts +1 -0
  155. package/templates/src/stores/preferencesStore.ts +35 -9
  156. package/templates/src/test/clerkMock.tsx +137 -0
  157. package/templates/src/test/fetchMock.ts +58 -0
  158. package/templates/src/test/index.ts +51 -2
  159. package/templates/src/test/mocks.ts +128 -1
  160. package/templates/src/test/providers.tsx +10 -4
  161. package/templates/src/test/supabaseMock.ts +112 -0
  162. package/templates/src/test-setup.ts +42 -2
  163. package/templates/src/types/database.ts +46 -0
  164. package/templates/src/types/index.ts +1 -0
  165. package/templates/src/types/supabase.ts +167 -0
  166. package/templates/src/vite-env.d.ts +6 -0
  167. package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
  168. package/templates/vitest.config.ts +9 -1
@@ -0,0 +1,103 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import { ASYNC_CONTENT_TIMEOUT } from '../fixtures';
4
+
5
+ // Check if auth credentials are configured
6
+ const hasAuthCredentials = !!(
7
+ process.env.CLERK_SECRET_KEY &&
8
+ process.env.E2E_CLERK_USER_USERNAME &&
9
+ process.env.E2E_CLERK_USER_PASSWORD
10
+ );
11
+
12
+ /**
13
+ * Authenticated Profile Page E2E Tests
14
+ *
15
+ * These tests run with an authenticated user (from auth.setup.ts).
16
+ * They test the full profile CRUD flow with Supabase integration.
17
+ *
18
+ * Required environment variables:
19
+ * - E2E_CLERK_USER_USERNAME: Test user email
20
+ * - E2E_CLERK_USER_PASSWORD: Test user password
21
+ * - CLERK_SECRET_KEY: Clerk secret key for testing
22
+ *
23
+ * @see https://clerk.com/docs/testing/playwright
24
+ */
25
+ test.describe('Authenticated Profile Tests', () => {
26
+ // Skip all tests if auth credentials aren't configured
27
+ test.skip(
28
+ !hasAuthCredentials,
29
+ 'Auth credentials required (CLERK_SECRET_KEY, E2E_CLERK_USER_USERNAME, E2E_CLERK_USER_PASSWORD)',
30
+ );
31
+
32
+ test.beforeEach(async ({ page }) => {
33
+ await page.goto('/profile');
34
+ // Wait for profile content to load (semantic selector instead of CSS class)
35
+ await expect(page.getByText('Full Name')).toBeVisible({ timeout: ASYNC_CONTENT_TIMEOUT });
36
+ });
37
+
38
+ test('displays profile card with user info', async ({ page }) => {
39
+ await expect(page.getByText('Your Profile')).toBeVisible();
40
+ await expect(page.getByText('Manage your profile information stored in Supabase')).toBeVisible();
41
+ });
42
+
43
+ test('shows user email in profile', async ({ page }) => {
44
+ const email = process.env.E2E_CLERK_USER_USERNAME;
45
+ if (email) {
46
+ await expect(page.getByText(email)).toBeVisible();
47
+ }
48
+ });
49
+
50
+ test('can enter edit mode for name', async ({ page }) => {
51
+ await page.getByRole('button', { name: /edit/i }).click();
52
+
53
+ // Input field should appear
54
+ await expect(page.getByRole('textbox')).toBeVisible();
55
+
56
+ // Save and Cancel buttons should be visible
57
+ await expect(page.getByRole('button', { name: /save/i })).toBeVisible();
58
+ await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible();
59
+ });
60
+
61
+ test('can cancel edit without saving', async ({ page }) => {
62
+ // Enter edit mode
63
+ await page.getByRole('button', { name: /edit/i }).click();
64
+
65
+ // Type a new name
66
+ const input = page.getByRole('textbox');
67
+ await input.clear();
68
+ await input.fill('Temporary Name');
69
+
70
+ // Cancel
71
+ await page.getByRole('button', { name: /cancel/i }).click();
72
+
73
+ // Should exit edit mode (input gone, edit button back)
74
+ await expect(input).not.toBeVisible();
75
+ await expect(page.getByRole('button', { name: /edit/i })).toBeVisible();
76
+ });
77
+
78
+ test('can update profile name', async ({ page }) => {
79
+ // Enter edit mode
80
+ await page.getByRole('button', { name: /edit/i }).click();
81
+
82
+ // Generate unique name with timestamp
83
+ const newName = `E2E Test User ${Date.now()}`;
84
+
85
+ // Update name
86
+ const input = page.getByRole('textbox');
87
+ await input.clear();
88
+ await input.fill(newName);
89
+
90
+ // Save
91
+ await page.getByRole('button', { name: /save/i }).click();
92
+
93
+ // Wait for save to complete (exit edit mode)
94
+ await expect(page.getByRole('button', { name: /edit/i })).toBeVisible({ timeout: ASYNC_CONTENT_TIMEOUT });
95
+
96
+ // Verify name is displayed
97
+ await expect(page.getByText(newName)).toBeVisible();
98
+ });
99
+
100
+ test('Full Name label is present', async ({ page }) => {
101
+ await expect(page.getByText('Full Name')).toBeVisible();
102
+ });
103
+ });
@@ -0,0 +1,64 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ /**
4
+ * Profile Page E2E Tests
5
+ *
6
+ * Note: These tests require a valid Clerk configuration to run.
7
+ * Without VITE_CLERK_PUBLISHABLE_KEY, the app may show an error page.
8
+ *
9
+ * For authenticated testing, see @clerk/testing package documentation.
10
+ * @see https://clerk.com/docs/testing/playwright
11
+ */
12
+
13
+ test.describe('Profile Page', () => {
14
+ test.beforeEach(async ({ page }) => {
15
+ // Check if the app loads (Clerk needs to be configured)
16
+ await page.goto('/');
17
+
18
+ // Wait for app to be ready (header should be visible if Clerk is configured)
19
+ const header = page.getByRole('banner');
20
+ const isAppReady = await header.isVisible({ timeout: 5000 }).catch(() => false);
21
+
22
+ if (!isAppReady) {
23
+ test.skip(true, 'App not ready - Clerk may not be configured');
24
+ }
25
+ });
26
+
27
+ test('protected route blocks unauthenticated access', async ({ page }) => {
28
+ await page.goto('/profile');
29
+
30
+ // Wait for Clerk to process the route
31
+ await page.waitForTimeout(3000);
32
+
33
+ // Profile content should NOT be visible without authentication
34
+ const profileCard = page.getByText('Your Profile');
35
+ const isVisible = await profileCard.isVisible().catch(() => false);
36
+
37
+ // Should either redirect or show sign-in UI, but NOT show profile
38
+ expect(isVisible).toBe(false);
39
+ });
40
+
41
+ test('profile link only visible when authenticated', async ({ page }) => {
42
+ await page.goto('/');
43
+
44
+ // Wait for Clerk to initialize
45
+ await page.waitForTimeout(2000);
46
+
47
+ // Check for Profile link/button in header
48
+ // This is wrapped in <SignedIn>, so it should only appear for authenticated users
49
+ const profileButton = page.getByRole('button', { name: /profile/i });
50
+ const profileLink = page.getByRole('link', { name: /profile/i });
51
+
52
+ const buttonVisible = await profileButton.isVisible().catch(() => false);
53
+ const linkVisible = await profileLink.isVisible().catch(() => false);
54
+
55
+ // Without authentication, neither should be visible
56
+ // (unless user happens to be authenticated, which is also valid)
57
+ expect(buttonVisible || linkVisible).toBe(false);
58
+ });
59
+ });
60
+
61
+ /**
62
+ * @see profile.auth.spec.ts for authenticated profile tests (edit, update, etc.)
63
+ * @see https://clerk.com/docs/testing/playwright
64
+ */
@@ -0,0 +1,38 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ test.describe('Registration Form', () => {
4
+ test.beforeEach(async ({ page }) => {
5
+ await page.goto('/');
6
+ });
7
+
8
+ test('displays registration form with all fields', async ({ page }) => {
9
+ await expect(page.getByLabel(/username/i)).toBeVisible();
10
+ await expect(page.getByLabel(/email/i)).toBeVisible();
11
+ await expect(page.getByLabel('Password', { exact: true })).toBeVisible();
12
+ await expect(page.getByLabel(/confirm password/i)).toBeVisible();
13
+ await expect(page.getByRole('button', { name: /create account/i })).toBeVisible();
14
+ });
15
+
16
+ test('shows validation errors for empty submission', async ({ page }) => {
17
+ await page.getByRole('button', { name: /create account/i }).click();
18
+
19
+ await expect(page.getByText(/username must be at least/i)).toBeVisible();
20
+ await expect(page.getByText(/valid email address/i)).toBeVisible();
21
+ await expect(page.getByText(/password must be at least/i)).toBeVisible();
22
+ });
23
+
24
+ test('validates password requirements', async ({ page }) => {
25
+ await page.getByLabel('Password', { exact: true }).fill('weak');
26
+ await page.getByRole('button', { name: /create account/i }).click();
27
+
28
+ await expect(page.getByText(/password must be at least 8 characters/i)).toBeVisible();
29
+ });
30
+
31
+ test('validates password confirmation match', async ({ page }) => {
32
+ await page.getByLabel('Password', { exact: true }).fill('StrongPass1');
33
+ await page.getByLabel(/confirm password/i).fill('DifferentPass1');
34
+ await page.getByRole('button', { name: /create account/i }).click();
35
+
36
+ await expect(page.getByText(/passwords don't match/i)).toBeVisible();
37
+ });
38
+ });
@@ -23,10 +23,15 @@ coverage/
23
23
  playwright-report/
24
24
  test-results/
25
25
  playwright/.cache/
26
+ e2e/.clerk/*
27
+ !e2e/.clerk/.gitkeep
26
28
 
27
29
  # i18n compiled catalogs (generated by Vite plugin during build)
28
30
  src/locales/*.mjs
29
31
 
32
+ # Supabase
33
+ supabase/.temp/
34
+
30
35
  # Misc
31
36
  tmp
32
37
  *.log
@@ -39,12 +39,17 @@
39
39
  "test": "vitest run",
40
40
  "test:watch": "vitest",
41
41
  "test:coverage": "vitest run --coverage",
42
- "e2e": "playwright test --project=functional",
43
- "e2e:ui": "playwright test --project=functional --ui",
42
+ "e2e": "playwright test --project=desktop",
43
+ "e2e:mobile": "playwright test --project=mobile",
44
+ "e2e:all": "playwright test --project=desktop --project=mobile",
45
+ "e2e:ui": "playwright test --ui",
44
46
  "e2e:perf": "PERF_TEST=true playwright test --project=performance",
45
47
  "e2e:perf:ui": "PERF_TEST=true playwright test --project=performance --ui",
46
- "e2e:all": "PERF_TEST=true playwright test",
47
48
  "i18n:extract": "lingui extract",
49
+ "db:types": "dotenv -- supabase gen types typescript --project-id $SUPABASE_PROJECT_ID > src/types/supabase.ts",
50
+ "db:push": "supabase db push",
51
+ "db:reset": "supabase db reset",
52
+ "db:studio": "supabase studio",
48
53
  "prepare": "husky",
49
54
  "mcp:build": "npm run build -w @react-spa-scaffold/mcp",
50
55
  "mcp:dev": "npm run dev -w @react-spa-scaffold/mcp",
@@ -55,12 +60,15 @@
55
60
  "release": "changeset publish"
56
61
  },
57
62
  "dependencies": {
63
+ "@clerk/react-router": "^2.3.7",
64
+ "@clerk/themes": "^2.4.46",
58
65
  "@fontsource-variable/inter": "^5.2.5",
59
66
  "@hookform/resolvers": "^5.0.1",
60
67
  "@lingui/core": "^5.7.0",
61
68
  "@lingui/react": "^5.7.0",
62
69
  "@radix-ui/react-slot": "^1.2.3",
63
70
  "@sentry/react": "^10.32.1",
71
+ "@supabase/supabase-js": "^2.89.0",
64
72
  "@tanstack/react-query": "^5.90.14",
65
73
  "class-variance-authority": "^0.7.1",
66
74
  "clsx": "^2.1.1",
@@ -69,6 +77,7 @@
69
77
  "react": "^19.1.0",
70
78
  "react-dom": "^19.1.0",
71
79
  "react-hook-form": "^7.58.0",
80
+ "react-hotkeys-hook": "^5.2.1",
72
81
  "react-performance-tracking": "^1.2.1",
73
82
  "react-router": "^7.11.0",
74
83
  "sonner": "^2.0.7",
@@ -80,6 +89,7 @@
80
89
  "devDependencies": {
81
90
  "@changesets/changelog-github": "^0.5.2",
82
91
  "@changesets/cli": "^2.29.8",
92
+ "@clerk/testing": "^1.13.26",
83
93
  "@commitlint/config-conventional": "^20.2.0",
84
94
  "@eslint/js": "^9.28.0",
85
95
  "@lingui/babel-plugin-lingui-macro": "^5.7.0",
@@ -102,6 +112,7 @@
102
112
  "babel-plugin-macros": "^3.1.0",
103
113
  "chrome-launcher": "^1.2.1",
104
114
  "commitlint": "^20.2.0",
115
+ "dotenv-cli": "^11.0.0",
105
116
  "eslint": "^9.28.0",
106
117
  "eslint-config-prettier": "^10.1.0",
107
118
  "eslint-plugin-lingui": "^0.11.0",
@@ -115,6 +126,7 @@
115
126
  "prettier": "^3.5.3",
116
127
  "prettier-plugin-tailwindcss": "^0.7.2",
117
128
  "shadcn": "^3.6.2",
129
+ "supabase": "^2.70.5",
118
130
  "tailwindcss": "^4.1.17",
119
131
  "typescript": "~5.9.0",
120
132
  "typescript-eslint": "^8.33.0",
@@ -1,5 +1,18 @@
1
1
  import { defineConfig, devices } from '@playwright/test';
2
2
 
3
+ import { AUTH_STATE_FILE } from './e2e/fixtures';
4
+
5
+ // CI performance tests use preview server (serves pre-built dist) for faster startup
6
+ // Local dev uses dev server with VITE_PERF_TEST for hot reload
7
+ const isPerfCI = process.env.PERF_CI === 'true';
8
+ const baseURL = isPerfCI ? 'http://localhost:4173' : 'http://localhost:5173';
9
+
10
+ function getWebServerCommand(): string {
11
+ if (isPerfCI) return 'npm run preview';
12
+ if (process.env.PERF_TEST) return 'VITE_PERF_TEST=true npm run dev';
13
+ return 'npm run dev';
14
+ }
15
+
3
16
  export default defineConfig({
4
17
  testDir: './e2e',
5
18
  fullyParallel: true,
@@ -8,7 +21,7 @@ export default defineConfig({
8
21
  workers: process.env.CI ? 1 : undefined,
9
22
  reporter: process.env.CI ? 'github' : [['list'], ['html']],
10
23
  use: {
11
- baseURL: 'http://localhost:5173',
24
+ baseURL,
12
25
  trace: 'on-first-retry',
13
26
  screenshot: 'only-on-failure',
14
27
  },
@@ -16,11 +29,33 @@ export default defineConfig({
16
29
  timeout: 10000,
17
30
  },
18
31
  projects: [
32
+ // Auth setup - runs first to create authenticated state
19
33
  {
20
- name: 'functional',
34
+ name: 'setup',
35
+ testDir: './e2e/auth',
36
+ testMatch: /.*\.setup\.ts/,
37
+ },
38
+ {
39
+ name: 'desktop',
21
40
  testDir: './e2e/tests',
22
41
  use: { ...devices['Desktop Chrome'] },
23
42
  },
43
+ {
44
+ name: 'mobile',
45
+ testDir: './e2e/tests',
46
+ use: { ...devices['Pixel 5'] },
47
+ },
48
+ // Authenticated tests - depend on setup project
49
+ {
50
+ name: 'authenticated',
51
+ testDir: './e2e/tests',
52
+ testMatch: /.*\.auth\.spec\.ts/,
53
+ dependencies: ['setup'],
54
+ use: {
55
+ ...devices['Desktop Chrome'],
56
+ storageState: AUTH_STATE_FILE,
57
+ },
58
+ },
24
59
  {
25
60
  name: 'performance',
26
61
  testDir: './e2e/performance',
@@ -34,8 +69,8 @@ export default defineConfig({
34
69
  },
35
70
  ],
36
71
  webServer: {
37
- command: process.env.PERF_TEST ? 'VITE_PERF_TEST=true npm run dev' : 'npm run dev',
38
- url: 'http://localhost:5173',
72
+ command: getWebServerCommand(),
73
+ url: baseURL,
39
74
  reuseExistingServer: !process.env.CI,
40
75
  timeout: 120000,
41
76
  },
@@ -3,7 +3,7 @@ import { lazy, Suspense } from 'react';
3
3
  import { Route, Routes } from 'react-router';
4
4
 
5
5
  import { Header } from '@/components/layout';
6
- import { SEO } from '@/components/shared';
6
+ import { ProfileSync, ProtectedRoute, SEO } from '@/components/shared';
7
7
  import { PageLoading } from '@/components/ui/loading';
8
8
  import { SkipLink } from '@/components/ui/visually-hidden';
9
9
  import { useThemeEffect } from '@/hooks';
@@ -14,29 +14,42 @@ import { ROUTES } from '@/lib/routes';
14
14
  const HomePage = lazy(() => import('@/pages/Home').then((m) => ({ default: m.HomePage })));
15
15
  // eslint-disable-next-line lingui/no-unlocalized-strings
16
16
  const NotFoundPage = lazy(() => import('@/pages/NotFound').then((m) => ({ default: m.NotFoundPage })));
17
+ // eslint-disable-next-line lingui/no-unlocalized-strings
18
+ const ProfilePage = lazy(() => import('@/pages/Profile').then((m) => ({ default: m.ProfilePage })));
17
19
 
18
20
  export default function App() {
19
21
  const { t } = useLingui();
20
22
  useThemeEffect();
21
23
 
22
24
  return (
23
- <div className="bg-background text-foreground min-h-screen">
24
- <SEO
25
- description={t({
26
- message: 'A modern React 19 application with TypeScript and Vite',
27
- comment: 'Default site-wide meta description for SEO',
28
- })}
29
- />
30
- <SkipLink />
31
- <Header />
32
- <main id="main">
33
- <Suspense fallback={<PageLoading />}>
34
- <Routes>
35
- <Route path={ROUTES.HOME} element={<HomePage />} />
36
- <Route path={ROUTES.NOT_FOUND} element={<NotFoundPage />} />
37
- </Routes>
38
- </Suspense>
39
- </main>
40
- </div>
25
+ <>
26
+ <ProfileSync />
27
+ <div className="bg-background text-foreground min-h-screen">
28
+ <SEO
29
+ description={t({
30
+ message: 'A modern React 19 application with TypeScript and Vite',
31
+ comment: 'Default site-wide meta description for SEO',
32
+ })}
33
+ />
34
+ <SkipLink />
35
+ <Header />
36
+ <main id="main">
37
+ <Suspense fallback={<PageLoading />}>
38
+ <Routes>
39
+ <Route path={ROUTES.HOME} element={<HomePage />} />
40
+ <Route
41
+ path={ROUTES.PROFILE}
42
+ element={
43
+ <ProtectedRoute>
44
+ <ProfilePage />
45
+ </ProtectedRoute>
46
+ }
47
+ />
48
+ <Route path={ROUTES.NOT_FOUND} element={<NotFoundPage />} />
49
+ </Routes>
50
+ </Suspense>
51
+ </main>
52
+ </div>
53
+ </>
41
54
  );
42
55
  }
@@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
2
2
  import { describe, expect, it } from 'vitest';
3
3
 
4
4
  import { Header } from '@/components/layout/Header';
5
- import { render } from '@/test';
5
+ import { render, setMockClerkSignedIn } from '@/test';
6
6
 
7
7
  describe('Header', () => {
8
8
  it('renders the app title', () => {
@@ -30,4 +30,20 @@ describe('Header', () => {
30
30
  const header = screen.getByRole('banner');
31
31
  expect(header).toBeInTheDocument();
32
32
  });
33
+
34
+ it('renders sign-in button when user is signed out', () => {
35
+ setMockClerkSignedIn(false);
36
+
37
+ render(<Header />);
38
+
39
+ expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
40
+ });
41
+
42
+ it('renders user button when user is signed in', () => {
43
+ setMockClerkSignedIn(true);
44
+
45
+ render(<Header />);
46
+
47
+ expect(screen.getByTestId('user-button')).toBeInTheDocument();
48
+ });
33
49
  });
@@ -1,6 +1,10 @@
1
+ import { SignedIn } from '@clerk/react-router';
1
2
  import { Trans } from '@lingui/react/macro';
3
+ import { Link } from 'react-router';
2
4
 
3
- import { LanguageSwitcher, ThemeToggle } from '@/components/shared';
5
+ import { AccountButton, LanguageSwitcher, ThemeToggle } from '@/components/shared';
6
+ import { Button } from '@/components/ui/button';
7
+ import { ROUTES } from '@/lib/routes';
4
8
 
5
9
  export function Header() {
6
10
  return (
@@ -10,8 +14,16 @@ export function Header() {
10
14
  <Trans comment="Application name displayed in the header navigation">My App</Trans>
11
15
  </h1>
12
16
  <div className="flex items-center gap-2">
17
+ <SignedIn>
18
+ <Button variant="ghost" size="sm" asChild>
19
+ <Link to={ROUTES.PROFILE}>
20
+ <Trans comment="Profile navigation link in header">Profile</Trans>
21
+ </Link>
22
+ </Button>
23
+ </SignedIn>
13
24
  <LanguageSwitcher />
14
25
  <ThemeToggle />
26
+ <AccountButton />
15
27
  </div>
16
28
  </div>
17
29
  </header>
@@ -0,0 +1,30 @@
1
+ import { screen } from '@testing-library/react';
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+
4
+ import { render, setMockClerkSignedIn, setMockClerkLoaded, resetClerkMocks } from '@/test';
5
+
6
+ import { AccountButton } from './AccountButton';
7
+
8
+ describe('AccountButton', () => {
9
+ beforeEach(() => {
10
+ resetClerkMocks();
11
+ });
12
+
13
+ it('shows skeleton when not loaded', () => {
14
+ setMockClerkLoaded(false);
15
+ const { container } = render(<AccountButton />);
16
+ expect(container.querySelector('.rounded-full')).toBeInTheDocument();
17
+ });
18
+
19
+ it('shows user button when signed in', () => {
20
+ render(<AccountButton />);
21
+ expect(screen.getByTestId('user-button')).toBeInTheDocument();
22
+ });
23
+
24
+ it('shows sign in button when signed out', () => {
25
+ setMockClerkSignedIn(false);
26
+ render(<AccountButton />);
27
+ expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
28
+ expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
29
+ });
30
+ });
@@ -0,0 +1,38 @@
1
+ import { SignedIn, SignedOut, SignInButton, useAuth, UserButton } from '@clerk/react-router';
2
+ import { useLingui } from '@lingui/react/macro';
3
+ import { User } from 'lucide-react';
4
+
5
+ import { Button } from '@/components/ui/button';
6
+ import { Skeleton } from '@/components/ui/skeleton';
7
+ import { useTouchSizes } from '@/hooks';
8
+
9
+ export function AccountButton() {
10
+ const { t } = useLingui();
11
+ const { isLoaded } = useAuth();
12
+ const sizes = useTouchSizes();
13
+
14
+ // Show skeleton while Clerk loads to prevent UI flash
15
+ if (!isLoaded) {
16
+ return <Skeleton className="size-9 rounded-full" />;
17
+ }
18
+
19
+ return (
20
+ <>
21
+ <SignedOut>
22
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */}
23
+ <SignInButton mode="modal">
24
+ <Button
25
+ variant="ghost"
26
+ size={sizes.iconButtonLg}
27
+ aria-label={t({ message: 'Sign in', comment: 'Sign in button aria label' })}
28
+ >
29
+ <User className="size-5" />
30
+ </Button>
31
+ </SignInButton>
32
+ </SignedOut>
33
+ <SignedIn>
34
+ <UserButton />
35
+ </SignedIn>
36
+ </>
37
+ );
38
+ }
@@ -0,0 +1 @@
1
+ export { AccountButton } from './AccountButton';
@@ -52,14 +52,14 @@ describe('ErrorBoundary', () => {
52
52
  expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
53
53
  });
54
54
 
55
- it('shows Refresh Page button', () => {
55
+ it('shows Reload Page button', () => {
56
56
  render(
57
57
  <ErrorBoundary>
58
58
  <ThrowingComponent />
59
59
  </ErrorBoundary>,
60
60
  );
61
61
 
62
- expect(screen.getByRole('button', { name: /refresh page/i })).toBeInTheDocument();
62
+ expect(screen.getByRole('button', { name: /reload page/i })).toBeInTheDocument();
63
63
  });
64
64
 
65
65
  it('renders custom fallback when provided', () => {
@@ -103,7 +103,7 @@ describe('ErrorBoundary', () => {
103
103
  expect(onReset).toHaveBeenCalledTimes(1);
104
104
  });
105
105
 
106
- it('reloads page when Refresh Page is clicked', () => {
106
+ it('reloads page when Reload Page is clicked', () => {
107
107
  const reloadMock = vi.fn();
108
108
  const originalLocation = window.location;
109
109
 
@@ -119,7 +119,7 @@ describe('ErrorBoundary', () => {
119
119
  </ErrorBoundary>,
120
120
  );
121
121
 
122
- fireEvent.click(screen.getByRole('button', { name: /refresh page/i }));
122
+ fireEvent.click(screen.getByRole('button', { name: /reload page/i }));
123
123
 
124
124
  expect(reloadMock).toHaveBeenCalledTimes(1);
125
125