diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f51035543..e567fb37a6 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,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 (path form is fine here, this job only runs on ubuntu) + # 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) @@ -458,9 +462,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/src/commands/start.ts b/tools/scripts/src/commands/start.ts index 07c8550aa0..40bf166c44 100644 --- a/tools/scripts/src/commands/start.ts +++ b/tools/scripts/src/commands/start.ts @@ -3,6 +3,7 @@ import { mkdir, rename, stat, open, type FileHandle } from 'node:fs/promises'; import os from 'node:os'; 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'; @@ -309,7 +310,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 f9f4d9ed72..8cce9a6f0c 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 }; @@ -21,7 +31,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'}`; } } @@ -48,19 +58,17 @@ 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 { - let onExit: ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; + async function runStartWithChildExit( + code: number | null, + signal: NodeJS.Signals | null, + appDir = baseDir, + ): Promise { 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', baseDir]); - 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 () => { @@ -78,6 +86,48 @@ describe('test/start-unit.test.ts', () => { expect(options.env.NODE_ENV).toBe('production'); }); + 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(appDir, 'package.json'), + JSON.stringify({ name: `${type}-typescript-app`, type, egg: { typescript: true } }), + ); + 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 runStartWithChildExit(0, null, appDir); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [, args] = spawnMock.mock.calls[0] as [string, string[]]; + 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 () => { const unref = vi.fn(); const disconnect = vi.fn(); @@ -134,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); }); }); 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..c9b860b058 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -532,3 +532,10 @@ 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-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. + +- 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 6601091125..59502ba2d8 100644 --- a/wiki/workflows/local-ci.md +++ b/wiki/workflows/local-ci.md @@ -14,6 +14,7 @@ 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 @@ -37,7 +38,13 @@ 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 +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.