Files
kdletters 4a46f89c9b
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 6m18s
Project CI / Native shell tests (push) Failing after 13m52s
接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。

- 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。
- Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。
- 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。
- DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。

验证:
- 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。
- 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。
- 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。
- 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。

验证边界:发行安装包和远端 CI 尚未验收。

Reviewed-on: #338
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-13 14:48:55 +08:00

303 lines
9.0 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test, vi } from 'vitest';
import {
isProcessGroupAlive,
spawnChild,
terminateChildTree,
} from '../scripts/start-dev-stack.mjs';
import {
buildTauriArguments,
runTauriDev as runTauriDevImpl,
withDevCargoFeatures,
} from '../scripts/start-tauri-dev.mjs';
const testEndpoint = {
host: '127.0.0.1',
port: 10005,
url: 'http://127.0.0.1:10005/',
markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json',
portRange: { start: 10000, end: 10099, label: '10000-10099' },
};
const resolveTestEndpoint = async () => testEndpoint;
const runTauriDev = (
argv: string[],
options: Parameters<typeof runTauriDevImpl>[1],
) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options });
async function waitForFile(path: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (existsSync(path)) {
return;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
}
throw new Error(`等待测试进程标记超时: ${path}`);
}
describe('AI 游戏创作 Tauri dev 启动参数', () => {
test('开发构建默认带上 Cocos 编辑器 feature', () => {
expect(
withDevCargoFeatures(['--no-watch'], ['cocos-editor-execute']),
).toEqual(['--features=cocos-editor-execute', '--no-watch']);
expect(withDevCargoFeatures(['--no-watch'], [])).toEqual(['--no-watch']);
expect(
withDevCargoFeatures(
['--features', 'custom-feature'],
['cocos-editor-execute'],
),
).toEqual(['--features', 'custom-feature']);
});
test('普通 dev 参数原样交给 Tauri CLI', () => {
expect(buildTauriArguments(['--no-watch'], testEndpoint.url)).toEqual([
'dev',
'--no-watch',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
]);
});
test('动态 devUrl 配置位于用户 Tauri 配置之后且不越过参数分隔符', () => {
expect(
buildTauriArguments(
['--config', 'custom.json', '--', '--', '--example-app-arg'],
testEndpoint.url,
),
).toEqual([
'dev',
'--config',
'custom.json',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
'--',
'--',
'--example-app-arg',
]);
});
test('单个启动器分隔符后的参数进入应用而不是 Cargo', () => {
expect(
buildTauriArguments(
['--', '--config-dir', 'C:\\temp\\agc-dev-config'],
testEndpoint.url,
),
).toEqual([
'dev',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
'--',
'--',
'--config-dir',
'C:\\temp\\agc-dev-config',
]);
});
});
describe('AI 游戏创作 Tauri dev 生命周期', () => {
test('前端就绪前不启动 Tauri,准备失败时清理自有服务', async () => {
const spawnCli = vi.fn();
const frontend = { pid: 1234 };
const terminateTree = vi.fn(async () => ({ stopped: true }));
await expect(
runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
prepareFrontend: async (_, { onChild }) => {
onChild(frontend);
expect(spawnCli).not.toHaveBeenCalled();
throw new Error('backend failed');
},
spawnCli,
terminateTree,
}),
).rejects.toThrow('backend failed');
expect(spawnCli).not.toHaveBeenCalled();
expect(terminateTree).toHaveBeenCalledWith(frontend);
});
test('动态端口预检失败时不启动 Tauri CLI', async () => {
const spawnCli = vi.fn();
await expect(
runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {
throw new Error('stale AGC Vite');
},
spawnCli,
}),
).rejects.toThrow('stale AGC Vite');
expect(spawnCli).not.toHaveBeenCalled();
});
test('预检先于 CLI 启动且 CLI 退出后始终清理进程树', async () => {
const order: string[] = [];
const child = Object.assign(new EventEmitter(), {
pid: 1234,
exitCode: 1,
signalCode: null,
kill: vi.fn(),
});
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {
order.push('preflight');
},
prepareFrontend: async () => {
order.push('frontend-ready');
},
spawnCli: () => {
order.push('spawn');
return child;
},
waitForCli: async () => {
order.push('exit');
return { type: 'exit', code: 1, signal: null };
},
terminateTree: async (receivedChild) => {
expect(receivedChild).toBe(child);
order.push('cleanup');
return { stopped: true, forced: false };
},
});
expect(result).toBe(1);
expect(order).toEqual([
'preflight',
'frontend-ready',
'spawn',
'exit',
'cleanup',
]);
});
const posixTest = process.platform === 'win32' ? test.skip : test;
posixTest('Tauri CLI leader 先退出后仍收束同 PGID 的客户端后代', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-tree-'));
const readyPath = join(tempDir, 'client-ready');
const stoppedPath = join(tempDir, 'client-stopped');
const descendantSource = `
const { writeFileSync } = require('node:fs');
const [readyPath, stoppedPath] = process.argv.slice(1);
process.on('SIGTERM', () => {
writeFileSync(stoppedPath, 'stopped');
process.exit(0);
});
writeFileSync(readyPath, 'ready');
setInterval(() => {}, 1000);
`;
const leaderSource = `
const { existsSync } = require('node:fs');
const { spawn } = require('node:child_process');
const [readyPath, stoppedPath, descendantSource] = process.argv.slice(1);
const descendant = spawn(
process.execPath,
['-e', descendantSource, readyPath, stoppedPath],
{ stdio: 'ignore' },
);
descendant.unref();
const timer = setInterval(() => {
if (existsSync(readyPath)) {
clearInterval(timer);
process.exit(42);
}
}, 10);
`;
let cliChild;
try {
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
process.execPath,
['-e', leaderSource, readyPath, stoppedPath, descendantSource],
{ cwd: process.cwd() },
);
return cliChild;
},
});
expect(result).toBe(42);
await waitForFile(stoppedPath);
} finally {
if (Number.isInteger(cliChild?.pid)) {
try {
process.kill(-cliChild.pid, 'SIGKILL');
} catch {
// 进程组已经由启动器收束。
}
}
rmSync(tempDir, { recursive: true, force: true });
}
});
posixTest('客户端后代忽略 TERM 时在有界宽限后升级 KILL', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-force-tree-'));
const readyPath = join(tempDir, 'client-ready');
const descendantSource = `
const { writeFileSync } = require('node:fs');
const [readyPath] = process.argv.slice(1);
process.on('SIGTERM', () => {});
writeFileSync(readyPath, 'ready');
setInterval(() => {}, 1000);
`;
const leaderSource = `
const { existsSync } = require('node:fs');
const { spawn } = require('node:child_process');
const [readyPath, descendantSource] = process.argv.slice(1);
const descendant = spawn(
process.execPath,
['-e', descendantSource, readyPath],
{ stdio: 'ignore' },
);
descendant.unref();
const timer = setInterval(() => {
if (existsSync(readyPath)) {
clearInterval(timer);
process.exit(42);
}
}, 10);
`;
let cliChild;
try {
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
process.execPath,
['-e', leaderSource, readyPath, descendantSource],
{ cwd: process.cwd() },
);
return cliChild;
},
terminateTree: (child) =>
terminateChildTree(child, {
gracefulTimeoutMs: 50,
forceTimeoutMs: 2000,
}),
});
expect(result).toBe(42);
expect(isProcessGroupAlive(cliChild.pid)).toBe(false);
} finally {
if (Number.isInteger(cliChild?.pid)) {
try {
process.kill(-cliChild.pid, 'SIGKILL');
} catch {
// 进程组已经由启动器强制收束。
}
}
rmSync(tempDir, { recursive: true, force: true });
}
});
});