Files
Genarrative/plugins/agc-unity-editor/src/entry.test.mjs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +08:00

257 lines
7.0 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 [
() => 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',
);
});