diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 863000fbc6..650e3ce8b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/packages/cluster/src/master.ts b/packages/cluster/src/master.ts index abcd45ff9b..b3a77ef0ce 100644 --- a/packages/cluster/src/master.ts +++ b/packages/cluster/src/master.ts @@ -252,9 +252,11 @@ export class Master extends ReadyEventEmitter { async detectPorts(): Promise { // 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(); diff --git a/packages/cluster/test/app_worker.test.ts b/packages/cluster/test/app_worker.test.ts index 61a2d0cfa4..846b439a91 100644 --- a/packages/cluster/test/app_worker.test.ts +++ b/packages/cluster/test/app_worker.test.ts @@ -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'; @@ -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((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 @@ -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 () => { @@ -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 () => { diff --git a/packages/cluster/test/fixtures/apps/app-listen-path/config/config.default.js b/packages/cluster/test/fixtures/apps/app-listen-path/config/config.default.js index 1db6860b4e..e10e3260ea 100644 --- a/packages/cluster/test/fixtures/apps/app-listen-path/config/config.default.js +++ b/packages/cluster/test/fixtures/apps/app-listen-path/config/config.default.js @@ -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'), }, }, }; diff --git a/packages/cluster/test/master/start-master.test.ts b/packages/cluster/test/master/start-master.test.ts index f44cb78df3..1bd61726e8 100644 --- a/packages/cluster/test/master/start-master.test.ts +++ b/packages/cluster/test/master/start-master.test.ts @@ -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; @@ -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'); diff --git a/packages/cluster/vitest.config.ts b/packages/cluster/vitest.config.ts index 57eec8c40a..268d897bbf 100644 --- a/packages/cluster/vitest.config.ts +++ b/packages/cluster/vitest.config.ts @@ -6,5 +6,6 @@ export default defineProject({ exclude: ['test/fixtures/**', '**/node_modules/**', '**/dist/**'], testTimeout: 25000, hookTimeout: 25000, + fileParallelism: false, }, }); diff --git a/packages/core/test/loader/egg_loader.test.ts b/packages/core/test/loader/egg_loader.test.ts index ddd88281cf..3c8e507bae 100644 --- a/packages/core/test/loader/egg_loader.test.ts +++ b/packages/core/test/loader/egg_loader.test.ts @@ -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', () => { diff --git a/packages/core/test/loader/get_app_info.test.ts b/packages/core/test/loader/get_app_info.test.ts index 6c34f87c11..84b1c8f410 100644 --- a/packages/core/test/loader/get_app_info.test.ts +++ b/packages/core/test/loader/get_app_info.test.ts @@ -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'; @@ -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', }); @@ -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', () => { diff --git a/plugins/development/vitest.config.ts b/plugins/development/vitest.config.ts index 2beb22f404..660ba20490 100644 --- a/plugins/development/vitest.config.ts +++ b/plugins/development/vitest.config.ts @@ -6,6 +6,7 @@ const config: UserWorkspaceConfig = defineProject({ hookTimeout: 20000, include: ['test/**/*.test.ts'], exclude: ['test/fixtures/**', 'test/bench/**', '**/node_modules/**', '**/dist/**'], + fileParallelism: false, }, }); diff --git a/plugins/logrotator/vitest.config.ts b/plugins/logrotator/vitest.config.ts index d34001c8b2..014f450c82 100644 --- a/plugins/logrotator/vitest.config.ts +++ b/plugins/logrotator/vitest.config.ts @@ -4,5 +4,6 @@ export default defineProject({ test: { testTimeout: 20000, hookTimeout: 20000, + fileParallelism: false, }, }); diff --git a/plugins/mock/src/lib/cluster.ts b/plugins/mock/src/lib/cluster.ts index 9a261cfbc4..9aeee194d0 100644 --- a/plugins/mock/src/lib/cluster.ts +++ b/plugins/mock/src/lib/cluster.ts @@ -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; } globalThis.eggMockMasterPort = 17000 + (process.pid % 1000); +globalThis.eggMockClusterPortCursor ??= Math.floor(Math.random() * 45000); +globalThis.eggMockClusterPorts ??= new Set(); + +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)) { @@ -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; diff --git a/plugins/mock/src/lib/types.ts b/plugins/mock/src/lib/types.ts index ed01cfe663..c3d39846a0 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'] } */ diff --git a/plugins/mock/vitest.config.ts b/plugins/mock/vitest.config.ts index c5aec38ed9..85cd66c3be 100644 --- a/plugins/mock/vitest.config.ts +++ b/plugins/mock/vitest.config.ts @@ -6,6 +6,7 @@ const config: UserWorkspaceConfig = defineProject({ exclude: ['test/fixtures/**', '**/node_modules/**', '**/dist/**'], testTimeout: 15000, hookTimeout: 20000, + fileParallelism: false, }, }); diff --git a/plugins/onerror/vitest.config.ts b/plugins/onerror/vitest.config.ts index d34001c8b2..014f450c82 100644 --- a/plugins/onerror/vitest.config.ts +++ b/plugins/onerror/vitest.config.ts @@ -4,5 +4,6 @@ export default defineProject({ test: { testTimeout: 20000, hookTimeout: 20000, + fileParallelism: false, }, }); diff --git a/plugins/redis/vitest.config.ts b/plugins/redis/vitest.config.ts index fc3e04eaf6..77b38ee0af 100644 --- a/plugins/redis/vitest.config.ts +++ b/plugins/redis/vitest.config.ts @@ -4,6 +4,7 @@ const config: UserWorkspaceConfig = defineConfig({ test: { testTimeout: 30000, hookTimeout: 30000, + fileParallelism: false, globals: true, }, }); diff --git a/plugins/schedule/test/executeError.test.ts b/plugins/schedule/test/executeError.test.ts index 12f6011464..2caf09daea 100644 --- a/plugins/schedule/test/executeError.test.ts +++ b/plugins/schedule/test/executeError.test.ts @@ -1,5 +1,3 @@ -import { setTimeout as sleep } from 'node:timers/promises'; - import { mm, type MockApplication } from '@eggjs/mock'; import { describe, it, afterAll, beforeAll, expect } from 'vitest'; @@ -16,8 +14,11 @@ describe.skipIf(process.platform === 'win32')('test/executeError.test.ts', () => afterAll(() => app.close()); it('should schedule execute error', async () => { - await sleep(5000); - const scheduleLog = getScheduleLogContent('executeError'); - expect(contains(scheduleLog, 'interval.js execute failed')).toBe(2); + await expect + .poll(() => contains(getScheduleLogContent('executeError'), 'interval.js execute failed'), { + interval: 500, + timeout: 10000, + }) + .toBe(2); }); }); diff --git a/plugins/schedule/test/safe-timers.test.ts b/plugins/schedule/test/safe-timers.test.ts index 9048ecee74..4a5fdb73f0 100644 --- a/plugins/schedule/test/safe-timers.test.ts +++ b/plugins/schedule/test/safe-timers.test.ts @@ -1,5 +1,3 @@ -import { setTimeout as sleep } from 'node:timers/promises'; - import { mm, type MockApplication } from '@eggjs/mock'; import { describe, it, afterAll, beforeAll, expect } from 'vitest'; @@ -16,15 +14,29 @@ describe.skipIf(process.platform === 'win32')('cluster', () => { afterAll(() => app.close()); it('should support interval and cron', async () => { - await sleep(5000); + await expect + .poll( + () => { + const log = getLogContent('safe-timers'); + const agentLog = getAgentLogContent('safe-timers'); + return ( + contains(log, 'interval') >= 1 && + contains(log, 'cron') >= 1 && + contains(agentLog, 'reschedule 4321') >= 2 && + contains(agentLog, 'reschedule') >= 4 + ); + }, + { + interval: 500, + timeout: 15000, + }, + ) + .toBe(true); const log = getLogContent('safe-timers'); - // console.log(log); + const agentLog = getAgentLogContent('safe-timers'); expect(contains(log, 'interval')).toBeGreaterThanOrEqual(1); expect(contains(log, 'cron')).toBeGreaterThanOrEqual(1); - - const agentLog = getAgentLogContent('safe-timers'); - // console.log(agentLog); expect(contains(agentLog, 'reschedule 4321')).toBeGreaterThanOrEqual(2); expect(contains(agentLog, 'reschedule')).toBeGreaterThanOrEqual(4); }); diff --git a/plugins/schedule/test/stop.test.ts b/plugins/schedule/test/stop.test.ts index 06b1c20e3d..40eaa9ba54 100644 --- a/plugins/schedule/test/stop.test.ts +++ b/plugins/schedule/test/stop.test.ts @@ -1,3 +1,4 @@ +import { rm } from 'node:fs/promises'; import { setTimeout as sleep } from 'node:timers/promises'; import { mm, type MockApplication } from '@eggjs/mock'; @@ -8,6 +9,7 @@ import { contains, getFixtures, getLogContent } from './utils.ts'; describe.skipIf(process.platform === 'win32')('test/stop.test.ts', () => { let app: MockApplication; beforeAll(async () => { + await rm(getFixtures('stop/logs'), { force: true, recursive: true }); app = mm.cluster({ baseDir: getFixtures('stop'), workers: 2 }); // app.debug(); await app.ready(); @@ -15,7 +17,7 @@ describe.skipIf(process.platform === 'win32')('test/stop.test.ts', () => { afterAll(() => app.close()); it('should thrown', async () => { - await sleep(10000); + await sleep(1000); const log = getLogContent('stop'); expect(contains(log, 'interval')).toBe(0); }); diff --git a/plugins/schedule/vitest.config.ts b/plugins/schedule/vitest.config.ts index 34afaf042f..4dbbde0420 100644 --- a/plugins/schedule/vitest.config.ts +++ b/plugins/schedule/vitest.config.ts @@ -2,6 +2,7 @@ import { defineProject } from 'vitest/config'; export default defineProject({ test: { + fileParallelism: false, testTimeout: 20000, hookTimeout: 60000, }, diff --git a/scripts/ci-run-vitest.mjs b/scripts/ci-run-vitest.mjs new file mode 100644 index 0000000000..a999d330e9 --- /dev/null +++ b/scripts/ci-run-vitest.mjs @@ -0,0 +1,176 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const COMMON_ARGS = [ + 'execute', + 'vitest', + 'run', + '--bail', + '1', + '--retry', + '2', + '--testTimeout', + '20000', + '--hookTimeout', + '20000', + '--reporter=dot', + '--silent', + '--passWithNoTests', +]; + +const STATEFUL_PROJECTS = [ + '@eggjs/cluster', + '@eggjs/development', + '@eggjs/logger', + '@eggjs/logrotator', + '@eggjs/mock', + '@eggjs/onerror', + '@eggjs/orm-plugin', + '@eggjs/redis', + '@eggjs/schedule', + '@eggjs/tegg-plugin', +]; + +const WORKSPACE_DIRS = ['packages', 'plugins', 'tegg/core', 'tegg/plugin', 'tegg/standalone']; + +const HEAVY_REST_LANES = [ + ['packages/core', 'packages/koa', 'tegg/plugin/controller'], + ['packages/egg', 'packages/errors', 'plugins/security', 'tools/create-egg'], +]; + +const lanes = [ + { + name: 'stateful-schedule-cluster', + args: [...COMMON_ARGS, 'packages/cluster', 'plugins/schedule'], + }, + { + name: 'stateful-plugins', + args: [ + ...COMMON_ARGS, + 'packages/logger', + 'plugins/development', + 'plugins/logrotator', + 'plugins/mock', + 'plugins/onerror', + 'plugins/redis', + 'tegg/plugin/orm', + 'tegg/plugin/tegg', + ], + }, +]; + +const restDirs = collectWorkspaceProjects() + .filter(({ name }) => !STATEFUL_PROJECTS.includes(name)) + .map(({ dir }) => dir); +const assignedRestDirs = new Set(HEAVY_REST_LANES.flat()); + +HEAVY_REST_LANES.forEach((dirs, index) => { + lanes.push({ + name: `rest-heavy-${index + 1}`, + args: [...COMMON_ARGS, ...dirs], + }); +}); + +const restLaneCount = Number(process.env.CI_TEST_REST_LANES) || 2; +const restLaneDirs = Array.from({ length: restLaneCount }, () => []); + +restDirs + .filter((dir) => !assignedRestDirs.has(dir)) + .forEach((dir, index) => { + restLaneDirs[index % restLaneCount].push(dir); + }); + +restLaneDirs.forEach((dirs, index) => { + lanes.push({ + name: `rest-${index + 1}-${restLaneCount}`, + args: [...COMMON_ARGS, ...dirs], + }); +}); + +const maxWorkers = process.env.CI_TEST_VITEST_WORKERS || '2'; + +function runLane({ name, args }) { + return new Promise((resolve) => { + const startedAt = Date.now(); + console.log(`[${name}] start: ut ${args.join(' ')}`); + + const isWindows = process.platform === 'win32'; + const command = 'ut'; + const child = spawn(command, args, { + env: { + ...process.env, + VITEST_MAX_WORKERS: maxWorkers, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + shell: isWindows, + }); + + child.stdout.on('data', (chunk) => { + process.stdout.write(prefixOutput(name, chunk)); + }); + child.stderr.on('data', (chunk) => { + process.stderr.write(prefixOutput(name, chunk)); + }); + child.on('error', (error) => { + console.error(`[${name}] failed to start: ${error.message}`); + resolve(1); + }); + child.on('close', (code) => { + const duration = ((Date.now() - startedAt) / 1000).toFixed(1); + console.log(`[${name}] exit ${code ?? 1} after ${duration}s`); + resolve(code ?? 1); + }); + }); +} + +function prefixOutput(name, chunk) { + const text = chunk.toString(); + return text + .split(/\r?\n/) + .map((line, index, lines) => { + if (index === lines.length - 1 && line === '') return ''; + return `[${name}] ${line}\n`; + }) + .join(''); +} + +function collectWorkspaceProjects() { + const dirs = [path.join(process.cwd(), 'tools/create-egg')]; + + for (const workspaceDir of WORKSPACE_DIRS) { + const absoluteDir = path.join(process.cwd(), workspaceDir); + if (!existsSync(absoluteDir)) continue; + + for (const entry of readdirSync(absoluteDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + dirs.push(path.join(absoluteDir, entry.name)); + } + } + } + + return dirs + .map((dir) => ({ + dir: path.relative(process.cwd(), dir).replaceAll(path.sep, '/'), + name: readPackageName(dir), + })) + .filter((project) => project.name) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function readPackageName(dir) { + const packageJSONPath = path.join(dir, 'package.json'); + if (!existsSync(packageJSONPath)) return; + + const packageJSON = JSON.parse(readFileSync(packageJSONPath, 'utf8')); + return packageJSON.name; +} + +const results = await Promise.all(lanes.map(runLane)); +const failed = results.filter((code) => code !== 0); + +if (failed.length > 0) { + console.error(`${failed.length} Vitest lane(s) failed.`); + process.exit(1); +} diff --git a/tegg/plugin/orm/vitest.config.ts b/tegg/plugin/orm/vitest.config.ts new file mode 100644 index 0000000000..2842870b3b --- /dev/null +++ b/tegg/plugin/orm/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject, type UserWorkspaceConfig } from 'vitest/config'; + +const config: UserWorkspaceConfig = defineProject({ + test: { + include: ['test/**/*.test.ts'], + exclude: ['test/fixtures/**', '**/node_modules/**', '**/dist/**'], + fileParallelism: false, + }, +}); + +export default config; diff --git a/tegg/plugin/tegg/vitest.config.ts b/tegg/plugin/tegg/vitest.config.ts new file mode 100644 index 0000000000..2842870b3b --- /dev/null +++ b/tegg/plugin/tegg/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject, type UserWorkspaceConfig } from 'vitest/config'; + +const config: UserWorkspaceConfig = defineProject({ + test: { + include: ['test/**/*.test.ts'], + exclude: ['test/fixtures/**', '**/node_modules/**', '**/dist/**'], + fileParallelism: false, + }, +}); + +export default config; diff --git a/tools/egg-bin/vitest.config.ts b/tools/egg-bin/vitest.config.ts index 58fb6135c6..8d584ca981 100644 --- a/tools/egg-bin/vitest.config.ts +++ b/tools/egg-bin/vitest.config.ts @@ -12,6 +12,7 @@ const config: ViteUserConfig = { exclude: ['**/test/fixtures/**', '**/node_modules/**', '**/dist/**'], testTimeout: 60000, globals: true, + maxWorkers: Number(process.env.VITEST_MAX_WORKERS) || 2, }, }; diff --git a/tools/egg-bundler/tsdown.config.ts b/tools/egg-bundler/tsdown.config.ts index 8ac08fd8c2..cc8c0e9d89 100644 --- a/tools/egg-bundler/tsdown.config.ts +++ b/tools/egg-bundler/tsdown.config.ts @@ -7,7 +7,7 @@ const config: UserConfig = defineConfig({ external: [/^@eggjs\//, 'egg', '@utoo/pack', /\.node$/], copy: [{ from: 'src/scripts/generate-manifest.mjs', to: 'dist/scripts/' }], unused: { - level: 'warn', + level: 'warning', ignore: ['@utoo/pack', 'egg', 'tsx', '@eggjs/core'], }, }); diff --git a/vitest.config.ts b/vitest.config.ts index f7768129fa..b9a94ea5e9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,12 @@ import { defineConfig, type UserWorkspaceConfig } from 'vitest/config'; +const maxWorkers = Number(process.env.VITEST_MAX_WORKERS) || (process.platform === 'linux' ? 8 : 3); + const config: UserWorkspaceConfig = defineConfig({ test: { pool: 'threads', - isolate: false, + isolate: true, + maxWorkers, projects: [ 'packages/*', 'plugins/*',