import { spawn, spawnSync } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import process, { env } from 'node:process' import { getPlaywrightStripeApiBaseUrl } from './playwright-stripe' import { getSupabaseWorktreeConfig } from './supabase-worktree-config' import { getSupabaseStatus, type SupabaseStatus } from './supabase-worktree-status' const repoRoot = process.cwd() const sourceEnvPath = resolve(repoRoot, 'supabase/functions/.env') const generatedEnvPath = resolve(repoRoot, '.context/playwright/supabase-functions.playwright.env') const supabaseConfig = getSupabaseWorktreeConfig(repoRoot) const stripeApiBaseUrl = getPlaywrightStripeApiBaseUrl(env) const webAppUrl = env.WEBAPP_URL || 'http://localhost:5173' const functionsReadyTimeoutMs = Number(env.PLAYWRIGHT_BACKEND_TIMEOUT_MS || '360000') // Comma-separated Supabase services to skip (`supabase start -x`). CI skips the ones E2E // never touches so a cold runner pulls fewer images before the stack is healthy. const supabaseStartExclude = env.PLAYWRIGHT_SUPABASE_EXCLUDE?.trim() function upsertEnvValue(content: string, key: string, value: string): string { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const matcher = new RegExp(`^${escapedKey}=.*$`, 'm') const line = `${key}=${value}` if (matcher.test(content)) return content.replace(matcher, line) return content.endsWith('\n') || content.length === 0 ? `${content}${line}\n` : `${content}\n${line}\n` } const baseEnv = existsSync(sourceEnvPath) ? readFileSync(sourceEnvPath, 'utf8') : '' const overriddenEnv = [ ['ENV', env.ENV || 'local'], ['S3_ENDPOINT', `127.0.0.1:${supabaseConfig.ports.api}/storage/v1/s3`], ['STRIPE_SECRET_KEY', env.STRIPE_SECRET_KEY || 'sk_test_emulator'], ['STRIPE_SECRET_KEY_US', env.STRIPE_SECRET_KEY_US || env.STRIPE_SECRET_KEY || 'sk_test_emulator'], ['STRIPE_API_BASE_URL', stripeApiBaseUrl], ['STRIPE_WEBHOOK_SECRET', env.STRIPE_WEBHOOK_SECRET || 'testsecret'], ['STRIPE_WEBHOOK_SECRET_US', env.STRIPE_WEBHOOK_SECRET_US || env.STRIPE_WEBHOOK_SECRET || 'testsecret'], ['WEBAPP_URL', webAppUrl], ] as const function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } function hasHealthySupabaseApi(status: SupabaseStatus | null) { return Boolean( status?.API_URL && (status?.ANON_KEY || status?.PUBLISHABLE_KEY) && (status?.SERVICE_ROLE_KEY || status?.SECRET_KEY), ) } function stopSupabase() { spawnSync('bun', ['run', 'supabase:stop'], { cwd: repoRoot, stdio: 'inherit', env: process.env, }) } function stopExistingPlaywrightBackend() { spawnSync('pkill', ['-f', 'supabase-functions.playwright.env'], { cwd: repoRoot, stdio: 'ignore', env: process.env, }) } async function resetSupabaseDb() { const maxAttempts = 3 for (let attempt = 1; attempt <= maxAttempts; attempt++) { const resetResult = spawnSync('bun', ['run', 'supabase:db:reset'], { cwd: repoRoot, stdio: 'inherit', env: process.env, }) if ((resetResult.status ?? 1) === 0) return stopSupabase() if (attempt === maxAttempts) process.exit(resetResult.status ?? 1) await sleep(attempt * 2000) await ensureSupabaseStarted() } } async function ensureSupabaseStarted() { const maxAttempts = 4 for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (hasHealthySupabaseApi(getSupabaseStatus())) return const startResult = spawnSync('bun', ['run', 'supabase:start', ...(supabaseStartExclude ? ['-x', supabaseStartExclude] : [])], { cwd: repoRoot, stdio: 'inherit', env: process.env, }) if ((startResult.status ?? 1) === 0 && hasHealthySupabaseApi(getSupabaseStatus())) return stopSupabase() if (attempt === maxAttempts) process.exit(startResult.status ?? 1) await sleep(attempt * 2000) } } async function waitForFunctionsReady(timeoutMs: number) { const apiUrl = getSupabaseStatus()?.API_URL || `http://127.0.0.1:${supabaseConfig.ports.api}` // The console's first paint now goes through the `private` function // (/private/console/query), so warm it too instead of only the `ok` probe. const targetUrls = [`${apiUrl}/functions/v1/ok`, `${apiUrl}/functions/v1/private/config`] const startedAt = Date.now() for (const targetUrl of targetUrls) { let ready = false while (!ready && Date.now() - startedAt < timeoutMs) { try { const response = await fetch(targetUrl) ready = response.ok } catch { // Keep polling until the edge runtime serves requests again. } if (!ready) await sleep(1000) } if (!ready) throw new Error(`Timed out waiting for Supabase functions at ${targetUrl}`) } } async function stopChildProcess(child: ReturnType, signal: NodeJS.Signals = 'SIGTERM') { if (child.exitCode !== null) return const exitPromise = new Promise((resolve) => { child.once('exit', () => resolve()) }) child.kill(signal) await Promise.race([ exitPromise, sleep(5000).then(() => { if (child.exitCode === null) child.kill('SIGKILL') return exitPromise }), ]) } let envFileContent = baseEnv for (const [key, value] of overriddenEnv) envFileContent = upsertEnvValue(envFileContent, key, value) mkdirSync(dirname(generatedEnvPath), { recursive: true }) writeFileSync(generatedEnvPath, envFileContent) stopExistingPlaywrightBackend() await ensureSupabaseStarted() // Playwright E2E expects the seeded schema helpers and deterministic fixture data. if (!env.SKIP_SUPABASE_DB_RESET) { await resetSupabaseDb() await ensureSupabaseStarted() } const child = spawn('bun', ['scripts/supabase-worktree.ts', 'functions', 'serve', '--env-file', generatedEnvPath], { cwd: repoRoot, stdio: 'inherit', env: process.env, }) let childStarted = false try { await waitForFunctionsReady(functionsReadyTimeoutMs) childStarted = true } finally { if (!childStarted) await stopChildProcess(child) } if (env.PLAYWRIGHT_READY_FILE) { mkdirSync(dirname(env.PLAYWRIGHT_READY_FILE), { recursive: true }) writeFileSync(env.PLAYWRIGHT_READY_FILE, 'ready\n') } const signalHandlers = new Map void>() function forwardSignal(signal: NodeJS.Signals) { const handler = () => { child.kill(signal) } signalHandlers.set(signal, handler) process.on(signal, handler) } forwardSignal('SIGINT') forwardSignal('SIGTERM') child.on('exit', (code, signal) => { if (signal) { for (const [registeredSignal, handler] of signalHandlers) process.off(registeredSignal, handler) process.kill(process.pid, signal) return } process.exit(code ?? 0) })