b96473836d
新增两种编辑器的内置 Skill 和场景、资源、UI、保存撤销示例 接入 DirectProject 按需读取与 Runtime 同源操作参考 补齐指南原文实机测试、安装投影及工具说明完整性检查 记录 Godot 图形补验结果并保留尚未验收的边界
381 lines
13 KiB
JavaScript
381 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 nativeRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
const repoRoot = path.resolve(nativeRoot, '../../../..');
|
|
const guidePath = path.join(
|
|
repoRoot,
|
|
'apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md',
|
|
);
|
|
const guide = fs.readFileSync(guidePath, 'utf8');
|
|
const examples = new Map(
|
|
[
|
|
...guide.matchAll(
|
|
/<!-- example:([a-z-]+) -->\r?\n```gdscript\r?\n([\s\S]*?)\r?\n```/g,
|
|
),
|
|
].map((match) => [match[1], match[2]]),
|
|
);
|
|
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('Godot guide fixture did not become ready');
|
|
}
|
|
|
|
function request(session, method, params = {}) {
|
|
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('Guide execution receipt 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,
|
|
})}\n`,
|
|
),
|
|
);
|
|
socket.on('data', (chunk) => {
|
|
data += chunk;
|
|
const newline = data.indexOf('\n');
|
|
if (newline < 0) return;
|
|
try {
|
|
const reply = JSON.parse(data.slice(0, newline));
|
|
assert.equal(reply.protocol, session.protocol);
|
|
assert.equal(reply.generation, session.generation);
|
|
assert.equal(reply.pid, session.pid);
|
|
assert.equal(reply.buildId, session.buildId);
|
|
assert.equal(
|
|
path.resolve(reply.projectPath).toLowerCase(),
|
|
path.resolve(session.projectPath).toLowerCase(),
|
|
);
|
|
resolve(reply.result);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
socket.end();
|
|
});
|
|
socket.once('end', () => {
|
|
if (!data.includes('\n')) reject(Error('Godot exited without a receipt'));
|
|
});
|
|
});
|
|
}
|
|
|
|
test('Godot guide examples are unique, extractable, and within the runtime read budget', () => {
|
|
assert.ok(Buffer.byteLength(guide, 'utf8') <= 14 * 1024);
|
|
assert.equal([...guide.matchAll(/<!-- example:/g)].length, examples.size);
|
|
assert.equal([...guide.matchAll(/```gdscript/g)].length, examples.size);
|
|
assert.deepEqual(
|
|
[...examples.keys()],
|
|
[
|
|
'query',
|
|
'create-node',
|
|
'update-node',
|
|
'delete-node',
|
|
'local-undo',
|
|
'pack-resource',
|
|
'instance-resource',
|
|
'create-ui',
|
|
'save-reopen',
|
|
'stop-play',
|
|
'diagnostics',
|
|
],
|
|
);
|
|
});
|
|
|
|
test(
|
|
'real headless Godot executes unchanged examples from the shipped guide',
|
|
{
|
|
skip: !executable,
|
|
timeout: 90000,
|
|
},
|
|
async (t) => {
|
|
assert.equal(process.platform, 'win32');
|
|
const fixture = path.join(nativeRoot, '.build', `guide-${Date.now()}`);
|
|
const project = path.join(fixture, 'project');
|
|
const cache = path.join(fixture, 'native-cache');
|
|
fs.mkdirSync(project, { recursive: true });
|
|
fs.mkdirSync(cache);
|
|
const projectText =
|
|
'config_version=5\n[application]\nconfig/name="AGC Godot Guide Fixture"\nrun/main_scene="res://main.tscn"\n[rendering]\nrenderer/rendering_method="gl_compatibility"\n';
|
|
fs.writeFileSync(path.join(project, 'project.godot'), projectText);
|
|
fs.writeFileSync(
|
|
path.join(project, 'main.tscn'),
|
|
'[gd_scene format=3]\n\n[node name="GuideRoot" type="Node2D"]\n\n[node name="Existing" type="Node2D" parent="."]\n',
|
|
);
|
|
const dll = path.join(cache, 'agc_godot_editor.dll');
|
|
fs.copyFileSync(
|
|
path.join(nativeRoot, 'bin/win-x64/agc_godot_editor.dll'),
|
|
dll,
|
|
);
|
|
const metadata = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(nativeRoot, 'bin/win-x64/metadata.json'),
|
|
'utf8',
|
|
),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(project, '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 args = [
|
|
'--headless',
|
|
'--quiet',
|
|
'--editor',
|
|
'--path',
|
|
project,
|
|
'--log-file',
|
|
path.join(fixture, 'editor.log'),
|
|
'res://main.tscn',
|
|
];
|
|
const child = spawn(executable, args, {
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const ownedPid = child.pid;
|
|
const exit = once(child, 'exit');
|
|
let output = '';
|
|
child.stdout.on('data', (chunk) => {
|
|
output += chunk;
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
output += chunk;
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(fixture, 'launch.json'),
|
|
JSON.stringify({ executable, args, pid: ownedPid }, null, 2),
|
|
);
|
|
const receipts = [];
|
|
let session;
|
|
try {
|
|
const sessionPath = path.join(
|
|
project,
|
|
'.godot/agc',
|
|
`editor-bridge-${ownedPid}.json`,
|
|
);
|
|
session = await until(() => {
|
|
if (child.exitCode !== null)
|
|
throw Error(`Owned Godot fixture exited: ${output}`);
|
|
try {
|
|
return JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
assert.equal(session.pid, ownedPid);
|
|
assert.equal(session.buildId, metadata.buildId);
|
|
const execute = (code) =>
|
|
request(session, 'execute', { code, timeoutMs: 5000 });
|
|
const run = async (name) => {
|
|
assert.ok(examples.has(name), `Missing example ${name}`);
|
|
const receipt = await execute(examples.get(name));
|
|
receipts.push({ example: name, receipt });
|
|
assert.equal(
|
|
receipt.ok,
|
|
true,
|
|
JSON.stringify({ example: name, receipt }),
|
|
);
|
|
return receipt.result;
|
|
};
|
|
const selection = await execute(
|
|
'var root := EditorInterface.get_edited_scene_root()\nassert(root != null)\nEditorInterface.get_selection().clear()\nEditorInterface.get_selection().add_node(root.get_node("Existing"))\nreturn true',
|
|
);
|
|
assert.equal(selection.ok, true, JSON.stringify(selection));
|
|
await t.test(
|
|
'query returns the edited scene, real hierarchy and selected relative path',
|
|
async () => {
|
|
const result = await run('query');
|
|
assert.equal(result.scene, 'res://main.tscn');
|
|
assert.deepEqual(result.selected, ['Existing']);
|
|
assert.deepEqual(result.nodes, [
|
|
{ path: '.', type: 'Node2D' },
|
|
{ path: 'Existing', type: 'Node2D' },
|
|
]);
|
|
},
|
|
);
|
|
await t.test(
|
|
'create and property changes return actual node values',
|
|
async () => {
|
|
assert.deepEqual(await run('create-node'), {
|
|
path: 'AGCGuideMarker',
|
|
position: [12, 24],
|
|
owned: true,
|
|
});
|
|
assert.deepEqual(await run('update-node'), {
|
|
path: 'AGCGuideMarker',
|
|
position: [24, 48],
|
|
});
|
|
},
|
|
);
|
|
await t.test(
|
|
'local UndoRedo performs and reverts the property change',
|
|
async () => {
|
|
assert.deepEqual(await run('local-undo'), {
|
|
changed: [80, 90],
|
|
restored: [24, 48],
|
|
matches: true,
|
|
});
|
|
},
|
|
);
|
|
await t.test(
|
|
'PackedScene writes owned children and instantiates the resource',
|
|
async () => {
|
|
assert.deepEqual(await run('pack-resource'), {
|
|
path: 'res://agc_guide_piece.tscn',
|
|
saved: true,
|
|
});
|
|
assert.match(
|
|
fs.readFileSync(path.join(project, 'agc_guide_piece.tscn'), 'utf8'),
|
|
/name="Anchor"/,
|
|
);
|
|
assert.deepEqual(await run('instance-resource'), {
|
|
path: 'AGCGuidePiece',
|
|
source: 'res://agc_guide_piece.tscn',
|
|
has_anchor: true,
|
|
});
|
|
},
|
|
);
|
|
await t.test(
|
|
'Control and Container UI is owned by the saved scene',
|
|
async () => {
|
|
assert.deepEqual(await run('create-ui'), {
|
|
path: 'AGCGuideHUD',
|
|
title: '关卡目标',
|
|
button: '开始',
|
|
anchors: [0, 0, 1, 1],
|
|
owned: [true, true, true, true, true],
|
|
});
|
|
},
|
|
);
|
|
await t.test(
|
|
'read-only old scene cannot discard unsaved edits by reloading stale disk content',
|
|
async () => {
|
|
const sceneFile = path.join(project, 'main.tscn');
|
|
const oldDisk = fs.readFileSync(sceneFile, 'utf8');
|
|
const identityCode =
|
|
'var root := EditorInterface.get_edited_scene_root()\nvar marker := root.get_node("AGCGuideMarker") as Node2D\nreturn {"id": str(root.get_instance_id()), "position": [marker.position.x, marker.position.y], "has_ui": root.has_node("AGCGuideHUD/Center/Column/Title")}';
|
|
const before = await execute(identityCode);
|
|
assert.equal(before.ok, true);
|
|
fs.chmodSync(sceneFile, 0o444);
|
|
try {
|
|
const receipt = await execute(examples.get('save-reopen'));
|
|
receipts.push({ example: 'save-reopen-read-only', receipt });
|
|
if (receipt.ok) {
|
|
assert.equal(receipt.result.reloaded, false);
|
|
} else {
|
|
assert.equal(receipt.error.code, 'godot_runtime_error');
|
|
}
|
|
const after = await execute(identityCode);
|
|
receipts.push({
|
|
example: 'read-only-memory-verification',
|
|
receipt: after,
|
|
});
|
|
assert.equal(after.ok, true, JSON.stringify(after));
|
|
assert.deepEqual(after.result, before.result);
|
|
assert.equal(fs.readFileSync(sceneFile, 'utf8'), oldDisk);
|
|
} finally {
|
|
fs.chmodSync(sceneFile, 0o666);
|
|
}
|
|
},
|
|
);
|
|
await t.test(
|
|
'save and reload retain the instance and UI hierarchy on disk and in editor',
|
|
async () => {
|
|
assert.deepEqual(await run('save-reopen'), {
|
|
scene: 'res://main.tscn',
|
|
saved: true,
|
|
reloaded: true,
|
|
has_piece: true,
|
|
has_ui: true,
|
|
});
|
|
const disk = fs.readFileSync(path.join(project, 'main.tscn'), 'utf8');
|
|
assert.match(disk, /agc_guide_piece\.tscn/);
|
|
assert.match(disk, /name="Title"/);
|
|
assert.match(disk, /position = Vector2\(24, 48\)/);
|
|
},
|
|
);
|
|
await t.test(
|
|
'diagnostics and stop when already stopped have faithful results',
|
|
async () => {
|
|
const diagnostics = await run('diagnostics');
|
|
assert.equal(diagnostics.editor, true);
|
|
assert.equal(diagnostics.scene, 'res://main.tscn');
|
|
assert.equal(diagnostics.playing, false);
|
|
assert.ok(diagnostics.open_scenes.includes('res://main.tscn'));
|
|
assert.deepEqual(await run('stop-play'), {
|
|
was_playing: false,
|
|
playing: false,
|
|
});
|
|
},
|
|
);
|
|
await t.test('delete removes only the selected guide node', async () => {
|
|
assert.deepEqual(await run('delete-node'), { removed: true });
|
|
const after = await run('query');
|
|
assert.ok(!after.nodes.some((node) => node.path === 'AGCGuideMarker'));
|
|
assert.ok(after.nodes.some((node) => node.path === 'Existing'));
|
|
assert.ok(
|
|
after.nodes.some(
|
|
(node) => node.path === 'AGCGuideHUD/Center/Column/Title',
|
|
),
|
|
);
|
|
});
|
|
assert.equal(
|
|
fs.readFileSync(path.join(project, 'project.godot'), 'utf8'),
|
|
projectText,
|
|
);
|
|
assert.equal((await request(session, 'shutdown')).accepted, true);
|
|
await until(() => !fs.existsSync(sessionPath));
|
|
} finally {
|
|
// This retained ChildProcess is the editor launched above, never a discovered user process.
|
|
assert.equal(child.pid, ownedPid);
|
|
if (child.exitCode === null) child.kill();
|
|
await Promise.race([exit, pause(5000)]);
|
|
fs.writeFileSync(path.join(fixture, 'editor-output.log'), output);
|
|
fs.writeFileSync(
|
|
path.join(fixture, 'receipts.json'),
|
|
JSON.stringify(receipts, null, 2),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(fixture, 'cleanup.json'),
|
|
JSON.stringify(
|
|
{
|
|
pid: ownedPid,
|
|
exited: child.exitCode !== null || child.signalCode !== null,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
assert.ok(
|
|
child.exitCode !== null || child.signalCode !== null,
|
|
'Owned fixture editor must exit',
|
|
);
|
|
}
|
|
},
|
|
);
|