Skip to content
Merged
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
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ jobs:
node: '24'
coverage: false
include:
- os: 'windows-latest'
node: '24'
coverage: false
- os: 'ubuntu-latest'
node: '24'
coverage: true
Expand Down Expand Up @@ -449,18 +452,23 @@ 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)
if: ${{ matrix.coverage }}
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
Expand Down
3 changes: 2 additions & 1 deletion tools/scripts/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -309,7 +310,7 @@ export default class Start<T extends typeof Start> extends BaseCommand<T> {
});
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);
}
Expand Down
86 changes: 65 additions & 21 deletions tools/scripts/test/start-unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@eggjs/utils')>();
return { ...actual, importResolve: vi.fn(actual.importResolve) };
});

vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
Expand All @@ -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'}`;
}
}

Expand All @@ -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<void> {
let onExit: ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined;
async function runStartWithChildExit(
code: number | null,
signal: NodeJS.Signals | null,
appDir = baseDir,
): Promise<void> {
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 () => {
Expand All @@ -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();
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion wiki/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions wiki/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
9 changes: 8 additions & 1 deletion wiki/workflows/local-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading