diff --git a/plugins/mock/README.md b/plugins/mock/README.md index f3465bfcb6..a0f1885cb6 100644 --- a/plugins/mock/README.md +++ b/plugins/mock/README.md @@ -253,6 +253,14 @@ Clean all logs directory, default is true. If you are using `ava`, disable it. +#### port {Number} + +The app server port used by `mm.cluster`. By default it is assigned from a process-scoped range to reduce conflicts in parallel tests. + +#### clusterPort {Number} + +The cluster-client leader port used by `mm.cluster`. By default it is assigned from a process-scoped range to reduce watcher leader conflicts in parallel tests. + ### app.mockLog([logger]) and app.expectLog(str[, logger]), app.notExpectLog(str[, logger]) Assert some string value in the logger instance. diff --git a/plugins/mock/README.zh_CN.md b/plugins/mock/README.zh_CN.md index 7410c33a4e..d69b95c41c 100644 --- a/plugins/mock/README.zh_CN.md +++ b/plugins/mock/README.zh_CN.md @@ -260,6 +260,14 @@ mm.app({ 如果是通过 ava 等并行测试框架进行测试,需要手动在执行测试前进行统一的日志清理,不能通过 mm 来处理,设置 `clean` 为 `false`。 +#### port {Number} + +`mm.cluster` 使用的应用服务端口。默认会从当前进程隔离的端口范围中分配,降低并行测试中的端口冲突。 + +#### clusterPort {Number} + +`mm.cluster` 使用的 cluster-client leader 端口。默认会从当前进程隔离的端口范围中分配,降低并行测试中的 watcher leader 冲突。 + ### app.mockLog([logger]) and app.expectLog(str[, logger]), app.notExpectLog(str[, logger]) 断言指定的字符串记录在指定的日志中。 diff --git a/plugins/mock/src/lib/cluster.ts b/plugins/mock/src/lib/cluster.ts index 9a261cfbc4..e1526bd62d 100644 --- a/plugins/mock/src/lib/cluster.ts +++ b/plugins/mock/src/lib/cluster.ts @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { debuglog } from 'node:util'; +import { threadId } from 'node:worker_threads'; import { Coffee } from 'coffee'; import { Ready } from 'get-ready'; @@ -15,13 +16,31 @@ import type { MockClusterOptions, MockClusterApplicationOptions } from './types. import { sleep, rimrafSync } from './utils.ts'; const debug = debuglog('egg/mock/lib/cluster'); +const MOCK_APP_PORT_START = 10000; +const MOCK_APP_PORT_RANGE_SIZE = 6000; +const MOCK_CLUSTER_PORT_START = 17000; +const MOCK_CLUSTER_PORT_WINDOW_SIZE = 100; +const MOCK_CLUSTER_PORT_WINDOWS = 480; const clusters = new Map(); declare global { // define the global variable to avoid the port conflict in parallel process mode var eggMockMasterPort: number; + var eggMockClusterPort: number; } -globalThis.eggMockMasterPort = 17000 + (process.pid % 1000); +function getMockAppPortStart(pid: number = process.pid, workerThreadId: number = threadId): number { + return MOCK_APP_PORT_START + ((pid * 31 + workerThreadId * 37) % MOCK_APP_PORT_RANGE_SIZE); +} + +export function getMockClusterPortStart(pid: number = process.pid, workerThreadId: number = threadId): number { + return ( + MOCK_CLUSTER_PORT_START + + ((pid * 31 + workerThreadId * 37) % MOCK_CLUSTER_PORT_WINDOWS) * MOCK_CLUSTER_PORT_WINDOW_SIZE + ); +} + +globalThis.eggMockMasterPort = getMockAppPortStart(); +globalThis.eggMockClusterPort = getMockClusterPortStart(); let serverBin = path.join(import.meta.dirname, 'start-cluster.js'); if (!existsSync(serverBin)) { @@ -83,6 +102,7 @@ export class ClusterApplication extends Coffee { // incremental port options.port = options.port ?? ++globalThis.eggMockMasterPort; + options.clusterPort = options.clusterPort ?? ++globalThis.eggMockClusterPort; // Set 1 worker when test if (!options.workers) { options.workers = 1; @@ -119,6 +139,18 @@ export class ClusterApplication extends Coffee { // data: { port: 17703, address: 'http://127.0.0.1:17703', protocol: 'http' } debug('on message egg-ready %o', msg); this._address = msg.data.address; + if (this._address) { + try { + const { port } = new URL(this._address); + if (port) { + this.port = Number(port); + } + } catch { + // Unix socket addresses are valid app addresses, but not URLs. + } + } else if (msg.data.port) { + this.port = msg.data.port; + } this.emit('close', 0); break; case 'app-worker-died': @@ -263,7 +295,7 @@ export class ClusterApplication extends Coffee { return supertestRequest(this); } - _callFunctionOnAppWorker(method: string, args: any[] = [], property: any = undefined, needResult = false): any { + _callFunctionOnAppWorker(method: string, args: any[] = [], property?: any, needResult = false): any { for (let i = 0; i < args.length; i++) { const arg = args[i]; if (typeof arg === 'function') { diff --git a/plugins/mock/src/lib/format_options.ts b/plugins/mock/src/lib/format_options.ts index baa098b409..6bf4c3975e 100644 --- a/plugins/mock/src/lib/format_options.ts +++ b/plugins/mock/src/lib/format_options.ts @@ -9,6 +9,23 @@ import type { MockOptions, MockApplicationOptions } from './types.ts'; import { getSourceDirname } from './utils.ts'; const debug = debuglog('egg/mock/lib/format_options'); +const MOCK_HOME_ENVS = new Set(['default', 'test', 'prod']); + +export function shouldMockProcessHome(): boolean { + return MOCK_HOME_ENVS.has(process.env.EGG_SERVER_ENV ?? '') || process.env.NODE_ENV === 'test'; +} + +export function mockProcessHome(baseDir: string): void { + if (!shouldMockProcessHome()) { + return; + } + if (!isMocked(process.env, 'HOME')) { + mm(process.env, 'HOME', baseDir); + } + if (!isMocked(process.env, 'EGG_HOME') && process.env.EGG_HOME === undefined) { + mm(process.env, 'EGG_HOME', baseDir); + } +} /** * format the options @@ -76,11 +93,8 @@ export function formatOptions(initOptions?: MockOptions): MockApplicationOptions } } - // mock HOME as baseDir, but ignore if it has been mocked - const env = process.env.EGG_SERVER_ENV; - if (!isMocked(process.env, 'HOME') && (env === 'default' || env === 'test' || env === 'prod')) { - mm(process.env, 'HOME', options.baseDir); - } + // mock HOME/EGG_HOME as baseDir for test-like envs, but ignore explicit mocks. + mockProcessHome(options.baseDir); // disable cache after call mm.env(), // otherwise it will use cache and won't load again. diff --git a/plugins/mock/src/lib/types.ts b/plugins/mock/src/lib/types.ts index ed01cfe663..4b0ed0d838 100644 --- a/plugins/mock/src/lib/types.ts +++ b/plugins/mock/src/lib/types.ts @@ -63,6 +63,7 @@ export interface MockClusterOptions extends MockOptions { workers?: number | string; cache?: boolean; port?: number; + clusterPort?: number; /** * opt pass to coffee, such as { execArgv: ['--debug'] } */ @@ -86,6 +87,7 @@ export interface MockClusterApplicationOptions extends MockClusterOptions { baseDir: string; framework: string; port: number; + clusterPort?: number; } export type { diff --git a/plugins/mock/test/cluster_constructor.test.ts b/plugins/mock/test/cluster_constructor.test.ts new file mode 100644 index 0000000000..c09576bfae --- /dev/null +++ b/plugins/mock/test/cluster_constructor.test.ts @@ -0,0 +1,162 @@ +import { strict as assert } from 'node:assert'; + +import { beforeEach, describe, it, vi } from 'vitest'; + +const coffeeOptions = vi.hoisted(() => [] as any[]); +const messageHandlers = vi.hoisted(() => [] as any[]); + +vi.mock('coffee', () => { + class Coffee { + proc = { + on(event: string, handler: any) { + if (event === 'message') { + messageHandlers.push(handler); + } + return this; + }, + }; + + constructor(options: any) { + coffeeOptions.push(options); + } + + debug() {} + + coverage() {} + + emit() {} + + end(callback: () => void) { + callback(); + return this; + } + } + + return { Coffee }; +}); + +import { ClusterApplication, getMockClusterPortStart } from '../src/lib/cluster.ts'; + +describe('test/cluster_constructor.test.ts', () => { + beforeEach(() => { + coffeeOptions.length = 0; + messageHandlers.length = 0; + }); + + it('should compute a deterministic mock cluster port window', () => { + const port = getMockClusterPortStart(1000, 2); + assert.equal(port, 17000 + ((1000 * 31 + 2 * 37) % 480) * 100); + assert.equal(port % 100, 0); + assert(port >= 17000); + assert(port < 65000); + }); + + it('should preserve child process env overrides', () => { + process.env.EGG_MOCK_INHERITED_ENV_TEST = 'should-not-be-forced'; + try { + new ClusterApplication({ + baseDir: '/tmp/mock-cluster-app', + cache: false, + clean: false, + coverage: false, + opt: { + env: { + EGG_HOME: '/tmp/custom-egg-home', + HOME: '/tmp/custom-home', + CUSTOM_ENV: 'custom', + }, + }, + } as any); + + const options = coffeeOptions[0]; + const startOptions = JSON.parse(options.args[0]); + assert.equal(options.opt.env.EGG_HOME, '/tmp/custom-egg-home'); + assert.equal(options.opt.env.HOME, '/tmp/custom-home'); + assert.equal(options.opt.env.CUSTOM_ENV, 'custom'); + assert.equal(options.opt.env.EGG_MOCK_INHERITED_ENV_TEST, undefined); + assert.equal(options.method, 'fork'); + assert.equal(startOptions.baseDir, '/tmp/mock-cluster-app'); + assert(startOptions.port >= 10000); + assert(startOptions.port < 16000); + assert.equal(typeof startOptions.clusterPort, 'number'); + } finally { + delete process.env.EGG_MOCK_INHERITED_ENV_TEST; + } + }); + + it('should leave child process env unset so fork inherits the formatted process env', () => { + new ClusterApplication({ + baseDir: '/tmp/mock-cluster-app', + cache: false, + clean: false, + coverage: false, + opt: { + execArgv: [], + }, + } as any); + + assert.equal(coffeeOptions[0].opt.env, undefined); + }); + + it('should update port from egg-ready url address', async () => { + const app = new ClusterApplication({ + baseDir: '/tmp/mock-cluster-app', + cache: false, + clean: false, + coverage: false, + port: 12000, + } as any); + + await new Promise((resolve) => process.nextTick(resolve)); + messageHandlers.at(-1)({ + action: 'egg-ready', + data: { + address: 'http://127.0.0.1:12001', + }, + }); + + assert.equal(app.address().port, 12001); + assert.equal(app.url, 'http://127.0.0.1:12001'); + }); + + it('should update port from egg-ready data when address is missing', async () => { + const app = new ClusterApplication({ + baseDir: '/tmp/mock-cluster-app', + cache: false, + clean: false, + coverage: false, + port: 12000, + } as any); + + await new Promise((resolve) => process.nextTick(resolve)); + messageHandlers.at(-1)({ + action: 'egg-ready', + data: { + port: 12002, + }, + }); + + assert.equal(app.address().port, 12002); + }); + + it('should keep port when egg-ready address is not a url', async () => { + const app = new ClusterApplication({ + baseDir: '/tmp/mock-cluster-app', + cache: false, + clean: false, + coverage: false, + port: 12000, + } as any); + + await new Promise((resolve) => process.nextTick(resolve)); + messageHandlers.at(-1)({ + action: 'egg-ready', + data: { + address: 'mock.sock', + }, + }); + + assert.equal(app.address().port, 12000); + assert.equal(app.url, 'mock.sock'); + }); +}); diff --git a/plugins/mock/test/format_options.test.ts b/plugins/mock/test/format_options.test.ts index 307e6fc204..b3532a6092 100644 --- a/plugins/mock/test/format_options.test.ts +++ b/plugins/mock/test/format_options.test.ts @@ -151,14 +151,45 @@ describe('test/format_options.test.ts', () => { mm(process.env, 'EGG_SERVER_ENV', 'default'); formatOptions(); assert.equal(process.env.HOME, baseDir); + assert.equal(process.env.EGG_HOME, baseDir); mm(process.env, 'EGG_SERVER_ENV', 'test'); formatOptions(); assert.equal(process.env.HOME, baseDir); + assert.equal(process.env.EGG_HOME, baseDir); mm(process.env, 'EGG_SERVER_ENV', 'prod'); formatOptions(); assert.equal(process.env.HOME, baseDir); + assert.equal(process.env.EGG_HOME, baseDir); + + mm.restore(); + mm(process.env, 'NODE_ENV', 'test'); + formatOptions(); + assert.equal(process.env.HOME, baseDir); + assert.equal(process.env.EGG_HOME, baseDir); + + mm.restore(); + mm(process.env, 'EGG_SERVER_ENV', 'unittest'); + mm(process.env, 'NODE_ENV', 'test'); + formatOptions(); + assert.equal(process.env.HOME, baseDir); + assert.equal(process.env.EGG_HOME, baseDir); + }); + + it('should preserve existing process.env.EGG_HOME', () => { + const baseDir = process.cwd(); + const eggHome = path.join(baseDir, '.custom-egg-home'); + mm(process.env, 'EGG_SERVER_ENV', 'default'); + process.env.EGG_HOME = eggHome; + try { + formatOptions(); + + assert.equal(process.env.HOME, baseDir); + assert.equal(process.env.EGG_HOME, eggHome); + } finally { + delete process.env.EGG_HOME; + } }); // FIXME: flaky test