54d0fb75ea
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档
264 lines
7.2 KiB
JavaScript
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',
|
|
);
|
|
});
|