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', ); });