From c84679a417644362b411fea7e9cc9e53aa06bb49 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:09:14 +0000 Subject: [PATCH 1/2] test: use the shared free port helper in the new serve tests Ports from the OS can be taken by other connections before the dev server listens, the helper takes them from a range of the Jest worker. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- test/serve/automatic-ports/automatic-ports.test.js | 13 +------------ .../error-handling/serve-error-handling.test.js | 6 ++---- test/serve/per-compiler/serve-per-compiler.test.js | 9 +++------ test/serve/rebuild/serve-rebuild.test.js | 6 ++---- test/utils/test-utils.js | 5 +++-- 5 files changed, 11 insertions(+), 28 deletions(-) diff --git a/test/serve/automatic-ports/automatic-ports.test.js b/test/serve/automatic-ports/automatic-ports.test.js index fe5c2ead9c7..7c3be9985f6 100644 --- a/test/serve/automatic-ports/automatic-ports.test.js +++ b/test/serve/automatic-ports/automatic-ports.test.js @@ -2,18 +2,7 @@ const net = require("node:net"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { runWatch } = require("../../utils/test-utils"); - -/** - * Ports below the ephemeral ranges (32768+ on Linux, 49152+ on Windows and macOS), so outgoing - * connections can't take the next ports, which the CLI picks for dev servers without a port - * @returns {Promise} free port - */ -const getFreePort = async () => { - const { default: getPort, portNumbers } = await import("get-port"); - - return getPort({ port: portNumbers(20000, 30000) }); -}; +const { getFreePort, runWatch } = require("../../utils/test-utils"); const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; diff --git a/test/serve/error-handling/serve-error-handling.test.js b/test/serve/error-handling/serve-error-handling.test.js index a5c3e399ffe..3c67d9c737a 100644 --- a/test/serve/error-handling/serve-error-handling.test.js +++ b/test/serve/error-handling/serve-error-handling.test.js @@ -7,9 +7,7 @@ const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { run, runWatch } = require("../../utils/test-utils"); - -const getGetPort = () => import("get-port"); +const { getFreePort, run, runWatch } = require("../../utils/test-utils"); // Only plugin mode uses the CLI's watch callback. const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; @@ -18,7 +16,7 @@ describeDevServer6("serve error handling", () => { let port; beforeEach(async () => { - port = await (await getGetPort()).default(); + port = await getFreePort(); }); test("should print the stats with errors and keep serving when the compilation fails", async () => { diff --git a/test/serve/per-compiler/serve-per-compiler.test.js b/test/serve/per-compiler/serve-per-compiler.test.js index 1cc72dbb041..ca31d6e5265 100644 --- a/test/serve/per-compiler/serve-per-compiler.test.js +++ b/test/serve/per-compiler/serve-per-compiler.test.js @@ -4,9 +4,7 @@ const path = require("node:path"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { runWatch } = require("../../utils/test-utils"); - -const getGetPort = () => import("get-port"); +const { getFreePort, runWatch } = require("../../utils/test-utils"); /** * Starts `webpack serve` and requests every bundle from every dev server once they listen. @@ -16,9 +14,8 @@ const getGetPort = () => import("get-port"); * @returns {Promise<{ exitCode: number, stdout: string, stderr: string, responses: Record }>} result */ const serve = async (args, config = "webpack.config.js", env = {}) => { - const { default: getPort } = await getGetPort(); - const appPort = await getPort(); - const adminPort = await getPort({ exclude: [appPort] }); + const appPort = await getFreePort(); + const adminPort = await getFreePort([appPort]); const names = config === "webpack.config.js" ? ["app", "admin"] : ["app"]; const ports = { app: appPort, admin: adminPort }; const responses = {}; diff --git a/test/serve/rebuild/serve-rebuild.test.js b/test/serve/rebuild/serve-rebuild.test.js index 9f2021cc55a..fd348b6eb35 100644 --- a/test/serve/rebuild/serve-rebuild.test.js +++ b/test/serve/rebuild/serve-rebuild.test.js @@ -3,9 +3,7 @@ const { readFileSync, writeFileSync } = require("node:fs"); const { resolve } = require("node:path"); const [devServerVersion] = require("webpack-dev-server/package.json").version; -const { processKill, runWatch } = require("../../utils/test-utils"); - -const getGetPort = () => import("get-port"); +const { getFreePort, processKill, runWatch } = require("../../utils/test-utils"); const entryPath = resolve(__dirname, "./src/index.js"); const originalEntry = readFileSync(entryPath, "utf8"); @@ -16,7 +14,7 @@ describe("serve recompilation", () => { let port; beforeEach(async () => { - port = await (await getGetPort()).default(); + port = await getFreePort(); }); afterEach(() => { diff --git a/test/utils/test-utils.js b/test/utils/test-utils.js index 7e87ddc67e4..927760a5908 100644 --- a/test/utils/test-utils.js +++ b/test/utils/test-utils.js @@ -408,14 +408,15 @@ const cleanStderr = (stderr) => * tests that install packages) can take it before the dev server listens, and Jest * workers running in parallel can get the same one. So each worker takes ports from its * own range below the ephemeral ones. + * @param {number[]=} exclude ports to skip * @returns {Promise} free port */ -const getFreePort = async () => { +const getFreePort = async (exclude = []) => { const { default: getPort, portNumbers } = await import("get-port"); // 120 ranges keep ports below 32768, where the ephemeral range of Linux starts const from = 20000 + ((Number(process.env.JEST_WORKER_ID || 1) - 1) % 120) * 100; - return getPort({ port: portNumbers(from, from + 99) }); + return getPort({ port: portNumbers(from, from + 99), exclude }); }; const uniqueDirectoryForTest = async () => { From c0cf5602c0510968fd0a296fc81d82180e52057e Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:22:30 +0000 Subject: [PATCH 2/2] fix: assign distinct ports to dev servers sharing devServer options Configurations can share one devServer object, the port assigned to the first dev server was written to it and reused by the others. Enable the test for multiple dev servers without ports. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- .changeset/shared-dev-server-options.md | 5 ++ packages/webpack-cli/src/webpack-cli.ts | 3 +- test/serve/basic/multi-dev-server.config.js | 54 ++++++--------- test/serve/basic/serve-basic.test.js | 74 +++++++++++++++++---- 4 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 .changeset/shared-dev-server-options.md diff --git a/.changeset/shared-dev-server-options.md b/.changeset/shared-dev-server-options.md new file mode 100644 index 00000000000..e881ffa9040 --- /dev/null +++ b/.changeset/shared-dev-server-options.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +fix: assign distinct ports to dev servers of configurations that share the same `devServer` object. diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 03cc7c66221..6633eb7ef60 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -2245,7 +2245,8 @@ class WebpackCLI { const getDevServerOptions = ( devServerOptions: DevServerConfiguration | undefined, ): DevServerConfiguration => { - const configuration: DevServerConfiguration = devServerOptions || {}; + // Configurations can share options, so a port assigned to one isn't used by others + const configuration: DevServerConfiguration = { ...devServerOptions }; const args: Record = {}; const values: ProcessedArguments = {}; diff --git a/test/serve/basic/multi-dev-server.config.js b/test/serve/basic/multi-dev-server.config.js index 7519a9c3a96..64f091cde8c 100644 --- a/test/serve/basic/multi-dev-server.config.js +++ b/test/serve/basic/multi-dev-server.config.js @@ -1,37 +1,25 @@ -const { getFreePort } = require("../../utils/test-utils"); const WebpackCLITestPlugin = require("../../utils/webpack-cli-test-plugin"); -const { devServerConfig } = require("./helper/base-dev-server.config"); +const devServerConfig = require("./helper/base-dev-server.config"); -module.exports = async () => { - const port1 = await getFreePort(); - const port2 = await getFreePort(); - - return [ - { - name: "one", - mode: "development", - devtool: false, - output: { - filename: "first-output/[name].js", - }, - devServer: { - ...devServerConfig, - port: port1, - }, - plugins: [new WebpackCLITestPlugin(["mode", "output"], false, "hooks.compilation.taps")], +module.exports = [ + { + name: "one", + mode: "development", + devtool: false, + output: { + filename: "first-output/[name].js", }, - { - name: "two", - mode: "development", - devtool: false, - entry: "./src/other.js", - output: { - filename: "second-output/[name].js", - }, - devServer: { - ...devServerConfig, - port: port2, - }, + devServer: devServerConfig, + plugins: [new WebpackCLITestPlugin(["mode", "output"], false, "hooks.compilation.taps")], + }, + { + name: "two", + mode: "development", + devtool: false, + entry: "./src/other.js", + output: { + filename: "second-output/[name].js", }, - ]; -}; + devServer: devServerConfig, + }, +]; diff --git a/test/serve/basic/serve-basic.test.js b/test/serve/basic/serve-basic.test.js index c796b9bee73..845ff0cbf6f 100644 --- a/test/serve/basic/serve-basic.test.js +++ b/test/serve/basic/serve-basic.test.js @@ -1,6 +1,7 @@ "use strict"; const path = require("node:path"); +const [devServerVersion] = require("webpack-dev-server/package.json").version; const { getFreePort, normalizeStderr, @@ -10,6 +11,8 @@ const { const testPath = path.resolve(__dirname); +const describeDevServer6 = devServerVersion === "5" ? describe.skip : describe; + const normalStdKillOptions = { stdoutKillStr: /webpack \d+\.\d+\.\d/, stderrKillStr: /Content not from webpack is served from/, @@ -95,21 +98,64 @@ describe("basic serve usage", () => { expect(stdout).toContain("second-output/main.js"); }); - // TODO need fix in future, edge case - // eslint-disable-next-line jest/no-disabled-tests - it.skip("should work in multi compiler mode with multiple dev servers", async () => { - const { stderr, stdout } = await runWatch( - __dirname, - ["serve", "--config", "multi-dev-server.config.js"], - normalStdKillOptions, - ); + // TODO in the next major release, remove the condition, only dev servers with plugin support + // select distinct ports for configurations without them + describeDevServer6("with multiple dev servers", () => { + it("should work in multi compiler mode without ports", async () => { + const ports = []; + const responses = {}; + const { exitCode, stdout } = await runWatch( + __dirname, + ["serve", "--config", "multi-dev-server.config.js", "--watch-options-stdin"], + { + // Automatic ports start from the base one + env: { WEBPACK_DEV_SERVER_BASE_PORT: String(port) }, + handler: (proc) => { + let output = ""; + + proc.stderr.on("data", (chunk) => { + output += chunk.toString(); + + if (ports.length > 0) { + return; + } + + const found = [...output.matchAll(/Loopback: http:\/\/localhost:(\d+)\//g)].map( + ([, found]) => found, + ); + + if (found.length < 2) { + return; + } + + ports.push(...found); + + // Every dev server serves all compilers + Promise.all( + ports.flatMap((port) => + ["first-output", "second-output"].map((directory) => + fetch( + `http://127.0.0.1:${port}/dev-server-my-public-path/${directory}/main.js`, + ).then((response) => { + responses[`${port}:${directory}`] = response.status; + }), + ), + ), + ).finally(() => { + proc.stdin.end(); + }); + }); + }, + }, + ); - expect(normalizeStderr(stderr)).toMatchSnapshot("stderr"); - expect(stdout).toContain("HotModuleReplacementPlugin"); - expect(stdout).toContain("one"); - expect(stdout).toContain("first-output/main.js"); - expect(stdout).toContain("two"); - expect(stdout).toContain("second-output/main.js"); + expect(exitCode).toBe(0); + expect(new Set(ports).size).toBe(2); + expect(Object.values(responses)).toEqual([200, 200, 200, 200]); + expect(stdout).toContain("HotModuleReplacementPlugin"); + expect(stdout).toContain("first-output/main.js"); + expect(stdout).toContain("second-output/main.js"); + }); }); it('should work with the "--mode" option', async () => {