SYM-206: fix Vitest mock API port collisions (#1271)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-05-07 12:03:53 -07:00
committed by GitHub
co-authored by Jwalin Shah
parent 333f60694e
commit ebd8c1685c
3 changed files with 168 additions and 27 deletions
@@ -0,0 +1,58 @@
import net from 'node:net';
import { afterEach, expect, it } from 'vitest';
// @ts-ignore - test-only JS module outside app/src
import {
getMockServerPort,
startMockServer,
stopMockServer,
} from '../../../scripts/mock-api-core.mjs';
const preferredPort = 5005;
function listenOn(port: number): Promise<net.Server> {
const server = net.createServer();
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', () => {
server.off('error', reject);
resolve(server);
});
});
}
function closeServer(server: net.Server | null): Promise<void> {
return new Promise(resolve => {
if (!server?.listening) {
resolve();
return;
}
server.close(() => resolve());
});
}
afterEach(async () => {
await stopMockServer();
await startMockServer(preferredPort, { retryIfInUse: true });
});
it('falls back to an available local port when the preferred Vitest mock port is occupied', async () => {
await stopMockServer();
const blocker = await listenOn(preferredPort);
try {
const started = await startMockServer(preferredPort, { retryIfInUse: true });
expect(started.alreadyRunning).toBe(false);
expect(started.requestedPort).toBe(preferredPort);
expect(started.retried).toBe(true);
expect(started.port).not.toBe(preferredPort);
expect(started.port).toBeGreaterThan(0);
expect(getMockServerPort()).toBe(started.port);
const response = await fetch(`http://127.0.0.1:${started.port}/__admin/health`);
await expect(response.json()).resolves.toMatchObject({ ok: true, port: started.port });
} finally {
await closeServer(blocker);
}
});
+17 -6
View File
@@ -10,7 +10,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import type React from 'react';
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
import { afterAll, afterEach, beforeEach, vi } from 'vitest';
// @ts-ignore - test-only JS module outside app/src
import {
@@ -20,9 +20,23 @@ import {
stopMockServer,
} from '../../../scripts/mock-api-core.mjs';
const DEFAULT_TEST_MOCK_API_PORT = 5005;
function readMockApiPort() {
const rawPort = process.env.VITEST_MOCK_API_PORT ?? process.env.MOCK_API_PORT;
const port = rawPort ? Number(rawPort) : DEFAULT_TEST_MOCK_API_PORT;
return Number.isInteger(port) && port > 0 ? port : DEFAULT_TEST_MOCK_API_PORT;
}
const mockApiServer = await startMockServer(readMockApiPort(), { retryIfInUse: true });
const mockApiUrl = `http://localhost:${mockApiServer.port}`;
process.env.VITEST_MOCK_API_URL = mockApiUrl;
process.env.VITE_BACKEND_URL = mockApiUrl;
// Mock import.meta.env defaults for tests
vi.stubEnv('DEV', true);
vi.stubEnv('MODE', 'test');
vi.stubEnv('VITE_BACKEND_URL', mockApiUrl);
function createStorageMock(): Storage {
const store = new Map<string, string>();
@@ -133,7 +147,7 @@ vi.mock('../utils/config', () => ({
DEV_FORCE_ONBOARDING: false,
SKILLS_GITHUB_REPO: 'test/skills',
SENTRY_DSN: undefined,
BACKEND_URL: 'http://localhost:5005',
BACKEND_URL: mockApiUrl,
TELEGRAM_BOT_USERNAME: 'openhuman_bot',
LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest',
APP_VERSION: '0.0.0-test',
@@ -142,7 +156,7 @@ vi.mock('../utils/config', () => ({
}));
vi.mock('../services/backendUrl', () => ({
getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'),
getBackendUrl: vi.fn().mockImplementation(() => Promise.resolve(mockApiUrl)),
}));
// Mock redux-persist to avoid CJS/ESM issues in vitest
@@ -206,9 +220,6 @@ if (!process.env.DEBUG_TESTS) {
}
// Shared mock API server lifecycle for unit tests (default)
beforeAll(async () => {
await startMockServer(5005);
});
afterEach(() => {
clearRequestLog();
cleanup();
+93 -21
View File
@@ -3,6 +3,7 @@ import http from "node:http";
const DEFAULT_PORT = 18473;
const MOCK_JWT = "e2e-mock-jwt-token";
const MAX_PORT_RETRY_ATTEMPTS = 10;
let requestLog = [];
let mockBehavior = {};
@@ -1351,29 +1352,99 @@ function handleWebSocketUpgrade(req, socket) {
socket.on("close", () => {});
}
function startMockServer(port = DEFAULT_PORT) {
return new Promise((resolve, reject) => {
if (server) {
resolve({ port: server.address()?.port ?? port, alreadyRunning: true });
return;
}
server = http.createServer((req, res) => {
handleRequest(req, res).catch((err) => {
console.error("[MockServer] Unhandled error:", err);
json(res, 500, { success: false, error: "Internal mock error" });
});
});
server.on("connection", (socket) => {
openSockets.add(socket);
socket.on("close", () => openSockets.delete(socket));
});
server.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket));
server.on("error", reject);
server.listen(port, "127.0.0.1", () => {
console.log(`[MockServer] Listening on http://127.0.0.1:${port}`);
resolve({ port });
function getMockServerPort() {
const address = server?.address();
return typeof address === "object" && address ? address.port : null;
}
function createServerInstance() {
const nextServer = http.createServer((req, res) => {
handleRequest(req, res).catch((err) => {
console.error("[MockServer] Unhandled error:", err);
json(res, 500, { success: false, error: "Internal mock error" });
});
});
nextServer.on("connection", (socket) => {
openSockets.add(socket);
socket.on("close", () => openSockets.delete(socket));
});
nextServer.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket));
return nextServer;
}
function listen(serverInstance, port) {
return new Promise((resolve, reject) => {
const onError = (err) => {
serverInstance.off("listening", onListening);
reject(err);
};
const onListening = () => {
serverInstance.off("error", onError);
const address = serverInstance.address();
const resolvedPort =
typeof address === "object" && address ? address.port : port;
resolve(resolvedPort);
};
serverInstance.once("error", onError);
serverInstance.once("listening", onListening);
serverInstance.listen(port, "127.0.0.1");
});
}
async function startMockServer(port = DEFAULT_PORT, options = {}) {
if (server) {
return { port: getMockServerPort() ?? port, alreadyRunning: true };
}
const preferredPort = Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT;
const retryIfInUse = options.retryIfInUse === true;
const candidatePorts = retryIfInUse
? [
preferredPort,
...Array.from(
{ length: MAX_PORT_RETRY_ATTEMPTS },
(_, i) => preferredPort + i + 1,
),
0,
]
: [preferredPort];
let lastError = null;
for (const candidatePort of candidatePorts) {
const nextServer = createServerInstance();
try {
const resolvedPort = await listen(nextServer, candidatePort);
server = nextServer;
const retryNote =
resolvedPort === preferredPort
? ""
: ` (preferred ${preferredPort} unavailable)`;
console.log(
`[MockServer] Listening on http://127.0.0.1:${resolvedPort}${retryNote}`,
);
return {
port: resolvedPort,
alreadyRunning: false,
requestedPort: preferredPort,
retried: resolvedPort !== preferredPort,
};
} catch (err) {
try {
nextServer.close();
} catch {
// The failed candidate may never have reached the listening state.
}
lastError = err;
if (!retryIfInUse || err?.code !== "EADDRINUSE") {
throw err;
}
console.warn(
`[MockServer] Port ${candidatePort} unavailable; trying another local port`,
);
}
}
throw lastError ?? new Error("Mock server failed to start");
}
function stopMockServer() {
@@ -1397,6 +1468,7 @@ function stopMockServer() {
export {
DEFAULT_PORT,
clearRequestLog,
getMockServerPort,
getMockBehavior,
getRequestLog,
resetMockBehavior,