Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
57 changes: 57 additions & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ jobs:
contents: read
packages: read
runs-on: ubuntu-latest
services:
mysql:
image: mysql:5.7
env:
MYSQL_ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: cnpmcore
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
redis:
image: redis
ports:
- 6379:6379
strategy:
fail-fast: false
matrix:
Expand All @@ -39,6 +52,50 @@ jobs:
npm run typecheck
npm run build
npm run prepublishOnly

# Clean build artifacts to avoid double-loading (src + dist)
npm run clean

# Run the full test suite
echo "Preparing databases..."
mysql -h 127.0.0.1 -u root -e "CREATE DATABASE IF NOT EXISTS cnpmcore_unittest"
CNPMCORE_DATABASE_NAME=cnpmcore_unittest bash ./prepare-database-mysql.sh
CNPMCORE_DATABASE_NAME=cnpmcore bash ./prepare-database-mysql.sh
EGG_FILE_PARALLELISM=false npm run test:local

# Deployment test: start the app and verify it boots correctly
npm run clean
echo "Preparing database for deployment test..."
CNPMCORE_DATABASE_NAME=cnpmcore bash ./prepare-database-mysql.sh

echo "Starting cnpmcore..."
CNPMCORE_FORCE_LOCAL_FS=true npx eggctl start &
SERVER_PID=$!

echo "Health checking cnpmcore..."
URL="http://127.0.0.1:7001"
PATTERN="instance_start_time"
TIMEOUT=60
TMP="$(mktemp)"
deadline=$((SECONDS + TIMEOUT))
last_status=""

while (( SECONDS < deadline )); do
last_status="$(curl -sS -o "$TMP" -w '%{http_code}' "$URL" || true)"
if [[ "$last_status" == "200" ]] && grep -q "$PATTERN" "$TMP"; then
echo "cnpmcore is ready (status=$last_status)"
rm -f "$TMP"
npx eggctl stop || true
exit 0
fi
sleep 1
done

echo "Health check failed: status=$last_status"
cat "$TMP" || true
rm -f "$TMP"
npx eggctl stop || true
exit 1
- name: examples
node-version: 24
command: |
Expand Down
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,41 @@ NODE_OPTIONS='--inspect-brk' pnpm --filter=egg run test test/app/extend/context.
- `EGG_TYPESCRIPT` - Enable TypeScript support (true/false)
- `DEBUG` - Enable debug output (egg:\*)

### Debugging E2E Tests Locally

The `ecosystem-ci/` directory contains E2E test infrastructure for downstream projects (e.g., cnpmcore). To reproduce and debug E2E failures locally:

```bash
# 1. Build all monorepo packages
pnpm run build

# 2. Pack all packages as tgz files (placed at workspace root)
pnpm -r pack

# 3. Clone the downstream project into ecosystem-ci/
git clone https://github.com/cnpmjs/cnpmcore.git ecosystem-ci/cnpmcore

# 4. Patch the project's package.json with local tgz overrides
npx tsx ecosystem-ci/patch-project.ts cnpmcore

# 5. Install (clean cache to avoid stale tgz)
cd ecosystem-ci/cnpmcore
npm cache clean --force
npm install

# 6. Run tests
npm run clean
npx egg-bin test test/path/to/specific.test.ts
```

After making changes to monorepo packages, repeat steps 1-5 (build → pack → patch → clean install).

**Key files:**

- `ecosystem-ci/patch-project.ts` - Generates `overrides` field in target project's package.json pointing to local tgz files
- `ecosystem-ci/repo.json` - Defines which downstream projects can be tested
- `.github/workflows/e2e-test.yml` - CI workflow that runs E2E tests with MySQL/Redis services

### Common Development Patterns

#### Creating a New Service
Expand Down
14 changes: 14 additions & 0 deletions plugins/mock/src/setup_vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ if (!g.afterEach) g.afterEach = afterEach;
// so setupApp() in app_handler.ts should skip registering duplicate hooks
(globalThis as Record<string, unknown>).__eggMockVitestSetup = true;

