mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 22:18:00 +08:00
feature: login w/ Github for production E2E tests (#41495)
* updated github script for login * refactored and cleans up github login * wip * updated github script * removed context dir for page access * updated auth script * updated the login methods * updated README * updated minor bugs * added error handling
This commit is contained in:
@@ -10,5 +10,7 @@ API_URL=http://localhost:8080
|
||||
IS_PLATFORM=true
|
||||
ORG_SLUG=
|
||||
SUPA_PAT=
|
||||
|
||||
EMAIL=
|
||||
PASSWORD=
|
||||
|
||||
16
e2e/studio/.env.local.github.example
Normal file
16
e2e/studio/.env.local.github.example
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
# Copy and paste this file and rename it to .env.local
|
||||
|
||||
STUDIO_URL=https://supabase.com/dashboard
|
||||
API_URL=https://api.supabase.com
|
||||
|
||||
# Required for platform tests
|
||||
IS_PLATFORM=true
|
||||
ORG_SLUG=
|
||||
SUPA_PAT=
|
||||
|
||||
GITHUB_TOTP=
|
||||
GITHUB_USER=
|
||||
GITHUB_PASS=
|
||||
|
||||
VERCEL_AUTOMATION_BYPASS_SELFHOSTED_STUDIO=
|
||||
3
e2e/studio/.gitignore
vendored
3
e2e/studio/.gitignore
vendored
@@ -5,4 +5,5 @@ node_modules/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
.env.staging
|
||||
keys.json
|
||||
keys.json
|
||||
browserContext-*
|
||||
@@ -10,15 +10,33 @@
|
||||
|
||||
#### For Platform Tests
|
||||
|
||||
1. **Create a platform account** with an email and password, these auths are used for the test
|
||||
1. **Create a platform account** - You can authenticate using either:
|
||||
- Email and password
|
||||
- GitHub OAuth (requires TOTP 2FA)
|
||||
2. **Create an organization** on the platform, this can be done if run locally through `mise fullstack`
|
||||
3. **Generate a Personal Access Token (PAT)** for API access
|
||||
4. Configure the environment variables below
|
||||
4. Configure the environment variables below (see Authentication section for details on email vs GitHub auth)
|
||||
|
||||
### Configure Environment
|
||||
|
||||
Choose the appropriate example file based on your testing scenario:
|
||||
|
||||
**For self-hosted tests:**
|
||||
|
||||
```bash
|
||||
cp .env.local.example .env.local
|
||||
cp .env.local.self-hosted.example .env.local
|
||||
```
|
||||
|
||||
**For platform tests with email authentication:**
|
||||
|
||||
```bash
|
||||
cp .env.local.email.example .env.local
|
||||
```
|
||||
|
||||
**For platform tests with GitHub authentication:**
|
||||
|
||||
```bash
|
||||
cp .env.local.github.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` and set the appropriate values based on your test environment (see Environment Variables section below).
|
||||
@@ -47,13 +65,34 @@ Configure your tests by setting the following environment variables in `.env.loc
|
||||
|
||||
#### Authentication (Required for Platform Tests)
|
||||
|
||||
⚠️ **Before running platform tests, you must create an account with an email, password, and organization on the platform you're testing.**
|
||||
⚠️ **Before running platform tests, you must create an account and organization on the platform you're testing.**
|
||||
|
||||
- **`EMAIL`**: Your platform account email (required for authentication)
|
||||
- **`PASSWORD`**: Your platform account password (required for authentication)
|
||||
Authentication is automatically enabled when either email/password OR GitHub credentials are configured.
|
||||
|
||||
##### Email Authentication
|
||||
|
||||
- **`EMAIL`**: Your platform account email
|
||||
- **`PASSWORD`**: Your platform account password
|
||||
- **`PROJECT_REF`**: Project reference (optional, will be auto-created if not provided)
|
||||
|
||||
When both `EMAIL` and `PASSWORD` are set, authentication is automatically enabled. HCaptcha is mocked during test setup.
|
||||
When both `EMAIL` and `PASSWORD` are set, the tests will authenticate using email/password. HCaptcha is mocked during test setup. Note this only works on local and staging environments
|
||||
|
||||
##### GitHub Authentication
|
||||
|
||||
- **`GITHUB_USER`**: Your GitHub username
|
||||
- **`GITHUB_PASS`**: Your GitHub password
|
||||
- **`GITHUB_TOTP`**: Your GitHub TOTP secret for 2FA (required, as GitHub enforces 2FA)
|
||||
|
||||
When `GITHUB_USER`, `GITHUB_PASS`, and `GITHUB_TOTP` are all set, the tests will authenticate using GitHub OAuth with TOTP-based 2FA. The authentication flow handles:
|
||||
|
||||
- Clicking "Sign In with GitHub" button
|
||||
- Filling GitHub credentials
|
||||
- Generating and submitting TOTP codes
|
||||
- Handling GitHub authorization prompts
|
||||
- Automatic retry on failure (up to 3 attempts)
|
||||
|
||||
**Getting your GitHub TOTP secret:**
|
||||
When setting up 2FA on GitHub, you'll see a QR code. Click "enter this text code instead" to reveal the secret key. This is the value to use for `GITHUB_TOTP`.
|
||||
|
||||
#### Platform-Specific Variables (Required when `IS_PLATFORM=true`)
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ export const env = {
|
||||
PASSWORD: process.env.PASSWORD,
|
||||
PROJECT_REF: process.env.PROJECT_REF || undefined,
|
||||
|
||||
GITHUB_USER: process.env.GITHUB_USER,
|
||||
GITHUB_PASS: process.env.GITHUB_PASS,
|
||||
GITHUB_TOTP: process.env.GITHUB_TOTP,
|
||||
|
||||
VERCEL_AUTOMATION_BYPASS_SELFHOSTED_STUDIO:
|
||||
process.env.VERCEL_AUTOMATION_BYPASS_SELFHOSTED_STUDIO || 'false',
|
||||
ORG_SLUG: process.env.ORG_SLUG || 'default',
|
||||
@@ -30,7 +34,9 @@ export const env = {
|
||||
|
||||
BRANCH_NAME: process.env.BRANCH_NAME || `e2e-test-local`,
|
||||
|
||||
AUTHENTICATION: Boolean(process.env.EMAIL && process.env.PASSWORD),
|
||||
AUTHENTICATION:
|
||||
Boolean(process.env.EMAIL && process.env.PASSWORD) ||
|
||||
Boolean(process.env.GITHUB_USER && process.env.GITHUB_PASS && process.env.GITHUB_TOTP),
|
||||
|
||||
IS_APP_RUNNING_ON_LOCALHOST:
|
||||
process.env.STUDIO_URL?.includes('localhost') || process.env.STUDIO_URL?.includes('127.0.0.1'),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { expect, test as setup } from '@playwright/test'
|
||||
import { test as setup } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
import { env, STORAGE_STATE_PATH } from '../env.config.js'
|
||||
import { env } from '../env.config.js'
|
||||
import { setupProjectForTests } from '../scripts/setup-platform-tests.js'
|
||||
import { loginWithEmail } from '../scripts/login/email.js'
|
||||
import { loginWithGithubWithRetry } from '../scripts/login/github.js'
|
||||
|
||||
/**
|
||||
* Run any setup tasks for the tests.
|
||||
@@ -81,200 +83,39 @@ To start API locally, run:
|
||||
return
|
||||
}
|
||||
|
||||
const signInUrl = `${studioUrl}/sign-in`
|
||||
console.log(`\n 🔑 Navigating to sign in page: ${signInUrl}`)
|
||||
const { EMAIL, PASSWORD } = env
|
||||
if (EMAIL && PASSWORD) {
|
||||
console.log(`\n 🔑 Authenticating user with email and password`)
|
||||
|
||||
await page.addInitScript(() => {
|
||||
;(window as any).hcaptcha = {
|
||||
execute: async (options?: any) => {
|
||||
console.log('HCaptcha execute called (init script)', options)
|
||||
// Return HCaptcha's official test token
|
||||
return { response: '10000000-aaaa-bbbb-cccc-000000000001', key: 'mock' }
|
||||
},
|
||||
render: (container: any, options: any) => {
|
||||
console.log('HCaptcha render called (init script)', container, options)
|
||||
return 'mock-widget-id'
|
||||
},
|
||||
reset: (widgetId?: any) => {
|
||||
console.log('HCaptcha reset called (init script)', widgetId)
|
||||
},
|
||||
remove: (widgetId?: any) => {
|
||||
console.log('HCaptcha remove called (init script)', widgetId)
|
||||
},
|
||||
getResponse: (widgetId?: any) => {
|
||||
console.log('HCaptcha getResponse called (init script)', widgetId)
|
||||
return '10000000-aaaa-bbbb-cccc-000000000001'
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock HCaptcha to bypass captcha verification in automated tests
|
||||
// HCaptcha detects automated browsers and will block Playwright
|
||||
// Also fixes CORS issues with custom Vercel headers being sent to hcaptcha.com
|
||||
await page.route('**/*hcaptcha.com/**', async (route) => {
|
||||
const url = route.request().url()
|
||||
console.log(`\n 🔒 Intercepting HCaptcha request: ${url}`)
|
||||
|
||||
// Mock the main hcaptcha script with a stub that auto-resolves
|
||||
if (url.includes('api.js') || url.includes('hcaptcha.js')) {
|
||||
console.log(`\n ✅ Mocking HCaptcha script`)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `
|
||||
console.log('HCaptcha mock loaded from route');
|
||||
window.hcaptcha = window.hcaptcha || {
|
||||
execute: async (options) => {
|
||||
console.log('HCaptcha execute called', options);
|
||||
return { response: '10000000-aaaa-bbbb-cccc-000000000001', key: 'mock' };
|
||||
},
|
||||
render: (container, options) => {
|
||||
console.log('HCaptcha render called', container, options);
|
||||
return 'mock-widget-id';
|
||||
},
|
||||
reset: (widgetId) => {
|
||||
console.log('HCaptcha reset called', widgetId);
|
||||
},
|
||||
remove: (widgetId) => {
|
||||
console.log('HCaptcha remove called', widgetId);
|
||||
},
|
||||
getResponse: (widgetId) => {
|
||||
console.log('HCaptcha getResponse called', widgetId);
|
||||
return '10000000-aaaa-bbbb-cccc-000000000001';
|
||||
}
|
||||
};
|
||||
`,
|
||||
try {
|
||||
await loginWithEmail(page, studioUrl, {
|
||||
email: EMAIL,
|
||||
password: PASSWORD,
|
||||
})
|
||||
} else {
|
||||
// For other hcaptcha requests, return success
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(signInUrl, { waitUntil: 'networkidle' })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Check if we're still on the sign-in page
|
||||
const currentUrl = page.url()
|
||||
console.log(`\n 📍 Current URL: ${currentUrl}`)
|
||||
|
||||
if (!currentUrl.includes('/sign-in')) {
|
||||
console.log('\n ⚠️ Redirected away from sign-in page. Checking if already authenticated...')
|
||||
|
||||
// Check if we're already on the projects page
|
||||
if (currentUrl.includes('/projects')) {
|
||||
console.log('\n ✅ Already authenticated, proceeding with tests')
|
||||
await page.context().storageState({ path: STORAGE_STATE_PATH })
|
||||
console.log(`\n ✅ Successfully authenticated with email`)
|
||||
return
|
||||
}
|
||||
|
||||
// If we're redirected somewhere else, try to navigate back to sign-in
|
||||
console.log('\n 🔄 Attempting to navigate back to sign-in page')
|
||||
await page.goto(signInUrl, { waitUntil: 'networkidle' })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Check URL again after second attempt
|
||||
const secondAttemptUrl = page.url()
|
||||
if (!secondAttemptUrl.includes('/sign-in')) {
|
||||
throw new Error(`Failed to reach sign-in page. Current URL: ${secondAttemptUrl}`)
|
||||
} catch (err) {
|
||||
console.error(`\n 🚨 Authentication failed with email/password`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const auth = {
|
||||
email: env.EMAIL,
|
||||
password: env.PASSWORD,
|
||||
projectRef: env.PROJECT_REF,
|
||||
}
|
||||
|
||||
expect(auth).toBeDefined()
|
||||
expect(auth.email).toBeDefined()
|
||||
expect(auth.password).toBeDefined()
|
||||
expect(auth.projectRef).toBeDefined()
|
||||
|
||||
// Wait for form elements with increased timeout
|
||||
const emailInput = page.getByLabel('Email')
|
||||
const passwordInput = page.locator('input[type="password"]')
|
||||
const signInButton = page.getByRole('button', { name: 'Sign In' })
|
||||
|
||||
// if found click opt out on telemetry
|
||||
const optOutButton = page.getByRole('button', { name: 'Opt out' })
|
||||
if ((await optOutButton.count()) > 0) {
|
||||
await optOutButton.click()
|
||||
}
|
||||
|
||||
// Debug element states
|
||||
console.log('\n 🔍 Checking form elements:')
|
||||
console.log(`Email input exists: ${(await emailInput.count()) > 0}`)
|
||||
console.log(`Password input exists: ${(await passwordInput.count()) > 0}`)
|
||||
console.log(`Sign in button exists: ${(await signInButton.count()) > 0}`)
|
||||
|
||||
await emailInput.waitFor({ state: 'visible', timeout: 15000 })
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 15000 })
|
||||
await signInButton.waitFor({ state: 'visible', timeout: 15000 })
|
||||
|
||||
// Listen for console messages to debug issues
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type()
|
||||
if (type === 'error' || type === 'warning') {
|
||||
console.log(`\n 🔍 Browser ${type}: ${msg.text()}`)
|
||||
const { GITHUB_USER, GITHUB_PASS, GITHUB_TOTP } = env
|
||||
if (GITHUB_USER && GITHUB_PASS && GITHUB_TOTP) {
|
||||
console.log(`\n 🔑 Authenticating user with GitHub`)
|
||||
try {
|
||||
await loginWithGithubWithRetry({
|
||||
page,
|
||||
githubTotp: GITHUB_TOTP,
|
||||
githubUser: GITHUB_USER,
|
||||
githubPass: GITHUB_PASS,
|
||||
supaDashboard: studioUrl,
|
||||
})
|
||||
console.log(`\n ✅ Successfully authenticated with GitHub`)
|
||||
return
|
||||
} catch (err) {
|
||||
console.error(`\n 🚨 Authentication failed with GitHub`)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
// Track network requests to see what's happening
|
||||
const authRequests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
const url = request.url()
|
||||
if (url.includes('auth') || url.includes('sign-in') || url.includes('password')) {
|
||||
authRequests.push(`${request.method()} ${url}`)
|
||||
console.log(`\n 📡 Auth request: ${request.method()} ${url}`)
|
||||
}
|
||||
})
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url()
|
||||
if (url.includes('auth') || url.includes('sign-in') || url.includes('password')) {
|
||||
const status = response.status()
|
||||
console.log(`\n 📨 Auth response: ${status} ${url}`)
|
||||
if (status >= 400) {
|
||||
try {
|
||||
const body = await response.text()
|
||||
console.log(`\n ❌ Error response body: ${body}`)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await emailInput.fill(auth.email ?? '')
|
||||
await passwordInput.fill(auth.password ?? '')
|
||||
|
||||
console.log(`\n 🔐 Submitting sign-in form...`)
|
||||
await signInButton.click()
|
||||
|
||||
// Wait for successful sign-in by checking we've navigated away from sign-in page
|
||||
// Could redirect to /organizations, /org/[slug], /new, or /project/default depending on configuration
|
||||
try {
|
||||
await page.waitForURL((url) => !url.pathname.includes('/sign-in'), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
console.log(`\n ✅ Successfully signed in, redirected to: ${page.url()}`)
|
||||
} catch (error) {
|
||||
console.log(`\n ❌ Sign-in timeout. Current URL: ${page.url()}`)
|
||||
console.log(`\n 📡 Auth requests made: ${authRequests.join(', ')}`)
|
||||
|
||||
// Take a screenshot for debugging
|
||||
await page.screenshot({ path: 'test-results/sign-in-failure.png', fullPage: true })
|
||||
console.log(`\n 📸 Screenshot saved to test-results/sign-in-failure.png`)
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE_PATH })
|
||||
})
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"dotenv": "^16.5.0"
|
||||
"dotenv": "^16.5.0",
|
||||
"otpauth": "^9.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
|
||||
@@ -63,23 +63,19 @@ export default defineConfig({
|
||||
'--no-sandbox', // Disables Chrome's sandbox - required in Docker/CI where user namespaces aren't available
|
||||
'--disable-setuid-sandbox', // Alternative sandbox method - disabled for CI compatibility
|
||||
'--allow-insecure-localhost', // Allows tests against localhost with self-signed certificates
|
||||
|
||||
// Memory and resource management
|
||||
'--disable-dev-shm-usage', // Use /tmp instead of /dev/shm to avoid shared memory issues in containers
|
||||
'--js-flags=--max_old_space_size=4096', // Increase V8 heap size to 4GB to handle memory-intensive tests
|
||||
'--memory-pressure-off', // Prevents Chrome from killing tabs due to memory pressure in CI
|
||||
'--enable-low-end-device-mode', // Optimizes memory usage for resource-constrained environments
|
||||
|
||||
// GPU and rendering (disabled for headless/CI performance)
|
||||
'--disable-gpu', // Disables hardware GPU - not needed in headless mode
|
||||
'--disable-software-rasterizer', // Disables software-based rendering fallback
|
||||
|
||||
// Performance optimizations for testing
|
||||
'--disable-background-timer-throttling', // Prevents Chrome from throttling timers in background tabs
|
||||
'--disable-backgrounding-occluded-windows', // Keeps hidden windows running at full speed
|
||||
'--disable-renderer-backgrounding', // Prevents renderer processes from being deprioritized
|
||||
'--disable-ipc-flooding-protection', // Allows high-frequency IPC messages needed for automation
|
||||
|
||||
// Disable unnecessary features to reduce overhead
|
||||
'--disable-extensions', // Disables all browser extensions
|
||||
'--disable-sync', // Disables Chrome sync service
|
||||
@@ -89,33 +85,27 @@ export default defineConfig({
|
||||
'--disable-features=TranslateUI', // Disables translation UI prompts
|
||||
'--disable-features=MediaRouter,site-per-process', // Disables Cast and site isolation for performance
|
||||
'--disable-features=HardwareMediaKeyHandling', // Disables hardware media key handling
|
||||
|
||||
// Disable monitoring and crash reporting
|
||||
'--disable-breakpad', // Disables crash reporting system
|
||||
'--disable-crash-reporter', // Disables crash reporter UI
|
||||
'--disable-hang-monitor', // Disables hang detection monitoring
|
||||
'--metrics-recording-only', // Disables metric uploads while still collecting them
|
||||
|
||||
// Disable security features not needed for testing
|
||||
'--disable-client-side-phishing-detection', // Disables phishing detection checks
|
||||
'--safebrowsing-disable-auto-update', // Disables safe browsing database updates
|
||||
'--disable-domain-reliability', // Disables domain reliability monitoring
|
||||
|
||||
// Disable user prompts and UI elements
|
||||
'--disable-popup-blocking', // Allows popups without user confirmation
|
||||
'--disable-prompt-on-repost', // Skips form resubmission confirmation dialogs
|
||||
'--no-first-run', // Skips first-run wizards and setup dialogs
|
||||
'--no-default-browser-check', // Prevents "set as default browser" prompts
|
||||
|
||||
// Process management
|
||||
'--no-zygote', // Disables zygote process for spawning renderers - reduces memory in single-use scenarios
|
||||
|
||||
// Headless mode configuration
|
||||
'--headless=new', // Uses new headless mode (more stable than old headless)
|
||||
'--window-size=1280,720', // Sets consistent viewport size for screenshot/visual consistency
|
||||
'--hide-scrollbars', // Hides scrollbars for cleaner screenshots
|
||||
'--mute-audio', // Prevents audio output during tests
|
||||
|
||||
// Network configuration
|
||||
'--enable-features=NetworkService,NetworkServiceInProcess', // Uses modern network service in-process for better performance
|
||||
],
|
||||
|
||||
201
e2e/studio/scripts/login/email.ts
Normal file
201
e2e/studio/scripts/login/email.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { expect, Page } from '@playwright/test'
|
||||
import { STORAGE_STATE_PATH } from '../../env.config.js'
|
||||
|
||||
/**
|
||||
* Authenticate user and save storage state
|
||||
*/
|
||||
export async function loginWithEmail(
|
||||
page: Page,
|
||||
studioUrl: string,
|
||||
credentials: { email: string; password: string }
|
||||
) {
|
||||
const signInUrl = `${studioUrl}/sign-in`
|
||||
console.log(`\n 🔑 Navigating to sign in page: ${signInUrl}`)
|
||||
|
||||
// Mock HCaptcha via init script
|
||||
await page.addInitScript(() => {
|
||||
;(window as any).hcaptcha = {
|
||||
execute: async (options?: any) => {
|
||||
console.log('HCaptcha execute called (init script)', options)
|
||||
// Return HCaptcha's official test token
|
||||
return { response: '10000000-aaaa-bbbb-cccc-000000000001', key: 'mock' }
|
||||
},
|
||||
render: (container: any, options: any) => {
|
||||
console.log('HCaptcha render called (init script)', container, options)
|
||||
return 'mock-widget-id'
|
||||
},
|
||||
reset: (widgetId?: any) => {
|
||||
console.log('HCaptcha reset called (init script)', widgetId)
|
||||
},
|
||||
remove: (widgetId?: any) => {
|
||||
console.log('HCaptcha remove called (init script)', widgetId)
|
||||
},
|
||||
getResponse: (widgetId?: any) => {
|
||||
console.log('HCaptcha getResponse called (init script)', widgetId)
|
||||
return '10000000-aaaa-bbbb-cccc-000000000001'
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock HCaptcha to bypass captcha verification in automated tests
|
||||
// HCaptcha detects automated browsers and will block Playwright
|
||||
// Also fixes CORS issues with custom Vercel headers being sent to hcaptcha.com
|
||||
await page.route('**/*hcaptcha.com/**', async (route) => {
|
||||
const url = route.request().url()
|
||||
console.log(`\n 🔒 Intercepting HCaptcha request: ${url}`)
|
||||
|
||||
// Mock the main hcaptcha script with a stub that auto-resolves
|
||||
if (url.includes('api.js') || url.includes('hcaptcha.js')) {
|
||||
console.log(`\n ✅ Mocking HCaptcha script`)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `
|
||||
console.log('HCaptcha mock loaded from route');
|
||||
window.hcaptcha = window.hcaptcha || {
|
||||
execute: async (options) => {
|
||||
console.log('HCaptcha execute called', options);
|
||||
return { response: '10000000-aaaa-bbbb-cccc-000000000001', key: 'mock' };
|
||||
},
|
||||
render: (container, options) => {
|
||||
console.log('HCaptcha render called', container, options);
|
||||
return 'mock-widget-id';
|
||||
},
|
||||
reset: (widgetId) => {
|
||||
console.log('HCaptcha reset called', widgetId);
|
||||
},
|
||||
remove: (widgetId) => {
|
||||
console.log('HCaptcha remove called', widgetId);
|
||||
},
|
||||
getResponse: (widgetId) => {
|
||||
console.log('HCaptcha getResponse called', widgetId);
|
||||
return '10000000-aaaa-bbbb-cccc-000000000001';
|
||||
}
|
||||
};
|
||||
`,
|
||||
})
|
||||
} else {
|
||||
// For other hcaptcha requests, return success
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(signInUrl, { waitUntil: 'networkidle' })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Check if we're still on the sign-in page
|
||||
const currentUrl = page.url()
|
||||
console.log(`\n 📍 Current URL: ${currentUrl}`)
|
||||
|
||||
if (!currentUrl.includes('/sign-in')) {
|
||||
console.log('\n ⚠️ Redirected away from sign-in page. Checking if already authenticated...')
|
||||
|
||||
// Check if we're already on the projects page
|
||||
if (currentUrl.includes('/projects')) {
|
||||
console.log('\n ✅ Already authenticated, proceeding with tests')
|
||||
await page.context().storageState({ path: STORAGE_STATE_PATH })
|
||||
return
|
||||
}
|
||||
|
||||
// If we're redirected somewhere else, try to navigate back to sign-in
|
||||
console.log('\n 🔄 Attempting to navigate back to sign-in page')
|
||||
await page.goto(signInUrl, { waitUntil: 'networkidle' })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Check URL again after second attempt
|
||||
const secondAttemptUrl = page.url()
|
||||
if (!secondAttemptUrl.includes('/sign-in')) {
|
||||
throw new Error(`Failed to reach sign-in page. Current URL: ${secondAttemptUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(credentials.email).toBeDefined()
|
||||
expect(credentials.password).toBeDefined()
|
||||
|
||||
// Wait for form elements with increased timeout
|
||||
const emailInput = page.getByLabel('Email')
|
||||
const passwordInput = page.locator('input[type="password"]')
|
||||
const signInButton = page.getByRole('button', { name: 'Sign In' })
|
||||
|
||||
// if found click opt out on telemetry
|
||||
const optOutButton = page.getByRole('button', { name: 'Opt out' })
|
||||
if ((await optOutButton.count()) > 0) {
|
||||
await optOutButton.click()
|
||||
}
|
||||
|
||||
// Debug element states
|
||||
console.log('\n 🔍 Checking form elements:')
|
||||
console.log(`Email input exists: ${(await emailInput.count()) > 0}`)
|
||||
console.log(`Password input exists: ${(await passwordInput.count()) > 0}`)
|
||||
console.log(`Sign in button exists: ${(await signInButton.count()) > 0}`)
|
||||
|
||||
await emailInput.waitFor({ state: 'visible', timeout: 15000 })
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 15000 })
|
||||
await signInButton.waitFor({ state: 'visible', timeout: 15000 })
|
||||
|
||||
// Listen for console messages to debug issues
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type()
|
||||
if (type === 'error' || type === 'warning') {
|
||||
console.log(`\n 🔍 Browser ${type}: ${msg.text()}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Track network requests to see what's happening
|
||||
const authRequests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
const url = request.url()
|
||||
if (url.includes('auth') || url.includes('sign-in') || url.includes('password')) {
|
||||
authRequests.push(`${request.method()} ${url}`)
|
||||
console.log(`\n 📡 Auth request: ${request.method()} ${url}`)
|
||||
}
|
||||
})
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url()
|
||||
if (url.includes('auth') || url.includes('sign-in') || url.includes('password')) {
|
||||
const status = response.status()
|
||||
console.log(`\n 📨 Auth response: ${status} ${url}`)
|
||||
if (status >= 400) {
|
||||
try {
|
||||
const body = await response.text()
|
||||
console.log(`\n ❌ Error response body: ${body}`)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await emailInput.fill(credentials.email)
|
||||
await passwordInput.fill(credentials.password)
|
||||
|
||||
console.log(`\n 🔐 Submitting sign-in form...`)
|
||||
await signInButton.click()
|
||||
|
||||
// Wait for successful sign-in by checking we've navigated away from sign-in page
|
||||
// Could redirect to /organizations, /org/[slug], /new, or /project/default depending on configuration
|
||||
try {
|
||||
await page.waitForURL((url) => !url.pathname.includes('/sign-in'), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
console.log(`\n ✅ Successfully signed in, redirected to: ${page.url()}`)
|
||||
} catch (error) {
|
||||
console.log(`\n ❌ Sign-in timeout. Current URL: ${page.url()}`)
|
||||
console.log(`\n 📡 Auth requests made: ${authRequests.join(', ')}`)
|
||||
|
||||
// Take a screenshot for debugging
|
||||
await page.screenshot({ path: 'test-results/sign-in-failure.png', fullPage: true })
|
||||
console.log(`\n 📸 Screenshot saved to test-results/sign-in-failure.png`)
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE_PATH })
|
||||
}
|
||||
199
e2e/studio/scripts/login/github.ts
Normal file
199
e2e/studio/scripts/login/github.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import * as OTPAuth from 'otpauth'
|
||||
|
||||
import { chromium, Page } from '@playwright/test'
|
||||
import { STORAGE_STATE_PATH } from '../../env.config.js'
|
||||
|
||||
export interface GitHubAuthentication {
|
||||
page: Page
|
||||
githubTotp: string
|
||||
githubUser: string
|
||||
githubPass: string
|
||||
supaDashboard: string
|
||||
}
|
||||
|
||||
const loginWithGithub = async ({
|
||||
page,
|
||||
githubTotp,
|
||||
githubUser,
|
||||
githubPass,
|
||||
supaDashboard,
|
||||
}: GitHubAuthentication) => {
|
||||
// GH auth always uses 2FA and it is not possible to disable it so we use TOTP
|
||||
let totp = new OTPAuth.TOTP({
|
||||
label: 'Github',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: githubTotp,
|
||||
})
|
||||
|
||||
try {
|
||||
// Mock the auth.supabase.io user endpoint that causes CORS errors
|
||||
// This allows the page to load properly and show the login buttons
|
||||
await page.route('**/auth.supabase.io/auth/v1/user', (route) => {
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Unauthorized' }),
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Handle CORS preflight requests
|
||||
await page.route('**/auth.supabase.io/**', (route, request) => {
|
||||
if (request.method() === 'OPTIONS') {
|
||||
route.fulfill({
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
},
|
||||
})
|
||||
} else {
|
||||
route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(supaDashboard)
|
||||
|
||||
// Wait for page to be fully loaded
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
try {
|
||||
console.log('Looking for "Continue with GitHub" button...')
|
||||
const button = page.locator('button:has-text("Continue with GitHub")').first()
|
||||
await button.waitFor({ state: 'visible', timeout: 10000 })
|
||||
console.log('Found button, clicking...')
|
||||
await button.click({ timeout: 5000 })
|
||||
console.log('Clicked, waiting for GitHub redirect...')
|
||||
// Wait for navigation to GitHub
|
||||
await page.waitForURL('**/github.com/**', { timeout: 10000 })
|
||||
console.log('Navigated to GitHub!')
|
||||
} catch (e) {
|
||||
console.log('Failed to find/click "Continue with GitHub", trying "Sign In with GitHub"...', e)
|
||||
const button = page.locator('button:has-text("Sign In with GitHub")').first()
|
||||
await button.waitFor({ state: 'visible', timeout: 10000 })
|
||||
await button.click({ timeout: 5000 })
|
||||
// Wait for navigation to GitHub
|
||||
await page.waitForURL('**/github.com/**', { timeout: 10000 })
|
||||
}
|
||||
|
||||
console.log('Filling GitHub login form...')
|
||||
// Redirected to GitHub: interact with login form
|
||||
await page.fill('input[name="login"]', githubUser)
|
||||
await page.fill('input[name="password"]', githubPass)
|
||||
await page.click('input[name="commit"]')
|
||||
console.log('Submitted credentials, filling 2FA...')
|
||||
|
||||
// Pass 2FA
|
||||
await page.fill('input[name="app_otp"]', totp.generate())
|
||||
// In case verification has not started automatically on code submission
|
||||
try {
|
||||
if (
|
||||
(await page.locator('button[type="submit"]').isVisible()) &&
|
||||
(await page.locator('button[type="submit"]').isEnabled())
|
||||
) {
|
||||
await page.click('button[type="submit"]')
|
||||
}
|
||||
} catch (e) {
|
||||
// that may be cause by auto submit and redirect
|
||||
console.log('2FA auto-submitted or error:', e)
|
||||
}
|
||||
|
||||
// Wait for redirect after 2FA - either to authorization page or directly to Supabase
|
||||
console.log('Waiting for redirect after 2FA...')
|
||||
await page.waitForURL(
|
||||
(url) => {
|
||||
const href = url.href
|
||||
// Either GitHub authorization page or redirect to Supabase
|
||||
return (
|
||||
href.includes('github.com/login/oauth/authorize') ||
|
||||
href.includes('supabase.com') ||
|
||||
href.includes('supabase.io') ||
|
||||
href.includes('supabase.green') ||
|
||||
href.includes('supabase.red')
|
||||
)
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
|
||||
// Check if we landed on GitHub authorization page by looking for the Authorize button
|
||||
console.log('Current URL after 2FA:', page.url())
|
||||
const reauthorizeButton = page.getByRole('button', { name: 'Authorize supabase' })
|
||||
|
||||
// Give a short window to check if the authorize button exists
|
||||
// Use waitFor with a short timeout and catch the error if it doesn't appear
|
||||
const needsAuthorization = await reauthorizeButton
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (needsAuthorization) {
|
||||
console.log('Authorization required, clicking Authorize button...')
|
||||
await reauthorizeButton.click()
|
||||
console.log('Clicked Authorize button, waiting for redirect to Supabase...')
|
||||
|
||||
// Wait for redirect to Supabase after clicking authorize
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.href.includes('supabase.com') ||
|
||||
url.href.includes('supabase.io') ||
|
||||
url.href.includes('supabase.green') ||
|
||||
url.href.includes('supabase.red'),
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
} else {
|
||||
console.log('No authorization needed, already on Supabase')
|
||||
}
|
||||
|
||||
console.log('Redirected to:', page.url())
|
||||
|
||||
// Wait for dashboard to fully load
|
||||
await Promise.race([
|
||||
page.waitForSelector('text=Organizations', { timeout: 30000 }),
|
||||
page.waitForSelector('[data-testid="project-card"]', { timeout: 30000 }),
|
||||
]).catch(() => {
|
||||
console.log('Dashboard load indicator not found, but continuing...')
|
||||
})
|
||||
|
||||
console.log('Organization page loaded successfully')
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE_PATH })
|
||||
} catch (e) {
|
||||
console.error('Authentication failed:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithGithubWithRetry(
|
||||
{ page, githubTotp, githubUser, githubPass, supaDashboard }: GitHubAuthentication,
|
||||
retries = 3
|
||||
) {
|
||||
const signInUrl = `${supaDashboard}/sign-in`
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
console.log(`Authentication attempt ${i + 1} of ${retries}...`)
|
||||
await loginWithGithub({
|
||||
page,
|
||||
githubTotp,
|
||||
githubUser,
|
||||
githubPass,
|
||||
supaDashboard: signInUrl,
|
||||
})
|
||||
console.log('Authentication successful!')
|
||||
return
|
||||
} catch (e) {
|
||||
console.log(`Attempt ${i + 1} failed:`, e)
|
||||
if (i === retries - 1) throw e
|
||||
// Optional: add a small delay before retry
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -6,63 +6,12 @@ settings:
|
||||
|
||||
catalogs:
|
||||
default:
|
||||
'@sentry/nextjs':
|
||||
specifier: ^10.26.0
|
||||
version: 10.27.0
|
||||
'@supabase/auth-js':
|
||||
specifier: 2.87.0
|
||||
version: 2.87.0
|
||||
'@supabase/postgrest-js':
|
||||
specifier: 2.87.0
|
||||
version: 2.87.0
|
||||
'@supabase/realtime-js':
|
||||
specifier: 2.87.0
|
||||
version: 2.87.0
|
||||
'@supabase/supabase-js':
|
||||
specifier: 2.87.0
|
||||
version: 2.87.0
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.13.14
|
||||
'@types/react':
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.3
|
||||
'@types/react-dom':
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.0
|
||||
next:
|
||||
specifier: ^15.5.9
|
||||
version: 15.5.9
|
||||
react:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1
|
||||
recharts:
|
||||
specifier: ^2.15.4
|
||||
version: 2.15.4
|
||||
tailwindcss:
|
||||
specifier: 3.4.1
|
||||
version: 3.4.1
|
||||
tsx:
|
||||
specifier: 4.20.3
|
||||
version: 4.20.3
|
||||
typescript:
|
||||
specifier: ~5.9.0
|
||||
version: 5.9.2
|
||||
valtio:
|
||||
specifier: ^1.12.0
|
||||
version: 1.12.0
|
||||
vite:
|
||||
specifier: ^7.1.11
|
||||
version: 7.1.11
|
||||
vitest:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.4
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
|
||||
overrides:
|
||||
'@eslint/eslintrc>js-yaml': ^4.1.1
|
||||
@@ -1890,6 +1839,9 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^16.5.0
|
||||
version: 16.5.0
|
||||
otpauth:
|
||||
specifier: ^9.4.1
|
||||
version: 9.4.1
|
||||
devDependencies:
|
||||
'@faker-js/faker':
|
||||
specifier: ^9.9.0
|
||||
@@ -15591,6 +15543,9 @@ packages:
|
||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
otpauth@9.4.1:
|
||||
resolution: {integrity: sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==}
|
||||
|
||||
outdent@0.8.0:
|
||||
resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==}
|
||||
|
||||
@@ -36395,6 +36350,10 @@ snapshots:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.1.0
|
||||
|
||||
otpauth@9.4.1:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
outdent@0.8.0: {}
|
||||
|
||||
outvariant@1.4.0: {}
|
||||
|
||||
Reference in New Issue
Block a user