mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
SYM-206: fix Vitest mock API port collisions (#1271)
Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
co-authored by
Jwalin Shah
parent
333f60694e
commit
ebd8c1685c
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user