From 0cf8ac6b52265c28970d342190cd11cfbd512188 Mon Sep 17 00:00:00 2001 From: gxkl Date: Wed, 5 Aug 2026 18:01:38 +0800 Subject: [PATCH 1/5] fix(scripts): use file URL for ESM sourcemap preload --- tools/scripts/src/commands/start.ts | 3 ++- tools/scripts/test/start-unit.test.ts | 30 ++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tools/scripts/src/commands/start.ts b/tools/scripts/src/commands/start.ts index d5046713f4..dc3aec114e 100644 --- a/tools/scripts/src/commands/start.ts +++ b/tools/scripts/src/commands/start.ts @@ -2,6 +2,7 @@ import { spawn, type SpawnOptions, type ChildProcess, execFile as _execFile } fr import { mkdir, rename, stat, open } from 'node:fs/promises'; import path from 'node:path'; import { scheduler } from 'node:timers/promises'; +import { pathToFileURL } from 'node:url'; import { debuglog, promisify } from 'node:util'; import { getFrameworkPath, importResolve } from '@eggjs/utils'; @@ -308,7 +309,7 @@ export default class Start extends BaseCommand { }); const sourceMapSupport = path.join(path.dirname(sourceMapSupportPkgPath), 'register.js'); if (this.isESM) { - execArgv.push('--import', sourceMapSupport); + execArgv.push('--import', pathToFileURL(sourceMapSupport).href); } else { execArgv.push('--require', sourceMapSupport); } diff --git a/tools/scripts/test/start-unit.test.ts b/tools/scripts/test/start-unit.test.ts index f82bcaf22e..38683b4b94 100644 --- a/tools/scripts/test/start-unit.test.ts +++ b/tools/scripts/test/start-unit.test.ts @@ -20,7 +20,7 @@ class TestStart extends (Start as any) { return '/fake/framework'; } async getServerBin() { - return '/fake/scripts/start-cluster.cjs'; + return `/fake/scripts/start-cluster.${this.isESM ? 'mjs' : 'cjs'}`; } } @@ -69,6 +69,34 @@ describe('test/start-unit.test.ts', () => { expect(options.env.NODE_ENV).toBe('production'); }); + it('preloads source-map-support with a file URL for ESM apps', async () => { + const esmBaseDir = path.join(homeDir, 'esm-typescript-app'); + await fs.mkdir(esmBaseDir); + await fs.writeFile( + path.join(esmBaseDir, 'package.json'), + JSON.stringify({ name: 'esm-typescript-app', type: 'module', egg: { typescript: true } }), + ); + spawnMock.mockImplementation(() => ({ + once: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + unref: vi.fn(), + disconnect: vi.fn(), + kill: vi.fn(), + pid: 1112, + })); + + await TestStart.run(['--workers=1', esmBaseDir]); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [, args] = spawnMock.mock.calls[0] as [string, string[]]; + expect(args).toContain('/fake/scripts/start-cluster.mjs'); + const importIndex = args.indexOf('--import'); + expect(importIndex).toBeGreaterThan(-1); + // A Windows drive path such as D:\app\register.js is parsed as the + // unsupported `d:` URL scheme by Node's ESM loader unless it is a file URL. + expect(args[importIndex + 1]).toMatch(/^file:/); + }); + it('daemon mode backgrounds once the child reports egg-ready over IPC', async () => { const unref = vi.fn(); const disconnect = vi.fn(); From 13b5cd2f211736931d2e2f9c8039a9eabbf62bbd Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 6 Oct 2026 17:28:20 +0800 Subject: [PATCH 2/5] test(scripts): cover preload URLs and foreground exits on Windows --- .github/workflows/ci.yml | 11 +++- tools/scripts/test/start-unit.test.ts | 72 +++++++++++++++++++-------- wiki/index.md | 2 +- wiki/log.md | 5 ++ wiki/workflows/local-ci.md | 10 +++- 5 files changed, 73 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f51035543..8e045adb0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -395,6 +395,9 @@ jobs: node: '24' coverage: false include: + - os: 'windows-latest' + node: '24' + coverage: false - os: 'ubuntu-latest' node: '24' coverage: true @@ -449,7 +452,7 @@ jobs: # own step for clear failure attribution; @eggjs/scripts has no build # script of its own, so build it through the root tsdown workspace - # filter (path form is fine here, this job only runs on ubuntu) + # filter (tsdown accepts the path form on every OS) - name: Build egg-scripts run: ut run build -- --workspace ./tools/scripts @@ -458,9 +461,13 @@ jobs: run: ut run ci --workspace @eggjs/scripts - name: Run tests - if: ${{ !matrix.coverage }} + if: ${{ !matrix.coverage && matrix.os != 'windows-latest' }} run: ut run test --workspace @eggjs/scripts + - name: Run Windows preload and foreground exit regression tests + if: ${{ matrix.os == 'windows-latest' }} + run: ut run test --workspace @eggjs/scripts -- test/start-unit.test.ts + - name: Code Coverage if: ${{ matrix.coverage }} uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 diff --git a/tools/scripts/test/start-unit.test.ts b/tools/scripts/test/start-unit.test.ts index f0d639f8c3..22d5650759 100644 --- a/tools/scripts/test/start-unit.test.ts +++ b/tools/scripts/test/start-unit.test.ts @@ -1,14 +1,24 @@ +import { execFile } from 'node:child_process'; import { fstatSync } from 'node:fs'; import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { importResolve } from '@eggjs/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Start from '../src/commands/start.ts'; const spawnMock = vi.hoisted(() => vi.fn()); +vi.mock('@eggjs/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, importResolve: vi.fn(actual.importResolve) }; +}); + vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawn: spawnMock }; @@ -48,14 +58,18 @@ describe('test/start-unit.test.ts', () => { // foreground mode waits for the child inside run(), so tests must report a // child exit for the command to settle - async function runStartWithChildExit(code: number | null, signal: NodeJS.Signals | null): Promise { + async function runStartWithChildExit( + code: number | null, + signal: NodeJS.Signals | null, + appDir = baseDir, + ): Promise { let onExit: ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; spawnMock.mockImplementation(() => ({ once: vi.fn((event: string, cb: (code: number | null, signal: NodeJS.Signals | null) => void) => { if (event === 'exit') onExit = cb; }), })); - const run = TestStart.run(['--workers=1', baseDir]); + const run = TestStart.run(['--workers=1', appDir]); await vi.waitFor(() => { if (!onExit) throw new Error('child not spawned yet'); }); @@ -78,32 +92,46 @@ describe('test/start-unit.test.ts', () => { expect(options.env.NODE_ENV).toBe('production'); }); - it('preloads source-map-support with a file URL for ESM apps', async () => { - const esmBaseDir = path.join(homeDir, 'esm-typescript-app'); - await fs.mkdir(esmBaseDir); + it.each(['module', 'commonjs'])('preloads source-map-support for %s apps and exits', async (type) => { + const appDir = path.join(homeDir, `${type}-typescript-app`); + await fs.mkdir(appDir); await fs.writeFile( - path.join(esmBaseDir, 'package.json'), - JSON.stringify({ name: 'esm-typescript-app', type: 'module', egg: { typescript: true } }), + path.join(appDir, 'package.json'), + JSON.stringify({ name: `${type}-typescript-app`, type, egg: { typescript: true } }), ); - spawnMock.mockImplementation(() => ({ - once: vi.fn().mockReturnThis(), - on: vi.fn().mockReturnThis(), - unref: vi.fn(), - disconnect: vi.fn(), - kill: vi.fn(), - pid: 1112, - })); + const require = createRequire(import.meta.url); + const originalPackage = require.resolve('source-map-support/package.json'); + const supportDir = path.join(homeDir, 'source map # support'); + await fs.cp(path.dirname(originalPackage), supportDir, { recursive: true }); + // Resolve the copied package's dependencies through the original installation. + await fs.symlink( + path.join(path.dirname(originalPackage), '../'), + path.join(supportDir, 'node_modules'), + 'junction', + ); + vi.mocked(importResolve).mockReturnValue(path.join(supportDir, 'package.json')); - await TestStart.run(['--workers=1', esmBaseDir]); + await runStartWithChildExit(0, null, appDir); expect(spawnMock).toHaveBeenCalledTimes(1); const [, args] = spawnMock.mock.calls[0] as [string, string[]]; - expect(args).toContain('/fake/scripts/start-cluster.mjs'); - const importIndex = args.indexOf('--import'); - expect(importIndex).toBeGreaterThan(-1); - // A Windows drive path such as D:\app\register.js is parsed as the - // unsupported `d:` URL scheme by Node's ESM loader unless it is a file URL. - expect(args[importIndex + 1]).toMatch(/^file:/); + const esm = type === 'module'; + expect(args).toContain(`/fake/scripts/start-cluster.${esm ? 'mjs' : 'cjs'}`); + const preloadFlag = esm ? '--import' : '--require'; + const preloadIndex = args.indexOf(preloadFlag); + expect(preloadIndex).toBeGreaterThan(-1); + const register = path.join(supportDir, 'register.js'); + expect(args[preloadIndex + 1]).toBe(esm ? pathToFileURL(register).href : register); + expect(args).not.toContain(esm ? '--require' : '--import'); + if (esm) expect(args[preloadIndex + 1]).toContain('source%20map%20%23%20support'); + const { stdout } = await promisify(execFile)(process.execPath, [ + preloadFlag, + args[preloadIndex + 1], + '--input-type=module', + '--eval', + 'console.log(typeof Error.prepareStackTrace)', + ]); + expect(stdout.trim()).toBe('function'); }); it('daemon mode backgrounds once the child reports egg-ready over IPC', async () => { diff --git a/wiki/index.md b/wiki/index.md index c316cf3bde..0cdb3746f2 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -17,7 +17,7 @@ Read this file before exploring raw sources. - [GitHub Actions performance plan](./workflows/ci-performance-plan.md) - PR/full matrix, sharding, test-runtime improvements, and acceptance criteria based on measured CI bottlenecks. - [CI parallel test metrics](./workflows/ci-parallel-test-metrics.md) - Test concurrency, resolved worker settings, retries, and shard inventories in CI artifacts. - [Docs and API Updates](./workflows/docs-and-api-updates.md) - How to handle changes that affect user-facing docs or durable project understanding. -- [Local CI](./workflows/local-ci.md) - Local validation should run tests from clean sources and avoid stale artifacts (`dist/` duplicate-proto; fixture `.egg` scan-manifest caches) before tegg tests. +- [Local CI](./workflows/local-ci.md) - Local validation and scripts Windows preload coverage; run main tests from clean sources and avoid stale artifacts (`dist/` duplicate-proto; fixture `.egg` scan-manifest caches) before tegg tests. - [PR preview packages](./workflows/pr-preview-packages.md) - Publish workspace previews through a pull request label and install them in consumer applications, including from stacked PRs. - [Egg-bin Windows shell probe hotspot](./workflows/egg-bin-windows-shell-probe.md) - How PR #6014 diagnosed hosted-Windows egg-bin startup slowness and why the final fix only presets SHELL. diff --git a/wiki/log.md b/wiki/log.md index b949dc4fdc..0cf7cad3e4 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -532,3 +532,8 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - Removed the exact packageManager pin, restored the utoo catalog range to ^1, and switched CI/bootstrap instructions to latest at the user’s request. Native workspace/catalog configuration remains authoritative. - Removed the E2E-only utoo@1.1.1 temporary installation; packing now reuses the latest CLI from setup-utoo and the tracked native configuration. + +## 2026-10-07 — Scripts Windows sourcemap regression coverage + +- Added Windows Node.js 24 coverage for ESM file URL preloads, special-character paths, CJS preloads, and foreground child exit handling in `.github/workflows/ci.yml` and `tools/scripts/test/start-unit.test.ts`. +- Refreshed [Local CI](./workflows/local-ci.md) to describe the targeted Windows job and scripts-only build prerequisite. diff --git a/wiki/workflows/local-ci.md b/wiki/workflows/local-ci.md index 6601091125..0261c8f9fa 100644 --- a/wiki/workflows/local-ci.md +++ b/wiki/workflows/local-ci.md @@ -14,12 +14,13 @@ source_files: - tools/create-egg/package.json - tools/egg-bin/package.json - tools/egg-bin/tsconfig.json + - tools/scripts/test/start-unit.test.ts - tools/scripts/src/commands/start.ts - packages/egg/src/lib/core/httpclient.ts - tegg/core/loader/src/impl/ModuleLoader.ts - tegg/core/metadata/src/model/graph/GlobalGraph.ts - tegg/plugin/controller/test/fixtures/apps -updated_at: 2026-10-06 +updated_at: 2026-10-07 status: active --- @@ -37,7 +38,12 @@ the complete Node.js 22/24/26 × Linux/macOS/Windows matrix without sharding. It coverage job, without building packages before tests. The egg-bin matrix runs Node.js 24 and 26 on Linux and Windows. The egg-scripts -matrix runs Node.js 22, 24, and 26 on Linux. The tegg Vitest adapter runs both +matrix runs Node.js 22, 24, and 26 on Linux, plus targeted preload and foreground +exit regression tests on Windows Node.js 24. The Windows tests load +source-map-support through real Node subprocesses from paths containing spaces +and `#`, covering ESM file URLs and unchanged CJS `--require` paths. The +egg-scripts job builds only that CLI before its tests because its bin discovers +commands from `dist`; this differs from the main suite. The tegg Vitest adapter runs both isolated and shared workers on Node.js 24 and 26 on Linux. Coverage reports come from the Linux Node.js 24 jobs. The main-suite coverage job checks the complete, disjoint shard inventory before merging Vitest blob coverage reports. From bf68d3b8ca8ea8768a4437acbd1838d46679fde7 Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 6 Oct 2026 17:32:48 +0800 Subject: [PATCH 3/5] docs(wiki): correct Windows regression log date --- wiki/log.md | 2 +- wiki/workflows/local-ci.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/wiki/log.md b/wiki/log.md index 0cf7cad3e4..3b0e28b01f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -533,7 +533,7 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - Removed the exact packageManager pin, restored the utoo catalog range to ^1, and switched CI/bootstrap instructions to latest at the user’s request. Native workspace/catalog configuration remains authoritative. - Removed the E2E-only utoo@1.1.1 temporary installation; packing now reuses the latest CLI from setup-utoo and the tracked native configuration. -## 2026-10-07 — Scripts Windows sourcemap regression coverage +## 2026-10-06 — Scripts Windows sourcemap regression coverage - Added Windows Node.js 24 coverage for ESM file URL preloads, special-character paths, CJS preloads, and foreground child exit handling in `.github/workflows/ci.yml` and `tools/scripts/test/start-unit.test.ts`. - Refreshed [Local CI](./workflows/local-ci.md) to describe the targeted Windows job and scripts-only build prerequisite. diff --git a/wiki/workflows/local-ci.md b/wiki/workflows/local-ci.md index 0261c8f9fa..ff59991cb8 100644 --- a/wiki/workflows/local-ci.md +++ b/wiki/workflows/local-ci.md @@ -20,7 +20,7 @@ source_files: - tegg/core/loader/src/impl/ModuleLoader.ts - tegg/core/metadata/src/model/graph/GlobalGraph.ts - tegg/plugin/controller/test/fixtures/apps -updated_at: 2026-10-07 +updated_at: 2026-10-06 status: active --- From 109c58a2aacb73d787aa2502a890d7e641e0733a Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 6 Oct 2026 17:42:35 +0800 Subject: [PATCH 4/5] fix(ci): skip CLI build for Windows source regression tests --- .github/workflows/ci.yml | 3 ++- wiki/log.md | 2 ++ wiki/workflows/local-ci.md | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e045adb0f..e567fb37a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,8 +452,9 @@ jobs: # own step for clear failure attribution; @eggjs/scripts has no build # script of its own, so build it through the root tsdown workspace - # filter (tsdown accepts the path form on every OS) + # filter. Windows runs only source-level regression tests and needs no build. - name: Build egg-scripts + if: ${{ matrix.os != 'windows-latest' }} run: ut run build -- --workspace ./tools/scripts - name: Run tests (with coverage) diff --git a/wiki/log.md b/wiki/log.md index 3b0e28b01f..c9b860b058 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -537,3 +537,5 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - Added Windows Node.js 24 coverage for ESM file URL preloads, special-character paths, CJS preloads, and foreground child exit handling in `.github/workflows/ci.yml` and `tools/scripts/test/start-unit.test.ts`. - Refreshed [Local CI](./workflows/local-ci.md) to describe the targeted Windows job and scripts-only build prerequisite. + +- Windows source-level regression tests skip the Linux CLI build step: utoo interprets its tsdown path filter as a workspace selection on Windows, while these tests do not require `dist`. diff --git a/wiki/workflows/local-ci.md b/wiki/workflows/local-ci.md index ff59991cb8..59502ba2d8 100644 --- a/wiki/workflows/local-ci.md +++ b/wiki/workflows/local-ci.md @@ -42,8 +42,9 @@ matrix runs Node.js 22, 24, and 26 on Linux, plus targeted preload and foregroun exit regression tests on Windows Node.js 24. The Windows tests load source-map-support through real Node subprocesses from paths containing spaces and `#`, covering ESM file URLs and unchanged CJS `--require` paths. The -egg-scripts job builds only that CLI before its tests because its bin discovers -commands from `dist`; this differs from the main suite. The tegg Vitest adapter runs both +Linux egg-scripts jobs build only that CLI before their tests because its bin discovers +commands from `dist`; the targeted Windows job imports sources and skips that +build. This differs from the main suite. The tegg Vitest adapter runs both isolated and shared workers on Node.js 24 and 26 on Linux. Coverage reports come from the Linux Node.js 24 jobs. The main-suite coverage job checks the complete, disjoint shard inventory before merging Vitest blob coverage reports. From 1e2820c6dc37f7dfded2f9501c58fd6205d3e937 Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 6 Oct 2026 17:56:42 +0800 Subject: [PATCH 5/5] test(scripts): emit child events without startup polling --- tools/scripts/test/start-unit.test.ts | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tools/scripts/test/start-unit.test.ts b/tools/scripts/test/start-unit.test.ts index 22d5650759..8cce9a6f0c 100644 --- a/tools/scripts/test/start-unit.test.ts +++ b/tools/scripts/test/start-unit.test.ts @@ -63,18 +63,12 @@ describe('test/start-unit.test.ts', () => { signal: NodeJS.Signals | null, appDir = baseDir, ): Promise { - let onExit: ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; spawnMock.mockImplementation(() => ({ once: vi.fn((event: string, cb: (code: number | null, signal: NodeJS.Signals | null) => void) => { - if (event === 'exit') onExit = cb; + if (event === 'exit') queueMicrotask(() => cb(code, signal)); }), })); - const run = TestStart.run(['--workers=1', appDir]); - await vi.waitFor(() => { - if (!onExit) throw new Error('child not spawned yet'); - }); - onExit!(code, signal); - return run; + return TestStart.run(['--workers=1', appDir]); } it('cluster mode spawns the start-cluster server bin with cluster options', async () => { @@ -190,20 +184,14 @@ describe('test/start-unit.test.ts', () => { }); it('foreground mode rejects when the child fails to spawn', async () => { - let onError: ((err: Error) => void) | undefined; + // A nonexistent --node executable emits 'error' and may never emit 'exit'. + const spawnError = new Error('spawn ENOENT'); spawnMock.mockImplementation(() => ({ once: vi.fn((event: string, cb: (err: Error) => void) => { - if (event === 'error') onError = cb; + if (event === 'error') queueMicrotask(() => cb(spawnError)); }), })); - const run = TestStart.run(['--workers=1', baseDir]); - await vi.waitFor(() => { - if (!onError) throw new Error('child not spawned yet'); - }); - // e.g. a nonexistent --node executable: 'error' fires and 'exit' never does - const spawnError = new Error('spawn ENOENT'); - onError!(spawnError); - // the raw child error is rethrown as-is, not wrapped - await expect(run).rejects.toBe(spawnError); + // The raw child error is rethrown as-is, not wrapped. + await expect(TestStart.run(['--workers=1', baseDir])).rejects.toBe(spawnError); }); });