From 009b42363752321834c643c97a3f115158e44ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Za=C5=84?= Date: Tue, 31 Mar 2026 23:09:01 +0200 Subject: [PATCH 1/4] Automatic port for manual tests. --- .changelog/20260331183135_master.md | 7 ++ .../lib/utils/manual-tests/createserver.js | 35 +++++++-- .../lib/utils/parsearguments.js | 1 + .../tests/utils/manual-tests/createserver.js | 74 ++++++++++++++++--- 4 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 .changelog/20260331183135_master.md diff --git a/.changelog/20260331183135_master.md b/.changelog/20260331183135_master.md new file mode 100644 index 000000000..e9f01920e --- /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. The server writes the selected port to a `.port` file in the build directory so that external scripts can discover which port is in use. 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..816116530 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,36 @@ 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 => { + if ( error.code === 'EADDRINUSE' ) { + 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 +64,10 @@ export default function createManualTestServer( sourcePath, port = 8125, onCreat process.exit(); } ); - logger().info( `[Server] Server running at http://localhost:${ port }/` ); + // Write the port to a file so that external scripts (e.g. check-manual-tests.sh) can discover which port the server is using. + fs.writeFileSync( path.join( sourcePath, '.port' ), String( port ) ); + + log.info( `[Server] Server running at http://localhost:${ port }/` ); if ( onCreate ) { onCreate( server ); diff --git a/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js b/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js index a2cf9edbd..6080728c2 100644 --- a/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js +++ b/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js @@ -73,6 +73,7 @@ export default function parseArguments( args, settings = {} ) { 'identity-file': null, language: 'en', notify: false, + port: 8125, production: false, reporter: 'mocha', repositories: [], 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..1efe5cc74 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,7 +42,9 @@ describe( 'createManualTestServer()', () => { } ); afterEach( () => { - server.close(); + for ( const s of servers ) { + s.close(); + } } ); it( 'should start http server', () => { @@ -49,37 +53,49 @@ describe( 'createManualTestServer()', () => { expect( vi.mocked( http ).createServer ).toHaveBeenCalledOnce(); } ); - it( 'should listen on given port', () => { + it( 'should listen on given port', async () => { createManualTestServer( 'workspace/build/.manual-tests', 8888 ); + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalled(); + } ); + expect( server ).toEqual( expect.objectContaining( { listen: expect.any( Function ) } ) ); - expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 8888 ); + expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 8888, expect.any( Function ) ); expect( loggerStub ).toHaveBeenCalledExactlyOnceWith( '[Server] Server running at http://localhost:8888/' ); } ); - 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 ).toHaveBeenCalled(); + } ); + expect( server ).toEqual( expect.objectContaining( { listen: expect.any( Function ) } ) ); - expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 8125 ); + expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 8125, expect.any( Function ) ); expect( loggerStub ).toHaveBeenCalledExactlyOnceWith( '[Server] Server running at http://localhost:8125/' ); } ); - 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 ); + 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() }; @@ -89,13 +105,53 @@ describe( 'createManualTestServer()', () => { createManualTestServer( 'workspace/build/.manual-tests' ); + 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 write the port to a .port file', async () => { + createManualTestServer( 'workspace/build/.manual-tests', 8888 ); + + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalled(); + } ); + + expect( vi.mocked( fs ).writeFileSync ).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining( '.port' ), + '8888' + ); + } ); + + 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( 8555, resolve ); + } ); + + createManualTestServer( 'workspace/build/.manual-tests', 8555 ); + + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalledWith( '[Server] Server running at http://localhost:8556/' ); + } ); + + expect( loggerStub ).toHaveBeenCalledWith( '[Server] Port 8555 is in use, trying 8556...' ); + + blockingServer.close(); + } ); + describe( 'request handler', () => { - beforeEach( () => { + beforeEach( async () => { createManualTestServer( 'workspace/build/.manual-tests' ); + + await vi.waitFor( () => { + expect( loggerStub ).toHaveBeenCalled(); + } ); } ); it( 'should handle a request for a favicon (`/favicon.ico`)', () => { From 4daf24882124a07a801acf69d4702a3134f44d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Za=C5=84?= Date: Wed, 1 Apr 2026 00:25:08 +0200 Subject: [PATCH 2/4] Review requests. --- .../lib/utils/manual-tests/createserver.js | 6 +++ .../lib/utils/parsearguments.js | 1 - .../tests/utils/manual-tests/createserver.js | 41 +++++++++++-------- 3 files changed, 30 insertions(+), 18 deletions(-) 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 816116530..cb9f40e99 100644 --- a/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js @@ -34,6 +34,12 @@ function tryListenOnPort( sourcePath, port, onCreate, resolve, reject ) { server.once( 'error', error => { 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 { diff --git a/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js b/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js index 6080728c2..a2cf9edbd 100644 --- a/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js +++ b/packages/ckeditor5-dev-tests/lib/utils/parsearguments.js @@ -73,7 +73,6 @@ export default function parseArguments( args, settings = {} ) { 'identity-file': null, language: 'en', notify: false, - port: 8125, production: false, reporter: 'mocha', repositories: [], 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 1efe5cc74..861317d2b 100644 --- a/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js @@ -47,14 +47,18 @@ describe( 'createManualTestServer()', () => { } } ); - 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', async () => { - createManualTestServer( 'workspace/build/.manual-tests', 8888 ); + createManualTestServer( 'workspace/build/.manual-tests', 49888 ); await vi.waitFor( () => { expect( loggerStub ).toHaveBeenCalled(); @@ -64,29 +68,32 @@ describe( 'createManualTestServer()', () => { listen: expect.any( Function ) } ) ); - expect( server.listen ).toHaveBeenCalledExactlyOnceWith( 8888, expect.any( Function ) ); - 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', async () => { createManualTestServer( 'workspace/build/.manual-tests' ); await vi.waitFor( () => { - expect( loggerStub ).toHaveBeenCalled(); + 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.any( Function ) ); - 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)', 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(); @@ -103,7 +110,7 @@ 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(); @@ -114,7 +121,7 @@ describe( 'createManualTestServer()', () => { } ); it( 'should write the port to a .port file', async () => { - createManualTestServer( 'workspace/build/.manual-tests', 8888 ); + createManualTestServer( 'workspace/build/.manual-tests', 49888 ); await vi.waitFor( () => { expect( loggerStub ).toHaveBeenCalled(); @@ -122,7 +129,7 @@ describe( 'createManualTestServer()', () => { expect( vi.mocked( fs ).writeFileSync ).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( '.port' ), - '8888' + '49888' ); } ); @@ -131,23 +138,23 @@ describe( 'createManualTestServer()', () => { const blockingServer = http.createServer.getMockImplementation()(); await new Promise( resolve => { - blockingServer.listen( 8555, resolve ); + blockingServer.listen( 49555, resolve ); } ); - createManualTestServer( 'workspace/build/.manual-tests', 8555 ); + createManualTestServer( 'workspace/build/.manual-tests', 49555 ); await vi.waitFor( () => { - expect( loggerStub ).toHaveBeenCalledWith( '[Server] Server running at http://localhost:8556/' ); + expect( loggerStub ).toHaveBeenCalledWith( '[Server] Server running at http://localhost:49556/' ); } ); - expect( loggerStub ).toHaveBeenCalledWith( '[Server] Port 8555 is in use, trying 8556...' ); + expect( loggerStub ).toHaveBeenCalledWith( '[Server] Port 49555 is in use, trying 49556...' ); blockingServer.close(); } ); describe( 'request handler', () => { beforeEach( async () => { - createManualTestServer( 'workspace/build/.manual-tests' ); + createManualTestServer( 'workspace/build/.manual-tests', 49800 ); await vi.waitFor( () => { expect( loggerStub ).toHaveBeenCalled(); From 8159004b3401d23b996a7a4bfd027efd9086b364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Za=C5=84?= Date: Wed, 1 Apr 2026 18:11:55 +0200 Subject: [PATCH 3/4] Improved exit handling. --- .../lib/utils/manual-tests/createserver.js | 10 ++++++++++ .../tests/utils/manual-tests/createserver.js | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) 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 cb9f40e99..fce7fb092 100644 --- a/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js @@ -33,6 +33,8 @@ function tryListenOnPort( sourcePath, port, onCreate, resolve, reject ) { } ); 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.' ) ); @@ -62,6 +64,14 @@ function tryListenOnPort( sourcePath, port, onCreate, resolve, reject ) { } process.on( 'SIGINT', () => { + const portFilePath = path.join( sourcePath, '.port' ); + + try { + fs.unlinkSync( portFilePath ); + } catch { + // Ignore if the file was already removed. + } + if ( server ) { server.close(); } 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 861317d2b..a89d418b3 100644 --- a/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js @@ -152,6 +152,25 @@ describe( 'createManualTestServer()', () => { 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( async () => { createManualTestServer( 'workspace/build/.manual-tests', 49800 ); From 70356103d18c1c70275ccba895f3e262f0f1ef21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Za=C5=84?= Date: Thu, 2 Apr 2026 15:00:17 +0200 Subject: [PATCH 4/4] Removed file writing. --- .changelog/20260331183135_master.md | 2 +- .../lib/utils/manual-tests/createserver.js | 11 ----------- .../tests/utils/manual-tests/createserver.js | 13 ------------- 3 files changed, 1 insertion(+), 25 deletions(-) diff --git a/.changelog/20260331183135_master.md b/.changelog/20260331183135_master.md index e9f01920e..a15a46366 100644 --- a/.changelog/20260331183135_master.md +++ b/.changelog/20260331183135_master.md @@ -4,4 +4,4 @@ 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. The server writes the selected port to a `.port` file in the build directory so that external scripts can discover which port is in use. +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 fce7fb092..36804681a 100644 --- a/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/lib/utils/manual-tests/createserver.js @@ -64,14 +64,6 @@ function tryListenOnPort( sourcePath, port, onCreate, resolve, reject ) { } process.on( 'SIGINT', () => { - const portFilePath = path.join( sourcePath, '.port' ); - - try { - fs.unlinkSync( portFilePath ); - } catch { - // Ignore if the file was already removed. - } - if ( server ) { server.close(); } @@ -80,9 +72,6 @@ function tryListenOnPort( sourcePath, port, onCreate, resolve, reject ) { process.exit(); } ); - // Write the port to a file so that external scripts (e.g. check-manual-tests.sh) can discover which port the server is using. - fs.writeFileSync( path.join( sourcePath, '.port' ), String( port ) ); - log.info( `[Server] Server running at http://localhost:${ port }/` ); if ( onCreate ) { 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 a89d418b3..e67bf8cb8 100644 --- a/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js +++ b/packages/ckeditor5-dev-tests/tests/utils/manual-tests/createserver.js @@ -120,19 +120,6 @@ describe( 'createManualTestServer()', () => { expect( readlineInterface.on ).toHaveBeenCalledExactlyOnceWith( 'SIGINT', expect.any( Function ) ); } ); - it( 'should write the port to a .port file', async () => { - createManualTestServer( 'workspace/build/.manual-tests', 49888 ); - - await vi.waitFor( () => { - expect( loggerStub ).toHaveBeenCalled(); - } ); - - expect( vi.mocked( fs ).writeFileSync ).toHaveBeenCalledExactlyOnceWith( - expect.stringContaining( '.port' ), - '49888' - ); - } ); - it( 'should try next port when the requested port is in use', async () => { // Occupy the port first. const blockingServer = http.createServer.getMockImplementation()();