@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,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
|
+
});
|
package/templates/gitignore
CHANGED
|
@@ -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
|
package/templates/package.json
CHANGED
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"e2e:perf": "PERF_TEST=true playwright test --project=performance",
|
|
47
47
|
"e2e:perf:ui": "PERF_TEST=true playwright test --project=performance --ui",
|
|
48
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",
|
|
49
53
|
"prepare": "husky",
|
|
50
54
|
"mcp:build": "npm run build -w @react-spa-scaffold/mcp",
|
|
51
55
|
"mcp:dev": "npm run dev -w @react-spa-scaffold/mcp",
|
|
@@ -64,6 +68,7 @@
|
|
|
64
68
|
"@lingui/react": "^5.7.0",
|
|
65
69
|
"@radix-ui/react-slot": "^1.2.3",
|
|
66
70
|
"@sentry/react": "^10.32.1",
|
|
71
|
+
"@supabase/supabase-js": "^2.89.0",
|
|
67
72
|
"@tanstack/react-query": "^5.90.14",
|
|
68
73
|
"class-variance-authority": "^0.7.1",
|
|
69
74
|
"clsx": "^2.1.1",
|
|
@@ -84,6 +89,7 @@
|
|
|
84
89
|
"devDependencies": {
|
|
85
90
|
"@changesets/changelog-github": "^0.5.2",
|
|
86
91
|
"@changesets/cli": "^2.29.8",
|
|
92
|
+
"@clerk/testing": "^1.13.26",
|
|
87
93
|
"@commitlint/config-conventional": "^20.2.0",
|
|
88
94
|
"@eslint/js": "^9.28.0",
|
|
89
95
|
"@lingui/babel-plugin-lingui-macro": "^5.7.0",
|
|
@@ -106,6 +112,7 @@
|
|
|
106
112
|
"babel-plugin-macros": "^3.1.0",
|
|
107
113
|
"chrome-launcher": "^1.2.1",
|
|
108
114
|
"commitlint": "^20.2.0",
|
|
115
|
+
"dotenv-cli": "^11.0.0",
|
|
109
116
|
"eslint": "^9.28.0",
|
|
110
117
|
"eslint-config-prettier": "^10.1.0",
|
|
111
118
|
"eslint-plugin-lingui": "^0.11.0",
|
|
@@ -119,6 +126,7 @@
|
|
|
119
126
|
"prettier": "^3.5.3",
|
|
120
127
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
121
128
|
"shadcn": "^3.6.2",
|
|
129
|
+
"supabase": "^2.70.5",
|
|
122
130
|
"tailwindcss": "^4.1.17",
|
|
123
131
|
"typescript": "~5.9.0",
|
|
124
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
|
|
24
|
+
baseURL,
|
|
12
25
|
trace: 'on-first-retry',
|
|
13
26
|
screenshot: 'only-on-failure',
|
|
14
27
|
},
|
|
@@ -16,6 +29,12 @@ export default defineConfig({
|
|
|
16
29
|
timeout: 10000,
|
|
17
30
|
},
|
|
18
31
|
projects: [
|
|
32
|
+
// Auth setup - runs first to create authenticated state
|
|
33
|
+
{
|
|
34
|
+
name: 'setup',
|
|
35
|
+
testDir: './e2e/auth',
|
|
36
|
+
testMatch: /.*\.setup\.ts/,
|
|
37
|
+
},
|
|
19
38
|
{
|
|
20
39
|
name: 'desktop',
|
|
21
40
|
testDir: './e2e/tests',
|
|
@@ -26,6 +45,17 @@ export default defineConfig({
|
|
|
26
45
|
testDir: './e2e/tests',
|
|
27
46
|
use: { ...devices['Pixel 5'] },
|
|
28
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
|
+
},
|
|
29
59
|
{
|
|
30
60
|
name: 'performance',
|
|
31
61
|
testDir: './e2e/performance',
|
|
@@ -39,8 +69,8 @@ export default defineConfig({
|
|
|
39
69
|
},
|
|
40
70
|
],
|
|
41
71
|
webServer: {
|
|
42
|
-
command:
|
|
43
|
-
url:
|
|
72
|
+
command: getWebServerCommand(),
|
|
73
|
+
url: baseURL,
|
|
44
74
|
reuseExistingServer: !process.env.CI,
|
|
45
75
|
timeout: 120000,
|
|
46
76
|
},
|
package/templates/src/App.tsx
CHANGED
|
@@ -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
|
-
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
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,6 +14,13 @@ 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 />
|
|
15
26
|
<AccountButton />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { screen } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
3
|
|
|
4
|
-
import { render,
|
|
4
|
+
import { render, setMockClerkSignedIn, setMockClerkLoaded, resetClerkMocks } from '@/test';
|
|
5
5
|
|
|
6
6
|
import { AccountButton } from './AccountButton';
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ describe('AccountButton', () => {
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('shows skeleton when not loaded', () => {
|
|
14
|
-
|
|
14
|
+
setMockClerkLoaded(false);
|
|
15
15
|
const { container } = render(<AccountButton />);
|
|
16
16
|
expect(container.querySelector('.rounded-full')).toBeInTheDocument();
|
|
17
17
|
});
|
|
@@ -22,7 +22,7 @@ describe('AccountButton', () => {
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
it('shows sign in button when signed out', () => {
|
|
25
|
-
|
|
25
|
+
setMockClerkSignedIn(false);
|
|
26
26
|
render(<AccountButton />);
|
|
27
27
|
expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
|
|
28
28
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { render, setMockClerkSignedIn, resetClerkMocks } from '@/test';
|
|
5
|
+
|
|
6
|
+
import { ProfileSync } from './ProfileSync';
|
|
7
|
+
|
|
8
|
+
// Mock the hooks
|
|
9
|
+
const mockMutate = vi.fn();
|
|
10
|
+
vi.mock('@/hooks', async () => {
|
|
11
|
+
const actual = await vi.importActual('@/hooks');
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
useCurrentProfile: vi.fn(() => ({ data: [], isLoading: false })),
|
|
15
|
+
useUpsertProfile: vi.fn(() => ({ mutate: mockMutate, isPending: false })),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('ProfileSync', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
resetClerkMocks();
|
|
22
|
+
mockMutate.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders nothing (invisible)', () => {
|
|
26
|
+
setMockClerkSignedIn(true);
|
|
27
|
+
const { container } = render(<ProfileSync />);
|
|
28
|
+
expect(container).toBeEmptyDOMElement();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not sync when signed out', async () => {
|
|
32
|
+
setMockClerkSignedIn(false);
|
|
33
|
+
render(<ProfileSync />);
|
|
34
|
+
await waitFor(() => expect(mockMutate).not.toHaveBeenCalled());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('syncs profile when signed in', async () => {
|
|
38
|
+
setMockClerkSignedIn(true);
|
|
39
|
+
render(<ProfileSync />);
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(mockMutate).toHaveBeenCalledWith(expect.objectContaining({ id: 'user_123' }), expect.any(Object));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileSync - Invisible component that syncs Clerk user data to Supabase.
|
|
3
|
+
*
|
|
4
|
+
* This component ensures the authenticated user's profile exists in Supabase
|
|
5
|
+
* and stays in sync with their Clerk account data. It runs once per session
|
|
6
|
+
* and handles first-time profile creation automatically.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* // Add to your app layout or protected routes
|
|
11
|
+
* function App() {
|
|
12
|
+
* return (
|
|
13
|
+
* <>
|
|
14
|
+
* <ProfileSync />
|
|
15
|
+
* <Routes>...</Routes>
|
|
16
|
+
* </>
|
|
17
|
+
* );
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useRef } from 'react';
|
|
23
|
+
import { useUser } from '@clerk/react-router';
|
|
24
|
+
|
|
25
|
+
import { useCurrentProfile, useUpsertProfile } from '@/hooks';
|
|
26
|
+
|
|
27
|
+
interface ProfileSyncProps {
|
|
28
|
+
/**
|
|
29
|
+
* Callback fired when profile sync completes successfully.
|
|
30
|
+
*/
|
|
31
|
+
onSyncComplete?: () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Callback fired when profile sync fails.
|
|
34
|
+
*/
|
|
35
|
+
onSyncError?: (error: Error) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Syncs the current Clerk user's data to Supabase profiles table.
|
|
40
|
+
*
|
|
41
|
+
* This component:
|
|
42
|
+
* 1. Waits for Clerk user to be loaded
|
|
43
|
+
* 2. Checks if a profile already exists
|
|
44
|
+
* 3. Creates or updates the profile if needed
|
|
45
|
+
* 4. Only syncs once per component mount to prevent loops
|
|
46
|
+
*
|
|
47
|
+
* The sync uses upsert, so it's safe to call multiple times - it will
|
|
48
|
+
* create the profile if it doesn't exist, or update if data changed.
|
|
49
|
+
*/
|
|
50
|
+
export function ProfileSync({ onSyncComplete, onSyncError }: ProfileSyncProps) {
|
|
51
|
+
const { user, isLoaded: isUserLoaded } = useUser();
|
|
52
|
+
const { data: profiles, isLoading: isProfileLoading } = useCurrentProfile();
|
|
53
|
+
const upsertProfile = useUpsertProfile();
|
|
54
|
+
|
|
55
|
+
// Track if we've already synced this session to prevent infinite loops
|
|
56
|
+
const hasSynced = useRef(false);
|
|
57
|
+
const isSyncing = useRef(false);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
// Skip if already synced, currently syncing, or data not ready
|
|
61
|
+
if (hasSynced.current || isSyncing.current) return;
|
|
62
|
+
if (!isUserLoaded || !user) return;
|
|
63
|
+
if (isProfileLoading) return;
|
|
64
|
+
|
|
65
|
+
const existingProfile = profiles?.[0];
|
|
66
|
+
|
|
67
|
+
// Check if sync is needed:
|
|
68
|
+
// - No profile exists, OR
|
|
69
|
+
// - Email has changed (user updated in Clerk)
|
|
70
|
+
const needsSync = !existingProfile || existingProfile.email !== user.primaryEmailAddress?.emailAddress;
|
|
71
|
+
|
|
72
|
+
if (!needsSync) {
|
|
73
|
+
hasSynced.current = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Perform the sync
|
|
78
|
+
isSyncing.current = true;
|
|
79
|
+
|
|
80
|
+
upsertProfile.mutate(
|
|
81
|
+
{
|
|
82
|
+
id: user.id,
|
|
83
|
+
email: user.primaryEmailAddress?.emailAddress ?? '',
|
|
84
|
+
full_name: user.fullName ?? null,
|
|
85
|
+
avatar_url: user.imageUrl ?? null,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
onSuccess: () => {
|
|
89
|
+
hasSynced.current = true;
|
|
90
|
+
isSyncing.current = false;
|
|
91
|
+
onSyncComplete?.();
|
|
92
|
+
},
|
|
93
|
+
onError: (error) => {
|
|
94
|
+
isSyncing.current = false;
|
|
95
|
+
// Don't set hasSynced so it can retry on next render
|
|
96
|
+
onSyncError?.(new Error(error.message));
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
}, [isUserLoaded, user, profiles, isProfileLoading, upsertProfile, onSyncComplete, onSyncError]);
|
|
101
|
+
|
|
102
|
+
// This component renders nothing - it's purely for side effects
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProfileSync } from './ProfileSync';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { screen } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
3
|
|
|
4
|
-
import { render,
|
|
4
|
+
import { render, setMockClerkSignedIn, setMockClerkLoaded, resetClerkMocks } from '@/test';
|
|
5
5
|
|
|
6
6
|
import { ProtectedRoute } from './ProtectedRoute';
|
|
7
7
|
|
|
@@ -20,7 +20,7 @@ describe('ProtectedRoute', () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it('shows loading when auth is not loaded', () => {
|
|
23
|
-
|
|
23
|
+
setMockClerkLoaded(false);
|
|
24
24
|
const { container } = render(
|
|
25
25
|
<ProtectedRoute>
|
|
26
26
|
<div>Protected Content</div>
|
|
@@ -31,7 +31,7 @@ describe('ProtectedRoute', () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
it('redirects when not signed in', () => {
|
|
34
|
-
|
|
34
|
+
setMockClerkSignedIn(false);
|
|
35
35
|
render(
|
|
36
36
|
<ProtectedRoute>
|
|
37
37
|
<div>Protected Content</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AccountButton } from './AccountButton';
|
|
2
2
|
export { ErrorBoundary } from './ErrorBoundary';
|
|
3
3
|
export { LanguageSwitcher } from './LanguageSwitcher';
|
|
4
|
+
export { ProfileSync } from './ProfileSync';
|
|
4
5
|
export { ProtectedRoute } from './ProtectedRoute';
|
|
5
6
|
export { RegisterForm } from './RegisterForm';
|
|
6
7
|
export { SEO } from './SEO';
|