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
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
257 lines
7.0 KiB
JavaScript
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',
|
|
);
|
|
});
|