Files
Genarrative/plugins/agc-cocos-editor/src/entry.test.mjs
T
kdletters c1d26f11df 移除 Cocos 和 Unity 插件的工程类型限制
统一插件列表、启动、面板和 Agent 工具的可用性判断,保留开关与平台约束

调整前端自动启动并修复 Cocos 项目切换的旧连接与订阅快照竞态

补充插件宿主、工具目录和前端回归测试,同步插件技术规范与共享决策
2026-09-20 10:53:16 +08:00

469 lines
14 KiB
JavaScript

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}"`));
});