c1d26f11df
统一插件列表、启动、面板和 Agent 工具的可用性判断,保留开关与平台约束 调整前端自动启动并修复 Cocos 项目切换的旧连接与订阅快照竞态 补充插件宿主、工具目录和前端回归测试,同步插件技术规范与共享决策
469 lines
14 KiB
JavaScript
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}"`));
|
|
});
|