// Auto-configure @eggjs/tegg-vitest runner for context injection.
// The runner checks __teggVitestConfig to decide whether to create
// per-test tegg module scopes (so app.currentContext is available).
// Tegg scope creation is handled exclusively by the runner, not here.
if (!(globalThis as Record<string, unknown>).__teggVitestConfig) {
(globalThis as Record<string, unknown>).__teggVitestConfig = {
restoreMocks: true,
getApp: async () => {
const bootstrap = await import('./bootstrap.ts');
return (bootstrap as any)?.app;
},
};
}

let app: MockApplication | undefined;

// Cache the startup promise so that:
Expand Down
8 changes: 6 additions & 2 deletions plugins/redis/src/lib/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import assert from 'node:assert';
import { once } from 'node:events';

import type { ILifecycleBoot, EggApplicationCore } from 'egg';
import { Redis } from 'ioredis';
import type { Redis } from 'ioredis';
import IoRedis from 'ioredis';

import type { RedisClusterOptions, RedisClientOptions } from '../config/config.default.ts';

Expand All @@ -25,7 +26,10 @@ export class RedisBoot implements ILifecycleBoot {

let count = 0;
function createClient(options: RedisClusterOptions | RedisClientOptions, app: EggApplicationCore) {
const RedisClass = app.config.redis.Redis ?? Redis;
// Use default import for ioredis CJS module to avoid named export interop issues.
// ioredis uses `exports = module.exports = require("./Redis").default` which causes
// cjs-module-lexer to fail resolving named exports in some ESM interop scenarios.
const RedisClass: typeof Redis = app.config.redis.Redis ?? (IoRedis as unknown as typeof Redis);
let client;

if ('cluster' in options && options.cluster === true) {
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 5 additions & 6 deletions scripts/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,17 @@ const isDryRun = args.includes('--dry-run');
const useProvenance = args.includes('--provenance');

let npmTag = 'latest';
const tagArg = args.find(arg => arg.startsWith('--tag='));
const tagArg = args.find((arg) => arg.startsWith('--tag='));
if (tagArg) {
npmTag = tagArg.split('=')[1];
}

const baseDir = path.join(import.meta.dirname, '..');
const packages = getPublishablePackages(baseDir);

console.log(`📦 Publishing ${packages.length} packages (tag: ${npmTag}${isDryRun ? ', dry-run' : ''}${useProvenance ? ', provenance' : ''})`);
console.log(
`📦 Publishing ${packages.length} packages (tag: ${npmTag}${isDryRun ? ', dry-run' : ''}${useProvenance ? ', provenance' : ''})`,
);

/**
* Check if a specific version of a package is already published on npm.
Expand All @@ -56,10 +58,7 @@ function isPublished(name, version) {
* so that workspace: protocol references are properly resolved).
*/
function publishOne(pkg) {
const publishArgs = [
'--filter', pkg.name,
'publish', '--no-git-checks', '--access', 'public', '--tag', npmTag,
];
const publishArgs = ['--filter', pkg.name, 'publish', '--no-git-checks', '--access', 'public', '--tag', npmTag];
if (useProvenance) publishArgs.push('--provenance');
if (isDryRun) publishArgs.push('--dry-run');

Expand Down
12 changes: 11 additions & 1 deletion tegg/core/common-util/src/NameUtil.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
export class NameUtil {
/**
* Strip $N suffix added by compiler decorator transforms (e.g., oxc/tsdown).
* During decorator downlevel, compilers may rename class expressions to avoid
* name conflicts (e.g., BackgroundTaskHelper -> BackgroundTaskHelper$1).
*/
static cleanName(name: string): string {
return name.replace(/\$\d+$/, '');
}

static getClassName(constructor: Function): string {
return constructor.name[0].toLowerCase() + constructor.name.substring(1);
const name = NameUtil.cleanName(constructor.name);
return name[0].toLowerCase() + name.substring(1);
}
}
6 changes: 3 additions & 3 deletions tegg/core/core-decorator/src/decorator/MultiInstanceProto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StackUtil } from '@eggjs/tegg-common-util';
import { NameUtil, StackUtil } from '@eggjs/tegg-common-util';
import { ObjectInitType, AccessLevel, DEFAULT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types';
import type {
EggMultiInstanceCallbackPrototypeInfo,
Expand All @@ -24,14 +24,14 @@ export function MultiInstanceProto(param: MultiInstancePrototypeParams) {
const property: EggMultiInstancePrototypeInfo = {
...DEFAULT_PARAMS,
...(param as MultiInstancePrototypeStaticParams),
className: clazz.name,
className: NameUtil.cleanName(clazz.name),
};
PrototypeUtil.setMultiInstanceStaticProperty(clazz, property);
} else if ((param as MultiInstancePrototypeCallbackParams).getObjects) {
const property: EggMultiInstanceCallbackPrototypeInfo = {
...DEFAULT_PARAMS,
...(param as MultiInstancePrototypeCallbackParams),
className: clazz.name,
className: NameUtil.cleanName(clazz.name),
};
PrototypeUtil.setMultiInstanceCallbackProperty(clazz, property);
}
Expand Down
2 changes: 1 addition & 1 deletion tegg/core/core-decorator/src/decorator/Prototype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function Prototype(param?: PrototypeParams): PrototypeDecorator {
const property: Partial<EggPrototypeInfo> = {
...DEFAULT_PARAMS,
...param,
className: clazz.name,
className: NameUtil.cleanName(clazz.name),
};
if (!property.name) {
property.name = NameUtil.getClassName(clazz);
Expand Down
7 changes: 6 additions & 1 deletion tegg/core/core-decorator/src/util/QualifierUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ export class QualifierUtil {
}

static getQualifierValue(clazz: EggProtoImplClass, attribute: QualifierAttribute): QualifierValue | undefined {
const qualifiers: Map<QualifierAttribute, QualifierValue> | undefined = MetadataUtil.getMetaData(
// Use getOwnMetaData instead of getMetaData to avoid reading qualifiers
// from parent classes via the prototype chain. Without this, subclasses
// (e.g., ElectronBinary extends GithubBinary) would incorrectly see the
// parent's qualifier as their own, causing the duplicate qualifier check
// in addProtoQualifier to throw a false positive.
const qualifiers: Map<QualifierAttribute, QualifierValue> | undefined = MetadataUtil.getOwnMetaData(
QUALIFIER_META_DATA,
clazz,
);
Expand Down
5 changes: 5 additions & 0 deletions tegg/core/loader/src/LoaderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export class LoaderUtil {
}

static async loadFile(filePath: string): Promise<EggProtoImplClass[]> {
const originalFilePath = filePath;
if (process.platform === 'win32') {
// convert to file:// url
// avoid windows path issue: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'd:'
Expand All @@ -82,6 +83,10 @@ export class LoaderUtil {
if (!isEggProto) {
continue;
}
// Correct FILE_PATH after async import, because decorators like @Schedule
// use StackUtil.getCalleeFromStack() which may return <anonymous> when
// modules are loaded via async import() (e.g., in vitest environment)
PrototypeUtil.setFilePath(clazz, originalFilePath);
clazzList.push(clazz);
}
return clazzList;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { QualifierUtil } from '@eggjs/core-decorator';
import { NameUtil } from '@eggjs/tegg-common-util';
import { type EggProtoImplClass, type ProtoDescriptor, ProtoDescriptorType } from '@eggjs/tegg-types';

import { AbstractProtoDescriptor, type AbstractProtoDescriptorOptions } from './AbstractProtoDescriptor.ts';
Expand All @@ -21,7 +22,7 @@ export class ClassProtoDescriptor extends AbstractProtoDescriptor {
...options,
});
this.clazz = options.clazz;
this.className = this.clazz.name;
this.className = NameUtil.cleanName(this.clazz.name);
}

equal(protoDescriptor: ProtoDescriptor): boolean {
Expand Down
25 changes: 15 additions & 10 deletions tegg/core/vitest/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,35 @@ export default class TeggVitestRunner extends VitestTestRunner {
const result = await super.importFile(filepath, source);

if (source === 'collect') {
// Use per-file config from configureTeggRunner() if available,
// otherwise fall back to default (auto-detect @eggjs/mock/bootstrap app)
const rawConfig = (globalThis as any).__teggVitestConfig;
if (rawConfig) {
delete (globalThis as any).__teggVitestConfig;
delete (globalThis as any).__teggVitestConfig;

const config: TeggRunnerConfig = {
restoreMocks: rawConfig.restoreMocks ?? true,
getApp: rawConfig.getApp ?? defaultGetApp,
};
const config: TeggRunnerConfig = {
restoreMocks: rawConfig?.restoreMocks ?? true,
getApp: rawConfig?.getApp ?? defaultGetApp,
};

if (rawConfig) {
debugLog(`captured config for ${filepath}`);
} else {
debugLog(`auto-detect app for ${filepath}`);
}

// Resolve app and await ready during collection
// Resolve app and await ready during collection
if (!this.fileAppMap.has(filepath)) {
try {
const app = await config.getApp();
if (app) {
await app.ready();
this.fileAppMap.set(filepath, { app, config });
debugLog(`app ready for ${filepath}`);
}
} catch (err) {
} catch {
if (!this.warned) {
this.warned = true;
// eslint-disable-next-line no-console
console.warn('[tegg-vitest] getApp failed, skip context injection.', err);
debugLog('getApp failed, skip context injection.');
}
}
}
Expand Down
11 changes: 3 additions & 8 deletions tegg/core/vitest/test/get_app_throw.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import assert from 'assert';

import { describe, it, afterAll, vi } from 'vitest';
import { describe, it } from 'vitest';

import { configureTeggRunner } from '../src/index.ts';

let getAppCalls = 0;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

configureTeggRunner({
getApp() {
Expand All @@ -17,12 +16,8 @@ configureTeggRunner({

describe('getApp throw handling', () => {
it('should not crash suite when getApp throws', () => {
// The runner calls getApp in onBeforeRunSuite, so it should have been called
// The runner calls getApp during importFile (collection phase),
// so it should have been called and the error handled gracefully.
assert(getAppCalls > 0);
assert(warnSpy.mock.calls.length > 0);
});

afterAll(() => {
warnSpy.mockRestore();
});
});
3 changes: 2 additions & 1 deletion tegg/plugin/langchain/src/lib/graph/GraphLoadUnitHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EggPrototypeCreatorFactory, EggPrototypeFactory, ProtoDescriptorHelper
import type { ClassProtoDescriptor, EggPrototypeWithClazz, LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata';
import { AccessLevel, LifecyclePostInject, MCPInfoUtil, SingletonProto } from '@eggjs/tegg';
import type { EggProtoImplClass, LifecycleHook } from '@eggjs/tegg';
import { NameUtil } from '@eggjs/tegg-common-util';
import { EggContainerFactory } from '@eggjs/tegg-runtime';
import { DynamicStructuredTool } from 'langchain';
import * as z from 'zod/v4';
Expand All @@ -29,7 +30,7 @@ export class GraphLoadUnitHook implements LifecycleHook<LoadUnitLifecycleContext
for (const clazz of clazzList) {
const meta = this.clazzMap.get(clazz as EggProtoImplClass);
if (meta) {
const protoName = clazz.name[0].toLowerCase() + clazz.name.substring(1);
const protoName = NameUtil.getClassName(clazz);
const graphMetadata = GraphInfoUtil.getGraphMetadata(clazz as EggProtoImplClass);
assert(graphMetadata, `${clazz.name} graphMetadata should not be null`);
const proto = new CompiledStateGraphProto(
Expand Down
Loading
Loading