Files
kdletters 54d0fb75ea 接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存
接入 AGC 插件开关、Runner、Agent 工具与权限审计
完善执行回执确认、不确定状态阻断及卸载恢复
补齐 Windows 分发资源、定向测试与实机验收文档
2026-09-20 16:35:04 +08:00

326 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const executable = process.env.AGC_GODOT_TEST_EXECUTABLE;
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(predicate, duration = 15000) {
const end = Date.now() + duration;
while (Date.now() < end) {
const value = predicate();
if (value) return value;
await pause(25);
}
throw Error('Native Godot condition timed out');
}
function call(session, method, params = {}, overrides = {}) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({
host: '127.0.0.1',
port: session.port,
});
let data = '';
socket.setTimeout(10000, () =>
socket.destroy(Error('Native response timed out')),
);
socket.once('error', reject);
socket.once('connect', () =>
socket.write(
JSON.stringify({
protocol: session.protocol,
id: 1,
generation: session.generation,
token: session.token,
method,
params,
...overrides,
}) + '\n',
),
);
socket.on('data', (chunk) => {
data += chunk;
const newline = data.indexOf('\n');
if (newline < 0) return;
try {
const response = JSON.parse(data.slice(0, newline));
assert.equal(response.protocol, session.protocol);
assert.equal(response.generation, session.generation);
assert.equal(response.pid, session.pid);
assert.equal(response.buildId, session.buildId);
assert.equal(
path.resolve(response.projectPath).toLowerCase(),
path.resolve(session.projectPath).toLowerCase(),
);
resolve(response.result);
} catch (error) {
reject(error);
}
socket.end();
});
socket.once('end', () => {
if (!data.includes('\n')) reject(Error('No receipt'));
});
});
}
test(
'real headless Godot native bridge execution and lifecycle',
{ skip: !executable, timeout: 90000 },
async (t) => {
assert.equal(process.platform, 'win32');
const fixture = path.join(root, '.build', `smoke-${Date.now()}`);
fs.mkdirSync(fixture, { recursive: true });
const projectText =
'config_version=5\n[application]\nconfig/name="AGC Native Bridge Smoke"\n[rendering]\nrenderer/rendering_method="gl_compatibility"\n';
fs.writeFileSync(path.join(fixture, 'project.godot'), projectText);
const dll = path.join(root, 'bin/win-x64/agc_godot_editor.dll');
const metadata = JSON.parse(
fs.readFileSync(path.join(root, 'bin/win-x64/metadata.json'), 'utf8'),
);
fs.writeFileSync(
path.join(fixture, 'agc-editor-bridge.gdextension'),
`[configuration]\nentry_symbol="agc_godot_editor_init"\ncompatibility_minimum="4.7"\nreloadable=false\n[libraries]\nwindows.editor.x86_64="${dll.replaceAll('\\', '/')}"\n`,
);
const child = spawn(
executable,
[
'--headless',
'--editor',
'--path',
fixture,
'--log-file',
path.join(fixture, 'editor.log'),
],
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
);
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.stderr.on('data', (chunk) => {
output += chunk;
});
const exit = once(child, 'exit');
let session;
try {
const sessionPath = path.join(
fixture,
'.godot/agc',
`editor-bridge-${child.pid}.json`,
);
session = await until(() => {
if (child.exitCode !== null) throw Error(`Godot exited: ${output}`);
try {
return JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
} catch {
return false;
}
}).catch((error) => {
throw Error(`${error.message}\n${output}`);
});
assert.equal(session.pid, child.pid);
assert.equal(session.buildId, metadata.buildId);
assert.match(session.startedFileTime, /^[0-9]+$/);
assert.match(session.token, /^[0-9a-f]{64}$/);
assert.match(session.generation, /^[0-9a-f]{64}$/);
const execute = (code, timeoutMs = 5000) =>
call(session, 'execute', { code, timeoutMs });
const armReload = async (unloadFirst, delay = 0.2) => {
const helper = `@tool\nextends Node\nfunc _ready():\n\tget_tree().create_timer(${delay}).timeout.connect(_swap)\nfunc _swap():\n${unloadFirst ? '\tGDExtensionManager.unload_extension("res://agc-editor-bridge.gdextension")\n' : ''}\tvar result = GDExtensionManager.load_extension("res://agc-editor-bridge.gdextension")\n\tif result != GDExtensionManager.LOAD_STATUS_OK:\n\t\tpush_error("AGC test reload failed")\n\tqueue_free()\n`;
const reply = await execute(
`var script := GDScript.new()\nscript.source_code = ${JSON.stringify(helper)}\nassert(script.reload() == OK)\nvar helper: Node = script.new()\nEditorInterface.get_base_control().get_tree().root.add_child(helper)\nreturn true`,
);
assert.equal(reply.ok, true);
};
const nextSession = async (previous) => {
session = await until(() => {
try {
const found = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
return found.generation !== previous ? found : false;
} catch {
return false;
}
}, 5000);
assert.equal(session.pid, child.pid);
assert.equal(session.buildId, metadata.buildId);
assert.equal((await execute('return 42')).result, 42);
};
await t.test(
'finite code, null, and JSON values have trusted receipts',
async () => {
const status = await call(session, 'status');
assert.equal(status.connected, true);
assert.equal(status.executing, false);
const result = await execute('return 6 * 7');
assert.equal(result.status, 'completed');
assert.equal(result.result, 42);
const nil = await execute('return null');
assert.equal(nil.ok, true);
assert.equal(nil.result, null);
const identity = await execute(
'return {"pid": OS.get_process_id(), "editor": Engine.is_editor_hint()}',
);
assert.deepEqual(identity.result, { pid: child.pid, editor: true });
},
);
await t.test(
'compile and runtime errors cannot become null success',
async () => {
const compilation = await execute('var broken =');
assert.equal(compilation.ok, false);
assert.equal(compilation.error.code, 'godot_compile_error');
const runtime = await execute(
'var values: Array = []\nreturn values[8]',
);
assert.equal(runtime.ok, false);
assert.equal(runtime.error.code, 'godot_runtime_error');
assert.ok(runtime.logs.some((entry) => entry.level === 'error'));
assert.equal((await execute('return 42')).result, 42);
},
);
await t.test(
'await holds occupancy and refuses shutdown until completion',
async () => {
const pending = execute(
'await (Engine.get_main_loop() as SceneTree).create_timer(0.25).timeout\nreturn 42',
);
await pause(60);
assert.equal((await call(session, 'status')).executing, true);
const concurrent = await execute('return 99');
assert.equal(concurrent.dispatched, false);
assert.equal(concurrent.error.code, 'godot_execution_in_progress');
assert.equal((await call(session, 'shutdown')).accepted, false);
assert.equal((await pending).result, 42);
assert.equal((await call(session, 'status')).executing, false);
},
);
await t.test(
'async timeout does not release an in-flight execution',
async () => {
const timeout = await execute(
'await (Engine.get_main_loop() as SceneTree).create_timer(0.3).timeout\nreturn 42',
80,
);
assert.equal(timeout.status, 'needs-reconciliation');
assert.equal(timeout.dispatched, true);
assert.equal((await call(session, 'status')).executing, true);
assert.equal((await call(session, 'shutdown')).accepted, false);
await pause(350);
assert.equal((await call(session, 'status')).executing, false);
},
);
await t.test('captured logs, code, and results are bounded', async () => {
const logged = await execute(
'for i in range(150):\n\tprint("entry " + str(i))\nreturn 42',
);
assert.equal(logged.result, 42);
assert.ok(logged.logs.length <= 128);
assert.equal(logged.logsTruncated, true);
const code = await execute('#'.repeat(128 * 1024 + 1));
assert.equal(code.dispatched, false);
const result = await execute('return "x".repeat(2 * 1024 * 1024)');
assert.equal(result.ok, false);
assert.equal(result.error.code, 'godot_result_not_serializable');
});
await t.test('stale identity fails closed', async () => {
await assert.rejects(
call(
session,
'execute',
{ code: 'return 99', timeoutMs: 1000 },
{ generation: 'stale' },
),
/No receipt/,
);
await assert.rejects(
call(session, 'status', {}, { token: 'wrong' }),
/No receipt/,
);
assert.equal((await execute('return 42')).result, 42);
});
await t.test(
'errors after await are still definite failures',
async () => {
const result = await execute(
'var values: Array = []\nawait (Engine.get_main_loop() as SceneTree).create_timer(0.05).timeout\nreturn values[9]',
1200,
);
assert.equal(result.ok, false);
assert.equal(result.error.code, 'godot_runtime_error');
assert.equal((await call(session, 'status')).executing, false);
},
);
await t.test(
'bootstrap cannot replace a busy generation and reports the conflict',
async () => {
const generation = session.generation;
const conflict = await execute(
'var bridge = EditorInterface.get_base_control().get_tree().root.get_node("_AGC_GODOT_EDITOR_BRIDGE")\nvar duplicate = bridge.get_script().new()\nduplicate.bootstrap(bridge.session.buildId, bridge.session.startedFileTime, bridge.session_path)\nreturn true',
);
assert.equal(conflict.ok, false);
assert.ok(
conflict.logs.some((entry) =>
entry.message.includes('godot_bridge_busy'),
),
);
assert.equal(
JSON.parse(fs.readFileSync(sessionPath, 'utf8')).generation,
generation,
);
assert.equal((await execute('return 42')).result, 42);
},
);
await t.test(
'unload and reload in one deferred flush transfers the fixed node name',
async () => {
const previous = session.generation;
await armReload(true);
await nextSession(previous);
assert.equal((await call(session, 'status')).executing, false);
},
);
await t.test(
'shutdown then a fresh controlled load reconnects without restarting the editor',
async () => {
const previous = session.generation;
await armReload(false, 0.4);
assert.equal((await call(session, 'shutdown')).accepted, true);
await until(() => !fs.existsSync(sessionPath), 5000);
await nextSession(previous);
},
);
await t.test(
'shutdown receipt precedes listener and own cache removal',
async () => {
assert.deepEqual(await call(session, 'shutdown'), {
accepted: true,
status: 'shutting-down',
});
await until(() => !fs.existsSync(sessionPath), 5000);
await assert.rejects(call(session, 'status'));
assert.equal(
fs.readFileSync(path.join(fixture, 'project.godot'), 'utf8'),
projectText,
);
assert.ok(
!output.includes(session.token),
'Session token must not enter engine logs',
);
},
);
} finally {
// This child was created by this test; no shared/user editor is touched.
child.kill();
await Promise.race([exit, pause(5000)]);
fs.writeFileSync(path.join(fixture, 'test-output.log'), output);
}
},
);