diff --git a/test/test.ports.ts b/test/test.ports.ts new file mode 100644 index 0000000..71a6bcf --- /dev/null +++ b/test/test.ports.ts @@ -0,0 +1,392 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { SmartNetwork, NetworkError } from '../ts/index.js'; +import * as net from 'net'; +import type { AddressInfo } from 'net'; + +// Helper to create a server on a specific port +const createServerOnPort = async (port: number): Promise => { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + return server; +}; + +// Helper to clean up servers +const cleanupServers = async (servers: net.Server[]): Promise => { + await Promise.all(servers.map(s => new Promise((res) => s.close(() => res())))); +}; + +// ========= isLocalPortUnused Tests ========= + +tap.test('isLocalPortUnused - should detect free port correctly', async () => { + const sn = new SmartNetwork(); + // Port 0 lets the OS assign a free port, we'll use a high range instead + const result = await sn.isLocalPortUnused(54321); + expect(typeof result).toEqual('boolean'); + // Most likely this high port is free, but we can't guarantee it +}); + +tap.test('isLocalPortUnused - should detect occupied port', async () => { + const sn = new SmartNetwork(); + const server = net.createServer(); + await new Promise((res) => server.listen(0, res)); + const addr = server.address() as AddressInfo; + + const isUnused = await sn.isLocalPortUnused(addr.port); + expect(isUnused).toBeFalse(); + + await new Promise((resolve) => server.close(() => resolve())); +}); + +tap.test('isLocalPortUnused - should handle multiple simultaneous checks', async () => { + const sn = new SmartNetwork(); + const ports = [55001, 55002, 55003, 55004, 55005]; + + // Check all ports simultaneously + const results = await Promise.all( + ports.map(port => sn.isLocalPortUnused(port)) + ); + + // All should likely be free + results.forEach(result => { + expect(typeof result).toEqual('boolean'); + }); +}); + +tap.test('isLocalPortUnused - should work with IPv6 loopback', async () => { + const sn = new SmartNetwork(); + const server = net.createServer(); + + // Explicitly bind to IPv6 + await new Promise((res) => server.listen(55100, '::', res)); + const addr = server.address() as AddressInfo; + + const isUnused = await sn.isLocalPortUnused(addr.port); + expect(isUnused).toBeFalse(); + + await new Promise((resolve) => server.close(() => resolve())); +}); + +tap.test('isLocalPortUnused - boundary port numbers', async () => { + const sn = new SmartNetwork(); + + // Test port 1 (usually requires root) + const port1Result = await sn.isLocalPortUnused(1); + expect(typeof port1Result).toEqual('boolean'); + + // Test port 65535 + const port65535Result = await sn.isLocalPortUnused(65535); + expect(typeof port65535Result).toEqual('boolean'); +}); + +// ========= findFreePort Tests ========= + +tap.test('findFreePort - should find free port in small range', async () => { + const sn = new SmartNetwork(); + const freePort = await sn.findFreePort(50000, 50010); + + expect(freePort).not.toBeNull(); + expect(freePort).toBeGreaterThanOrEqual(50000); + expect(freePort).toBeLessThanOrEqual(50010); + + // Verify the port is actually free + if (freePort !== null) { + const isUnused = await sn.isLocalPortUnused(freePort); + expect(isUnused).toBeTrue(); + } +}); + +tap.test('findFreePort - should find first available port', async () => { + const sn = new SmartNetwork(); + const servers = []; + + // Occupy ports 50100 and 50101 + servers.push(await createServerOnPort(50100)); + servers.push(await createServerOnPort(50101)); + + // Port 50102 should be free + const freePort = await sn.findFreePort(50100, 50105); + expect(freePort).toEqual(50102); + + await cleanupServers(servers); +}); + +tap.test('findFreePort - should handle fully occupied range', async () => { + const sn = new SmartNetwork(); + const servers = []; + const startPort = 50200; + const endPort = 50202; + + // Occupy all ports in range + for (let port = startPort; port <= endPort; port++) { + servers.push(await createServerOnPort(port)); + } + + const freePort = await sn.findFreePort(startPort, endPort); + expect(freePort).toBeNull(); + + await cleanupServers(servers); +}); + +tap.test('findFreePort - should validate port boundaries', async () => { + const sn = new SmartNetwork(); + + // Test port < 1 + try { + await sn.findFreePort(0, 100); + throw new Error('Should have thrown for port < 1'); + } catch (err: any) { + expect(err).toBeInstanceOf(NetworkError); + expect(err.code).toEqual('EINVAL'); + expect(err.message).toContain('between 1 and 65535'); + } + + // Test port > 65535 + try { + await sn.findFreePort(100, 70000); + throw new Error('Should have thrown for port > 65535'); + } catch (err: any) { + expect(err).toBeInstanceOf(NetworkError); + expect(err.code).toEqual('EINVAL'); + } + + // Test negative ports + try { + await sn.findFreePort(-100, 100); + throw new Error('Should have thrown for negative port'); + } catch (err: any) { + expect(err).toBeInstanceOf(NetworkError); + expect(err.code).toEqual('EINVAL'); + } +}); + +tap.test('findFreePort - should validate range order', async () => { + const sn = new SmartNetwork(); + + try { + await sn.findFreePort(200, 100); + throw new Error('Should have thrown for startPort > endPort'); + } catch (err: any) { + expect(err).toBeInstanceOf(NetworkError); + expect(err.code).toEqual('EINVAL'); + expect(err.message).toContain('less than or equal to end port'); + } +}); + +tap.test('findFreePort - should handle single port range', async () => { + const sn = new SmartNetwork(); + + // Test when start and end are the same + const freePort = await sn.findFreePort(50300, 50300); + // Should either be 50300 or null + expect(freePort === 50300 || freePort === null).toBeTrue(); +}); + +tap.test('findFreePort - should work with large ranges', async () => { + const sn = new SmartNetwork(); + + // Test with a large range + const freePort = await sn.findFreePort(40000, 50000); + expect(freePort).not.toBeNull(); + expect(freePort).toBeGreaterThanOrEqual(40000); + expect(freePort).toBeLessThanOrEqual(50000); +}); + +tap.test('findFreePort - should handle intermittent occupied ports', async () => { + const sn = new SmartNetwork(); + const servers = []; + + // Occupy every other port + servers.push(await createServerOnPort(50400)); + servers.push(await createServerOnPort(50402)); + servers.push(await createServerOnPort(50404)); + + // Should find 50401, 50403, or 50405 + const freePort = await sn.findFreePort(50400, 50405); + expect([50401, 50403, 50405]).toContain(freePort); + + await cleanupServers(servers); +}); + +// ========= isRemotePortAvailable Tests ========= + +tap.test('isRemotePortAvailable - should detect open HTTP port', async () => { + const sn = new SmartNetwork(); + + // Test with string format + const open1 = await sn.isRemotePortAvailable('example.com:80'); + expect(open1).toBeTrue(); + + // Test with separate parameters + const open2 = await sn.isRemotePortAvailable('example.com', 80); + expect(open2).toBeTrue(); + + // Test with options object + const open3 = await sn.isRemotePortAvailable('example.com', { port: 80 }); + expect(open3).toBeTrue(); +}); + +tap.test('isRemotePortAvailable - should detect closed port', async () => { + const sn = new SmartNetwork(); + + // Port 12345 is likely closed on example.com + const closed = await sn.isRemotePortAvailable('example.com', 12345); + expect(closed).toBeFalse(); +}); + +tap.test('isRemotePortAvailable - should handle retries', async () => { + const sn = new SmartNetwork(); + + // Test with retries + const result = await sn.isRemotePortAvailable('example.com', { + port: 80, + retries: 3, + timeout: 1000 + }); + expect(result).toBeTrue(); +}); + +tap.test('isRemotePortAvailable - should reject UDP protocol', async () => { + const sn = new SmartNetwork(); + + try { + await sn.isRemotePortAvailable('example.com', { + port: 53, + protocol: 'udp' + }); + throw new Error('Should have thrown for UDP protocol'); + } catch (err: any) { + expect(err).toBeInstanceOf(NetworkError); + expect(err.code).toEqual('ENOTSUP'); + expect(err.message).toContain('UDP port check not supported'); + } +}); + +tap.test('isRemotePortAvailable - should require port specification', async () => { + const sn = new SmartNetwork(); + + try { + await sn.isRemotePortAvailable('example.com'); + throw new Error('Should have thrown for missing port'); + } catch (err: any) { + expect(err).toBeInstanceOf(NetworkError); + expect(err.code).toEqual('EINVAL'); + expect(err.message).toContain('Port not specified'); + } +}); + +tap.test('isRemotePortAvailable - should parse port from host:port string', async () => { + const sn = new SmartNetwork(); + + // Valid formats + const result1 = await sn.isRemotePortAvailable('example.com:443'); + expect(result1).toBeTrue(); + + // With options overriding the string port + const result2 = await sn.isRemotePortAvailable('example.com:8080', { port: 80 }); + expect(result2).toBeTrue(); // Should use port 80 from options, not 8080 +}); + +tap.test('isRemotePortAvailable - should handle localhost', async () => { + const sn = new SmartNetwork(); + const server = net.createServer(); + + // Start a local server + await new Promise((res) => server.listen(51000, 'localhost', res)); + + // Should detect it as open + const isOpen = await sn.isRemotePortAvailable('localhost', 51000); + expect(isOpen).toBeTrue(); + + await new Promise((resolve) => server.close(() => resolve())); + + // After closing, might still show as open due to TIME_WAIT, or closed + // We won't assert on this as it's OS-dependent +}); + +tap.test('isRemotePortAvailable - should handle invalid hosts gracefully', async () => { + const sn = new SmartNetwork(); + + // Non-existent domain + const result = await sn.isRemotePortAvailable('this-domain-definitely-does-not-exist-12345.com', 80); + expect(result).toBeFalse(); +}); + +tap.test('isRemotePortAvailable - edge case ports', async () => { + const sn = new SmartNetwork(); + + // Test HTTPS port + const https = await sn.isRemotePortAvailable('example.com', 443); + expect(https).toBeTrue(); + + // Test SSH port (likely closed on example.com) + const ssh = await sn.isRemotePortAvailable('example.com', 22); + expect(ssh).toBeFalse(); +}); + +// ========= Integration Tests ========= + +tap.test('Integration - findFreePort and isLocalPortUnused consistency', async () => { + const sn = new SmartNetwork(); + + // Find a free port + const freePort = await sn.findFreePort(52000, 52100); + expect(freePort).not.toBeNull(); + + if (freePort !== null) { + // Verify it's actually free + const isUnused1 = await sn.isLocalPortUnused(freePort); + expect(isUnused1).toBeTrue(); + + // Start a server on it + const server = await createServerOnPort(freePort); + + // Now it should be in use + const isUnused2 = await sn.isLocalPortUnused(freePort); + expect(isUnused2).toBeFalse(); + + // findFreePort should skip it + const nextFreePort = await sn.findFreePort(freePort, freePort + 10); + expect(nextFreePort).not.toEqual(freePort); + + await cleanupServers([server]); + } +}); + +tap.test('Integration - stress test with many concurrent port checks', async () => { + const sn = new SmartNetwork(); + const portRange = Array.from({ length: 20 }, (_, i) => 53000 + i); + + // Check all ports concurrently + const results = await Promise.all( + portRange.map(async port => ({ + port, + isUnused: await sn.isLocalPortUnused(port) + })) + ); + + // All operations should complete without error + results.forEach(result => { + expect(typeof result.isUnused).toEqual('boolean'); + }); +}); + +tap.test('Performance - findFreePort with large range', async () => { + const sn = new SmartNetwork(); + const startTime = Date.now(); + + // This should be fast even with a large range + const freePort = await sn.findFreePort(30000, 60000); + const duration = Date.now() - startTime; + + expect(freePort).not.toBeNull(); + // Should complete quickly (within 100ms) as it should find a port early + expect(duration).toBeLessThan(100); +}); + +tap.start(); \ No newline at end of file