Files
Genarrative/plugins/agc-unity-editor/src/entry.test.mjs
T
kdletters 54d0fb75ea 接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存
接入 AGC 插件开关、Runner、Agent 工具与权限审计
完善执行回执确认、不确定状态阻断及卸载恢复
补齐 Windows 分发资源、定向测试与实机验收文档
2026-09-20 16:35:04 +08:00

264 lines
7.2 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
createUnityEditorPlugin,
UNITY_CONNECTION_CAPABILITY_ID,
UNITY_EXECUTE_COMMAND_ID,
} from './entry.mjs';
const success = {
ok: true,
status: 'completed',
dispatched: true,
retryAllowed: false,
result: 2,
};
async function fixture(t, rpc = () => success, options = {}) {
const requests = [];
const replies = new Map();
let inbound = 1000;
const plugin = createUnityEditorPlugin({
...options,
send(message) {
if (!message.method) {
replies.set(message.id, message);
return;
}
requests.push(message);
Promise.resolve()
.then(async () => {
const result =
message.method === 'host.rpc'
? await rpc(message.params)
: message.method === 'host.events.subscribe'
? { projectPath: 'C:/Unity/A', subscriptionId: 'project' }
: {};
await plugin.handleMessage({
jsonrpc: '2.0',
id: message.id,
result,
});
})
.catch(() => undefined);
},
});
t.after(() => plugin.dispose());
await plugin.start();
return {
plugin,
requests,
async call(params, method = UNITY_EXECUTE_COMMAND_ID) {
const id = inbound++;
await plugin.handleMessage({ jsonrpc: '2.0', id, method, params });
return replies.get(id);
},
async project(projectPath) {
await plugin.handleMessage({
jsonrpc: '2.0',
method: 'host.event',
params: { type: 'project.changed', payload: { projectPath } },
});
},
};
}
test('注册声明的命令与连接能力,并使用宿主提供的当前项目', async (t) => {
const f = await fixture(t);
assert.deepEqual(
f.requests.slice(0, 3).map((item) => item.method),
[
'host.registerCommand',
'host.registerCapability',
'host.events.subscribe',
],
);
assert.deepEqual((await f.call({ code: 'return 1 + 1;' })).result, success);
assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Unity/A');
await f.project('C:/Unity/B');
await f.call({ operation: 'detect' }, UNITY_CONNECTION_CAPABILITY_ID);
assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Unity/B');
const manifest = JSON.parse(
await readFile(new URL('../plugin.json', import.meta.url), 'utf8'),
);
assert.equal(
manifest.extensions['world.genarrative.agc'].adapter,
'unity-editor',
);
});
test('显式项目、任意payload和非法代码在宿主派发前拒绝', async (t) => {
const f = await fixture(t);
for (const input of [
{ code: 'return 1;', projectPath: 'C:/Other' },
{ code: 'return 1;', payloadPath: 'C:/evil.dll' },
{ code: '' },
{ code: 'x\0y' },
{ code: '中'.repeat(128 * 1024) },
{ code: 'return 1;', timeoutMs: 60_001 },
])
assert.equal((await f.call(input)).result.dispatched, false);
assert.equal(
f.requests.filter((item) => item.method === 'host.rpc').length,
0,
);
await f.project(null);
assert.equal((await f.call({ code: 'return 1;' })).result.dispatched, false);
});
test('并发写请求立即失败,不会在上一请求结束后补发', async (t) => {
let finish;
const f = await fixture(
t,
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const first = f.call({ code: 'return 1;' });
await new Promise((resolve) => setImmediate(resolve));
assert.equal((await f.call({ code: 'return 2;' })).result.dispatched, false);
finish(success);
await first;
assert.equal(
f.requests.filter((item) => item.method === 'host.rpc').length,
1,
);
});
test('未知结果经过断开及项目切换仍阻断写入', async (t) => {
const f = await fixture(t, ({ method }) =>
method === 'editor.execute'
? {
ok: false,
status: 'needs-reconciliation',
retryAllowed: false,
dispatched: true,
error: 'lost',
}
: { connected: false },
);
assert.equal(
(await f.call({ code: 'return 1;' })).result.status,
'needs-reconciliation',
);
await f.call({ operation: 'disconnect' }, UNITY_CONNECTION_CAPABILITY_ID);
await f.project('C:/Unity/B');
assert.equal(
(await f.call({ code: 'return 2;' })).result.status,
'needs-reconciliation',
);
assert.equal(
f.requests.filter((item) => item.params?.method === 'editor.execute')
.length,
1,
);
});
test('可信运行失败可以修正代码后再次执行', async (t) => {
let count = 0;
const f = await fixture(t, () =>
++count === 1
? {
ok: false,
status: 'failed',
retryAllowed: false,
dispatched: true,
error: { code: 'runtime-error', message: 'division by zero' },
}
: success,
);
assert.equal(
(await f.call({ code: 'return 1 / 0;' })).result.status,
'failed',
);
assert.equal((await f.call({ code: 'return 2;' })).result.ok, true);
assert.equal(count, 2);
});
test('超时回执与不完整终态均保守阻断,不自行重放', async (t) => {
for (const rpc of [
() => ({ ...success, error: { code: 'conflict', message: 'both' } }),
() => ({
...success,
ok: false,
status: 'failed',
error: { code: 'conflict', message: 'both' },
}),
() => new Promise(() => {}),
() => ({ ok: true }),
() => ({
ok: true,
status: 'completed',
dispatched: true,
retryAllowed: false,
}),
() => ({
ok: false,
status: 'failed',
dispatched: true,
retryAllowed: false,
error: 'untrusted',
}),
]) {
const f = await fixture(t, rpc, { timeoutMs: 10 });
assert.equal(
(await f.call({ code: 'return 1;' })).result.status,
'needs-reconciliation',
);
assert.equal(
(await f.call({ code: 'return 2;' })).result.status,
'needs-reconciliation',
);
assert.equal(
f.requests.filter((item) => item.method === 'host.rpc').length,
1,
);
}
});
test('迟到的订阅快照不能覆盖已收到的新项目事件', async (t) => {
const requests = [];
const plugin = createUnityEditorPlugin({
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',
payload: { projectPath: 'C:/New' },
},
});
}
await plugin.handleMessage({
jsonrpc: '2.0',
id: message.id,
result:
message.method === 'host.events.subscribe'
? { projectPath: 'C:/Old' }
: success,
});
});
},
});
t.after(() => plugin.dispose());
await plugin.start();
await plugin.handleMessage({
jsonrpc: '2.0',
id: 500,
method: UNITY_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
assert.equal(
requests.find((item) => item.method === 'host.rpc').params.params
.projectPath,
'C:/New',
);
});