Files
Genarrative/plugins/agc-godot-editor/src/entry.test.mjs
T
kdletters 288e1d4f4a
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 13s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 13s
Project CI / AI game creator shell Rust smoke (push) Failing after 12s
Project CI / AI game creator shell Rust crates (push) Failing after 20s
Project CI / Backend tests (push) Failing after 23s
Project CI / Native shell tests (push) Failing after 12s
Project CI / Frontend tests (push) Failing after 5s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 13s
同步最新主干并修复 Godot 插件启动竞态
合入主干 Rust 1.98.1 工具链及对应 CI 文档更新
Godot 插件先接收受控项目快照再注册命令和连接能力
补充启动竞态回归、失败回执诊断及插件启动合同
2026-09-20 17:46:53 +08:00

419 lines
11 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
createGodotEditorPlugin,
GODOT_CONNECTION_CAPABILITY_ID,
GODOT_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 = createGodotEditorPlugin({
...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:/Godot/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 = GODOT_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.events.subscribe',
'host.registerCommand',
'host.registerCapability',
],
);
assert.deepEqual((await f.call({ code: 'return 1 + 1;' })).result, success);
assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Godot/A');
await f.project('C:/Godot/B');
await f.call({ operation: 'detect' }, GODOT_CONNECTION_CAPABILITY_ID);
assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Godot/B');
const manifest = JSON.parse(
await readFile(new URL('../plugin.json', import.meta.url), 'utf8'),
);
assert.equal(
manifest.extensions['world.genarrative.agc'].adapter,
'godot-editor',
);
});
test('命令和能力公开时已绑定项目,立即执行无需等待后续启动请求', async (t) => {
const messages = [];
const plugin = createGodotEditorPlugin({
send(message) {
messages.push(message);
if (!message.method) return;
queueMicrotask(async () => {
if (message.method === 'host.registerCapability') {
await plugin.handleMessage({
jsonrpc: '2.0',
id: 1000,
method: GODOT_EXECUTE_COMMAND_ID,
params: { code: 'return 2' },
});
}
await plugin.handleMessage({
jsonrpc: '2.0',
id: message.id,
result:
message.method === 'host.events.subscribe'
? { projectPath: 'C:/Godot/Ready' }
: success,
});
});
},
});
t.after(() => plugin.dispose());
await plugin.start();
assert.deepEqual(
messages.find((message) => message.id === 1000).result,
success,
);
assert.equal(
messages.find((message) => message.method === 'host.rpc').params.params
.projectPath,
'C:/Godot/Ready',
);
});
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' }, GODOT_CONNECTION_CAPABILITY_ID);
await f.project('C:/Godot/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 = createGodotEditorPlugin({
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: GODOT_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
assert.equal(
requests.find((item) => item.method === 'host.rpc').params.params
.projectPath,
'C:/New',
);
});
test('连接身份由宿主绑定,不接受进程、端口、令牌或库路径覆盖', async (t) => {
const f = await fixture(t);
for (const forbidden of [
'processId',
'projectPath',
'port',
'token',
'dllPath',
]) {
const reply = await f.call(
{ operation: 'connect', [forbidden]: 123 },
GODOT_CONNECTION_CAPABILITY_ID,
);
assert.equal(reply.error.code, -32602);
}
assert.equal(
f.requests.filter((item) => item.method === 'host.rpc').length,
0,
);
});
test('nil 返回值保留真实完成语义', async (t) => {
const result = { ...success, result: null };
const f = await fixture(t, () => result);
assert.deepEqual((await f.call({ code: 'return null' })).result, result);
});
test('await 未完成时拒绝断开,不卸载在途执行', async (t) => {
let finish;
const f = await fixture(
t,
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const first = f.call({ code: 'await get_tree().process_frame\nreturn 42' });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
(await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID))
.error.code,
-32602,
);
assert.equal(
f.requests.filter((item) => item.method === 'host.rpc').length,
1,
);
finish(success);
assert.equal((await first).result.status, 'completed');
});
test('shutdown 受理和不确定回执不能冒充卸载完成,也不能解除执行阻断', async (t) => {
for (const result of [
{ accepted: true, status: 'shutting-down' },
{ accepted: true, connected: false, status: 'shutting-down' },
{
accepted: false,
connected: false,
error: { code: 'busy', message: 'busy' },
},
{ connected: false, status: 'needs-reconciliation' },
]) {
const f = await fixture(t, () => result);
assert.equal(
(
await f.call(
{ operation: 'disconnect' },
GODOT_CONNECTION_CAPABILITY_ID,
)
).error.code,
-32602,
);
assert.equal(
(await f.call({ code: 'return 42' })).result.status,
'needs-reconciliation',
);
assert.equal(
f.requests.filter((item) => item.method === 'host.rpc').length,
1,
);
}
const f = await fixture(t, () => ({
adapter: 'godot-editor',
connected: false,
}));
assert.equal(
(await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID))
.result.connected,
false,
);
});
test('项目切换后旧项目的执行回执不能当成新项目成功', async (t) => {
let finish;
const f = await fixture(
t,
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const first = f.call({ code: 'return 42' });
await new Promise((resolve) => setImmediate(resolve));
await f.project('C:/Godot/B');
finish(success);
assert.equal((await first).result.status, 'needs-reconciliation');
assert.equal(
(await f.call({ code: 'return 43' })).result.status,
'needs-reconciliation',
);
assert.equal(
f.requests.filter((item) => item.method === 'host.rpc').length,
1,
);
});