import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { buildEditorRpcRequest, COCOS_EDITOR_OPERATIONS, validateExecuteCode, } from './cocos-editor-adapter.mjs'; import { COCOS_CONNECTION_CAPABILITY_ID, COCOS_EDITOR_PANEL, COCOS_EXECUTE_COMMAND_ID, COCOS_OPERATION_COMMAND_ID, COCOS_PLUGIN_PROTOCOL_VERSION, createCocosEditorPlugin, PROJECT_CHANGED_EVENT, } from './entry.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const pluginRoot = path.join(here, '..'); const tick = () => new Promise((resolve) => setImmediate(resolve)); function createHarness(timeoutMs) { const outbound = []; const plugin = createCocosEditorPlugin({ timeoutMs, send: (message) => outbound.push(structuredClone(message)), }); const respond = (id, result) => plugin.handleMessage({ jsonrpc: '2.0', id, result }); return { plugin, outbound, respond }; } async function startPlugin(harness, projectPath = 'C:\\demo') { const started = harness.plugin.start(); await tick(); harness.respond(harness.outbound.at(-1).id, { registered: true }); await tick(); harness.respond(harness.outbound.at(-1).id, { registered: true }); await tick(); harness.respond(harness.outbound.at(-1).id, { registered: true }); await tick(); harness.respond(harness.outbound.at(-1).id, { registered: true }); await tick(); harness.respond(harness.outbound.at(-1).id, { subscriptionId: 'sub-1', projectPath, }); return started; } test('runtime entry registers command, capability, panel and project event', async () => { const harness = createHarness(); const result = await startPlugin(harness); const methods = harness.outbound.map((message) => message.method); assert.deepEqual(methods, [ 'host.registerCommand', 'host.registerCapability', 'host.registerCommand', 'host.registerPanel', 'host.events.subscribe', ]); assert.equal(harness.outbound[0].params.id, COCOS_EXECUTE_COMMAND_ID); assert.equal(harness.outbound[1].params.id, COCOS_CONNECTION_CAPABILITY_ID); assert.equal(harness.outbound[2].params.id, COCOS_OPERATION_COMMAND_ID); assert.deepEqual(harness.outbound[3].params, { ...COCOS_EDITOR_PANEL }); assert.equal(harness.outbound[4].params.type, PROJECT_CHANGED_EVENT); assert.equal(result.subscriptionId, 'sub-1'); assert.equal(harness.plugin.activeProjectPath, 'C:\\demo'); }); test('operation command validates and routes a named Cocos operation', async () => { const harness = createHarness(); await startPlugin(harness); const handling = harness.plugin.handleMessage({ jsonrpc: '2.0', id: 78, method: COCOS_OPERATION_COMMAND_ID, params: { operation: 'cocos_get_hierarchy', args: { maxNodes: 10 } }, }); await tick(); const rpc = harness.outbound.at(-1); assert.equal(rpc.method, 'host.rpc'); assert.equal(rpc.params.method, 'editor.execute'); assert.match(rpc.params.params.code, /cocos_get_hierarchy/); assert.equal(rpc.params.params.timeoutMs, 60_000); harness.respond(rpc.id, { ok: true }); await handling; assert.equal(harness.outbound.at(-1).result.status, 'completed'); }); test('semantic reconciliation from an operation blocks later execute', async () => { const harness = createHarness(); await startPlugin(harness); const handling = harness.plugin.handleMessage({ jsonrpc: '2.0', id: 178, method: COCOS_OPERATION_COMMAND_ID, params: { operation: 'cocos_get_hierarchy', args: {} }, }); await tick(); await harness.respond(harness.outbound.at(-1).id, { ok: true, result: { status: 'needs-reconciliation', rollbackError: 'restore failed' }, }); await handling; assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation'); const count = harness.outbound.length; await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 179, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 1;' }, }); assert.equal(harness.outbound.length, count + 1); assert.equal(harness.outbound.at(-1).result.retryAllowed, false); harness.plugin.dispose(); }); test('execute command routes through host.rpc with the active project', async () => { const harness = createHarness(); await startPlugin(harness); const handling = harness.plugin.handleMessage({ jsonrpc: '2.0', id: 77, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return Editor.Project.path;' }, }); await tick(); const rpc = harness.outbound.at(-1); assert.equal(rpc.method, 'host.rpc'); assert.deepEqual(rpc.params, { method: 'editor.execute', params: { projectPath: 'C:\\demo', code: 'return Editor.Project.path;', }, }); harness.respond(rpc.id, { ok: true, requestId: 'cocos-1' }); await handling; const reply = harness.outbound.at(-1); assert.equal(reply.id, 77); assert.equal(reply.result.status, 'completed'); }); test('execute command fails closed without a project or with invalid code', async () => { const harness = createHarness(); await startPlugin(harness, null); await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 1, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 1;' }, }); assert.match(harness.outbound.at(-1).error.message, /项目路径/); await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 2, method: COCOS_EXECUTE_COMMAND_ID, params: { code: ' ', projectPath: 'C:\\demo' }, }); assert.match(harness.outbound.at(-1).error.message, /不能为空/); }); test('connection capability dispatches adapter operations', async () => { const harness = createHarness(); await startPlugin(harness); const handling = harness.plugin.handleMessage({ jsonrpc: '2.0', id: 5, method: COCOS_CONNECTION_CAPABILITY_ID, params: { operation: 'ping', processId: 42 }, }); await tick(); const rpc = harness.outbound.at(-1); assert.deepEqual(rpc.params, { method: 'editor.ping', params: { processId: 42, projectPath: 'C:\\demo' }, }); harness.respond(rpc.id, { ok: true }); await handling; assert.equal(harness.outbound.at(-1).result.ok, true); await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 6, method: COCOS_CONNECTION_CAPABILITY_ID, params: { operation: 'eval' }, }); assert.match(harness.outbound.at(-1).error.message, /不支持的能力操作/); }); test('project.changed event updates the cached project path', async () => { const harness = createHarness(); await startPlugin(harness); await harness.plugin.handleMessage({ jsonrpc: '2.0', method: 'host.event', params: { type: PROJECT_CHANGED_EVENT, payload: { projectPath: 'D:\\other' }, }, }); assert.equal(harness.plugin.activeProjectPath, 'D:\\other'); await harness.plugin.handleMessage({ jsonrpc: '2.0', method: 'host.event', params: { type: PROJECT_CHANGED_EVENT, payload: { projectPath: null } }, }); assert.equal(harness.plugin.activeProjectPath, null); }); for (const [snapshotPath, projectPath] of [ [null, 'C:/New'], ['C:/Old', 'C:/New'], ['C:/Old', null], ]) { test(`迟到的订阅快照 ${snapshotPath} 不能覆盖新项目事件 ${projectPath}`, async (t) => { const requests = []; const plugin = createCocosEditorPlugin({ send(message) { requests.push(message); if (!message.method) return; queueMicrotask(async () => { if (message.method === 'host.events.subscribe') { await plugin.handleMessage({ jsonrpc: '2.0', method: 'host.event', params: { type: PROJECT_CHANGED_EVENT, payload: { projectPath }, }, }); } await plugin.handleMessage({ jsonrpc: '2.0', id: message.id, result: message.method === 'host.events.subscribe' ? { subscriptionId: 'sub-1', projectPath: snapshotPath } : { ok: true }, }); }); }, }); t.after(() => plugin.dispose()); await plugin.start(); assert.equal(plugin.activeProjectPath, projectPath); await plugin.handleMessage({ jsonrpc: '2.0', id: 500, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 2;' }, }); const rpc = requests.find((message) => message.method === 'host.rpc'); if (projectPath) { assert.equal(rpc.params.params.projectPath, projectPath); } else { assert.equal(rpc, undefined); assert.match(requests.at(-1).error.message, /项目路径/); } }); } test('execute rejects concurrent requests and blocks later requests after uncertainty', async () => { const harness = createHarness(); await startPlugin(harness); const first = harness.plugin.handleMessage({ jsonrpc: '2.0', id: 71, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 1;' }, }); const second = harness.plugin.handleMessage({ jsonrpc: '2.0', id: 72, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 2;' }, }); await tick(); const requests = harness.outbound.filter( (item) => item.method === 'host.rpc', ); assert.equal(requests.length, 1); await harness.respond(requests[0].id, { ok: false, status: 'needs-reconciliation', retryAllowed: false, }); await Promise.all([first, second]); await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 73, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 3;' }, }); assert.equal( harness.outbound.filter((item) => item.method === 'host.rpc').length, 1, ); assert.equal( harness.outbound.find((item) => item.id === 72 && item.result).result .retryAllowed, false, ); for (const id of [71, 73]) { const reply = harness.outbound.find( (item) => item.id === id && item.result, ); assert.equal(reply.result.status, 'needs-reconciliation'); assert.equal(reply.result.retryAllowed, false); } }); test('host RPC failure blocks later execute without resending', async () => { const harness = createHarness(); await startPlugin(harness); const first = harness.plugin.handleMessage({ jsonrpc: '2.0', id: 81, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 1;' }, }); await tick(); const rpc = harness.outbound.at(-1); await harness.plugin.handleMessage({ jsonrpc: '2.0', id: rpc.id, error: { message: 'connection closed' }, }); await first; await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 82, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 2;' }, }); assert.equal( harness.outbound.filter((item) => item.method === 'host.rpc').length, 1, ); assert.equal(harness.outbound.at(-1).result.retryAllowed, false); }); test('execute timeout keeps later requests blocked even after a late success', async () => { const harness = createHarness(100); await startPlugin(harness); await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 91, method: COCOS_EXECUTE_COMMAND_ID, params: { code: 'return 1;' }, }); const rpc = harness.outbound.find((item) => item.method === 'host.rpc'); assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation'); await harness.respond(rpc.id, { ok: true }); await harness.plugin.handleMessage({ jsonrpc: '2.0', id: 92, method: COCOS_CONNECTION_CAPABILITY_ID, params: { operation: 'execute', code: 'return 2;' }, }); assert.equal( harness.outbound.filter((item) => item.method === 'host.rpc').length, 1, ); assert.equal(harness.outbound.at(-1).result.retryAllowed, false); }); test('adapter request builder enforces per-operation parameters', () => { assert.deepEqual( buildEditorRpcRequest('status', { processId: 7, projectPath: 'C:\\demo', }), { method: 'editor.status', params: { processId: 7, projectPath: 'C:\\demo' }, }, ); assert.throws( () => buildEditorRpcRequest('execute', { projectPath: 'C:\\demo', code: '' }), /不能为空/, ); assert.deepEqual( buildEditorRpcRequest('inject', { processId: 1, projectPath: 'C:\\demo', payloadPath: 'C:\\payload\\cocos-editor-bridge.dll', }).params.payloadPath, 'C:\\payload\\cocos-editor-bridge.dll', ); assert.throws( () => buildEditorRpcRequest('ping', { processId: 1, projectPath: 'C:\\demo', payloadPath: 'C:\\payload\\cocos-editor-bridge.dll', }), /payloadPath/, ); assert.throws(() => buildEditorRpcRequest('detect', {}), /缺少 projectPath/); assert.throws(() => buildEditorRpcRequest('eval', {}), /不支持/); assert.throws(() => validateExecuteCode('x'.repeat(128 * 1024 + 1)), /上限/); }); test('manifest, native adapter and SDK agree on ids and protocol', () => { const manifest = JSON.parse( fs.readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8'), ); assert.equal( manifest.$schema, 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', ); assert.equal(manifest.name, 'agc-cocos-editor'); const runtime = manifest.extensions['world.genarrative.agc']; assert.equal(runtime.apiVersion, 'v1'); assert.equal(runtime.adapter, 'cocos-editor'); assert.equal(runtime.entry, './src/entry.mjs'); assert.ok(fs.existsSync(path.join(pluginRoot, runtime.entry))); assert.deepEqual( runtime.panels.map((panel) => panel.id), [COCOS_EDITOR_PANEL.id], ); for (const panel of runtime.panels) { assert.ok( fs.existsSync(path.join(pluginRoot, panel.entry)), `panel entry missing: ${panel.entry}`, ); } assert.ok(runtime.permissions.includes('editor.rpc')); assert.ok(runtime.permissions.includes('capability.register')); const nativeAdapter = fs.readFileSync( path.join(pluginRoot, 'native/cocos-editor-bridge/src/adapter.rs'), 'utf8', ); const nativeOperations = [ ...nativeAdapter.matchAll(/\("editor\.([a-z]+)", "([a-z]+)"\)/gu), ].map((match) => match[1]); assert.deepEqual(nativeOperations, [...COCOS_EDITOR_OPERATIONS]); assert.ok(nativeAdapter.includes(`"${runtime.adapter}"`)); const sdk = fs.readFileSync( path.join(pluginRoot, '../../packages/agc-plugin-sdk/src/index.ts'), 'utf8', ); assert.ok( sdk.includes( `AGC_PLUGIN_PROTOCOL_VERSION = '${COCOS_PLUGIN_PROTOCOL_VERSION}'`, ), ); const host = fs.readFileSync( path.join( pluginRoot, '../../apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs', ), 'utf8', ); assert.ok(host.includes(`"${COCOS_PLUGIN_PROTOCOL_VERSION}"`)); assert.ok(host.includes(`"${runtime.apiVersion}"`)); });