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:
Ali Waseem
2025-12-19 10:48:27 -07:00
committed by GitHub
parent c6fc750b0b
commit e07a3e2838
11 changed files with 516 additions and 261 deletions

View File

@@ -10,5 +10,7 @@ API_URL=http://localhost:8080
IS_PLATFORM=true
ORG_SLUG=
SUPA_PAT=
EMAIL=
PASSWORD=

View 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=

View File

@@ -5,4 +5,5 @@ node_modules/
/playwright/.cache/
/playwright/.auth/
.env.staging
keys.json
keys.json
browserContext-*

View File

@@ -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`)

View File

@@ -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'),

View File

@@ -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 })
})

View File

@@ -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",

View File

@@ -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
],

View 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 })
}

View 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
View File

@@ -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: {}