Skip to content

Commit f5aaaef

Browse files
committed
feat(e2e): add test fixtures for CLI session and OAuth
- Add cli-session.ts with tuistory PTY emulation and file-based IPC - Add oauth-helpers.ts for GitHub OAuth automation with TOTP support - Add infra.ts for Docker database and web server management - Add test-context.ts with Playwright test fixtures
1 parent 56b7ca7 commit f5aaaef

File tree

4 files changed

+661
-0
lines changed

4 files changed

+661
-0
lines changed

e2e/fixtures/cli-session.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/**
2+
* CLI session fixture for e2e tests
3+
* Wraps tuistory with login URL capture capability
4+
*/
5+
6+
import path from 'path'
7+
import fs from 'fs'
8+
import os from 'os'
9+
import { fileURLToPath } from 'url'
10+
import { launchTerminal } from 'tuistory'
11+
12+
import type { E2EServer } from './infra'
13+
14+
const __filename = fileURLToPath(import.meta.url)
15+
const __dirname = path.dirname(__filename)
16+
const CLI_PATH = path.join(__dirname, '../../cli/src/index.tsx')
17+
18+
type TerminalSession = Awaited<ReturnType<typeof launchTerminal>>
19+
20+
/**
21+
* Status written by CLI to coordination file for e2e tests
22+
*/
23+
interface E2ELoginUrlStatus {
24+
status: 'pending' | 'ready' | 'error'
25+
loginUrl?: string
26+
error?: string
27+
timestamp: number
28+
}
29+
30+
export interface CLISession {
31+
terminal: TerminalSession
32+
credentialsDir: string
33+
e2eUrlFile: string
34+
/**
35+
* Wait for CLI to provide a login URL via file-based IPC
36+
*/
37+
waitForLoginUrl: (timeoutMs?: number) => Promise<string>
38+
/**
39+
* Get the current terminal text
40+
*/
41+
text: () => Promise<string>
42+
/**
43+
* Wait for text to appear in terminal
44+
*/
45+
waitForText: (pattern: string | RegExp, options?: { timeout?: number }) => Promise<void>
46+
/**
47+
* Type text into the terminal
48+
*/
49+
type: (text: string) => Promise<void>
50+
/**
51+
* Press a key or key combination
52+
*/
53+
press: (key: string | string[]) => Promise<void>
54+
/**
55+
* Close the CLI session and clean up
56+
*/
57+
close: () => Promise<void>
58+
}
59+
60+
export interface LaunchCLIOptions {
61+
server: E2EServer
62+
args?: string[]
63+
cols?: number
64+
rows?: number
65+
/** API key override - omit or set to undefined to force login flow, or provide a string to use specific key */
66+
apiKey?: string
67+
}
68+
69+
function sleep(ms: number): Promise<void> {
70+
return new Promise((resolve) => setTimeout(resolve, ms))
71+
}
72+
73+
/**
74+
* Get a unique credentials directory for a session
75+
*/
76+
function getCredentialsDir(sessionId: string): string {
77+
return path.join(os.tmpdir(), `codebuff-e2e-oauth-${sessionId}`)
78+
}
79+
80+
/**
81+
* Clean up credentials directory
82+
*/
83+
function cleanupCredentialsDir(credentialsDir: string): void {
84+
try {
85+
if (fs.existsSync(credentialsDir)) {
86+
fs.rmSync(credentialsDir, { recursive: true, force: true })
87+
}
88+
} catch {
89+
// Ignore cleanup errors
90+
}
91+
}
92+
93+
/**
94+
* Launch CLI session for login flow testing
95+
* The CLI will print login URLs instead of opening browser when CODEBUFF_E2E_NO_BROWSER=true
96+
*/
97+
export async function launchCLISession(options: LaunchCLIOptions): Promise<CLISession> {
98+
const { server, args = [], cols = 120, rows = 30 } = options
99+
const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
100+
const credentialsDir = getCredentialsDir(sessionId)
101+
const e2eUrlFile = path.join(os.tmpdir(), `codebuff-e2e-url-${sessionId}.json`)
102+
103+
// Ensure credentials directory exists
104+
fs.mkdirSync(credentialsDir, { recursive: true })
105+
106+
// Create config directory structure
107+
// Note: We use 'manicode-dev' because the CLI reads NEXT_PUBLIC_CB_ENVIRONMENT from
108+
// .env.local (which is 'dev') before our --env-file overrides take effect.
109+
// The important thing is that this directory is empty (no credentials.json),
110+
// which triggers the login flow.
111+
const configDir = path.join(credentialsDir, '.config', 'manicode-dev')
112+
fs.mkdirSync(configDir, { recursive: true })
113+
114+
// Build a minimal environment for CLI to prevent inheriting CODEBUFF_API_KEY from parent
115+
// Bun inherits process.env from parent, so we must NOT spread it to avoid auth bypass
116+
// Only include essential system vars and explicitly set test-specific vars
117+
const essentialVars = ['PATH', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL', 'TMPDIR']
118+
const cliEnv: Record<string, string> = {}
119+
120+
// Copy only essential system variables
121+
for (const key of essentialVars) {
122+
if (process.env[key]) {
123+
cliEnv[key] = process.env[key] as string
124+
}
125+
}
126+
127+
// Set test-specific environment variables
128+
// All NEXT_PUBLIC_* vars are required by the env schema validation
129+
Object.assign(cliEnv, {
130+
// Point CLI to the e2e test server
131+
NEXT_PUBLIC_CODEBUFF_APP_URL: server.url,
132+
NEXT_PUBLIC_CODEBUFF_BACKEND_URL: server.backendUrl,
133+
// Use dev environment (matches what .env.local would normally set)
134+
NEXT_PUBLIC_CB_ENVIRONMENT: 'dev',
135+
// Required env vars from clientEnvSchema (use test values or inherit from parent)
136+
NEXT_PUBLIC_SUPPORT_EMAIL: process.env.NEXT_PUBLIC_SUPPORT_EMAIL || 'test@example.com',
137+
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY || 'test-posthog-key',
138+
NEXT_PUBLIC_POSTHOG_HOST_URL: process.env.NEXT_PUBLIC_POSTHOG_HOST_URL || 'https://app.posthog.com',
139+
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_placeholder',
140+
NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: process.env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL || 'https://billing.stripe.com/test',
141+
NEXT_PUBLIC_WEB_PORT: process.env.NEXT_PUBLIC_WEB_PORT || '3011',
142+
// Override HOME to use isolated credentials directory
143+
HOME: credentialsDir,
144+
XDG_CONFIG_HOME: path.join(credentialsDir, '.config'),
145+
// Disable browser opening - use file-based IPC instead
146+
CODEBUFF_E2E_NO_BROWSER: 'true',
147+
// File for login URL coordination (file-based IPC)
148+
CODEBUFF_E2E_URL_FILE: e2eUrlFile,
149+
// Disable file logs
150+
CODEBUFF_DISABLE_FILE_LOGS: 'true',
151+
})
152+
153+
// Handle API key based on options:
154+
// - apiKey undefined: don't set CODEBUFF_API_KEY at all to force login flow
155+
// - apiKey string: use the provided API key (valid or invalid for testing)
156+
if (options.apiKey !== undefined) {
157+
cliEnv.CODEBUFF_API_KEY = options.apiKey
158+
}
159+
// When apiKey is undefined, we simply don't include CODEBUFF_API_KEY in the env
160+
161+
// Launch CLI with tuistory
162+
// IMPORTANT: Run from credentialsDir (which has no .env.local) to prevent
163+
// Bun from loading .env.local from project root which contains CODEBUFF_API_KEY
164+
// CLI_PATH is absolute so it will still find the source files
165+
const terminal = await launchTerminal({
166+
command: 'bun',
167+
args: ['run', CLI_PATH, ...args],
168+
cols,
169+
rows,
170+
env: cliEnv,
171+
cwd: credentialsDir, // Run from isolated dir to prevent .env.local loading
172+
})
173+
174+
// Create reliable typing helper
175+
const originalPress = terminal.press.bind(terminal)
176+
const reliableType = async (text: string) => {
177+
for (const char of text) {
178+
if (char === ' ') {
179+
await originalPress('space')
180+
} else {
181+
await originalPress(char as any)
182+
}
183+
await sleep(35)
184+
}
185+
}
186+
187+
const session: CLISession = {
188+
terminal,
189+
credentialsDir,
190+
e2eUrlFile,
191+
192+
async waitForLoginUrl(timeoutMs = 30000): Promise<string> {
193+
const startTime = Date.now()
194+
195+
while (Date.now() - startTime < timeoutMs) {
196+
// Check file-based IPC for login URL
197+
if (fs.existsSync(e2eUrlFile)) {
198+
try {
199+
const content = fs.readFileSync(e2eUrlFile, 'utf8')
200+
const status: E2ELoginUrlStatus = JSON.parse(content)
201+
202+
if (status.status === 'ready' && status.loginUrl) {
203+
return status.loginUrl
204+
}
205+
206+
if (status.status === 'error') {
207+
throw new Error(`Login URL fetch failed: ${status.error || 'Unknown error'}`)
208+
}
209+
210+
// status === 'pending' - keep waiting
211+
} catch (err) {
212+
// JSON parse error - file might be partially written, keep waiting
213+
if (err instanceof SyntaxError) {
214+
await sleep(100)
215+
continue
216+
}
217+
throw err
218+
}
219+
}
220+
await sleep(500)
221+
}
222+
223+
// On timeout, try to get CLI output for debugging
224+
const cliText = await terminal.text()
225+
throw new Error(
226+
`Timed out waiting for login URL after ${timeoutMs}ms.\n` +
227+
`Coordination file: ${e2eUrlFile}\n` +
228+
`File exists: ${fs.existsSync(e2eUrlFile)}\n` +
229+
`CLI output (last 500 chars): ${cliText.slice(-500)}`
230+
)
231+
},
232+
233+
async text(): Promise<string> {
234+
return terminal.text()
235+
},
236+
237+
async waitForText(pattern: string | RegExp, options?: { timeout?: number }): Promise<void> {
238+
await terminal.waitForText(pattern, options)
239+
},
240+
241+
async type(text: string): Promise<void> {
242+
await reliableType(text)
243+
},
244+
245+
async press(key: string | string[]): Promise<void> {
246+
await originalPress(key as any)
247+
},
248+
249+
async close(): Promise<void> {
250+
try {
251+
await originalPress(['ctrl', 'c'])
252+
await sleep(300)
253+
await originalPress(['ctrl', 'c'])
254+
await sleep(500)
255+
} catch {
256+
// Ignore errors during shutdown
257+
} finally {
258+
terminal.close()
259+
cleanupCredentialsDir(credentialsDir)
260+
// Clean up the e2e URL coordination file
261+
try {
262+
if (fs.existsSync(e2eUrlFile)) {
263+
fs.unlinkSync(e2eUrlFile)
264+
}
265+
} catch {
266+
// Ignore cleanup errors
267+
}
268+
}
269+
},
270+
}
271+
272+
return session
273+
}

e2e/fixtures/infra.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Infrastructure fixture for e2e tests
3+
* Reuses CLI e2e utilities for Docker database and web server management
4+
*/
5+
6+
import path from 'path'
7+
import fs from 'fs'
8+
import { execSync } from 'child_process'
9+
import { fileURLToPath } from 'url'
10+
11+
const __filename = fileURLToPath(import.meta.url)
12+
const __dirname = path.dirname(__filename)
13+
14+
export interface E2EDatabase {
15+
containerId: string
16+
containerName: string
17+
port: number
18+
databaseUrl: string
19+
}
20+
21+
export interface E2EServer {
22+
process: import('child_process').ChildProcess
23+
port: number
24+
url: string
25+
backendUrl: string
26+
}
27+
28+
export interface E2EInfrastructure {
29+
db: E2EDatabase
30+
server: E2EServer
31+
cleanup: () => Promise<void>
32+
}
33+
34+
/**
35+
* Create e2e infrastructure with isolated database and server
36+
*/
37+
export async function createE2EInfrastructure(testId: string): Promise<E2EInfrastructure> {
38+
// Import CLI e2e utilities dynamically
39+
// Note: These imports work because bun handles __dirname in the imported module's context
40+
const testDbUtils = await import('../../cli/src/__tests__/e2e/test-db-utils')
41+
const testServerUtils = await import('../../cli/src/__tests__/e2e/test-server-utils')
42+
43+
console.log(`[E2E Infra] Creating infrastructure for test: ${testId}`)
44+
45+
// Create database
46+
const db = await testDbUtils.createE2EDatabase(testId)
47+
console.log(`[E2E Infra] Database ready on port ${db.port}`)
48+
49+
// Start server - let bun's env hierarchy handle port selection from .env.development.local
50+
// Don't specify a port to allow the test-server-utils to use environment defaults
51+
const server = await testServerUtils.startE2EServer(db.databaseUrl)
52+
console.log(`[E2E Infra] Server ready at ${server.url}`)
53+
54+
const cleanup = async () => {
55+
console.log(`[E2E Infra] Cleaning up infrastructure for test: ${testId}`)
56+
await testServerUtils.stopE2EServer(server)
57+
await testDbUtils.destroyE2EDatabase(db)
58+
console.log(`[E2E Infra] Cleanup complete`)
59+
}
60+
61+
return { db, server, cleanup }
62+
}
63+
64+
/**
65+
* Check if Docker is available
66+
*/
67+
export function isDockerAvailable(): boolean {
68+
try {
69+
execSync('docker info', { stdio: 'pipe' })
70+
return true
71+
} catch {
72+
return false
73+
}
74+
}
75+
76+
/**
77+
* Check if SDK is built
78+
*/
79+
export function isSDKBuilt(): boolean {
80+
try {
81+
const sdkDistDir = path.join(__dirname, '../../sdk/dist')
82+
const possibleArtifacts = ['index.js', 'index.mjs', 'index.cjs']
83+
return possibleArtifacts.some((file) =>
84+
fs.existsSync(path.join(sdkDistDir, file)),
85+
)
86+
} catch {
87+
return false
88+
}
89+
}
90+
91+
/**
92+
* Clean up any orphaned e2e containers
93+
*/
94+
export function cleanupOrphanedInfrastructure(): void {
95+
console.log('[E2E Infra] Cleaning up orphaned infrastructure...')
96+
97+
// Clean containers
98+
try {
99+
const containers = execSync(
100+
'docker ps -aq --filter "name=manicode-e2e-"',
101+
{ encoding: 'utf8' }
102+
).trim()
103+
104+
if (containers) {
105+
execSync(`docker rm -f ${containers.split('\n').join(' ')}`, { stdio: 'pipe' })
106+
console.log('[E2E Infra] Cleaned up orphaned containers')
107+
}
108+
} catch {
109+
// Ignore errors
110+
}
111+
112+
// Clean up ports 3100-3199
113+
for (let port = 3100; port < 3200; port++) {
114+
try {
115+
const pid = execSync(`lsof -t -i:${port}`, { encoding: 'utf8' }).trim()
116+
if (pid) {
117+
execSync(`kill -9 ${pid}`, { stdio: 'pipe' })
118+
}
119+
} catch {
120+
// Port not in use
121+
}
122+
}
123+
}

0 commit comments

Comments
 (0)