Skip to content
Draft
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
118 changes: 118 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,124 @@ jobs:
run: ut install --from pnpm

- name: Run tests
run: |
ut run pretest --workspaces --if-present
node scripts/ci-run-vitest.mjs

- name: Run example tests
if: ${{ matrix.os != 'windows-latest' }}
run: |
ut run example:test:all

coverage:
strategy:
fail-fast: false
matrix:
os: ['ubuntu-latest']
node: ['24']

name: Coverage (${{ matrix.os }}, ${{ matrix.node }})
runs-on: ${{ matrix.os }}

concurrency:
group: coverage-${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }}-(${{ matrix.os }}, ${{ matrix.node }})
cancel-in-progress: true

steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6

- name: Start Redis (MacOS or Linux)
if: ${{ matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' }}
uses: shogo82148/actions-setup-redis@cff708d63a30aebc0bfaa7276fb709d173f36cb6 # v1
with:
redis-version: '7'
auto-start: 'true'

- name: Start Redis (Windows via Memurai)
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
run: |
# retry up to 3 times to handle Chocolatey feed flakiness
for ($i = 1; $i -le 3; $i++) {
choco install -y memurai-developer.install
if ($LASTEXITCODE -eq 0) { break }
if ($i -lt 3) {
Write-Host "choco install failed (attempt $i/3), retrying in 15s..."
Start-Sleep -Seconds 15
} else {
Write-Error "choco install failed after 3 attempts"
exit 1
}
}

# ensure service exists and running (avoid non-zero return code of net start)
$svc = Get-Service -Name Memurai -ErrorAction SilentlyContinue
if (-not $svc) {
Write-Error "Memurai service not found after install"
exit 1
}
if ($svc.Status -ne 'Running') {
Start-Service -Name Memurai -ErrorAction Stop
# wait for 20s until Running
$svc.WaitForStatus('Running', '00:00:20')
} else {
Write-Host "Memurai already running."
}

# wait for 6379 port ready (max ~60s)
$deadline = (Get-Date).AddSeconds(60)
$ready = $false
while ((Get-Date) -lt $deadline -and -not $ready) {
try {
$client = New-Object System.Net.Sockets.TcpClient
$async = $client.BeginConnect('127.0.0.1', 6379, $null, $null)
$ok = $async.AsyncWaitHandle.WaitOne(2000)
if ($ok -and $client.Connected) { $ready = $true }
$client.Close()
} catch { }
}
if (-not $ready) {
Write-Error "Memurai (Redis) not ready on 127.0.0.1:6379"
exit 1
}

Write-Host "Memurai is ready on 127.0.0.1:6379"

# install and start MySQL (will automatically start mysqld)
- name: Start MySQL (macOS or Linux)
if: ${{ matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' }}
uses: shogo82148/actions-setup-mysql@27e74fac04c136a9f4c2dc2ed457df57331b3e0c # v1
with:
mysql-version: '8'
auto-start: 'true'
- name: Init DB (macOS or Linux)
if: ${{ matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' }}
run: |
mysql -uroot -e "CREATE DATABASE IF NOT EXISTS test;"

- name: Start MySQL (Windows)
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
run: |
choco install -y mysql
refreshenv
# MySQL default root has no password, set a password and create database/user
# & mysqladmin -u root password root
& mysql -uroot -e "CREATE DATABASE IF NOT EXISTS test;"

- name: Setup utoo
uses: utooland/setup-utoo@3a51006d0b66afcc32d1b9177a4b200b74f4a8cb # main

- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ matrix.node }}

- name: Install dependencies
run: ut install --from pnpm

- name: Run tests with coverage
run: ut run ci

- name: Run example tests
Expand Down
8 changes: 5 additions & 3 deletions packages/cluster/src/master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,11 @@ export class Master extends ReadyEventEmitter {
async detectPorts(): Promise<void> {
// Detect cluster client port
try {
const clusterPort = await detectPort();
this.options.clusterPort = clusterPort;
this.log('[master] detected cluster port: %s', clusterPort);
if (!this.options.clusterPort) {
const clusterPort = await detectPort();
this.options.clusterPort = clusterPort;
this.log('[master] detected cluster port: %s', clusterPort);
}
// If sticky mode, detect worker port
if (this.options.sticky) {
const stickyWorkerPort = await detectPort();
Expand Down
71 changes: 64 additions & 7 deletions packages/cluster/test/app_worker.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { strict as assert } from 'node:assert';
import { rm } from 'node:fs/promises';
import { rm, stat } from 'node:fs/promises';
import { request as httpRequest } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { scheduler } from 'node:timers/promises';

import { mm, type MockApplication } from '@eggjs/mock';
Expand All @@ -8,7 +11,53 @@ import { ip } from 'address';
import urllib from 'urllib';
import { describe, it, afterEach, beforeEach, beforeAll, afterAll } from 'vitest';

import { cluster, getFilepath } from './utils.ts';
import { cluster } from './utils.ts';

async function waitForFile(filepath: string) {
const deadline = Date.now() + 5000;
do {
try {
await stat(filepath);
return;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || Date.now() >= deadline) {
throw err;
}
await scheduler.wait(50);
}
} while (true);
}

async function requestUnixSocket(filepath: string) {
const deadline = Date.now() + 5000;
do {
try {
await new Promise<void>((resolve, reject) => {
const req = httpRequest({ socketPath: filepath, path: '/', method: 'GET' }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
assert.equal(res.statusCode, 200);
assert.equal(Buffer.concat(chunks).toString(), 'done');
resolve();
} catch (err) {
reject(err);
}
});
});
req.on('error', reject);
req.end();
});
return;
} catch (err) {
if (!String((err as Error).message).includes('ENOENT') || Date.now() >= deadline) {
throw err;
}
await scheduler.wait(50);
}
} while (true);
}

// node v24 will hang when test this file
// FIXME: should enable this test after node v24 is stable
Expand Down Expand Up @@ -204,8 +253,9 @@ describe.skipIf(process.version.startsWith('v24') || process.platform === 'win32
});

describe('listen config', () => {
const sockFile = getFilepath('apps/app-listen-path/my.sock');
beforeEach(() => {
const sockFile = path.join(tmpdir(), `egg-app-listen-path-${process.pid}.sock`);
beforeEach(async () => {
await rm(sockFile, { force: true, recursive: true });
mm.env('default');
});
afterEach(async () => {
Expand Down Expand Up @@ -276,15 +326,22 @@ describe.skipIf(process.version.startsWith('v24') || process.platform === 'win32
});

it('should use path in config', async () => {
app = cluster('apps/app-listen-path');
app = cluster('apps/app-listen-path', {
opt: {
env: {
...process.env,
EGG_CLUSTER_LISTEN_PATH: sockFile,
},
},
});
// app.debug();
await app.ready();

app.expect('code', 0);
app.expect('stdout', new RegExp(`egg started on ${sockFile}`));

const sock = encodeURIComponent(sockFile);
await request(`http+unix://${sock}`).get('/').expect('done').expect(200);
await waitForFile(sockFile);
await requestUnixSocket(sockFile);
});

it.skipIf(process.platform !== 'linux')('should use reusePort in config on Linux', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module.exports = (app) => {
keys: '123',
cluster: {
listen: {
path: path.join(app.baseDir, 'my.sock'),
path: process.env.EGG_CLUSTER_LISTEN_PATH || path.join(app.baseDir, 'my.sock'),
},
},
};
Expand Down
34 changes: 34 additions & 0 deletions packages/cluster/test/master/start-master.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { strict as assert } from 'node:assert';
import { mm, type MockApplication } from '@eggjs/mock';
import { describe, it, afterEach } from 'vitest';

import { Master } from '../../src/master.ts';
import { cluster } from '../utils.ts';

let app: MockApplication;
Expand All @@ -12,6 +13,39 @@ afterEach(mm.restore);
describe('start master', () => {
afterEach(() => app && app.close());

describe('detectPorts()', () => {
it('should detect clusterPort when it is not specified', async () => {
const options: { clusterPort?: number } = {};
const ctx = {
options,
log: () => {},
logger: {
error: () => {},
},
};

await Master.prototype.detectPorts.call(ctx as unknown as Master);

assert.equal(typeof ctx.options.clusterPort, 'number');
});

it('should keep the specified clusterPort', async () => {
const ctx = {
options: {
clusterPort: 34567,
},
log: () => {},
logger: {
error: () => {},
},
};

await Master.prototype.detectPorts.call(ctx as unknown as Master);

assert.equal(ctx.options.clusterPort, 34567);
});
});

it.skip('start success in local env', async () => {
mm.env('local');
app = cluster('apps/master-worker-started');
Expand Down
1 change: 1 addition & 0 deletions packages/cluster/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export default defineProject({
exclude: ['test/fixtures/**', '**/node_modules/**', '**/dist/**'],
testTimeout: 25000,
hookTimeout: 25000,
fileParallelism: false,
},
});
2 changes: 1 addition & 1 deletion packages/core/test/loader/egg_loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('test/loader/egg_loader.test.ts', () => {
(userInfo as any).homedir = undefined;
mm(os, 'userInfo', () => userInfo);
}
assert.equal(app.loader.getHomedir(), process.env.HOME);
assert.equal(app.loader.getHomedir(), process.env.HOME || os.homedir());
});

it('should return /home/admin when process.env.HOME is not exist', () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/test/loader/get_app_info.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import os from 'node:os';

import { mm } from 'mm';
import { describe, it, afterEach } from 'vitest';
Expand All @@ -15,7 +16,7 @@ describe('test/loader/get_app_info.test.ts', () => {
assert.equal(app.loader.appInfo.name, 'appinfo');
assert.equal(app.loader.appInfo.baseDir, getFilepath('appinfo'));
assert.equal(app.loader.appInfo.env, 'unittest');
assert.equal(app.loader.appInfo.HOME, process.env.HOME);
assert.equal(app.loader.appInfo.HOME, process.env.HOME || os.homedir());
assert.deepEqual(app.loader.appInfo.pkg, {
name: 'appinfo',
});
Expand All @@ -36,7 +37,7 @@ describe('test/loader/get_app_info.test.ts', () => {
it('should get root when unittest', () => {
mm(process.env, 'EGG_SERVER_ENV', 'default');
app = createApp('appinfo');
assert.equal(app.loader.appInfo.root, process.env.HOME);
assert.equal(app.loader.appInfo.root, process.env.HOME || os.homedir());
});

it('should get scope when specified', () => {
Expand Down
1 change: 1 addition & 0 deletions plugins/development/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const config: UserWorkspaceConfig = defineProject({
hookTimeout: 20000,
include: ['test/**/*.test.ts'],
exclude: ['test/fixtures/**', 'test/bench/**', '**/node_modules/**', '**/dist/**'],
fileParallelism: false,
},
});

Expand Down
1 change: 1 addition & 0 deletions plugins/logrotator/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export default defineProject({
test: {
testTimeout: 20000,
hookTimeout: 20000,
fileParallelism: false,
},
});
15 changes: 15 additions & 0 deletions plugins/mock/src/lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,22 @@ const clusters = new Map();
declare global {
// define the global variable to avoid the port conflict in parallel process mode
var eggMockMasterPort: number;
var eggMockClusterPortCursor: number;
var eggMockClusterPorts: Set<number>;
}
globalThis.eggMockMasterPort = 17000 + (process.pid % 1000);
globalThis.eggMockClusterPortCursor ??= Math.floor(Math.random() * 45000);
globalThis.eggMockClusterPorts ??= new Set<number>();

function nextMockClusterPort(): number {
while (true) {
const port = 20000 + (++globalThis.eggMockClusterPortCursor % 45000);
if (!globalThis.eggMockClusterPorts.has(port)) {
globalThis.eggMockClusterPorts.add(port);
return port;
}
}
}

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

// incremental port
options.port = options.port ?? ++globalThis.eggMockMasterPort;
options.clusterPort = options.clusterPort ?? nextMockClusterPort();
// Set 1 worker when test
if (!options.workers) {
options.workers = 1;
Expand Down
1 change: 1 addition & 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;
/**
* opt pass to coffee, such as { execArgv: ['--debug'] }
*/
Expand Down
Loading
Loading