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
合入主干 Rust 1.98.1 工具链及对应 CI 文档更新 Godot 插件先接收受控项目快照再注册命令和连接能力 补充启动竞态回归、失败回执诊断及插件启动合同
419 lines
11 KiB
JavaScript
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,
|
|
);
|
|
});
|