Skip to content

Commit 4c92451

Browse files
committed
test: isolate mock cluster resources
1 parent 871e596 commit 4c92451

7 files changed

Lines changed: 264 additions & 7 deletions

File tree

plugins/mock/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,14 @@ Clean all logs directory, default is true.
253253

254254
If you are using `ava`, disable it.
255255

256+
#### port {Number}
257+
258+
The app server port used by `mm.cluster`. By default it is assigned from a process-scoped range to reduce conflicts in parallel tests.
259+
260+
#### clusterPort {Number}
261+
262+
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.
263+
256264
### app.mockLog([logger]) and app.expectLog(str[, logger]), app.notExpectLog(str[, logger])
257265

258266
Assert some string value in the logger instance.

plugins/mock/README.zh_CN.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,14 @@ mm.app({
260260

261261
如果是通过 ava 等并行测试框架进行测试,需要手动在执行测试前进行统一的日志清理,不能通过 mm 来处理,设置 `clean``false`
262262

263+
#### port {Number}
264+
265+
`mm.cluster` 使用的应用服务端口。默认会从当前进程隔离的端口范围中分配,降低并行测试中的端口冲突。
266+
267+
#### clusterPort {Number}
268+
269+
`mm.cluster` 使用的 cluster-client leader 端口。默认会从当前进程隔离的端口范围中分配,降低并行测试中的 watcher leader 冲突。
270+
263271
### app.mockLog([logger]) and app.expectLog(str[, logger]), app.notExpectLog(str[, logger])
264272

265273
断言指定的字符串记录在指定的日志中。

plugins/mock/src/lib/cluster.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
66
import { debuglog } from 'node:util';
7+
import { threadId } from 'node:worker_threads';
78

89
import { Coffee } from 'coffee';
910
import { Ready } from 'get-ready';
@@ -15,13 +16,31 @@ import type { MockClusterOptions, MockClusterApplicationOptions } from './types.
1516
import { sleep, rimrafSync } from './utils.ts';
1617

1718
const debug = debuglog('egg/mock/lib/cluster');
19+
const MOCK_APP_PORT_START = 10000;
20+
const MOCK_APP_PORT_RANGE_SIZE = 6000;
21+
const MOCK_CLUSTER_PORT_START = 17000;
22+
const MOCK_CLUSTER_PORT_WINDOW_SIZE = 100;
23+
const MOCK_CLUSTER_PORT_WINDOWS = 480;
1824

1925
const clusters = new Map();
2026
declare global {
2127
// define the global variable to avoid the port conflict in parallel process mode
2228
var eggMockMasterPort: number;
29+
var eggMockClusterPort: number;
2330
}
24-
globalThis.eggMockMasterPort = 17000 + (process.pid % 1000);
31+
function getMockAppPortStart(pid: number = process.pid, workerThreadId: number = threadId): number {
32+
return MOCK_APP_PORT_START + ((pid * 31 + workerThreadId * 37) % MOCK_APP_PORT_RANGE_SIZE);
33+
}
34+
35+
export function getMockClusterPortStart(pid: number = process.pid, workerThreadId: number = threadId): number {
36+
return (
37+
MOCK_CLUSTER_PORT_START +
38+
((pid * 31 + workerThreadId * 37) % MOCK_CLUSTER_PORT_WINDOWS) * MOCK_CLUSTER_PORT_WINDOW_SIZE
39+
);
40+
}
41+
42+
globalThis.eggMockMasterPort = getMockAppPortStart();
43+
globalThis.eggMockClusterPort = getMockClusterPortStart();
2544

2645
let serverBin = path.join(import.meta.dirname, 'start-cluster.js');
2746
if (!existsSync(serverBin)) {
@@ -83,6 +102,7 @@ export class ClusterApplication extends Coffee {
83102

84103
// incremental port
85104
options.port = options.port ?? ++globalThis.eggMockMasterPort;
105+
options.clusterPort = options.clusterPort ?? ++globalThis.eggMockClusterPort;
86106
// Set 1 worker when test
87107
if (!options.workers) {
88108
options.workers = 1;
@@ -119,6 +139,18 @@ export class ClusterApplication extends Coffee {
119139
// data: { port: 17703, address: 'http://127.0.0.1:17703', protocol: 'http' }
120140
debug('on message egg-ready %o', msg);
121141
this._address = msg.data.address;
142+
if (this._address) {
143+
try {
144+
const { port } = new URL(this._address);
145+
if (port) {
146+
this.port = Number(port);
147+
}
148+
} catch {
149+
// Unix socket addresses are valid app addresses, but not URLs.
150+
}
151+
} else if (msg.data.port) {
152+
this.port = msg.data.port;
153+
}
122154
this.emit('close', 0);
123155
break;
124156
case 'app-worker-died':
@@ -263,7 +295,7 @@ export class ClusterApplication extends Coffee {
263295
return supertestRequest(this);
264296
}
265297

266-
_callFunctionOnAppWorker(method: string, args: any[] = [], property: any = undefined, needResult = false): any {
298+
_callFunctionOnAppWorker(method: string, args: any[] = [], property?: any, needResult = false): any {
267299
for (let i = 0; i < args.length; i++) {
268300
const arg = args[i];
269301
if (typeof arg === 'function') {

plugins/mock/src/lib/format_options.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ import type { MockOptions, MockApplicationOptions } from './types.ts';
99
import { getSourceDirname } from './utils.ts';
1010

1111
const debug = debuglog('egg/mock/lib/format_options');
12+
const MOCK_HOME_ENVS = new Set(['default', 'test', 'prod']);
13+
14+
export function shouldMockProcessHome(): boolean {
15+
return MOCK_HOME_ENVS.has(process.env.EGG_SERVER_ENV ?? '') || process.env.NODE_ENV === 'test';
16+
}
17+
18+
export function mockProcessHome(baseDir: string): void {
19+
if (!shouldMockProcessHome()) {
20+
return;
21+
}
22+
if (!isMocked(process.env, 'HOME')) {
23+
mm(process.env, 'HOME', baseDir);
24+
}
25+
if (!isMocked(process.env, 'EGG_HOME') && process.env.EGG_HOME === undefined) {
26+
mm(process.env, 'EGG_HOME', baseDir);
27+
}
28+
}
1229

1330
/**
1431
* format the options
@@ -76,11 +93,8 @@ export function formatOptions(initOptions?: MockOptions): MockApplicationOptions
7693
}
7794
}
7895

79-
// mock HOME as baseDir, but ignore if it has been mocked
80-
const env = process.env.EGG_SERVER_ENV;
81-
if (!isMocked(process.env, 'HOME') && (env === 'default' || env === 'test' || env === 'prod')) {
82-
mm(process.env, 'HOME', options.baseDir);
83-
}
96+
// mock HOME/EGG_HOME as baseDir for test-like envs, but ignore explicit mocks.
97+
mockProcessHome(options.baseDir);
8498

8599
// disable cache after call mm.env(),
86100
// otherwise it will use cache and won't load again.

plugins/mock/src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface MockClusterOptions extends MockOptions {
6363
workers?: number | string;
6464
cache?: boolean;
6565
port?: number;
66+
clusterPort?: number;
6667
/**
6768
* opt pass to coffee, such as { execArgv: ['--debug'] }
6869
*/
@@ -86,6 +87,7 @@ export interface MockClusterApplicationOptions extends MockClusterOptions {
8687
baseDir: string;
8788
framework: string;
8889
port: number;
90+
clusterPort?: number;
8991
}
9092

9193
export type {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { strict as assert } from 'node:assert';
2+
3+
import { beforeEach, describe, it, vi } from 'vitest';
4+
5+
const coffeeOptions = vi.hoisted(() => [] as any[]);
6+
const messageHandlers = vi.hoisted(() => [] as any[]);
7+
8+
vi.mock('coffee', () => {
9+
class Coffee {
10+
proc = {
11+
on(event: string, handler: any) {
12+
if (event === 'message') {
13+
messageHandlers.push(handler);
14+
}
15+
return this;
16+
},
17+
};
18+
19+
constructor(options: any) {
20+
coffeeOptions.push(options);
21+
}
22+
23+
debug() {}
24+
25+
coverage() {}
26+
27+
emit() {}
28+
29+
end(callback: () => void) {
30+
callback();
31+
return this;
32+
}
33+
}
34+
35+
return { Coffee };
36+
});
37+
38+
import { ClusterApplication, getMockClusterPortStart } from '../src/lib/cluster.ts';
39+
40+
describe('test/cluster_constructor.test.ts', () => {
41+
beforeEach(() => {
42+
coffeeOptions.length = 0;
43+
messageHandlers.length = 0;
44+
});
45+
46+
it('should compute a deterministic mock cluster port window', () => {
47+
const port = getMockClusterPortStart(1000, 2);
48+
assert.equal(port, 17000 + ((1000 * 31 + 2 * 37) % 480) * 100);
49+
assert.equal(port % 100, 0);
50+
assert(port >= 17000);
51+
assert(port < 65000);
52+
});
53+
54+
it('should preserve child process env overrides', () => {
55+
process.env.EGG_MOCK_INHERITED_ENV_TEST = 'should-not-be-forced';
56+
try {
57+
new ClusterApplication({
58+
baseDir: '/tmp/mock-cluster-app',
59+
cache: false,
60+
clean: false,
61+
coverage: false,
62+
opt: {
63+
env: {
64+
EGG_HOME: '/tmp/custom-egg-home',
65+
HOME: '/tmp/custom-home',
66+
CUSTOM_ENV: 'custom',
67+
},
68+
},
69+
} as any);
70+
71+
const options = coffeeOptions[0];
72+
const startOptions = JSON.parse(options.args[0]);
73+
assert.equal(options.opt.env.EGG_HOME, '/tmp/custom-egg-home');
74+
assert.equal(options.opt.env.HOME, '/tmp/custom-home');
75+
assert.equal(options.opt.env.CUSTOM_ENV, 'custom');
76+
assert.equal(options.opt.env.EGG_MOCK_INHERITED_ENV_TEST, undefined);
77+
assert.equal(options.method, 'fork');
78+
assert.equal(startOptions.baseDir, '/tmp/mock-cluster-app');
79+
assert(startOptions.port >= 10000);
80+
assert(startOptions.port < 16000);
81+
assert.equal(typeof startOptions.clusterPort, 'number');
82+
} finally {
83+
delete process.env.EGG_MOCK_INHERITED_ENV_TEST;
84+
}
85+
});
86+
87+
it('should leave child process env unset so fork inherits the formatted process env', () => {
88+
new ClusterApplication({
89+
baseDir: '/tmp/mock-cluster-app',
90+
cache: false,
91+
clean: false,
92+
coverage: false,
93+
opt: {
94+
execArgv: [],
95+
},
96+
} as any);
97+
98+
assert.equal(coffeeOptions[0].opt.env, undefined);
99+
});
100+
101+
it('should update port from egg-ready url address', async () => {
102+
const app = new ClusterApplication({
103+
baseDir: '/tmp/mock-cluster-app',
104+
cache: false,
105+
clean: false,
106+
coverage: false,
107+
port: 12000,
108+
} as any);
109+
110+
await new Promise((resolve) => process.nextTick(resolve));
111+
messageHandlers.at(-1)({
112+
action: 'egg-ready',
113+
data: {
114+
address: 'http://127.0.0.1:12001',
115+
},
116+
});
117+
118+
assert.equal(app.address().port, 12001);
119+
assert.equal(app.url, 'http://127.0.0.1:12001');
120+
});
121+
122+
it('should update port from egg-ready data when address is missing', async () => {
123+
const app = new ClusterApplication({
124+
baseDir: '/tmp/mock-cluster-app',
125+
cache: false,
126+
clean: false,
127+
coverage: false,
128+
port: 12000,
129+
} as any);
130+
131+
await new Promise((resolve) => process.nextTick(resolve));
132+
messageHandlers.at(-1)({
133+
action: 'egg-ready',
134+
data: {
135+
port: 12002,
136+
},
137+
});
138+
139+
assert.equal(app.address().port, 12002);
140+
});
141+
142+
it('should keep port when egg-ready address is not a url', async () => {
143+
const app = new ClusterApplication({
144+
baseDir: '/tmp/mock-cluster-app',
145+
cache: false,
146+
clean: false,
147+
coverage: false,
148+
port: 12000,
149+
} as any);
150+
151+
await new Promise((resolve) => process.nextTick(resolve));
152+
messageHandlers.at(-1)({
153+
action: 'egg-ready',
154+
data: {
155+
address: 'mock.sock',
156+
},
157+
});
158+
159+
assert.equal(app.address().port, 12000);
160+
assert.equal(app.url, 'mock.sock');
161+
});
162+
});

plugins/mock/test/format_options.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,45 @@ describe('test/format_options.test.ts', () => {
151151
mm(process.env, 'EGG_SERVER_ENV', 'default');
152152
formatOptions();
153153
assert.equal(process.env.HOME, baseDir);
154+
assert.equal(process.env.EGG_HOME, baseDir);
154155

155156
mm(process.env, 'EGG_SERVER_ENV', 'test');
156157
formatOptions();
157158
assert.equal(process.env.HOME, baseDir);
159+
assert.equal(process.env.EGG_HOME, baseDir);
158160

159161
mm(process.env, 'EGG_SERVER_ENV', 'prod');
160162
formatOptions();
161163
assert.equal(process.env.HOME, baseDir);
164+
assert.equal(process.env.EGG_HOME, baseDir);
165+
166+
mm.restore();
167+
mm(process.env, 'NODE_ENV', 'test');
168+
formatOptions();
169+
assert.equal(process.env.HOME, baseDir);
170+
assert.equal(process.env.EGG_HOME, baseDir);
171+
172+
mm.restore();
173+
mm(process.env, 'EGG_SERVER_ENV', 'unittest');
174+
mm(process.env, 'NODE_ENV', 'test');
175+
formatOptions();
176+
assert.equal(process.env.HOME, baseDir);
177+
assert.equal(process.env.EGG_HOME, baseDir);
178+
});
179+
180+
it('should preserve existing process.env.EGG_HOME', () => {
181+
const baseDir = process.cwd();
182+
const eggHome = path.join(baseDir, '.custom-egg-home');
183+
mm(process.env, 'EGG_SERVER_ENV', 'default');
184+
process.env.EGG_HOME = eggHome;
185+
try {
186+
formatOptions();
187+
188+
assert.equal(process.env.HOME, baseDir);
189+
assert.equal(process.env.EGG_HOME, eggHome);
190+
} finally {
191+
delete process.env.EGG_HOME;
192+
}
162193
});
163194

164195
// FIXME: flaky test

0 commit comments

Comments
 (0)