Skip to content

Commit f28d41a

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

7 files changed

Lines changed: 270 additions & 8 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: 38 additions & 3 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;
30+
}
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+
);
2340
}
24-
globalThis.eggMockMasterPort = 17000 + (process.pid % 1000);
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)) {
@@ -78,11 +97,15 @@ export class ClusterApplication extends Coffee {
7897
* ```
7998
*/
8099
constructor(options: MockClusterApplicationOptions) {
81-
const opt = options.opt;
100+
const opt = (options.opt ?? {}) as Record<string, any>;
101+
if (opt.env === undefined) {
102+
opt.env = { ...process.env };
103+
}
82104
delete options.opt;
83105

84106
// incremental port
85107
options.port = options.port ?? ++globalThis.eggMockMasterPort;
108+
options.clusterPort = options.clusterPort ?? ++globalThis.eggMockClusterPort;
86109
// Set 1 worker when test
87110
if (!options.workers) {
88111
options.workers = 1;
@@ -119,6 +142,18 @@ export class ClusterApplication extends Coffee {
119142
// data: { port: 17703, address: 'http://127.0.0.1:17703', protocol: 'http' }
120143
debug('on message egg-ready %o', msg);
121144
this._address = msg.data.address;
145+
if (this._address) {
146+
try {
147+
const { port } = new URL(this._address);
148+
if (port) {
149+
this.port = Number(port);
150+
}
151+
} catch {
152+
// Unix socket addresses are valid app addresses, but not URLs.
153+
}
154+
} else if (msg.data.port) {
155+
this.port = msg.data.port;
156+
}
122157
this.emit('close', 0);
123158
break;
124159
case 'app-worker-died':
@@ -263,7 +298,7 @@ export class ClusterApplication extends Coffee {
263298
return supertestRequest(this);
264299
}
265300

266-
_callFunctionOnAppWorker(method: string, args: any[] = [], property: any = undefined, needResult = false): any {
301+
_callFunctionOnAppWorker(method: string, args: any[] = [], property?: any, needResult = false): any {
267302
for (let i = 0; i < args.length; i++) {
268303
const arg = args[i];
269304
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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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 pass inherited process env when child process env is not configured', () => {
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.PATH, process.env.PATH);
99+
assert.equal(coffeeOptions[0].opt.env.HOME, process.env.HOME);
100+
assert.equal(coffeeOptions[0].opt.env.EGG_HOME, process.env.EGG_HOME);
101+
});
102+
103+
it('should update port from egg-ready url address', async () => {
104+
const app = new ClusterApplication({
105+
baseDir: '/tmp/mock-cluster-app',
106+
cache: false,
107+
clean: false,
108+
coverage: false,
109+
port: 12000,
110+
} as any);
111+
112+
await new Promise((resolve) => process.nextTick(resolve));
113+
messageHandlers.at(-1)({
114+
action: 'egg-ready',
115+
data: {
116+
address: 'http://127.0.0.1:12001',
117+
},
118+
});
119+
120+
assert.equal(app.address().port, 12001);
121+
assert.equal(app.url, 'http://127.0.0.1:12001');
122+
});
123+
124+
it('should update port from egg-ready data when address is missing', async () => {
125+
const app = new ClusterApplication({
126+
baseDir: '/tmp/mock-cluster-app',
127+
cache: false,
128+
clean: false,
129+
coverage: false,
130+
port: 12000,
131+
} as any);
132+
133+
await new Promise((resolve) => process.nextTick(resolve));
134+
messageHandlers.at(-1)({
135+
action: 'egg-ready',
136+
data: {
137+
port: 12002,
138+
},
139+
});
140+
141+
assert.equal(app.address().port, 12002);
142+
});
143+
144+
it('should keep port when egg-ready address is not a url', async () => {
145+
const app = new ClusterApplication({
146+
baseDir: '/tmp/mock-cluster-app',
147+
cache: false,
148+
clean: false,
149+
coverage: false,
150+
port: 12000,
151+
} as any);
152+
153+
await new Promise((resolve) => process.nextTick(resolve));
154+
messageHandlers.at(-1)({
155+
action: 'egg-ready',
156+
data: {
157+
address: 'mock.sock',
158+
},
159+
});
160+
161+
assert.equal(app.address().port, 12000);
162+
assert.equal(app.url, 'mock.sock');
163+
});
164+
});

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)