Skip to content

Commit 9fd558a

Browse files
authored
test(e2e): allocate dynamic host ports for local runs (#2538)
The variant runner hardcoded host ports (backend 3000, redpanda 19092, …) from variant.json, so e2e collided with anything already on those ports — e.g. a dev Console on :3000. When the readiness curl hit the *other* server, tests ran against it and failed confusingly. run-variant.mjs now picks free ports in a non-ephemeral range (20000–45000, to avoid the OS handing them to outbound connections before Docker binds) for each service the variant exposes, exports them via E2E_PORTS_OVERRIDE + REACT_APP_ORIGIN, and global-setup.mjs merges the override onto variant.json. CI keeps its fixed ports (separate runners) unless E2E_DYNAMIC_PORTS=1.
1 parent 42040e4 commit 9fd558a

2 files changed

Lines changed: 77 additions & 2 deletions

File tree

frontend/tests/scripts/run-variant.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,65 @@
11
import { discoverVariants, getVariant } from './discover-variants.mjs';
22
import { spawn } from 'node:child_process';
33
import { existsSync } from 'node:fs';
4+
import net from 'node:net';
45
import { dirname, join, resolve } from 'node:path';
56
import { fileURLToPath } from 'node:url';
67

78
const __filename = fileURLToPath(import.meta.url);
89
const __dirname = dirname(__filename);
910
const testsDir = resolve(__dirname, '..');
1011

12+
// Find a free TCP port in a NON-ephemeral range. Binding to :0 returns an
13+
// ephemeral port (macOS 49152–65535), which the OS may hand to an outbound
14+
// connection in the gap before Docker binds it (→ "address already in use").
15+
// Probing a fixed range below that window avoids the race. `taken` excludes
16+
// ports already chosen this run so we don't hand out duplicates.
17+
function getFreePort(taken = new Set(), min = 20_000, max = 45_000) {
18+
return new Promise((resolveP, rejectP) => {
19+
let attempts = 0;
20+
const tryPort = () => {
21+
if (attempts++ > 100) {
22+
rejectP(new Error('Could not find a free port'));
23+
return;
24+
}
25+
const port = min + Math.floor(Math.random() * (max - min));
26+
if (taken.has(port)) {
27+
tryPort();
28+
return;
29+
}
30+
const srv = net.createServer();
31+
srv.unref();
32+
srv.once('error', () => tryPort());
33+
srv.listen(port, '127.0.0.1', () => {
34+
srv.close(() => {
35+
taken.add(port);
36+
resolveP(port);
37+
});
38+
});
39+
};
40+
tryPort();
41+
});
42+
}
43+
44+
// Allocate a fresh free host port for every service the variant exposes, so
45+
// local runs never collide with whatever is already on the default ports (e.g.
46+
// a dev Console on :3000). CI keeps the fixed variant.json ports — separate
47+
// runners, no collisions — unless E2E_DYNAMIC_PORTS=1 is set.
48+
async function resolveDynamicPorts(variant) {
49+
const staticPorts = variant.config.ports ?? {};
50+
const useDynamic =
51+
process.env.E2E_DYNAMIC_PORTS === '1' || (!process.env.CI && process.env.E2E_DYNAMIC_PORTS !== '0');
52+
if (!useDynamic) {
53+
return null;
54+
}
55+
const dynamic = {};
56+
const taken = new Set();
57+
for (const key of Object.keys(staticPorts)) {
58+
dynamic[key] = await getFreePort(taken);
59+
}
60+
return dynamic;
61+
}
62+
1163
function printUsage() {
1264
console.log('Usage: bun run e2e-test:variant <variant-name> [playwright-options]');
1365
console.log('');
@@ -44,6 +96,20 @@ async function runVariant(variantName, playwrightArgs = []) {
4496
console.log(`Config: ${configPath}`);
4597
console.log('');
4698

99+
// Pick free host ports up front (before Playwright loads its config, so the
100+
// config's baseURL can read REACT_APP_ORIGIN). global-setup merges
101+
// E2E_PORTS_OVERRIDE over variant.json for the container port mappings.
102+
const dynamicPorts = await resolveDynamicPorts(variant);
103+
const portEnv = {};
104+
if (dynamicPorts) {
105+
portEnv.E2E_PORTS_OVERRIDE = JSON.stringify(dynamicPorts);
106+
if (dynamicPorts.backend && !process.env.REACT_APP_ORIGIN) {
107+
portEnv.REACT_APP_ORIGIN = `http://localhost:${dynamicPorts.backend}`;
108+
}
109+
console.log(`Using dynamic host ports: ${JSON.stringify(dynamicPorts)}`);
110+
console.log('');
111+
}
112+
47113
const args = ['playwright', 'test', '--config', configPath, ...playwrightArgs];
48114

49115
const child = spawn('npx', args, {
@@ -53,6 +119,7 @@ async function runVariant(variantName, playwrightArgs = []) {
53119
...process.env,
54120
// Force IPv4 for testcontainers wait strategies (localhost resolves to ::1 on macOS, causing hangs)
55121
TESTCONTAINERS_HOST_OVERRIDE: process.env.TESTCONTAINERS_HOST_OVERRIDE ?? '127.0.0.1',
122+
...portEnv,
56123
},
57124
});
58125

frontend/tests/shared/global-setup.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,9 +1017,17 @@ export default async function globalSetup(config = {}) {
10171017
const needsShadowlink = config?.metadata?.needsShadowlink ?? false;
10181018
const needsConnect = config?.metadata?.needsConnect ?? false;
10191019

1020-
// Load ports from variant's config/variant.json
1020+
// Load ports from variant's config/variant.json, then apply any dynamic
1021+
// host-port overrides chosen by run-variant.mjs (E2E_PORTS_OVERRIDE) so local
1022+
// runs avoid collisions with whatever is on the default ports.
10211023
const variantConfig = loadVariantConfig(variantName);
1022-
const ports = variantConfig.ports;
1024+
let portsOverride = {};
1025+
try {
1026+
portsOverride = JSON.parse(process.env.E2E_PORTS_OVERRIDE ?? '{}');
1027+
} catch {
1028+
portsOverride = {};
1029+
}
1030+
const ports = { ...variantConfig.ports, ...portsOverride };
10231031

10241032
console.log('\n\n========================================');
10251033
console.log(`🚀 GLOBAL SETUP: ${variantName}${needsShadowlink ? ' + SHADOWLINK' : ''}`);

0 commit comments

Comments
 (0)