{"content":"# Neon Test Branches\n\nCreate isolated Neon database branches for testing. Schema-only branches with auto-cleanup via TTL, test server orchestration, and environment variable management.\n\n## Environment Variables\n\nFirst, create a script config file for the Neon API access:\n\n```typescript\n// scripts/tests/config.ts\nimport { configSchema, server } from \"better-env/config-schema\";\n\nexport const neonConfig = configSchema(\"Neon\", {\n  apiKey: server({ env: \"NEON_API_KEY\" }),\n  projectId: server({ env: \"NEON_PROJECT_ID\" }),\n});\n```\n\nAdd the environment variables to `.env.local`:\n\n```\nNEON_API_KEY=your-neon-api-key\nNEON_PROJECT_ID=your-neon-project-id\n```\n\nGet the API key from the Neon Console under Account Settings > API Keys. The project ID is visible in your project's dashboard URL.\n\n---\n\n## Create Branch Script\n\nCreate `scripts/tests/create-branch.ts` to create isolated database branches for testing:\n\n```typescript\n// scripts/tests/create-branch.ts\n#!/usr/bin/env bun\n/**\n * Create a Neon database branch for testing\n *\n * Usage:\n *   bun run scripts/tests/create-branch.ts\n *   bun run scripts/tests/create-branch.ts --name=\"test-branch-name\"\n *\n * The branch is created with:\n * - Schema-only (no data from parent, schema already applied)\n * - 1 hour TTL (auto-deletes after expiration)\n * - A read-write compute endpoint with auto-suspend\n */\n\nimport { loadEnvConfig } from \"@next/env\";\nloadEnvConfig(process.cwd());\n\nimport { neonConfig } from \"./config\";\n\nconst green = (s: string) => `\\x1b[32m${s}\\x1b[0m`;\nconst yellow = (s: string) => `\\x1b[33m${s}\\x1b[0m`;\nconst red = (s: string) => `\\x1b[31m${s}\\x1b[0m`;\nconst dim = (s: string) => `\\x1b[2m${s}\\x1b[0m`;\nconst bold = (s: string) => `\\x1b[1m${s}\\x1b[0m`;\n\nexport class BranchAlreadyExistsError extends Error {\n  constructor(branchName: string) {\n    super(`Branch \"${branchName}\" already exists`);\n    this.name = \"BranchAlreadyExistsError\";\n  }\n}\n\nfunction parseArgs(): { name?: string; ttlHours?: number } {\n  const args = process.argv.slice(2);\n  const result: { name?: string; ttlHours?: number } = {};\n\n  for (const arg of args) {\n    if (arg.startsWith(\"--name=\")) {\n      result.name = arg.split(\"=\")[1];\n    }\n    if (arg.startsWith(\"--ttl=\")) {\n      result.ttlHours = parseInt(arg.split(\"=\")[1], 10);\n    }\n  }\n\n  return result;\n}\n\ntype NeonBranchResponse = {\n  branch: {\n    id: string;\n    name: string;\n    project_id: string;\n    parent_id: string;\n    created_at: string;\n  };\n  endpoints: Array<{\n    id: string;\n    host: string;\n    branch_id: string;\n  }>;\n  connection_uris: Array<{\n    connection_uri: string;\n    connection_parameters: {\n      database: string;\n      role: string;\n      host: string;\n      pooler_host: string;\n    };\n  }>;\n};\n\nexport async function createBranch(\n  branchName?: string,\n  ttlHours: number = 1,\n): Promise<{\n  branchId: string;\n  branchName: string;\n  connectionString: string;\n}> {\n  const { apiKey, projectId } = neonConfig.server;\n\n  const name = branchName || `test-${Date.now()}`;\n\n  // Calculate expiration timestamp (TTL)\n  const expiresAt = new Date(\n    Date.now() + ttlHours * 60 * 60 * 1000,\n  ).toISOString();\n\n  console.log(bold(\"\\nCreating Neon test branch...\\n\"));\n  console.log(dim(`  Project ID: ${projectId}`));\n  console.log(dim(`  Branch name: ${name}`));\n  console.log(dim(`  TTL: ${ttlHours} hour(s) (expires at ${expiresAt})\\n`));\n\n  const response = await fetch(\n    `https://console.neon.tech/api/v2/projects/${projectId}/branches`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n        \"Content-Type\": \"application/json\",\n        Accept: \"application/json\",\n      },\n      body: JSON.stringify({\n        branch: {\n          name,\n          // Schema-only: copies schema from main without data\n          init_source: \"schema-only\",\n          // Auto-delete after TTL\n          expires_at: expiresAt,\n        },\n        endpoints: [\n          {\n            type: \"read_write\",\n            settings: {\n              suspend_timeout_seconds: 300,\n              autoscaling_limit_min_cu: 0.25,\n              autoscaling_limit_max_cu: 1,\n            },\n          },\n        ],\n      }),\n    },\n  );\n\n  if (!response.ok) {\n    const errorText = await response.text();\n\n    if (response.status === 409 || errorText.includes(\"already exists\")) {\n      throw new BranchAlreadyExistsError(name);\n    }\n\n    throw new Error(\n      `Failed to create branch: ${response.status} - ${errorText}`,\n    );\n  }\n\n  const data: NeonBranchResponse = await response.json();\n\n  const connectionUri = data.connection_uris[0];\n  if (!connectionUri) {\n    throw new Error(\"No connection URI returned from Neon API\");\n  }\n\n  // Use pooler host for better connection handling\n  const poolerConnectionString = connectionUri.connection_uri.replace(\n    connectionUri.connection_parameters.host,\n    connectionUri.connection_parameters.pooler_host,\n  );\n\n  console.log(green(`  ✓ Branch created: ${data.branch.name}`));\n  console.log(dim(`    Branch ID: ${data.branch.id}`));\n  console.log(dim(`    Endpoint: ${data.endpoints[0]?.host}`));\n  console.log(\"\");\n\n  return {\n    branchId: data.branch.id,\n    branchName: data.branch.name,\n    connectionString: poolerConnectionString,\n  };\n}\n\nasync function main(): Promise<void> {\n  const args = parseArgs();\n\n  try {\n    const result = await createBranch(args.name, args.ttlHours);\n\n    console.log(bold(\"Connection String:\\n\"));\n    console.log(yellow(result.connectionString));\n    console.log(\"\");\n\n    console.log(dim(\"Set this as DATABASE_URL for your test run:\"));\n    console.log(dim(`  DATABASE_URL=\"${result.connectionString}\" bun test`));\n    console.log(\"\");\n\n    process.exit(0);\n  } catch (error) {\n    if (error instanceof BranchAlreadyExistsError) {\n      console.error(red(\"\\nBranch already exists:\"));\n      console.error(red(error.message));\n      console.error(dim(\"Use a different branch name with --name=<name>\"));\n    } else {\n      console.error(red(\"\\nError creating branch:\"));\n      console.error(error instanceof Error ? error.message : error);\n    }\n    process.exit(1);\n  }\n}\n\nif (import.meta.main) {\n  main();\n}\n```\n\n---\n\n## Neon Config File\n\nCreate the config file at `scripts/tests/config.ts`:\n\n```typescript\nimport { configSchema, server } from \"better-env/config-schema\";\n\nexport const neonConfig = configSchema(\"Neon\", {\n  apiKey: server({ env: \"NEON_API_KEY\" }),\n  projectId: server({ env: \"NEON_PROJECT_ID\" }),\n});\n```\n\n---\n\n## Test Server Script\n\nCreate `scripts/tests/test-server.ts` to manage the Next.js dev server during tests:\n\n```typescript\n#!/usr/bin/env bun\n/**\n * Test Server Manager\n *\n * Starts a Next.js dev server for testing with custom environment variables.\n * Kills any existing server on the port first, then starts a fresh one.\n */\n\nimport { spawn, spawnSync, type ChildProcess } from \"child_process\";\n\nconst green = (s: string) => `\\x1b[32m${s}\\x1b[0m`;\nconst yellow = (s: string) => `\\x1b[33m${s}\\x1b[0m`;\nconst red = (s: string) => `\\x1b[31m${s}\\x1b[0m`;\nconst dim = (s: string) => `\\x1b[2m${s}\\x1b[0m`;\n\nexport type TestServerOptions = {\n  port: number;\n  databaseUrl: string;\n  timeout?: number;\n};\n\nexport type TestServer = {\n  port: number;\n  url: string;\n  kill: () => void;\n};\n\nfunction killProcessOnPort(port: number): boolean {\n  const lsofResult = spawnSync(\"lsof\", [\"-ti\", `:${port}`], {\n    encoding: \"utf-8\",\n  });\n\n  const pids = lsofResult.stdout\n    .trim()\n    .split(\"\\n\")\n    .filter((pid) => pid.length > 0);\n\n  if (pids.length === 0) {\n    return false;\n  }\n\n  for (const pid of pids) {\n    spawnSync(\"kill\", [\"-9\", pid]);\n  }\n\n  return true;\n}\n\nasync function waitForServer(url: string, timeout: number): Promise<boolean> {\n  const startTime = Date.now();\n  const pollInterval = 500;\n\n  while (Date.now() - startTime < timeout) {\n    try {\n      const response = await fetch(url, {\n        method: \"HEAD\",\n        signal: AbortSignal.timeout(2000),\n      });\n      if (response.ok || response.status === 404) {\n        return true;\n      }\n    } catch {\n      // Server not ready yet\n    }\n    await new Promise((resolve) => setTimeout(resolve, pollInterval));\n  }\n\n  return false;\n}\n\nexport async function startTestServer(\n  options: TestServerOptions,\n): Promise<TestServer> {\n  const { port, databaseUrl, timeout = 60000 } = options;\n  const url = `http://localhost:${port}`;\n\n  console.log(dim(`  Checking for existing process on port ${port}...`));\n  const killed = killProcessOnPort(port);\n  if (killed) {\n    console.log(yellow(`  Killed existing process on port ${port}`));\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n  }\n\n  console.log(dim(`  Starting test server on port ${port}...`));\n\n  const serverProcess: ChildProcess = spawn(\n    \"bun\",\n    [\"run\", \"next\", \"dev\", \"-p\", String(port)],\n    {\n      env: {\n        ...process.env,\n        PORT: String(port),\n        DATABASE_URL: databaseUrl,\n        NODE_ENV: \"test\",\n        NEXT_TELEMETRY_DISABLED: \"1\",\n      },\n      stdio: [\"ignore\", \"pipe\", \"pipe\"],\n      detached: false,\n    },\n  );\n\n  let serverOutput = \"\";\n  serverProcess.stdout?.on(\"data\", (data) => {\n    serverOutput += data.toString();\n  });\n  serverProcess.stderr?.on(\"data\", (data) => {\n    serverOutput += data.toString();\n  });\n\n  serverProcess.on(\"error\", (error) => {\n    console.error(red(`  Test server process error: ${error.message}`));\n  });\n\n  console.log(\n    dim(`  Waiting for server to be ready (timeout: ${timeout / 1000}s)...`),\n  );\n  const isReady = await waitForServer(url, timeout);\n\n  if (!isReady) {\n    serverProcess.kill(\"SIGTERM\");\n    console.error(red(\"\\n  Server output:\"));\n    console.error(dim(serverOutput.slice(-2000)));\n    throw new Error(`Test server failed to start within ${timeout / 1000}s`);\n  }\n\n  console.log(green(`  ✓ Test server ready at ${url}`));\n\n  return {\n    port,\n    url,\n    kill: () => {\n      if (!serverProcess.killed) {\n        console.log(dim(`  Stopping test server...`));\n        serverProcess.kill(\"SIGTERM\");\n\n        setTimeout(() => {\n          if (!serverProcess.killed) {\n            serverProcess.kill(\"SIGKILL\");\n          }\n        }, 5000);\n      }\n    },\n  };\n}\n```\n\n---\n\n## Test Orchestration Script\n\nCreate `scripts/tests/test-with-branch.ts` to orchestrate the full test workflow:\n\n```typescript\n#!/usr/bin/env bun\n/**\n * Run tests with a fresh Neon database branch and isolated test server\n *\n * Usage:\n *   bun run scripts/tests/test-with-branch.ts                    # Run all tests\n *   bun run scripts/tests/test-with-branch.ts --unit             # Run only unit tests\n *   bun run scripts/tests/test-with-branch.ts --integration      # Run only integration tests\n *   bun run scripts/tests/test-with-branch.ts --playwright       # Run only Playwright tests\n *   bun run scripts/tests/test-with-branch.ts -- --timeout 60000\n *\n * This script:\n * 1. Creates a schema-only branch from main (with 1hr TTL)\n * 2. Kills any existing server on port 3000\n * 3. Starts a test server on port 3000 with the branch's DATABASE_URL\n * 4. Runs unit tests (src/**/*.test.ts)\n * 5. Runs integration tests (tests/integration/)\n * 6. Runs Playwright tests (tests/playwright/)\n * 7. Cleans up the test server (branch auto-deletes via TTL)\n */\n\nimport { loadEnvConfig } from \"@next/env\";\nloadEnvConfig(process.cwd());\n\nimport { spawn } from \"child_process\";\nimport { createBranch, BranchAlreadyExistsError } from \"./create-branch\";\nimport { startTestServer, type TestServer } from \"./test-server\";\n\nconst green = (s: string) => `\\x1b[32m${s}\\x1b[0m`;\nconst red = (s: string) => `\\x1b[31m${s}\\x1b[0m`;\nconst bold = (s: string) => `\\x1b[1m${s}\\x1b[0m`;\nconst dim = (s: string) => `\\x1b[2m${s}\\x1b[0m`;\n\nconst TEST_SERVER_PORT = 3000;\n\nasync function runCommand(\n  command: string,\n  args: string[],\n  env: Record<string, string>,\n): Promise<number> {\n  return new Promise((resolve, reject) => {\n    const child = spawn(command, args, {\n      stdio: \"inherit\",\n      env: { ...process.env, ...env },\n    });\n\n    child.on(\"error\", reject);\n    child.on(\"close\", (code) => resolve(code ?? 1));\n  });\n}\n\nasync function main() {\n  const args = process.argv.slice(2);\n  const runUnitOnly = args.includes(\"--unit\");\n  const runIntegrationOnly = args.includes(\"--integration\");\n  const runPlaywrightOnly = args.includes(\"--playwright\");\n  const runAll = !runUnitOnly && !runIntegrationOnly && !runPlaywrightOnly;\n\n  const dashDashIndex = args.indexOf(\"--\");\n  const extraArgs = dashDashIndex >= 0 ? args.slice(dashDashIndex + 1) : [];\n\n  let testServer: TestServer | null = null;\n\n  try {\n    const branchName = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n    console.log(bold(\"\\n=== Creating test database branch ===\\n\"));\n    const branch = await createBranch(branchName);\n\n    console.log(bold(\"\\n=== Starting test server ===\\n\"));\n    testServer = await startTestServer({\n      port: TEST_SERVER_PORT,\n      databaseUrl: branch.connectionString,\n      timeout: 90000,\n    });\n\n    const testEnv = {\n      DATABASE_URL: branch.connectionString,\n      TEST_BASE_URL: testServer.url,\n      NODE_ENV: \"test\",\n    };\n\n    console.log(bold(\"\\n=== Test environment ===\\n\"));\n    console.log(dim(`  TEST_BASE_URL: ${testServer.url}`));\n    console.log(\n      dim(`  DATABASE_URL: ${branch.connectionString.slice(0, 50)}...`),\n    );\n    console.log(\"\");\n\n    let unitTestCode = 0;\n    let integrationTestCode = 0;\n    let playwrightTestCode = 0;\n\n    if (runAll || runUnitOnly) {\n      console.log(bold(\"\\n=== Running unit tests (src/) ===\\n\"));\n      unitTestCode = await runCommand(\n        \"bun\",\n        [\"test\", \"src\", ...extraArgs],\n        testEnv,\n      );\n\n      if (unitTestCode === 0) {\n        console.log(green(bold(\"\\n=== Unit tests passed ===\\n\")));\n      } else {\n        console.log(red(bold(\"\\n=== Unit tests failed ===\\n\")));\n      }\n    }\n\n    if (runAll || runIntegrationOnly) {\n      console.log(bold(\"\\n=== Running integration tests ===\\n\"));\n      integrationTestCode = await runCommand(\n        \"bun\",\n        [\"test\", \"tests/integration\", ...extraArgs],\n        testEnv,\n      );\n\n      if (integrationTestCode === 0) {\n        console.log(green(bold(\"\\n=== Integration tests passed ===\\n\")));\n      } else {\n        console.log(red(bold(\"\\n=== Integration tests failed ===\\n\")));\n      }\n    }\n\n    if (runAll || runPlaywrightOnly) {\n      console.log(bold(\"\\n=== Running Playwright tests ===\\n\"));\n      playwrightTestCode = await runCommand(\n        \"bunx\",\n        [\"playwright\", \"test\", ...extraArgs],\n        testEnv,\n      );\n\n      if (playwrightTestCode === 0) {\n        console.log(green(bold(\"\\n=== Playwright tests passed ===\\n\")));\n      } else {\n        console.log(red(bold(\"\\n=== Playwright tests failed ===\\n\")));\n      }\n    }\n\n    const finalCode = unitTestCode || integrationTestCode || playwrightTestCode;\n    if (finalCode === 0) {\n      console.log(green(bold(\"\\n=== All tests passed ===\\n\")));\n    } else {\n      console.log(red(bold(\"\\n=== Some tests failed ===\\n\")));\n    }\n\n    return finalCode;\n  } catch (error) {\n    if (error instanceof BranchAlreadyExistsError) {\n      console.error(\n        red(\"\\nBranch already exists - aborting.\"),\n      );\n      return 1;\n    }\n\n    console.error(red(\"\\nError running tests:\"));\n    console.error(error instanceof Error ? error.message : error);\n    return 1;\n  } finally {\n    if (testServer) {\n      console.log(dim(\"\\n=== Cleaning up ===\\n\"));\n      testServer.kill();\n    }\n  }\n}\n\nmain()\n  .then((code) => process.exit(code))\n  .catch((error) => {\n    console.error(red(\"\\nUnexpected error:\"));\n    console.error(error);\n    process.exit(1);\n  });\n```\n\n---\n\n## Package.json Scripts\n\nAdd test scripts to `package.json`:\n\n```json\n{\n  \"scripts\": {\n    \"test\": \"bun run scripts/tests/test-with-branch.ts\",\n    \"test:unit\": \"bun run scripts/tests/test-with-branch.ts --unit\",\n    \"test:integration\": \"bun run scripts/tests/test-with-branch.ts --integration\",\n    \"test:playwright\": \"bun run scripts/tests/test-with-branch.ts --playwright\",\n    \"db:branch:create\": \"bun run scripts/tests/create-branch.ts\"\n  }\n}\n```\n\n---\n\n## How It Works\n\n1. **Branch Creation**: Creates a schema-only branch from the main database with a 1-hour TTL\n2. **Server Start**: Starts Next.js with the branch's `DATABASE_URL`\n3. **Test Execution**: Runs tests against the isolated environment\n4. **Auto-Cleanup**: Branch auto-deletes after TTL expires (no manual cleanup needed)\n\nSchema-only branches contain the database structure but no data, ensuring tests start with a clean slate."}