From 339e876e41c288a19b69ffc59b58ef7f31ac960c Mon Sep 17 00:00:00 2001 From: zhouzz <275028888@qq.com> Date: Mon, 24 Aug 2026 17:14:48 +0800 Subject: [PATCH 1/3] refactor(scene): support pluggable scene command providers --- .../__snapshots__/dts-snapshot.test.ts.snap | 61 +++- .../__tests__/dts-snapshot.test.ts | 1 + src/core/scene/main-process/rpc.ts | 223 ++++++++++--- .../main-process/scene-command-provider.ts | 61 ++++ .../main-process/scene-host-local-executor.ts | 55 ++++ src/core/scene/main-process/scene-worker.ts | 35 ++- src/core/scene/test/main-process-rpc.test.ts | 293 ++++++++++++++++++ src/core/scene/test/scene-worker.test.ts | 20 ++ src/lib/scene/scene.ts | 24 ++ tests/lib-scene-command-provider.test.ts | 55 ++++ workflow/generate-dts.ts | 4 + 11 files changed, 792 insertions(+), 40 deletions(-) create mode 100644 src/core/scene/main-process/scene-command-provider.ts create mode 100644 src/core/scene/main-process/scene-host-local-executor.ts create mode 100644 src/core/scene/test/main-process-rpc.test.ts create mode 100644 tests/lib-scene-command-provider.test.ts diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index e2852a1c3..7ad3feb06 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -7664,6 +7664,7 @@ export { } exports[`DTS API compatibility index.d.ts should match snapshot 1`] = ` "import { __private } from 'cc'; +import type { ChildProcess } from 'child_process'; import { ChunkInfo } from '@cocos/creator-programming-quick-pack/lib/loader'; import { EventEmitter } from 'events'; import { default as i18n_2 } from 'i18next'; @@ -8712,6 +8713,12 @@ export declare interface IResolvedCustomJointTextureLayout { textureLength: number; contents: IResolvedChunkContent[]; } +export declare interface ISceneCommandProvider { + request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; + notify?(module: string, method: string, args?: any[]): void; + isConnect?(): boolean | undefined; + dispose?(): void; +} export declare interface ISocketConfig { connection: (socket: any) => void; disconnect: (socket: any) => void; @@ -9141,6 +9148,7 @@ export declare interface RenderTextureAssetUserData extends TextureBaseAssetUser width: number; height: number; } +export declare function resetCommandProvider(): void; export declare interface RtSpriteFrameAssetUserData { imageUuidOrDatabaseUri: string; width?: number; @@ -9153,9 +9161,21 @@ export declare function saveSerializedData(uuidOrUrlOrPath: string, patch: Seria export declare namespace Scene { export { init_6 as init, - startupWorker + startupWorker, + setCommandProvider, + resetCommandProvider, + ISceneCommandProvider, + SceneCommandProviderRegistration, + SceneCommandRequestOptions, + WorkerSceneCommandProvider } } +export declare interface SceneCommandProviderRegistration { + dispose(): void; +} +export declare interface SceneCommandRequestOptions { + timeout?: number; +} export declare namespace Scripting { export { init_7 as init, @@ -9205,6 +9225,7 @@ export declare namespace Server { } } export declare function set(key: string, value: T, scope?: ConfigurationScope): Promise; +export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; export declare function setFileSystemProvider(provider: IAssetFileSystemProvider): void; export declare interface SharedSettings { useDefineForClassFields: boolean; @@ -9335,6 +9356,14 @@ export declare interface Vec4 { z: number; w: number; } +export declare class WorkerSceneCommandProvider implements ISceneCommandProvider { + private readonly rpc; + constructor(process: ChildProcess | NodeJS.Process); + request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; + notify(module: string, method: string, args?: any[]): void; + isConnect(): boolean | undefined; + dispose(): void; +} export declare type WrapMode = 'repeat' | 'clamp-to-edge' | 'mirrored-repeat'; export { } " @@ -9406,6 +9435,36 @@ export { } " `; +exports[`DTS API compatibility scene.d.ts should match snapshot 1`] = ` +"import type { ChildProcess } from 'child_process'; +export declare function init(): Promise; +export declare interface ISceneCommandProvider { + request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; + notify?(module: string, method: string, args?: any[]): void; + isConnect?(): boolean | undefined; + dispose?(): void; +} +export declare function resetCommandProvider(): void; +export declare interface SceneCommandProviderRegistration { + dispose(): void; +} +export declare interface SceneCommandRequestOptions { + timeout?: number; +} +export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; +export declare function startupWorker(projectPath: string): Promise; +export declare class WorkerSceneCommandProvider implements ISceneCommandProvider { + private readonly rpc; + constructor(process: ChildProcess | NodeJS.Process); + request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; + notify(module: string, method: string, args?: any[]): void; + isConnect(): boolean | undefined; + dispose(): void; +} +export { } +" +`; + exports[`DTS API compatibility scripting.d.ts should match snapshot 1`] = ` "import { ChunkInfo } from '@cocos/creator-programming-quick-pack/lib/loader'; export declare enum AssetActionEnum { diff --git a/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts b/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts index ce7c26b05..3f6da8469 100644 --- a/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts +++ b/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts @@ -12,6 +12,7 @@ const dtsFiles = [ 'configuration.d.ts', 'engine.d.ts', 'project.d.ts', + 'scene.d.ts', 'scripting.d.ts', ]; diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index f438f0848..576ef2d07 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -1,62 +1,211 @@ +import type { ChildProcess } from 'child_process'; +import type { IPublicServiceManager } from '../scene-process'; import { ProcessRPC } from '../process-rpc'; -import { ChildProcess } from 'child_process'; -import { assetManager } from '../../assets'; -import scriptManager from '../../scripting'; -import { sceneConfigInstance } from '../scene-configs'; -import i18n from '../../base/i18n'; -import { referenceImageFiles } from './reference-image-files'; -import { referenceImageStore } from './reference-image-store'; +import { + ISceneCommandProvider, + SceneCommandRequestOptions, + SceneCommandProviderRegistration, + WorkerSceneCommandProvider, +} from './scene-command-provider'; +import { SceneHostLocalExecutor } from './scene-host-local-executor'; -import type { IPublicServiceManager } from '../scene-process'; +type AnySceneMethod = (...args: any[]) => any; +type SceneServiceMethod< + K extends keyof IPublicServiceManager, + M extends keyof IPublicServiceManager[K], +> = Extract; export { ProcessRPC }; +export type { + ISceneCommandProvider, + SceneCommandProviderRegistration, + SceneCommandRequestOptions, +} from './scene-command-provider'; +export { WorkerSceneCommandProvider } from './scene-command-provider'; +export { SceneHostLocalExecutor } from './scene-host-local-executor'; +export type { SceneHostModules } from './scene-host-local-executor'; + +/** Minimal interface exposed to callers by `Rpc.getInstance()`. */ +export interface SceneRpcClient { + request( + module: K, + method: M, + ...rest: Parameters> extends [] + ? [args?: [], options?: SceneCommandRequestOptions] + : [args: Parameters>, options?: SceneCommandRequestOptions] + ): Promise>>>; + + notify( + module: K, + method: M, + args?: Parameters>, + ): void; + + executeLocal(module: string, method: string, args?: any[]): Promise; + isConnect(): boolean | undefined; +} -export class RpcProxy { - private rpcInstance: ProcessRPC | null = null; +export class RpcProxy implements SceneRpcClient { + private commandProvider: ISceneCommandProvider | null = null; + private commandProviderRegistration: SceneCommandProviderRegistration | null = null; + private hostLocalExecutor: SceneHostLocalExecutor | null = null; - public getInstance() { - if (!this.rpcInstance) { + public getInstance(): SceneRpcClient { + if (!this.hostLocalExecutor) { throw new Error('[Node] Rpc instance is not started!'); } - return this.rpcInstance; + return this; } - public isConnect() { - return this.rpcInstance?.isConnect(); + public isConnect(): boolean | undefined { + return this.commandProvider?.isConnect?.(); } - async startup(prc?: ChildProcess | NodeJS.Process) { + /** + * Preserves the existing startup behavior: + * - When a process is provided, `WorkerSceneCommandProvider` connects to the Scene Worker. + * - Otherwise, only `SceneHostLocalExecutor` is initialized for the Scene Webview runtime. + */ + startup(prc: ChildProcess | NodeJS.Process): SceneCommandProviderRegistration; + startup(prc?: undefined): undefined; + startup(prc?: ChildProcess | NodeJS.Process): SceneCommandProviderRegistration | undefined { // 在创建新实例前,先清理旧实例,防止内存泄漏 this.dispose(); - this.rpcInstance = new ProcessRPC(); + this.ensureHostLocalExecutor(); if (prc) { - this.rpcInstance.attach(prc); + const registration = this.setCommandProvider( + new WorkerSceneCommandProvider(prc), + ); + console.log('[Node] Scene Process RPC ready (Attached)'); + return registration; } - this.rpcInstance.register({ - assetManager: assetManager, - programming: scriptManager, - sceneConfigInstance: sceneConfigInstance, - i18n: i18n, - // Feature-owned Node modules: external file reads and serialized local configuration writes. - referenceImageFiles, - referenceImageStore, - }); - console.log(`[Node] Scene Process RPC ready ${prc ? '(Attached)' : '(Detached - Web Mode)'}`); + console.log('[Node] Scene Process RPC ready (Detached - Web Mode)'); + return undefined; + } + + /** + * Installs the host-provided `ISceneCommandProvider`. + * Switches to the new provider before disposing the previous one. Errors from the new provider + * propagate directly without falling back to another provider or retrying the request. + */ + public setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration { + if (!provider || typeof provider.request !== 'function') { + throw new TypeError('[Node] Scene command provider must implement request()'); + } + if (provider === this.commandProvider && this.commandProviderRegistration) { + return this.commandProviderRegistration; + } + + this.ensureHostLocalExecutor(); + const previousProvider = this.commandProvider; + let disposed = false; + const registration: SceneCommandProviderRegistration = { + dispose: () => { + if (disposed) { + return; + } + disposed = true; + // A stale registration must not clear a newer provider, even when both use the same object. + if (this.commandProviderRegistration !== registration) { + return; + } + this.commandProvider = null; + this.commandProviderRegistration = null; + this.disposeCommandProvider(provider); + }, + }; + + this.commandProvider = provider; + this.commandProviderRegistration = registration; + this.disposeCommandProvider(previousProvider); + console.log('[Node] Scene command provider installed'); + return registration; + } + + /** Clears and disposes the active Scene command provider. */ + public resetCommandProvider(): void { + const provider = this.commandProvider; + this.commandProvider = null; + this.commandProviderRegistration = null; + this.disposeCommandProvider(provider); + } + + public request( + module: K, + method: M, + ...rest: Parameters> extends [] + ? [args?: [], options?: SceneCommandRequestOptions] + : [args: Parameters>, options?: SceneCommandRequestOptions] + ): Promise>>> { + const provider = this.commandProvider; + if (!provider) { + return Promise.reject(new Error('[Node] No Scene command provider is installed')); + } + + const [args, options] = rest; + return provider.request( + String(module), + String(method), + (args ?? []) as any[], + options, + ) as Promise>>>; + } + + public notify( + module: K, + method: M, + args?: Parameters>, + ): void { + const provider = this.commandProvider; + if (!provider) { + throw new Error('[Node] No Scene command provider is installed'); + } + if (!provider.notify) { + throw new Error('[Node] Scene command provider does not support notify()'); + } + provider.notify(String(module), String(method), (args ?? []) as any[]); + } + + public executeLocal(module: string, method: string, args: any[] = []): Promise { + const executor = this.hostLocalExecutor; + if (!executor) { + return Promise.reject(new Error('[Node] Scene host local executor is not started!')); + } + return executor.executeLocal(module, method, args); } /** * 清理 RPC 实例 */ dispose(): void { - if (this.rpcInstance) { - console.log('[Node] Disposing RPC instance'); - try { - this.rpcInstance.dispose(); - } catch (error) { - console.warn('[Node] Error disposing RPC instance:', error); - } finally { - this.rpcInstance = null; - } + if (!this.commandProvider && !this.hostLocalExecutor) { + return; + } + + console.log('[Node] Disposing RPC instance'); + this.resetCommandProvider(); + try { + this.hostLocalExecutor?.dispose(); + } catch (error) { + console.warn('[Node] Error disposing Scene host local executor:', error); + } finally { + this.hostLocalExecutor = null; + } + } + + private ensureHostLocalExecutor(): SceneHostLocalExecutor { + this.hostLocalExecutor ??= new SceneHostLocalExecutor(); + return this.hostLocalExecutor; + } + + private disposeCommandProvider(provider: ISceneCommandProvider | null): void { + if (!provider?.dispose) { + return; + } + try { + provider.dispose(); + } catch (error) { + console.warn('[Node] Error disposing Scene command provider:', error); } } } diff --git a/src/core/scene/main-process/scene-command-provider.ts b/src/core/scene/main-process/scene-command-provider.ts new file mode 100644 index 000000000..542b6c134 --- /dev/null +++ b/src/core/scene/main-process/scene-command-provider.ts @@ -0,0 +1,61 @@ +import type { ChildProcess } from 'child_process'; +import { ProcessRPC } from '../process-rpc'; +import type { IPublicServiceManager } from '../scene-process'; +import { registerDefaultSceneHostModules } from './scene-host-local-executor'; + +export interface SceneCommandRequestOptions { + timeout?: number; +} + +/** + * `ISceneCommandProvider` defines how Scene commands are dispatched. + * Each call uses only the selected provider. Errors must propagate without retrying through + * another provider. + */ +export interface ISceneCommandProvider { + request( + module: string, + method: string, + args?: any[], + options?: SceneCommandRequestOptions, + ): Promise; + notify?(module: string, method: string, args?: any[]): void; + isConnect?(): boolean | undefined; + dispose?(): void; +} + +/** Ownership-bound registration returned when a provider is installed. */ +export interface SceneCommandProviderRegistration { + dispose(): void; +} + +/** Default provider used by standalone cocos-cli to connect to the Scene Worker. */ +export class WorkerSceneCommandProvider implements ISceneCommandProvider { + private readonly rpc = new ProcessRPC(); + + constructor(process: ChildProcess | NodeJS.Process) { + this.rpc.attach(process); + registerDefaultSceneHostModules(this.rpc); + } + + public request( + module: string, + method: string, + args: any[] = [], + options?: SceneCommandRequestOptions, + ): Promise { + return this.rpc.request(module as any, method as any, args as any, options); + } + + public notify(module: string, method: string, args: any[] = []): void { + this.rpc.notify(module as any, method as any, args as any); + } + + public isConnect(): boolean | undefined { + return this.rpc.isConnect(); + } + + public dispose(): void { + this.rpc.dispose(); + } +} diff --git a/src/core/scene/main-process/scene-host-local-executor.ts b/src/core/scene/main-process/scene-host-local-executor.ts new file mode 100644 index 000000000..16bccb720 --- /dev/null +++ b/src/core/scene/main-process/scene-host-local-executor.ts @@ -0,0 +1,55 @@ +import { assetManager } from '../../assets'; +import scriptManager from '../../scripting'; +import i18n from '../../base/i18n'; +import { ProcessRPC } from '../process-rpc'; +import { sceneConfigInstance } from '../scene-configs'; +import { referenceImageFiles } from './reference-image-files'; +import { referenceImageStore } from './reference-image-store'; + +export interface SceneHostModules { + assetManager: typeof assetManager; + programming: typeof scriptManager; + sceneConfigInstance: typeof sceneConfigInstance; + i18n: typeof i18n; + referenceImageFiles: typeof referenceImageFiles; + referenceImageStore: typeof referenceImageStore; +} + +const defaultSceneHostModules: SceneHostModules = { + assetManager, + programming: scriptManager, + sceneConfigInstance, + i18n, + // Feature-owned Node modules: external file reads and serialized local configuration writes. + referenceImageFiles, + referenceImageStore, +}; + +/** Registers the default host modules with the specified Scene RPC transport. */ +export function registerDefaultSceneHostModules(rpc: ProcessRPC): void { + rpc.register(defaultSceneHostModules); +} + +/** + * `SceneHostLocalExecutor` handles reverse RPC calls from the Scene runtime in the Scene host + * process. In hosted mode, the integrating application provides this process. + * + * `SceneHostLocalExecutor` is transport-agnostic. The Scene Webview runtime invokes it through + * the HTTP `/rpc/:module/:method` route. The worker provider uses the helper above to register + * the same host modules with the Scene Worker transport. + */ +export class SceneHostLocalExecutor { + private readonly rpc = new ProcessRPC(); + + constructor(private readonly modules: SceneHostModules = defaultSceneHostModules) { + this.rpc.register(modules); + } + + public executeLocal(module: string, method: string, args: any[] = []): Promise { + return this.rpc.executeLocal(module as any, method as any, args); + } + + public dispose(): void { + this.rpc.dispose(); + } +} diff --git a/src/core/scene/main-process/scene-worker.ts b/src/core/scene/main-process/scene-worker.ts index e755b5b2f..5c63653dd 100644 --- a/src/core/scene/main-process/scene-worker.ts +++ b/src/core/scene/main-process/scene-worker.ts @@ -3,6 +3,7 @@ import path from 'path'; import { EventEmitter } from 'events'; import { SceneProcessEventTag, SceneReadyChannel } from '../common'; import { Rpc } from './rpc'; +import type { SceneCommandProviderRegistration } from './rpc'; import { getServerUrl } from '../../../server'; import { disposeModuleMessages, listenModuleMessages } from './messages'; import { getAvailablePort } from '../../../server/utils'; @@ -32,6 +33,7 @@ export class SceneWorker { private projectPath: string = ''; // 项目路径 private isRestarting = false; // 是否正在重启中 private isManualStop = false; // 是否手动停止 + private commandProviderRegistration: SceneCommandProviderRegistration | null = null; async start(enginePath: string, projectPath: string): Promise { if (this._process) { @@ -46,6 +48,7 @@ export class SceneWorker { return new Promise(async (resolve) => { let isResolved = false; let startupTimer: NodeJS.Timeout | null = null; + let registration: SceneCommandProviderRegistration | null = null; const cleanup = () => { if (startupTimer) { @@ -62,6 +65,12 @@ export class SceneWorker { } }; + const releaseRegistration = () => { + const ownedRegistration = registration; + registration = null; + this.releaseCommandProvider(ownedRegistration); + }; + try { const args = [ `--enginePath=${enginePath}`, @@ -83,6 +92,7 @@ export class SceneWorker { this._process?.off('error', onError); this._process?.off('exit', onEarlyExit); this._process = null; + releaseRegistration(); resolveOnce(false); }; @@ -92,6 +102,7 @@ export class SceneWorker { this._process?.off('error', onError); this._process?.off('exit', onEarlyExit); this._process = null; + releaseRegistration(); resolveOnce(false); }; @@ -106,6 +117,7 @@ export class SceneWorker { this._process.kill('SIGTERM'); this._process = null; } + releaseRegistration(); resolveOnce(false); }; @@ -132,6 +144,7 @@ export class SceneWorker { this._process.kill('SIGTERM'); this._process = null; } + releaseRegistration(); resolveOnce(false); }, 30000); @@ -141,13 +154,15 @@ export class SceneWorker { this._process.on('message', onReady); // 启动RPC和注册监听器 - Rpc.startup(this._process); + registration = Rpc.startup(this._process); + this.commandProviderRegistration = registration; listenerPromise = this.registerListener(); listenerPromise.catch(failStartup); } catch (error) { console.error('创建场景进程失败:', error); this._process = null; + releaseRegistration(); resolveOnce(false); } }); @@ -155,7 +170,10 @@ export class SceneWorker { async stop() { const process = this._process; - if (!process) return true; + if (!process) { + this.releaseCommandProvider(); + return true; + } this.isManualStop = true; disposeModuleMessages(); return new Promise((resolve) => { @@ -283,6 +301,7 @@ export class SceneWorker { } async registerListener() { + const registration = this.commandProviderRegistration; this.process.on('message', (msg: { type: string, event: string, args: any[] }) => { if (msg && msg.type === SceneProcessEventTag) { @@ -312,6 +331,7 @@ export class SceneWorker { }); this.process.on('exit', (code: number, signal) => { + this.releaseCommandProvider(registration); disposeModuleMessages(); if (code !== 0) { console.error(`场景进程退出异常 code:${code}, signal:${signal}`); @@ -340,6 +360,16 @@ export class SceneWorker { await listenModuleMessages(); } + /** Releases only the provider registration acquired by the current Scene Worker. */ + private releaseCommandProvider( + registration: SceneCommandProviderRegistration | null = this.commandProviderRegistration, + ): void { + if (this.commandProviderRegistration === registration) { + this.commandProviderRegistration = null; + } + registration?.dispose(); + } + /** * 监听指定类型的事件(类型安全版本) * @param event 事件名称 @@ -426,6 +456,7 @@ export class SceneWorker { this.eventEmitter.removeAllListeners(event); } else { disposeModuleMessages(); + this.releaseCommandProvider(); this.eventEmitter.removeAllListeners(); // 重置重启相关状态 this.currentRestartCount = 0; diff --git a/src/core/scene/test/main-process-rpc.test.ts b/src/core/scene/test/main-process-rpc.test.ts new file mode 100644 index 000000000..4610cf9fe --- /dev/null +++ b/src/core/scene/test/main-process-rpc.test.ts @@ -0,0 +1,293 @@ +import { EventEmitter } from 'events'; + +const mockQueryAssetInfo = jest.fn((uuid: string) => ({ uuid })); +const mockProgrammingCall = jest.fn(async (value: string) => `programming:${value}`); +const mockConfigGet = jest.fn(async (key: string) => `config:${key}`); +const mockGetBundle = jest.fn(async () => ({ lang: 'en', data: {} })); + +jest.mock('../../assets', () => ({ + assetManager: { + queryAssetInfo: (uuid: string) => mockQueryAssetInfo(uuid), + }, +})); + +jest.mock('../../scripting', () => ({ + __esModule: true, + default: { + testCall: (value: string) => mockProgrammingCall(value), + }, +})); + +jest.mock('../scene-configs', () => ({ + sceneConfigInstance: { + get: (key: string) => mockConfigGet(key), + }, +})); + +jest.mock('../../base/i18n', () => ({ + __esModule: true, + default: { + getBundle: () => mockGetBundle(), + }, +})); + +import type { ISceneCommandProvider } from '../main-process/rpc'; +import { RpcProxy } from '../main-process/rpc'; + +interface FakeProcess extends EventEmitter { + connected: boolean; + send: jest.Mock; +} + +function createFakeProcess(): FakeProcess { + const process = new EventEmitter() as FakeProcess; + process.connected = true; + process.send = jest.fn(); + return process; +} + +async function flushEvents(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('main-process Scene RPC providers', () => { + let rpc: RpcProxy; + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + rpc = new RpcProxy(); + mockQueryAssetInfo.mockClear(); + mockProgrammingCall.mockClear(); + mockConfigGet.mockClear(); + mockGetBundle.mockClear(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + rpc.dispose(); + logSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('preserves getInstance startup and detached Webview reverse-call behavior', async () => { + expect(() => rpc.getInstance()).toThrow('[Node] Rpc instance is not started!'); + + await rpc.startup(); + const facade = rpc.getInstance(); + + await expect(facade.executeLocal('assetManager', 'queryAssetInfo', ['asset-uuid'])) + .resolves.toEqual({ uuid: 'asset-uuid' }); + await expect(facade.executeLocal('programming', 'testCall', ['value'])) + .resolves.toBe('programming:value'); + await expect(facade.executeLocal('sceneConfigInstance', 'get', ['camera'])) + .resolves.toBe('config:camera'); + await expect(facade.executeLocal('i18n', 'getBundle')) + .resolves.toEqual({ lang: 'en', data: {} }); + + expect(facade.isConnect()).toBeUndefined(); + await expect(facade.request('Editor', 'hasOpen')) + .rejects.toThrow('[Node] No Scene command provider is installed'); + }); + + it('uses the Worker Provider by default and registers host modules for reverse calls', async () => { + const workerProcess = createFakeProcess(); + workerProcess.send.mockImplementation((message: any) => { + if (message?.type === 'request') { + setImmediate(() => workerProcess.emit('message', { + id: message.id, + type: 'response', + result: true, + })); + } + }); + + await rpc.startup(workerProcess as any); + const facade = rpc.getInstance(); + + await expect(facade.request('Editor', 'hasOpen')).resolves.toBe(true); + expect(facade.isConnect()).toBe(true); + expect(workerProcess.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'request', + module: 'Editor', + method: 'hasOpen', + args: [], + })); + + workerProcess.emit('message', { + id: 91, + type: 'request', + module: 'assetManager', + method: 'queryAssetInfo', + args: ['reverse-call-uuid'], + }); + await flushEvents(); + + expect(mockQueryAssetInfo).toHaveBeenCalledWith('reverse-call-uuid'); + expect(workerProcess.send).toHaveBeenCalledWith({ + id: 91, + type: 'response', + result: { uuid: 'reverse-call-uuid' }, + }); + }); + + it('installs an explicit Provider and never falls back after its request fails', async () => { + const workerProcess = createFakeProcess(); + await rpc.startup(workerProcess as any); + const facade = rpc.getInstance(); + const providerFailure = new Error('Host runtime rejected the command'); + const hostProvider: ISceneCommandProvider = { + request: jest.fn().mockRejectedValue(providerFailure), + isConnect: jest.fn(() => true), + dispose: jest.fn(), + }; + + workerProcess.send.mockClear(); + rpc.setCommandProvider(hostProvider); + + await expect(facade.request('Editor', 'hasOpen')).rejects.toBe(providerFailure); + expect(hostProvider.request).toHaveBeenCalledWith('Editor', 'hasOpen', [], undefined); + expect(workerProcess.send).not.toHaveBeenCalled(); + expect(workerProcess.listenerCount('message')).toBe(0); + }); + + it('can install a host Provider before startup while keeping local execution available', async () => { + const hostProvider: ISceneCommandProvider = { + request: jest.fn(async (_module, _method, args) => args?.[0]), + dispose: jest.fn(), + }; + + rpc.setCommandProvider(hostProvider); + const facade = rpc.getInstance(); + + await expect(facade.request('Editor', 'open', [{ urlOrUUID: 'db://assets/main.scene' }])) + .resolves.toEqual({ urlOrUUID: 'db://assets/main.scene' }); + await expect(facade.executeLocal('assetManager', 'queryAssetInfo', ['local-uuid'])) + .resolves.toEqual({ uuid: 'local-uuid' }); + }); + + it('disposes an explicitly installed Provider with the Rpc lifecycle', () => { + const hostProvider: ISceneCommandProvider = { + request: jest.fn(), + dispose: jest.fn(), + }; + rpc.setCommandProvider(hostProvider); + + rpc.dispose(); + + expect(hostProvider.dispose).toHaveBeenCalledTimes(1); + expect(() => rpc.getInstance()).toThrow('[Node] Rpc instance is not started!'); + }); + + it('switches to the new Provider before disposing the previous Provider', () => { + const firstProvider: ISceneCommandProvider = { + request: jest.fn(), + isConnect: jest.fn(() => false), + dispose: jest.fn(() => { + expect(rpc.isConnect()).toBe(true); + }), + }; + const nextProvider: ISceneCommandProvider = { + request: jest.fn(), + isConnect: jest.fn(() => true), + }; + + rpc.setCommandProvider(firstProvider); + rpc.setCommandProvider(nextProvider); + + expect(firstProvider.dispose).toHaveBeenCalledTimes(1); + expect(rpc.isConnect()).toBe(true); + }); + + it('returns the same registration when installing the current Provider again', () => { + const provider: ISceneCommandProvider = { + request: jest.fn(), + dispose: jest.fn(), + }; + + const firstRegistration = rpc.setCommandProvider(provider); + const nextRegistration = rpc.setCommandProvider(provider); + + expect(nextRegistration).toBe(firstRegistration); + expect(provider.dispose).not.toHaveBeenCalled(); + + firstRegistration.dispose(); + firstRegistration.dispose(); + + expect(provider.dispose).toHaveBeenCalledTimes(1); + }); + + it('does not let a stale registration clear a later Provider', async () => { + const workerProvider: ISceneCommandProvider = { + request: jest.fn(async () => 'worker'), + dispose: jest.fn(), + }; + const hostProvider: ISceneCommandProvider = { + request: jest.fn(async () => 'host'), + dispose: jest.fn(), + }; + + const workerRegistration = rpc.setCommandProvider(workerProvider); + rpc.setCommandProvider(hostProvider); + workerRegistration.dispose(); + + await expect(rpc.request('Editor', 'hasOpen')).resolves.toBe('host'); + expect(workerProvider.dispose).toHaveBeenCalledTimes(1); + expect(hostProvider.dispose).not.toHaveBeenCalled(); + }); + + it('keeps old registrations stale when the same Provider object is installed again', async () => { + const sharedProvider: ISceneCommandProvider = { + request: jest.fn(async () => true), + dispose: jest.fn(), + }; + const intermediateProvider: ISceneCommandProvider = { + request: jest.fn(async () => false), + dispose: jest.fn(), + }; + + const oldRegistration = rpc.setCommandProvider(sharedProvider); + rpc.setCommandProvider(intermediateProvider); + const currentRegistration = rpc.setCommandProvider(sharedProvider); + oldRegistration.dispose(); + + await expect(rpc.request('Editor', 'hasOpen')).resolves.toBe(true); + + currentRegistration.dispose(); + currentRegistration.dispose(); + + await expect(rpc.request('Editor', 'hasOpen')) + .rejects.toThrow('[Node] No Scene command provider is installed'); + expect(sharedProvider.dispose).toHaveBeenCalledTimes(2); + expect(intermediateProvider.dispose).toHaveBeenCalledTimes(1); + }); + + it('resets the current Provider idempotently', async () => { + const provider: ISceneCommandProvider = { + request: jest.fn(async () => true), + dispose: jest.fn(), + }; + rpc.setCommandProvider(provider); + + rpc.resetCommandProvider(); + rpc.resetCommandProvider(); + + expect(provider.dispose).toHaveBeenCalledTimes(1); + await expect(rpc.request('Editor', 'hasOpen')) + .rejects.toThrow('[Node] No Scene command provider is installed'); + }); + + it('forwards request options unchanged and rejects notify when unsupported', async () => { + const provider: ISceneCommandProvider = { + request: jest.fn(async () => true), + }; + rpc.setCommandProvider(provider); + + await expect(rpc.request('Editor', 'hasOpen', [], { timeout: 1234 })).resolves.toBe(true); + expect(provider.request).toHaveBeenCalledWith('Editor', 'hasOpen', [], { timeout: 1234 }); + expect(() => rpc.notify('Editor', 'hasOpen')).toThrow( + '[Node] Scene command provider does not support notify()', + ); + }); +}); diff --git a/src/core/scene/test/scene-worker.test.ts b/src/core/scene/test/scene-worker.test.ts index f8f647d09..f55d1b9cc 100644 --- a/src/core/scene/test/scene-worker.test.ts +++ b/src/core/scene/test/scene-worker.test.ts @@ -6,6 +6,7 @@ import { SceneWorker } from '../main-process/scene-worker'; const mockFork = jest.fn(); const mockRpcStartup = jest.fn(); +const mockProviderRegistrationDispose = jest.fn(); const mockListenModuleMessages = jest.fn(); const mockDisposeModuleMessages = jest.fn(); const mockGetAvailablePort = jest.fn(async (_port: number) => 9230); @@ -50,6 +51,10 @@ describe('SceneWorker', () => { beforeEach(() => { jest.clearAllMocks(); mockGetAvailablePort.mockResolvedValue(9230); + mockRpcStartup.mockReturnValue({ + dispose: mockProviderRegistrationDispose, + }); + mockListenModuleMessages.mockResolvedValue(undefined); }); it('returns true when stop gets EPIPE before exit during manual shutdown', async () => { @@ -96,4 +101,19 @@ describe('SceneWorker', () => { expect(mockRpcStartup).toHaveBeenCalledWith(process); expect(mockListenModuleMessages).toHaveBeenCalledTimes(1); }); + + it('releases its Provider registration when the Worker exits', async () => { + const worker = new SceneWorker(); + const process = new MockChildProcess(); + mockFork.mockReturnValue(process); + + const startPromise = worker.start('/engine', '/project'); + await Promise.resolve(); + process.emit('message', SceneReadyChannel); + await expect(startPromise).resolves.toBe(true); + + process.emit('exit', 0, null); + + expect(mockProviderRegistrationDispose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/scene/scene.ts b/src/lib/scene/scene.ts index 338843cd3..a7903ce61 100644 --- a/src/lib/scene/scene.ts +++ b/src/lib/scene/scene.ts @@ -1,5 +1,17 @@ import { init as sceneInit } from '../../core/scene'; import { GlobalPaths } from '../../global'; +import { Rpc } from '../../core/scene/main-process/rpc'; +import type { + ISceneCommandProvider, + SceneCommandProviderRegistration, +} from '../../core/scene/main-process/rpc'; + +export type { + ISceneCommandProvider, + SceneCommandProviderRegistration, + SceneCommandRequestOptions, +} from '../../core/scene/main-process/rpc'; +export { WorkerSceneCommandProvider } from '../../core/scene/main-process/rpc'; /** * Initialize the scene module. @@ -18,3 +30,15 @@ export async function startupWorker(projectPath: string): Promise { const { sceneWorker } = await import('../../core/scene/main-process/scene-worker'); await sceneWorker.start(GlobalPaths.enginePath, projectPath); } + +/** Installs a Scene command provider and returns an ownership-bound registration. */ +export function setCommandProvider( + provider: ISceneCommandProvider, +): SceneCommandProviderRegistration { + return Rpc.setCommandProvider(provider); +} + +/** Clears and disposes the active Scene command provider. */ +export function resetCommandProvider(): void { + Rpc.resetCommandProvider(); +} diff --git a/tests/lib-scene-command-provider.test.ts b/tests/lib-scene-command-provider.test.ts new file mode 100644 index 000000000..63d7724d0 --- /dev/null +++ b/tests/lib-scene-command-provider.test.ts @@ -0,0 +1,55 @@ +const mockSetCommandProvider = jest.fn(); +const mockResetCommandProvider = jest.fn(); + +jest.mock('../src/core/scene', () => ({ + init: jest.fn(), +})); + +jest.mock('../src/core/scene/main-process/rpc', () => ({ + Rpc: { + setCommandProvider: (provider: unknown) => mockSetCommandProvider(provider), + resetCommandProvider: () => mockResetCommandProvider(), + }, + WorkerSceneCommandProvider: class WorkerSceneCommandProvider {}, +})); + +jest.mock('../src/global', () => ({ + GlobalPaths: { + enginePath: '/engine', + }, +})); + +import type { ISceneCommandProvider } from '../src/lib/scene/scene'; +import { + resetCommandProvider, + setCommandProvider, + WorkerSceneCommandProvider, +} from '../src/lib/scene/scene'; + +describe('Scene library command Provider entry', () => { + beforeEach(() => { + mockSetCommandProvider.mockReset(); + mockResetCommandProvider.mockReset(); + }); + + it('forwards the typed Provider and returns its identity-bound registration', () => { + const provider: ISceneCommandProvider = { + request: jest.fn(), + }; + const registration = { dispose: jest.fn() }; + mockSetCommandProvider.mockReturnValue(registration); + + expect(setCommandProvider(provider)).toBe(registration); + expect(mockSetCommandProvider).toHaveBeenCalledWith(provider); + }); + + it('forwards reset to the core Rpc owner', () => { + resetCommandProvider(); + + expect(mockResetCommandProvider).toHaveBeenCalledTimes(1); + }); + + it('exports the standalone Worker Provider', () => { + expect(typeof WorkerSceneCommandProvider).toBe('function'); + }); +}); diff --git a/workflow/generate-dts.ts b/workflow/generate-dts.ts index d0b904108..19c249ce7 100644 --- a/workflow/generate-dts.ts +++ b/workflow/generate-dts.ts @@ -161,6 +161,10 @@ const entries: IDtsEntry[] = [ name: 'project', source: 'src/lib/project/project.ts', output: 'project.d.ts' + }, { + name: 'scene', + source: 'src/lib/scene/scene.ts', + output: 'scene.d.ts' }, { name: 'scripting', source: 'src/lib/scripting/scripting.ts', From 43f4b30f24f024eb1f1230ec884f60295acfde68 Mon Sep 17 00:00:00 2001 From: zhouzz <275028888@qq.com> Date: Mon, 24 Aug 2026 17:17:06 +0800 Subject: [PATCH 2/3] feat(mcp): expose generic tool-call context hooks --- .github/workflows/check-dts.yml | 8 +- .../__snapshots__/dts-snapshot.test.ts.snap | 33 ++- .../__tests__/dts-snapshot.test.ts | 1 + src/lib/mcp/mcp.ts | 9 + src/mcp/mcp.middleware.ts | 45 +++- src/mcp/tool-call-context.ts | 157 ++++++++++++ .../mcp-middleware-tool-call-context.test.ts | 223 ++++++++++++++++++ tests/mcp-tool-call-context.test.ts | 208 ++++++++++++++++ workflow/generate-dts.ts | 4 + 9 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 src/mcp/tool-call-context.ts create mode 100644 tests/mcp-middleware-tool-call-context.test.ts create mode 100644 tests/mcp-tool-call-context.test.ts diff --git a/.github/workflows/check-dts.yml b/.github/workflows/check-dts.yml index 65eed1dbd..c9a5bff07 100644 --- a/.github/workflows/check-dts.yml +++ b/.github/workflows/check-dts.yml @@ -71,15 +71,15 @@ jobs: local diff_output="$1" local noise_filter='grep -v "^exports\[" | grep -v "^$" | grep -v "^[[:space:]]*$" | grep -v "^\`;" | grep -v "^[[:space:]]*}[;,]\?$" | grep -v "^export { }$" | grep -v "^[[:space:]]*static readonly version" | grep -v "^export { .*_[0-9]\+ as "' - # Extract deleted lines (strip leading -, normalize quotes and Object casing) + # Extract deleted lines (strip leading -, normalize non-semantic syntax differences) local deleted deleted=$(echo "$diff_output" | grep '^-' | grep -v '^---' | sed 's/^-//' \ - | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g" | sort -u || true) + | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g; s/,[[:space:]]*$//" | sort -u || true) - # Extract added lines (strip leading +, normalize quotes and Object casing) + # Extract added lines (strip leading +, normalize non-semantic syntax differences) local added added=$(echo "$diff_output" | grep '^+' | grep -v '^+++' | sed 's/^+//' \ - | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g" | sort -u || true) + | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g; s/,[[:space:]]*$//" | sort -u || true) # Lines in deleted but NOT in added = truly removed if [ -n "$deleted" ]; then diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 7ad3feb06..63380237f 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -8168,6 +8168,7 @@ export declare function get(key: string, scope?: ConfigurationScope): Promise export declare function get_2(): Promise; export declare function getConfig(useDefault?: boolean): Promise; export declare function getConfigPath(scope?: ConfigurationScope): Promise; +export declare function getCurrentToolCallContext(): Readonly | undefined; export declare function getInfo(): Promise; export declare function getInfo_2(): Promise; export declare function getMetadata(): Promise; @@ -8882,9 +8883,19 @@ export declare namespace Mcp { export { register, unregister, - getStatus + getStatus, + McpToolCallContext, + McpToolCallLifecycleState, + getCurrentToolCallContext, + registerToolCallFinalizer } } +export declare interface McpToolCallContext { + readonly operationId: string; + readonly lifecycleState: McpToolCallLifecycleState; + getHeader(name: string): string | undefined; +} +export declare type McpToolCallLifecycleState = 'running' | 'completing' | 'completed'; export declare interface MeshClusterOptions { enable: boolean; coneCluster?: boolean; @@ -9140,6 +9151,7 @@ export declare function queryUUID(urlOrPath: string): Promise; export declare function refresh(dir: string): Promise; export declare function register(): Promise; export declare function register_2(name: string, module: IMiddlewareContribution): Promise; +export declare function registerToolCallFinalizer(key: string | symbol, callback: () => void | Promise): void; export declare function reimportAsset(pathOrUrlOrUUID: string): Promise; export declare function reload(): Promise; export declare function remove(key: string, scope?: ConfigurationScope): Promise; @@ -9369,6 +9381,25 @@ export { } " `; +exports[`DTS API compatibility mcp.d.ts should match snapshot 1`] = ` +"export declare function getCurrentToolCallContext(): Readonly | undefined; +export declare function getStatus(): { + registered: boolean; + url?: string; +}; +export declare interface McpToolCallContext { + readonly operationId: string; + readonly lifecycleState: McpToolCallLifecycleState; + getHeader(name: string): string | undefined; +} +export declare type McpToolCallLifecycleState = 'running' | 'completing' | 'completed'; +export declare function register(): Promise; +export declare function registerToolCallFinalizer(key: string | symbol, callback: () => void | Promise): void; +export declare function unregister(): Promise; +export { } +" +`; + exports[`DTS API compatibility project.d.ts should match snapshot 1`] = ` "export declare function close_2(): Promise; export { close_2 as close } diff --git a/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts b/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts index 3f6da8469..dafbc5aeb 100644 --- a/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts +++ b/packages/cocos-cli-types/__tests__/dts-snapshot.test.ts @@ -11,6 +11,7 @@ const dtsFiles = [ 'cli.d.ts', 'configuration.d.ts', 'engine.d.ts', + 'mcp.d.ts', 'project.d.ts', 'scene.d.ts', 'scripting.d.ts', diff --git a/src/lib/mcp/mcp.ts b/src/lib/mcp/mcp.ts index 8c59192cf..12af09a5c 100644 --- a/src/lib/mcp/mcp.ts +++ b/src/lib/mcp/mcp.ts @@ -8,6 +8,15 @@ * and registering MCP routes on the running server. */ +export type { + McpToolCallContext, + McpToolCallLifecycleState, +} from '../../mcp/tool-call-context'; +export { + getCurrentToolCallContext, + registerToolCallFinalizer, +} from '../../mcp/tool-call-context'; + let mcpUrl: string | undefined; let registeringPromise: Promise | undefined; diff --git a/src/mcp/mcp.middleware.ts b/src/mcp/mcp.middleware.ts index d06340ba7..2d9e7df6e 100644 --- a/src/mcp/mcp.middleware.ts +++ b/src/mcp/mcp.middleware.ts @@ -14,11 +14,40 @@ import stripAnsi from 'strip-ansi'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { assetManager } from '../core/assets'; +import { + completeMcpToolCallContext, + runWithMcpToolCallContext, +} from './tool-call-context'; +import type { McpRequestHeaders } from './tool-call-context'; export function isToolErrorCode(code: unknown): boolean { return typeof code === 'number' && code >= 500 && code < 600; } +interface McpToolHandlerExtra { + requestInfo?: { + headers?: McpRequestHeaders; + }; +} + +function withMcpToolCallContext( + callback: (args: any) => Promise, +): (args: any, extra: McpToolHandlerExtra) => Promise { + return async (args, extra) => runWithMcpToolCallContext(extra.requestInfo?.headers, async () => { + try { + return await callback(args); + } finally { + try { + await completeMcpToolCallContext(); + } catch { + // Do not log finalization error details or request metadata, which may contain + // sensitive information. A finalization failure must not replace the tool result. + console.error('[MCP] Tool-call finalization failed.'); + } + } + }); +} + export class McpMiddleware { private server: McpServer; private resourceManager: ResourceManager; @@ -146,7 +175,7 @@ export class McpMiddleware { toolName, meta.description || `Tool: ${toolName}`, inputSchemaFields, - async (args) => { + withMcpToolCallContext(async (args) => { // args 已经是验证过的参数对象 (对于 builder-build.options 是 any) try { this.builderHook.onBeforeExecute(toolName, args); @@ -154,9 +183,9 @@ export class McpMiddleware { // 注意:args 是对象,prepareMethodArguments 需要处理对象 const methodArgs = this.prepareMethodArguments(meta, args, toolName); const result = await this.callToolMethod(target, meta, methodArgs); - + const formattedResult = this.formatToolResult(meta, result); - + let structuredContent: any; if (meta.returnSchema) { try { @@ -178,21 +207,21 @@ export class McpMiddleware { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; - + let detailedReason = `Tool execution failed (${toolName}): ${errorMessage}`; if (errorStack && process.env.NODE_ENV === 'development') { detailedReason += `\n\nStack trace:\n${errorStack}`; } detailedReason += `\n\nParameters passed:\n${JSON.stringify(args, null, 2)}`; - + console.error(`[MCP] ${detailedReason}`); - + const errorResult: { code: HttpStatusCode; data?: any; reason?: string } = { code: HTTP_STATUS.INTERNAL_SERVER_ERROR, data: undefined, reason: detailedReason, }; - + const formattedResult = JSON.stringify({ result: errorResult }, null, 2); return { content: [{ type: 'text' as const, text: formattedResult }], @@ -200,7 +229,7 @@ export class McpMiddleware { isError: true }; } - } + }) ); } catch (error) { console.error(`Failed to register tool ${toolName}:`, error); diff --git a/src/mcp/tool-call-context.ts b/src/mcp/tool-call-context.ts new file mode 100644 index 000000000..5487400bc --- /dev/null +++ b/src/mcp/tool-call-context.ts @@ -0,0 +1,157 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { randomUUID } from 'crypto'; + +export type McpToolCallLifecycleState = 'running' | 'completing' | 'completed'; + +export interface McpToolCallContext { + readonly operationId: string; + readonly lifecycleState: McpToolCallLifecycleState; + getHeader(name: string): string | undefined; +} + +export type McpRequestHeaders = Readonly>; + +type ToolCallFinalizer = () => void | Promise; + +interface MutableMcpToolCallContext { + publicContext: Readonly; + lifecycleState: McpToolCallLifecycleState; + readonly finalizers: Map; + completionPromise?: Promise; +} + +const toolCallContextStorage = new AsyncLocalStorage(); + +/** Returns the MCP tool-call context for the current asynchronous scope. */ +export function getCurrentToolCallContext(): Readonly | undefined { + return toolCallContextStorage.getStore()?.publicContext; +} + +/** Returns the current MCP tool-call context, or throws when called outside a tool handler. */ +export function requireMcpToolCallContext(): Readonly { + const context = getCurrentToolCallContext(); + if (!context) { + throw new Error('MCP tool-call context is unavailable outside a tool handler'); + } + return context; +} + +/** Runs a complete MCP tool call in an isolated asynchronous context. */ +export function runWithMcpToolCallContext( + headers: McpRequestHeaders | undefined, + callback: () => T, +): T { + const headerSnapshot = snapshotHeaders(headers); + const mutableContext = { + lifecycleState: 'running' as McpToolCallLifecycleState, + finalizers: new Map(), + } as MutableMcpToolCallContext; + const publicContext: Readonly = Object.freeze({ + operationId: randomUUID(), + get lifecycleState() { + return mutableContext.lifecycleState; + }, + getHeader(name: string): string | undefined { + return headerSnapshot.get(name.toLowerCase()); + }, + }); + mutableContext.publicContext = publicContext; + + return toolCallContextStorage.run(mutableContext, callback); +} + +/** + * Registers a finalizer for the current MCP tool call. Only the first callback for each key is + * retained, allowing callers to register the same cleanup safely more than once per tool call. + */ +export function registerToolCallFinalizer( + key: string | symbol, + callback: () => void | Promise, +): void { + const context = requireMutableContext(); + if (context.lifecycleState !== 'running') { + throw new Error(`MCP tool-call context is ${context.lifecycleState}`); + } + if (typeof key !== 'string' && typeof key !== 'symbol') { + throw new TypeError('MCP tool-call finalizer key must be a string or symbol'); + } + if (typeof callback !== 'function') { + throw new TypeError('MCP tool-call finalizer must be a function'); + } + + if (!context.finalizers.has(key)) { + context.finalizers.set(key, callback); + } +} + +/** + * Runs all finalizers for the current MCP tool call in registration order. A failure does not + * prevent later finalizers from running, and the context always transitions to `completed`. + */ +export function completeMcpToolCallContext(): Promise { + const context = requireMutableContext(); + if (context.completionPromise) { + return context.completionPromise; + } + if (context.lifecycleState === 'completed') { + return Promise.resolve(); + } + + context.lifecycleState = 'completing'; + const finalizers = [...context.finalizers.values()]; + context.finalizers.clear(); + + // Defer execution to a microtask so concurrent completion calls can reuse the same promise. + context.completionPromise = Promise.resolve().then(async () => { + const errors: unknown[] = []; + try { + for (const finalizer of finalizers) { + try { + await finalizer(); + } catch (error) { + errors.push(error); + } + } + + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Multiple MCP tool-call finalizers failed'); + } + } finally { + context.lifecycleState = 'completed'; + } + }); + + return context.completionPromise; +} + +function requireMutableContext(): MutableMcpToolCallContext { + const context = toolCallContextStorage.getStore(); + if (!context) { + throw new Error('MCP tool-call context is unavailable outside a tool handler'); + } + return context; +} + +function snapshotHeaders(headers: McpRequestHeaders | undefined): ReadonlyMap { + const snapshot = new Map(); + if (!headers) { + return snapshot; + } + + for (const [name, value] of Object.entries(headers)) { + const normalizedName = name.toLowerCase(); + if (snapshot.has(normalizedName)) { + continue; + } + + const firstValue = typeof value === 'string' ? value : value?.[0]; + if (firstValue !== undefined) { + snapshot.set(normalizedName, firstValue); + } + } + + return snapshot; +} diff --git a/tests/mcp-middleware-tool-call-context.test.ts b/tests/mcp-middleware-tool-call-context.test.ts new file mode 100644 index 000000000..2631f625f --- /dev/null +++ b/tests/mcp-middleware-tool-call-context.test.ts @@ -0,0 +1,223 @@ +const mockRegisteredTools = new Map Promise>(); +const mockToolExecution = jest.fn(); +const mockToolRegistry = new Map([ + ['context-probe', { + target: { + execute: (...args: unknown[]) => mockToolExecution(...args), + }, + meta: { + toolName: 'context-probe', + description: 'Context probe', + paramSchemas: [], + methodName: 'execute', + }, + }], +]); + +jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ + McpServer: jest.fn().mockImplementation(() => ({ + tool: jest.fn((name: string, _description: string, _schema: unknown, callback: any) => { + mockRegisteredTools.set(name, callback); + }), + resource: jest.fn(), + connect: jest.fn(), + server: { + setRequestHandler: jest.fn(), + }, + })), + ResourceTemplate: jest.fn(), +})); + +jest.mock('../src/api/decorator/decorator', () => ({ + toolRegistry: mockToolRegistry, +})); + +jest.mock('../src/mcp/resources', () => ({ + ResourceManager: jest.fn().mockImplementation(() => ({ + loadAllResources: jest.fn(() => []), + })), +})); + +jest.mock('../src/mcp/hooks/builder.hook', () => ({ + BuilderHook: jest.fn().mockImplementation(() => ({ + onBeforeExecute: jest.fn(), + onRegisterParam: jest.fn(), + onValidationFailed: jest.fn(), + })), +})); + +jest.mock('../src/core/assets', () => ({ + assetManager: { + queryAssetInfos: jest.fn(() => []), + }, +})); + +import { McpMiddleware } from '../src/mcp/mcp.middleware'; +import { + getCurrentToolCallContext, + registerToolCallFinalizer, + requireMcpToolCallContext, +} from '../src/mcp/tool-call-context'; + +function requestExtra(headers?: Record): any { + return { + requestInfo: headers ? { headers } : undefined, + requestId: 1, + signal: new AbortController().signal, + sendNotification: jest.fn(), + sendRequest: jest.fn(), + }; +} + +describe('McpMiddleware tool-call context integration', () => { + let debugSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + mockRegisteredTools.clear(); + mockToolExecution.mockReset(); + debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + new McpMiddleware(); + }); + + afterEach(() => { + debugSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('covers the target method async lifecycle without logging request headers', async () => { + const headerSecret = 'secret-opaque-value'; + const snapshots: ReturnType[] = []; + mockToolExecution.mockImplementation(async () => { + snapshots.push(requireMcpToolCallContext()); + await Promise.resolve(); + snapshots.push(requireMcpToolCallContext()); + return { code: 200 }; + }); + + const handler = mockRegisteredTools.get('context-probe'); + expect(handler).toBeDefined(); + await handler!({}, requestExtra({ 'x-HOST-ROUTE': headerSecret })); + + expect(snapshots).toHaveLength(2); + expect(snapshots[0]).toBe(snapshots[1]); + expect(snapshots[0].getHeader('X-host-route')).toBe(headerSecret); + expect(snapshots[0].lifecycleState).toBe('completed'); + expect(snapshots[0]).not.toHaveProperty('origin'); + expect(snapshots[0]).not.toHaveProperty('routeToken'); + expect(getCurrentToolCallContext()).toBeUndefined(); + + const logOutput = JSON.stringify([ + ...debugSpy.mock.calls, + ...errorSpy.mock.calls, + ]); + expect(logOutput).not.toContain(headerSecret); + }); + + it('creates a context when the SDK request has no headers', async () => { + let observedContext: ReturnType | undefined; + mockToolExecution.mockImplementation(async () => { + observedContext = requireMcpToolCallContext(); + return { code: 200 }; + }); + + const handler = mockRegisteredTools.get('context-probe'); + await handler!({}, requestExtra()); + + expect(observedContext?.getHeader('x-host-route')).toBeUndefined(); + expect(observedContext?.lifecycleState).toBe('completed'); + }); + + it('awaits one keyed finalizer after a successful tool call', async () => { + let notifyFinalizerStarted!: () => void; + let releaseFinalizer!: () => void; + const finalizerStarted = new Promise(resolve => { + notifyFinalizerStarted = resolve; + }); + const finalizerGate = new Promise(resolve => { + releaseFinalizer = resolve; + }); + const firstFinalizer = jest.fn(async () => { + notifyFinalizerStarted(); + await finalizerGate; + }); + const ignoredFinalizer = jest.fn(); + let observedContext: ReturnType | undefined; + mockToolExecution.mockImplementation(async () => { + observedContext = requireMcpToolCallContext(); + registerToolCallFinalizer('host-operation', firstFinalizer); + await Promise.resolve(); + registerToolCallFinalizer('host-operation', ignoredFinalizer); + return { code: 200, data: 'ok' }; + }); + + const handler = mockRegisteredTools.get('context-probe'); + const handlerPromise = handler!({}, requestExtra()) as Promise<{ isError: boolean }>; + let handlerSettled = false; + void handlerPromise.finally(() => { + handlerSettled = true; + }); + + await finalizerStarted; + await Promise.resolve(); + expect(handlerSettled).toBe(false); + expect(observedContext?.lifecycleState).toBe('completing'); + releaseFinalizer(); + const result = await handlerPromise; + + expect(result.isError).toBe(false); + expect(firstFinalizer).toHaveBeenCalledTimes(1); + expect(ignoredFinalizer).not.toHaveBeenCalled(); + expect(observedContext?.lifecycleState).toBe('completed'); + }); + + it('preserves the tool error when finalization also fails and logs no failure detail', async () => { + const headerSecret = 'secret-route-value'; + const finalizerSecret = 'private-finalizer-failure'; + const finalizer = jest.fn(async () => { + throw new Error(`${finalizerSecret}: ${headerSecret}`); + }); + mockToolExecution.mockImplementation(async () => { + registerToolCallFinalizer('host-operation', finalizer); + throw new Error('original-tool-failure'); + }); + + const handler = mockRegisteredTools.get('context-probe'); + const result = await handler!({}, requestExtra({ + 'X-Host-Route': headerSecret, + })) as { + isError: boolean; + structuredContent: { result: { reason: string } }; + }; + + expect(result.isError).toBe(true); + expect(result.structuredContent.result.reason).toContain('original-tool-failure'); + expect(result.structuredContent.result.reason).not.toContain(finalizerSecret); + expect(finalizer).toHaveBeenCalledTimes(1); + + const logOutput = JSON.stringify(errorSpy.mock.calls); + expect(logOutput).toContain('Tool-call finalization failed'); + expect(logOutput).not.toContain(finalizerSecret); + expect(logOutput).not.toContain(headerSecret); + }); + + it('preserves a successful result when finalization fails', async () => { + mockToolExecution.mockImplementation(async () => { + registerToolCallFinalizer('host-operation', async () => { + throw new Error('private-finalizer-failure'); + }); + return { code: 200, data: 'ok' }; + }); + + const handler = mockRegisteredTools.get('context-probe'); + const result = await handler!({}, requestExtra()) as { + isError: boolean; + structuredContent: { result: { data: string } }; + }; + + expect(result.isError).toBe(false); + expect(result.structuredContent.result.data).toBe('ok'); + expect(errorSpy).toHaveBeenCalledWith('[MCP] Tool-call finalization failed.'); + }); +}); diff --git a/tests/mcp-tool-call-context.test.ts b/tests/mcp-tool-call-context.test.ts new file mode 100644 index 000000000..0db10bef4 --- /dev/null +++ b/tests/mcp-tool-call-context.test.ts @@ -0,0 +1,208 @@ +import { + getCurrentToolCallContext, + registerToolCallFinalizer, +} from '../src/lib/mcp/mcp'; +import { + completeMcpToolCallContext, + requireMcpToolCallContext, + runWithMcpToolCallContext, +} from '../src/mcp/tool-call-context'; + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('MCP tool-call context', () => { + it('exposes a frozen context and a case-insensitive header snapshot across awaits', async () => { + const repeatedHeader = ['first-value', 'second-value']; + const headers: Record = { + 'X-Host-Route': repeatedHeader, + }; + + const result = await runWithMcpToolCallContext(headers, async () => { + const beforeAwait = requireMcpToolCallContext(); + headers['X-Host-Route'] = 'changed-value'; + repeatedHeader[0] = 'changed-array-value'; + await Promise.resolve(); + const afterAwait = getCurrentToolCallContext(); + + return { beforeAwait, afterAwait }; + }); + + expect(result.beforeAwait).toBe(result.afterAwait); + expect(result.beforeAwait.getHeader('x-HOST-route')).toBe('first-value'); + expect(result.beforeAwait.getHeader('missing-header')).toBeUndefined(); + expect(result.beforeAwait.lifecycleState).toBe('running'); + expect(result.beforeAwait.operationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(Object.isFrozen(result.beforeAwait)).toBe(true); + expect(getCurrentToolCallContext()).toBeUndefined(); + }); + + it('supports calls without request headers', () => { + const context = runWithMcpToolCallContext(undefined, () => requireMcpToolCallContext()); + + expect(context.getHeader('x-host-route')).toBeUndefined(); + }); + + it('isolates concurrent calls and assigns a unique operation id to each one', async () => { + const firstEntered = deferred(); + const secondEntered = deferred(); + const releaseFirst = deferred(); + const releaseSecond = deferred(); + + const firstCall = runWithMcpToolCallContext({ + 'x-operation-route': 'first-route', + }, async () => { + const beforeAwait = requireMcpToolCallContext(); + firstEntered.resolve(); + await releaseFirst.promise; + return { beforeAwait, afterAwait: requireMcpToolCallContext() }; + }); + const secondCall = runWithMcpToolCallContext({ + 'X-OPERATION-ROUTE': 'second-route', + }, async () => { + const beforeAwait = requireMcpToolCallContext(); + secondEntered.resolve(); + await releaseSecond.promise; + return { beforeAwait, afterAwait: requireMcpToolCallContext() }; + }); + + await Promise.all([firstEntered.promise, secondEntered.promise]); + expect(getCurrentToolCallContext()).toBeUndefined(); + + releaseSecond.resolve(); + releaseFirst.resolve(); + const [first, second] = await Promise.all([firstCall, secondCall]); + + expect(first.beforeAwait).toBe(first.afterAwait); + expect(second.beforeAwait).toBe(second.afterAwait); + expect(first.beforeAwait.getHeader('x-operation-route')).toBe('first-route'); + expect(second.beforeAwait.getHeader('x-operation-route')).toBe('second-route'); + expect(first.beforeAwait.operationId).not.toBe(second.beforeAwait.operationId); + }); + + it('fails explicitly when context-only APIs are used outside a tool call', () => { + expect(getCurrentToolCallContext()).toBeUndefined(); + expect(() => requireMcpToolCallContext()).toThrow( + 'MCP tool-call context is unavailable outside a tool handler', + ); + expect(() => registerToolCallFinalizer('cleanup', jest.fn())).toThrow( + 'MCP tool-call context is unavailable outside a tool handler', + ); + }); + + it('keeps the first finalizer for a key and runs distinct keys in registration order', async () => { + const calls: string[] = []; + const sharedKey = 'shared-cleanup'; + const symbolKey = Symbol('second-cleanup'); + const firstFinalizer = jest.fn(async () => { + calls.push('first'); + }); + const ignoredFinalizer = jest.fn(async () => { + calls.push('ignored'); + }); + const secondFinalizer = jest.fn(() => { + calls.push('second'); + }); + let context!: ReturnType; + + await runWithMcpToolCallContext(undefined, async () => { + context = requireMcpToolCallContext(); + registerToolCallFinalizer(sharedKey, firstFinalizer); + registerToolCallFinalizer(sharedKey, ignoredFinalizer); + registerToolCallFinalizer(symbolKey, secondFinalizer); + await completeMcpToolCallContext(); + await completeMcpToolCallContext(); + }); + + expect(calls).toEqual(['first', 'second']); + expect(firstFinalizer).toHaveBeenCalledTimes(1); + expect(ignoredFinalizer).not.toHaveBeenCalled(); + expect(secondFinalizer).toHaveBeenCalledTimes(1); + expect(context.lifecycleState).toBe('completed'); + }); + + it('shares one completion and rejects registration once completion starts', async () => { + const completionEntered = deferred(); + const releaseCompletion = deferred(); + let context!: ReturnType; + + await runWithMcpToolCallContext(undefined, async () => { + context = requireMcpToolCallContext(); + registerToolCallFinalizer('cleanup', async () => { + completionEntered.resolve(); + await releaseCompletion.promise; + }); + + const firstCompletion = completeMcpToolCallContext(); + const secondCompletion = completeMcpToolCallContext(); + expect(secondCompletion).toBe(firstCompletion); + expect(context.lifecycleState).toBe('completing'); + expect(() => registerToolCallFinalizer('late-cleanup', jest.fn())).toThrow( + 'MCP tool-call context is completing', + ); + + await completionEntered.promise; + releaseCompletion.resolve(); + await firstCompletion; + expect(context.lifecycleState).toBe('completed'); + expect(() => registerToolCallFinalizer('later-cleanup', jest.fn())).toThrow( + 'MCP tool-call context is completed', + ); + }); + }); + + it('runs every finalizer and aggregates multiple failures', async () => { + const firstFailure = new Error('first private failure'); + const secondFailure = new Error('second private failure'); + const middleFinalizer = jest.fn(); + let context!: ReturnType; + + await runWithMcpToolCallContext(undefined, async () => { + context = requireMcpToolCallContext(); + registerToolCallFinalizer('first', async () => { + throw firstFailure; + }); + registerToolCallFinalizer('middle', middleFinalizer); + registerToolCallFinalizer('second', async () => { + throw secondFailure; + }); + + const completion = completeMcpToolCallContext(); + await expect(completion).rejects.toMatchObject({ + name: 'AggregateError', + errors: [firstFailure, secondFailure], + }); + await expect(completeMcpToolCallContext()).rejects.toMatchObject({ + name: 'AggregateError', + errors: [firstFailure, secondFailure], + }); + }); + + expect(middleFinalizer).toHaveBeenCalledTimes(1); + expect(context.lifecycleState).toBe('completed'); + }); + + it('rethrows one finalizer failure without wrapping it', async () => { + const failure = new Error('private failure'); + + await runWithMcpToolCallContext(undefined, async () => { + registerToolCallFinalizer('cleanup', async () => { + throw failure; + }); + + await expect(completeMcpToolCallContext()).rejects.toBe(failure); + }); + }); +}); diff --git a/workflow/generate-dts.ts b/workflow/generate-dts.ts index 19c249ce7..3196d313b 100644 --- a/workflow/generate-dts.ts +++ b/workflow/generate-dts.ts @@ -157,6 +157,10 @@ const entries: IDtsEntry[] = [ name: 'engine', source: 'src/lib/engine/engine.ts', output: 'engine.d.ts' + }, { + name: 'mcp', + source: 'src/lib/mcp/mcp.ts', + output: 'mcp.d.ts' }, { name: 'project', source: 'src/lib/project/project.ts', From 734e29bf63f7cdec2cd784d9aeda485594d5ff4a Mon Sep 17 00:00:00 2001 From: zhouzz <275028888@qq.com> Date: Thu, 20 Aug 2026 14:23:12 +0800 Subject: [PATCH 3/3] fix(scene): create scene assets through Asset Manager --- src/api/scene/scene.ts | 25 +++-- tests/scene-create-asset-backend.test.ts | 112 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 tests/scene-create-asset-backend.test.ts diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index 94538dabb..24940d36a 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -18,7 +18,8 @@ import { } from './schema'; import { description, param, result, title, tool } from '../decorator/decorator.js'; import { COMMON_STATUS, CommonResultType, getCommonErrorStatus } from '../base/schema-base'; -import { Scene, TSceneTemplateType } from '../../core/scene'; +import { Scene } from '../../core/scene'; +import { assetManager } from '../../core/assets'; import { ComponentApi } from './component'; import { NodeApi } from './node'; import { PrefabApi } from './prefab'; @@ -129,16 +130,22 @@ export class SceneApi { @result(SchemaCreateResult) async createScene(@param(SchemaCreateOptions) options: TCreateOptions): Promise> { try { - const data = await Scene.create({ - type: 'scene', - baseName: options.baseName, - targetDirectory: options.dbURL, - templateType: options.templateType as TSceneTemplateType, - }); - + const assetInfo = await assetManager.createAssetByType( + 'scene', + options.dbURL, + options.baseName, + { templateName: options.templateType ?? '2d' }, + ); + const data: TCreateResult = { + assetName: assetInfo.name, + assetUuid: assetInfo.uuid, + assetUrl: assetInfo.url, + assetType: assetInfo.type, + }; + return { code: COMMON_STATUS.SUCCESS, - data: data as TCreateResult, + data, }; } catch (e) { console.error(e); diff --git a/tests/scene-create-asset-backend.test.ts b/tests/scene-create-asset-backend.test.ts new file mode 100644 index 000000000..6f02ef5fb --- /dev/null +++ b/tests/scene-create-asset-backend.test.ts @@ -0,0 +1,112 @@ +const mockCreateAssetByType = jest.fn(); +const mockSceneCreate = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/assets', () => ({ + assetManager: { + createAssetByType: (...args: unknown[]) => mockCreateAssetByType(...args), + }, +})); + +jest.mock('../src/core/scene', () => ({ + NodeType: { + EMPTY: 'Node', + SPRITE: 'Sprite', + }, + SCENE_TEMPLATE_TYPE: ['2d', '3d'], + Scene: { + create: (...args: unknown[]) => mockSceneCreate(...args), + }, +})); + +import { SceneApi } from '../src/api/scene/scene'; +import { COMMON_STATUS } from '../src/api/base/schema-base'; + +describe('scene-create AssetManager backend', () => { + beforeEach(() => { + mockCreateAssetByType.mockReset(); + mockSceneCreate.mockReset(); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('creates the scene through AssetManager and maps the legacy identifier result', async () => { + mockCreateAssetByType.mockResolvedValue({ + name: 'Main.scene', + uuid: 'scene-uuid', + url: 'db://assets/scenes/Main.scene', + type: 'cc.SceneAsset', + }); + + const result = await new SceneApi().createScene({ + baseName: 'Main', + dbURL: 'db://assets/scenes', + templateType: '3d', + }); + + expect(mockCreateAssetByType).toHaveBeenCalledWith( + 'scene', + 'db://assets/scenes', + 'Main', + { templateName: '3d' }, + ); + expect(result).toEqual({ + code: COMMON_STATUS.SUCCESS, + data: { + assetName: 'Main.scene', + assetUuid: 'scene-uuid', + assetUrl: 'db://assets/scenes/Main.scene', + assetType: 'cc.SceneAsset', + }, + }); + expect(mockSceneCreate).not.toHaveBeenCalled(); + }); + + it('uses the 2d template by default and still performs zero Scene RPCs', async () => { + mockCreateAssetByType.mockResolvedValue({ + name: 'Default.scene', + uuid: 'default-scene-uuid', + url: 'db://assets/Default.scene', + type: 'cc.SceneAsset', + }); + + const result = await new SceneApi().createScene({ + baseName: 'Default', + dbURL: 'db://assets', + }); + + expect(result.code).toBe(COMMON_STATUS.SUCCESS); + expect(mockCreateAssetByType).toHaveBeenCalledWith( + 'scene', + 'db://assets', + 'Default', + { templateName: '2d' }, + ); + expect(mockSceneCreate).not.toHaveBeenCalled(); + }); + + it('returns the existing scene-create failure contract without falling back to Scene RPC', async () => { + mockCreateAssetByType.mockRejectedValue(new Error('asset creation failed')); + + const result = await new SceneApi().createScene({ + baseName: 'Broken', + dbURL: 'db://assets', + }); + + expect(result).toEqual({ + code: COMMON_STATUS.FAIL, + reason: 'asset creation failed', + }); + expect(mockSceneCreate).not.toHaveBeenCalled(); + }); +});