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