diff --git a/.changelog/20260331183135_master.md b/.changelog/20260331183135_master.md new file mode 100644 index 000000000..a15a46366 --- /dev/null +++ b/.changelog/20260331183135_master.md @@ -0,0 +1,7 @@ +--- +type: Feature +scope: + - ckeditor5-dev-tests +--- + +The manual test server now automatically finds a free port at startup. When the preferred port (default 8125) is already in use, the server tries subsequent ports until an available one is found. The `--port` option can be used to set the starting port. diff --git a/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js b/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js index d2c82ac84..36804681a 100644 --- a/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js @@ -12,18 +12,44 @@ import combine from 'dom-combiner'; import { logger } from '@ckeditor/ckeditor5-dev-utils'; /** - * Basic HTTP server. + * Basic HTTP server with automatic free port detection. * * @param {string} sourcePath Base path where the compiler saved the files. - * @param {number} [port=8125] Port to listen at. + * @param {number} [port=8125] Preferred port to listen at. If the port is already in use, + * the server will automatically try subsequent ports until a free one is found. * @param {function} [onCreate] A callback called with the reference to the HTTP server when it is up and running. */ export default function createManualTestServer( sourcePath, port = 8125, onCreate ) { - return new Promise( resolve => { - const server = http.createServer( ( request, response ) => { - onRequest( sourcePath, request, response ); - } ).listen( port ); + return new Promise( ( resolve, reject ) => { + tryListenOnPort( sourcePath, port, onCreate, resolve, reject ); + } ); +} + +function tryListenOnPort( sourcePath, port, onCreate, resolve, reject ) { + const log = logger(); + + const server = http.createServer( ( request, response ) => { + onRequest( sourcePath, request, response ); + } ); + + server.once( 'error', error => { + server.close(); + + if ( error.code === 'EADDRINUSE' ) { + if ( port >= 65535 ) { + reject( new Error( 'Could not find a free port. All ports from the starting port to 65535 are in use.' ) ); + + return; + } + + log.info( `[Server] Port ${ port } is in use, trying ${ port + 1 }...` ); + tryListenOnPort( sourcePath, port + 1, onCreate, resolve, reject ); + } else { + reject( error ); + } + } ); + server.listen( port, () => { // SIGINT isn't caught on Windows in process. However, `CTRL+C` can be caught // by `readline` module. After that we can emit SIGINT to the process manually. if ( process.platform === 'win32' ) { @@ -46,7 +72,7 @@ export default function createManualTestServer( sourcePath, port = 8125, onCreat process.exit(); } ); - logger().info( `[Server] Server running at http://localhost:${ port }/` ); + log.info( `[Server] Server running at http://localhost:${ port }/` ); if ( onCreate ) { onCreate( server ); diff --git a/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js b/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js index cc56f5fde..e67bf8cb8 100644 --- a/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js @@ -19,11 +19,12 @@ vi.mock( 'glob' ); vi.mock( 'dom-combiner' ); describe( 'createManualTestServer()', () => { - let loggerStub, server; + let loggerStub, server, servers; beforeEach( async () => { const { createServer } = http; + servers = []; loggerStub = vi.fn(); vi.mocked( logger ).mockReturnValue( { @@ -32,6 +33,7 @@ describe( 'createManualTestServer()', () => { vi.spyOn( http, 'createServer' ).mockImplementation( ( ...theArgs ) => { server = createServer( ...theArgs ); + servers.push( server ); vi.spyOn( server, 'listen' ); @@ -40,46 +42,67 @@ describe( 'createManualTestServer()', () => { } ); afterEach( () => { - server.close(); + for ( const s of servers ) { + s.close(); + } } ); - it( 'should start http server', () => { - createManualTestServer( 'workspace/build/.manual-tests' ); + it( 'should start http server', async () => { + createManualTestServer( 'workspace/build/.manual-tests', 49700 ); + + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalled(); + } ); expect( vi.mocked( http ).createServer ).toHaveBeenCalledOnce(); } ); - it( 'should listen on given port', () => { - createManualTestServer( 'workspace/build/.manual-tests', 8888 ); + it( 'should listen on given port', async () => { + createManualTestServer( 'workspace/build/.manual-tests', 49888 ); + + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalled(); + } ); expect( server ).toEqual( expect.objectContaining( { listen: expect.any( Function ) } ) ); - expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 8888 ); - expect( loggerStub ).toHaveBeenCalledExactlyOnceWith( '[Server] Server running at http://localhost:8888/' ); + expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 49888, expect.any( Function ) ); + expect( loggerStub ).toHaveBeenCalledExactlyOnceWith( '[Server] Server running at http://localhost:49888/' ); } ); - it( 'should listen on 8125 port if no specific port was given', () => { + it( 'should listen on 8125 port if no specific port was given', async () => { createManualTestServer( 'workspace/build/.manual-tests' ); + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalledWith( + expect.stringContaining( '[Server] Server running at http://localhost:' ) + ); + } ); + expect( server ).toEqual( expect.objectContaining( { listen: expect.any( Function ) } ) ); - expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 8125 ); - expect( loggerStub ).toHaveBeenCalledExactlyOnceWith( '[Server] Server running at http://localhost:8125/' ); + // The first listen attempt should always be on the default port 8125, even if the server + // ended up on a different port due to EADDRINUSE retries. + expect( servers[ 0 ].listen ).toHaveBeenCalledWith( 8125, expect.any( Function ) ); } ); - it( 'should call the specified callback when the server is running (e.g. to allow running web sockets)', () => { + it( 'should call the specified callback when the server is running (e.g. to allow running web sockets)', async () => { const spy = vi.fn(); - createManualTestServer( 'workspace/build/.manual-tests', 1234, spy ); + createManualTestServer( 'workspace/build/.manual-tests', 49234, spy ); + + await vi.waitFor( () => { + expect( spy ).toHaveBeenCalled(); + } ); expect( spy ).toHaveBeenCalledExactlyOnceWith( server ); } ); - it( 'should use "readline" to listen to the SIGINT event on Windows', () => { + it( 'should use "readline" to listen to the SIGINT event on Windows', async () => { const readlineInterface = { on: vi.fn() }; @@ -87,15 +110,61 @@ describe( 'createManualTestServer()', () => { vi.mocked( readline ).createInterface.mockReturnValue( readlineInterface ); vi.spyOn( process, 'platform', 'get' ).mockReturnValue( 'win32' ); - createManualTestServer( 'workspace/build/.manual-tests' ); + createManualTestServer( 'workspace/build/.manual-tests', 49900 ); + + await vi.waitFor( () => { + expect( vi.mocked( readline ).createInterface ).toHaveBeenCalled(); + } ); expect( vi.mocked( readline ).createInterface ).toHaveBeenCalledOnce(); expect( readlineInterface.on ).toHaveBeenCalledExactlyOnceWith( 'SIGINT', expect.any( Function ) ); } ); + it( 'should try next port when the requested port is in use', async () => { + // Occupy the port first. + const blockingServer = http.createServer.getMockImplementation()(); + + await new Promise( resolve => { + blockingServer.listen( 49555, resolve ); + } ); + + createManualTestServer( 'workspace/build/.manual-tests', 49555 ); + + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalledWith( '[Server] Server running at http://localhost:49556/' ); + } ); + + expect( loggerStub ).toHaveBeenCalledWith( '[Server] Port 49555 is in use, trying 49556...' ); + + blockingServer.close(); + } ); + + it( 'should reject when a non-EADDRINUSE error occurs', async () => { + const originalCreateServer = http.createServer.getMockImplementation(); + const fakeServer = originalCreateServer(); + + servers.push( fakeServer ); + vi.spyOn( fakeServer, 'listen' ).mockImplementation( () => { + // Simulate a non-EADDRINUSE error. + process.nextTick( () => { + fakeServer.emit( 'error', new Error( 'EACCES: permission denied' ) ); + } ); + } ); + vi.spyOn( fakeServer, 'close' ).mockImplementation( () => {} ); + + vi.mocked( http.createServer ).mockReturnValue( fakeServer ); + + await expect( createManualTestServer( 'workspace/build/.manual-tests', 49700 ) ) + .rejects.toThrow( 'EACCES: permission denied' ); + } ); + describe( 'request handler', () => { - beforeEach( () => { - createManualTestServer( 'workspace/build/.manual-tests' ); + beforeEach( async () => { + createManualTestServer( 'workspace/build/.manual-tests', 49800 ); + + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalled(); + } ); } ); it( 'should handle a request for a favicon (`/favicon.ico`)', () => {