import { request } from '@playwright/test'; import { spawn } from 'node:child_process'; import { access, open, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { setTimeout as delay } from 'node:timers/promises'; import { adminPassword, adminStorageStatePath, adminUsername, authDir, clusterRunDir, cnBaseURL, metadataPath, ovBaseURL, playwrightArtifactRoot, readyMarkerPath, repoRoot, rootPassword, rootStorageStatePath, rootUsername, serverBinaryDir, serverBinaryPath, serverLogPath, webRoot, } from './fixtures/cluster'; type LoginResponse = { success: boolean; message?: string; data?: { id: number; username: string; display_name: string; role: number; status: number; group: string; }; }; type SimpleResponse = { success: boolean; message?: string; error?: string; }; async function runCommand(command: string, args: string[], cwd: string) { await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], shell: false, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('error', reject); child.on('close', (code) => { if (code === 0) { resolve(); return; } reject( new Error( `${command} ${args.join(' ')} failed with code ${code}\n${stdout}${stderr}`, ), ); }); }); } async function killPreviousServer() { try { const raw = await readFile(metadataPath, 'utf8'); const metadata = JSON.parse(raw) as { serverPid?: number }; if (!metadata.serverPid) { return; } await killProcessTree(metadata.serverPid); } catch { // ignore stale or missing metadata } } async function killProcessTree(pid: number) { if (process.platform === 'win32') { await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot); return; } process.kill(pid, 'SIGTERM'); } async function cleanupListenerPorts() { if (process.platform !== 'win32') { return; } await runCommand( 'powershell', [ '-NoProfile', '-Command', "$ports = 3100,3101; " + "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | " + "Where-Object { $ports -contains $_.LocalPort } | " + 'Select-Object -ExpandProperty OwningProcess -Unique | ' + 'ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }', ], repoRoot, ); } async function waitForServerReady() { const deadline = Date.now() + 180_000; while (Date.now() < deadline) { try { await access(readyMarkerPath); const response = await fetch(`${cnBaseURL}/api/status`, { headers: { 'Cache-Control': 'no-store' }, }); if (response.ok) { return; } } catch { // retry } await delay(500); } throw new Error( `user migration UI server did not become ready in time, see ${serverLogPath}`, ); } async function login(username: string, password: string) { const api = await request.newContext({ baseURL: cnBaseURL, extraHTTPHeaders: { 'Cache-Control': 'no-store', }, }); const response = await api.post('/api/user/login', { data: { username, password, }, }); if (!response.ok()) { throw new Error(`login request for ${username} failed: ${response.status()}`); } const payload = (await response.json()) as LoginResponse; if (!payload.success || !payload.data) { throw new Error(`login failed for ${username}: ${payload.message ?? 'unknown error'}`); } return { api, user: payload.data }; } async function loginWithRetry( username: string, password: string, timeoutMs = 60_000, ) { const deadline = Date.now() + timeoutMs; let lastError = `login failed for ${username}`; while (Date.now() < deadline) { try { return await login(username, password); } catch (error) { lastError = error instanceof Error ? error.message : String(error); await delay(500); } } throw new Error(lastError); } async function writeStorageState( api: Awaited>, user: LoginResponse['data'], outputPath: string, ) { const state = await api.storageState(); await writeFile( outputPath, JSON.stringify( { cookies: state.cookies, origins: [ { origin: cnBaseURL, localStorage: [ { name: 'user', value: JSON.stringify(user), }, ], }, ], }, null, 2, ), 'utf8', ); } export default async function globalSetup() { await killPreviousServer(); await cleanupListenerPorts(); await rm(playwrightArtifactRoot, { recursive: true, force: true }); await mkdir(authDir, { recursive: true }); await mkdir(serverBinaryDir, { recursive: true }); await runCommand('bun', ['run', 'build'], webRoot); await runCommand( 'go', ['build', '-o', serverBinaryPath, './test/e2e/cmd/user_migration_ui_server'], repoRoot, ); const logFile = await open(serverLogPath, 'a'); const child = spawn(serverBinaryPath, [], { cwd: repoRoot, stdio: ['ignore', logFile.fd, logFile.fd], }); await logFile.close(); await writeFile( metadataPath, JSON.stringify( { serverPid: child.pid, serverBinaryPath, serverLogPath, runDir: clusterRunDir, baseURL: cnBaseURL, ovBaseURL, }, null, 2, ), 'utf8', ); await waitForServerReady(); const rootSession = await loginWithRetry(rootUsername, rootPassword); try { const createAdminResponse = await rootSession.api.post('/api/user/', { headers: { 'New-API-User': String(rootSession.user.id), }, data: { username: adminUsername, password: adminPassword, display_name: 'Playwright Admin', role: 10, }, }); if (!createAdminResponse.ok()) { throw new Error( `create admin request failed: ${createAdminResponse.status()} ${createAdminResponse.statusText()}`, ); } const createAdminPayload = (await createAdminResponse.json()) as SimpleResponse; const createAdminMessage = createAdminPayload.message ?? createAdminPayload.error ?? 'unknown error'; const adminAlreadyExists = createAdminMessage.includes('UNIQUE constraint failed: users.username') || createAdminMessage.includes('Duplicate entry'); if (!createAdminPayload.success && !adminAlreadyExists) { throw new Error( `create admin failed: ${createAdminMessage}`, ); } await writeStorageState( rootSession.api, rootSession.user, rootStorageStatePath, ); } finally { await rootSession.api.dispose(); } const adminSession = await loginWithRetry(adminUsername, adminPassword, 20_000); try { await writeStorageState( adminSession.api, adminSession.user, adminStorageStatePath, ); } finally { await adminSession.api.dispose(); } }