Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions plugins/mock/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions plugins/mock/README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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])

断言指定的字符串记录在指定的日志中。
Expand Down
36 changes: 34 additions & 2 deletions plugins/mock/src/lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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') {
Expand Down
24 changes: 19 additions & 5 deletions plugins/mock/src/lib/format_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions plugins/mock/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface MockClusterOptions extends MockOptions {
workers?: number | string;
cache?: boolean;
port?: number;
clusterPort?: number;
Comment thread
killagu marked this conversation as resolved.
/**
* opt pass to coffee, such as { execArgv: ['--debug'] }
*/
Expand All @@ -86,6 +87,7 @@ export interface MockClusterApplicationOptions extends MockClusterOptions {
baseDir: string;
framework: string;
port: number;
clusterPort?: number;
}

export type {
Expand Down
162 changes: 162 additions & 0 deletions plugins/mock/test/cluster_constructor.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
31 changes: 31 additions & 0 deletions plugins/mock/test/format_options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading