diff --git a/xftp-web/test/globalSetup.ts b/xftp-web/test/globalSetup.ts index 13b35020b..ab4289154 100644 --- a/xftp-web/test/globalSetup.ts +++ b/xftp-web/test/globalSetup.ts @@ -1,85 +1,55 @@ import {spawn, execSync, ChildProcess} from 'child_process' import {createHash} from 'crypto' -import {createConnection} from 'net' +import {createConnection, createServer} from 'net' import {resolve, join} from 'path' import {readFileSync, mkdtempSync, writeFileSync, copyFileSync, existsSync, unlinkSync} from 'fs' import {tmpdir} from 'os' -const XFTP_PORT = 7000 const LOCK_FILE = join(tmpdir(), 'xftp-test-server.pid') +export const PORT_FILE = join(tmpdir(), 'xftp-test-server.port') + +// Find a free port by binding to port 0 +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr && typeof addr === 'object') { + const port = addr.port + srv.close(() => resolve(port)) + } else { + srv.close(() => reject(new Error('Could not get port'))) + } + }) + srv.on('error', reject) + }) +} let server: ChildProcess | null = null let isOwner = false -// Kill any process listening on the given port (cross-platform) -function killProcessOnPort(port: number): void { - try { - // Try lsof first (common on Mac/Linux) - execSync(`lsof -ti :${port} | xargs kill -9 2>/dev/null`, {stdio: 'ignore'}) - return - } catch (_) {} - try { - // Fallback: use netstat + awk (works on most systems) - const cmd = process.platform === 'darwin' - ? `netstat -anv -p tcp | awk '$4 ~ /:${port}$/ && $6 == "LISTEN" {print $9}' | xargs kill -9 2>/dev/null` - : `ss -tlnp 'sport = :${port}' | awk 'NR>1 {match($0, /pid=([0-9]+)/, a); print a[1]}' | xargs kill -9 2>/dev/null` - execSync(cmd, {stdio: 'ignore'}) - } catch (_) {} -} - -// Check if port is currently in use -function isPortInUse(port: number): Promise { - return new Promise((resolve) => { - const sock = createConnection({port, host: 'localhost'}, () => { - sock.destroy() - resolve(true) - }) - sock.on('error', () => { - sock.destroy() - resolve(false) - }) - }) -} - export async function setup() { - // Always check if port is in use first, regardless of lock file state - if (await isPortInUse(XFTP_PORT)) { - // Check if we have a valid lock file owner - if (existsSync(LOCK_FILE)) { - const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10) - try { - process.kill(pid, 0) // check if process exists - // Lock owner is alive and port is in use — likely a valid running server - await waitForPort(XFTP_PORT) - return - } catch (_) { - // Lock owner is dead but port is in use — orphaned process - } - } - // Port in use but no valid lock owner — kill the orphaned process - console.log('[globalSetup] Port in use without valid lock, killing orphaned process...') - killProcessOnPort(XFTP_PORT) - await new Promise(r => setTimeout(r, 500)) - // Verify port is now free - if (await isPortInUse(XFTP_PORT)) { - throw new Error(`Port ${XFTP_PORT} still in use after cleanup attempt`) - } - } - - // Clean up stale lock file if it exists - if (existsSync(LOCK_FILE)) { + // Check if another test process owns the server + if (existsSync(LOCK_FILE) && existsSync(PORT_FILE)) { const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10) + const port = parseInt(readFileSync(PORT_FILE, 'utf-8').trim(), 10) try { - process.kill(pid, 0) - // Process exists and port wasn't in use — wait for it - await waitForPort(XFTP_PORT) + process.kill(pid, 0) // check if process exists + // Lock owner is alive — wait for server to be ready + await waitForPort(port) return } catch (_) { - unlinkSync(LOCK_FILE) + // Lock owner is dead — clean up + try { unlinkSync(LOCK_FILE) } catch (_) {} + try { unlinkSync(PORT_FILE) } catch (_) {} } } + // Find a free port dynamically + const xftpPort = await findFreePort() + writeFileSync(LOCK_FILE, String(process.pid)) + writeFileSync(PORT_FILE, String(xftpPort)) isOwner = true const fixtures = resolve(__dirname, '../../tests/fixtures') @@ -107,7 +77,7 @@ enable: off [TRANSPORT] host: localhost -port: ${XFTP_PORT} +port: ${xftpPort} [FILES] path: ${filesDir} @@ -136,12 +106,13 @@ key: ${join(fixtures, 'web.key')} }) // Poll-connect until the server is actually listening - await waitForServerReady(server, XFTP_PORT) + await waitForServerReady(server, xftpPort) } export async function teardown() { if (isOwner) { try { unlinkSync(LOCK_FILE) } catch (_) {} + try { unlinkSync(PORT_FILE) } catch (_) {} if (server) { server.kill('SIGTERM') await new Promise(resolve => { diff --git a/xftp-web/vite.config.ts b/xftp-web/vite.config.ts index e2fb61c79..4564d5231 100644 --- a/xftp-web/vite.config.ts +++ b/xftp-web/vite.config.ts @@ -2,6 +2,7 @@ import {defineConfig, type Plugin} from 'vite' import {readFileSync} from 'fs' import {createHash} from 'crypto' import presets from './web/servers.json' +import {PORT_FILE} from './test/globalSetup' function parseHost(addr: string): string { const m = addr.match(/@(.+)$/) @@ -35,7 +36,9 @@ export default defineConfig(({mode}) => { const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64') const fp = createHash('sha256').update(der).digest('base64') .replace(/\+/g, '-').replace(/\//g, '_') - servers = [`xftp://${fp}@localhost:7000`] + // PORT_FILE is written by globalSetup before vite build runs + const port = readFileSync(PORT_FILE, 'utf-8').trim() + servers = [`xftp://${fp}@localhost:${port}`] define['__XFTP_SERVERS__'] = JSON.stringify(servers) } else { servers = [...presets.simplex, ...presets.flux] diff --git a/xftp-web/vitest.config.ts b/xftp-web/vitest.config.ts index 8cc87733f..6f7461981 100644 --- a/xftp-web/vitest.config.ts +++ b/xftp-web/vitest.config.ts @@ -1,19 +1,33 @@ -import {defineConfig} from 'vitest/config' +import {defineConfig, type Plugin} from 'vitest/config' import {readFileSync} from 'fs' import {createHash} from 'crypto' +import {PORT_FILE} from './test/globalSetup' // Compute fingerprint from ca.crt (SHA-256 of DER, same as Haskell's loadFileFingerprint) const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8') const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64') const fingerprint = createHash('sha256').update(der).digest('base64').replace(/\+/g, '-').replace(/\//g, '_') -const serverAddr = `xftp://${fingerprint}@localhost:7000` + +// Plugin to inject XFTP_SERVER at transform time (after globalSetup writes PORT_FILE) +function xftpServerPlugin(): Plugin { + let serverAddr: string | null = null + return { + name: 'xftp-server-define', + transform(code, id) { + if (!code.includes('import.meta.env.XFTP_SERVER')) return null + if (!serverAddr) { + const port = readFileSync(PORT_FILE, 'utf-8').trim() + serverAddr = `xftp://${fingerprint}@localhost:${port}` + } + return code.replace(/import\.meta\.env\.XFTP_SERVER/g, JSON.stringify(serverAddr)) + } + } +} export default defineConfig({ esbuild: {target: 'esnext'}, optimizeDeps: {esbuildOptions: {target: 'esnext'}}, - define: { - 'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr) - }, + plugins: [xftpServerPlugin()], test: { include: ['test/**/*.test.ts'], browser: {