945 lines
28 KiB
JavaScript
945 lines
28 KiB
JavaScript
import { spawn } from 'node:child_process';
|
||
import { accessSync } from 'node:fs';
|
||
import fs from 'node:fs/promises';
|
||
import http from 'node:http';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
const inheritedChildEnvironment = { ...globalThis['process']['env'] };
|
||
const localConfigPath = path.join(appRoot, 'game-creator.config.local.json');
|
||
const projectRoot = path.join(
|
||
os.tmpdir(),
|
||
`genarrative-ai-game-creator-smoke-${Date.now()}`,
|
||
);
|
||
const prompt = '像素风反弹弹幕厨房';
|
||
const smokeAssetPath = 'assets/uploads/smoke-chef.png';
|
||
const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef';
|
||
const smokeAssetBytes = Buffer.concat([
|
||
Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||
'base64',
|
||
),
|
||
Buffer.from(smokeAssetMarker),
|
||
]);
|
||
const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3';
|
||
const smokeAudioAssetBytes = 'SMOKE_LOCAL_AUDIO:bounce';
|
||
const smokeProjectConversationMarker =
|
||
'SMOKE_CONVERSATION_CONTEXT:moonlight-wok';
|
||
const smokeAgentConversationMarker =
|
||
'SMOKE_AGENT_CONVERSATION_CONTEXT:neon-kitchen';
|
||
|
||
function handoffs() {
|
||
return [
|
||
[
|
||
'design',
|
||
'Gameplay',
|
||
'定义反弹厨房核心循环',
|
||
['game/game_design.md'],
|
||
'交给数值、美术、音乐、程序组',
|
||
],
|
||
[
|
||
'balance',
|
||
'Difficulty',
|
||
'给出锅盖反弹速度、生命和得分口径',
|
||
['game/balance.json'],
|
||
'交给程序组读取',
|
||
],
|
||
[
|
||
'art',
|
||
'Asset',
|
||
'规划像素厨师、夜间厨房和锅盖弹幕资产',
|
||
['assets/manifest.art.json'],
|
||
'进入画板链路',
|
||
],
|
||
[
|
||
'audio',
|
||
'SFX',
|
||
'规划锅盖反弹音效和厨房节奏 BGM',
|
||
['assets/manifest.audio.json'],
|
||
'进入音频链路',
|
||
],
|
||
[
|
||
'code',
|
||
'Code',
|
||
'生成 canvas 可玩原型',
|
||
['game/index.html'],
|
||
'交给 Playtest',
|
||
],
|
||
[
|
||
'publishing',
|
||
'Publish',
|
||
'整理标题、标签和发布说明',
|
||
['exports/README.md'],
|
||
'等待预览验收',
|
||
],
|
||
].map(([group, role, summary, outputs, next]) => ({
|
||
group,
|
||
role,
|
||
summary,
|
||
outputs,
|
||
next,
|
||
}));
|
||
}
|
||
|
||
function draft({ withInput }) {
|
||
const inputCode = withInput
|
||
? "window.addEventListener('keydown', (event) => { if (event.key.toLowerCase() === 'r') resetGame(); player.x += event.key === 'ArrowRight' ? 8 : event.key === 'ArrowLeft' ? -8 : 0; });"
|
||
: '';
|
||
return {
|
||
title: '反弹弹幕厨房',
|
||
designMarkdown:
|
||
'玩家控制像素厨师移动锅盖反弹月光弹幕,点亮三口锅后获胜,被弹幕击中耗尽生命则失败。',
|
||
balance: {
|
||
playerSpeed: 220,
|
||
playerLives: 3,
|
||
scorePerPot: 100,
|
||
difficultyRamp: '每 20 秒增加一枚弹幕',
|
||
},
|
||
artManifest: {
|
||
source: 'local-provider',
|
||
items: [
|
||
{ kind: 'character', title: '像素厨师', status: 'needs-canvas' },
|
||
{ kind: 'scene', title: '夜间厨房', status: 'needs-canvas' },
|
||
],
|
||
},
|
||
audioManifest: {
|
||
source: 'local-provider',
|
||
items: [
|
||
{
|
||
kind: 'background-music',
|
||
title: '厨房节奏 BGM',
|
||
status: 'needs-canvas',
|
||
},
|
||
{ kind: 'sound-effect', title: '锅盖反弹音', status: 'needs-canvas' },
|
||
],
|
||
},
|
||
publishReadme:
|
||
'## 标签\n\n弹幕 / 反弹 / 厨房\n\n## 下一步\n\n试玩锅盖反弹手感。',
|
||
handoffs: handoffs(),
|
||
handoffSummary:
|
||
'策划组 / Gameplay:反弹厨房核心循环\n数值组 / Difficulty:生命、速度和得分\n美术组 / Asset:像素厨房资产\n音乐组 / SFX:反弹音效\n程序组 / Code:canvas 原型\n运营组 / Publish:发布包装',
|
||
gameHtml: `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="UTF-8"><title>反弹弹幕厨房</title></head>
|
||
<body>
|
||
<canvas id="game" width="480" height="270"></canvas>
|
||
<script>
|
||
const canvas = document.getElementById('game');
|
||
const ctx = canvas.getContext('2d');
|
||
const player = { x: 80, y: 150, hp: 3 };
|
||
const chef = new Image();
|
||
chef.src = '/${smokeAssetPath}';
|
||
const bounceSound = new Audio('/${smokeAudioAssetPath}');
|
||
let frameCount = 0;
|
||
let pots = 0;
|
||
let state = 'playing';
|
||
const marker = 'LOCAL_E2E_MECHANIC:reflect-kitchen';
|
||
function resetGame() { player.x = 80; player.hp = 3; pots = 0; state = 'playing'; }
|
||
function frame() {
|
||
frameCount += 1;
|
||
document.body.dataset.smokeFrame = String(frameCount);
|
||
document.body.dataset.smokeAudio = bounceSound.src.includes('${smokeAudioAssetPath}') ? 'ready' : 'missing';
|
||
ctx.fillStyle = '#151824';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = '#ff00ff';
|
||
ctx.fillRect(4, 4, 8, 24);
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.fillRect(12, 4, 8, 24);
|
||
ctx.fillStyle = '#ffff00';
|
||
ctx.fillRect(20, 4, 8, 24);
|
||
ctx.fillStyle = '#f8e16c';
|
||
ctx.fillText(marker, 20, 32);
|
||
if (chef.complete && chef.naturalWidth > 0) ctx.drawImage(chef, player.x, player.y, 24, 24);
|
||
ctx.fillText('目标:反弹月光弹幕,点亮三口锅', 20, 60);
|
||
ctx.fillText('胜利:点亮三口锅 / 失败:生命耗尽 / R 重开', 20, 86);
|
||
if (pots >= 3) state = '胜利';
|
||
if (player.hp <= 0) state = '失败';
|
||
const sample = ctx.getImageData(4, 4, 24, 24).data;
|
||
let litPixels = 0;
|
||
const colors = new Set();
|
||
for (let index = 0; index < sample.length; index += 4) {
|
||
const red = sample[index];
|
||
const green = sample[index + 1];
|
||
const blue = sample[index + 2];
|
||
const alpha = sample[index + 3];
|
||
if (alpha > 0 && (red > 240 || green > 240 || blue > 240)) {
|
||
litPixels += 1;
|
||
if (colors.size < 64) colors.add(red + ',' + green + ',' + blue + ',' + alpha);
|
||
}
|
||
}
|
||
document.body.dataset.smokeCanvasPixels = String(litPixels);
|
||
document.body.dataset.smokeCanvasColors = String(colors.size);
|
||
requestAnimationFrame(frame);
|
||
}
|
||
${inputCode}
|
||
requestAnimationFrame(frame);
|
||
</script>
|
||
</body>
|
||
</html>`,
|
||
};
|
||
}
|
||
|
||
const responses = [
|
||
'## 核心循环\n\n反弹弹幕点亮三口锅。\n\n## Evaluator 验收\n\n必须有输入监听、主循环、胜负状态和重开路径。',
|
||
JSON.stringify(draft({ withInput: false })),
|
||
JSON.stringify(draft({ withInput: true })),
|
||
];
|
||
|
||
let responseIndex = 0;
|
||
const requestBodies = [];
|
||
const server = http.createServer((request, response) => {
|
||
let requestBody = '';
|
||
request.setEncoding('utf8');
|
||
request.on('data', (chunk) => {
|
||
requestBody += chunk;
|
||
});
|
||
request.on('end', () => {
|
||
requestBodies.push(requestBody);
|
||
let requestJson = null;
|
||
try {
|
||
requestJson = JSON.parse(requestBody);
|
||
} catch {
|
||
// Keep the request invalid so the provider fixture can return its normal error path.
|
||
}
|
||
const content = responses[responseIndex++];
|
||
if (!content) {
|
||
response.writeHead(500, { 'content-type': 'application/json' });
|
||
response.end(JSON.stringify({ error: 'local provider exhausted' }));
|
||
return;
|
||
}
|
||
if (requestJson?.stream === true) {
|
||
writeStreamingChatCompletion(response, content);
|
||
return;
|
||
}
|
||
const body = JSON.stringify({
|
||
id: `chatcmpl-local-${responseIndex}`,
|
||
model: 'local-game-creator-smoke',
|
||
choices: [
|
||
{
|
||
message: { content },
|
||
finish_reason: 'stop',
|
||
},
|
||
],
|
||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||
});
|
||
response.writeHead(200, {
|
||
'content-type': 'application/json',
|
||
'content-length': Buffer.byteLength(body),
|
||
});
|
||
response.end(body);
|
||
});
|
||
});
|
||
|
||
function writeStreamingChatCompletion(response, content) {
|
||
response.writeHead(200, {
|
||
'content-type': 'text/event-stream; charset=utf-8',
|
||
'cache-control': 'no-cache',
|
||
});
|
||
const splitAt = Math.max(1, Math.floor(content.length / 2));
|
||
for (const chunk of [content.slice(0, splitAt), content.slice(splitAt)]) {
|
||
if (!chunk) {
|
||
continue;
|
||
}
|
||
response.write(
|
||
`data: ${JSON.stringify({
|
||
id: `chatcmpl-local-${responseIndex}`,
|
||
model: 'local-game-creator-smoke',
|
||
choices: [{ delta: { content: chunk }, finish_reason: null }],
|
||
})}\n\n`,
|
||
);
|
||
}
|
||
response.write(
|
||
`data: ${JSON.stringify({
|
||
id: `chatcmpl-local-${responseIndex}`,
|
||
model: 'local-game-creator-smoke',
|
||
choices: [{ delta: {}, finish_reason: 'stop' }],
|
||
})}\n\n`,
|
||
);
|
||
response.end('data: [DONE]\n\n');
|
||
}
|
||
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
const address = server.address();
|
||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||
const previousLocalConfig = await readOptionalFile(localConfigPath);
|
||
|
||
try {
|
||
await seedLocalAsset();
|
||
await writeSmokeLocalConfig(baseUrl);
|
||
const {
|
||
output,
|
||
previewHtml,
|
||
previewUrl,
|
||
previewAsset,
|
||
previewAssetHead,
|
||
previewAudio,
|
||
previewAudioHead,
|
||
previewDom,
|
||
} = await runAgent();
|
||
const tracePath = path.join(projectRoot, '.agent/run.latest.json');
|
||
const trace = JSON.parse(await fs.readFile(tracePath, 'utf8'));
|
||
const pass2TaskGraph = JSON.parse(
|
||
await fs.readFile(
|
||
path.join(projectRoot, '.agent/passes/pass-2/task-graph.json'),
|
||
'utf8',
|
||
),
|
||
);
|
||
const agentDbRecords = (
|
||
await fs.readFile(path.join(projectRoot, '.agent/agent.db'), 'utf8')
|
||
)
|
||
.trim()
|
||
.split('\n')
|
||
.map((line) => JSON.parse(line));
|
||
const previewLog = await fs.readFile(
|
||
path.join(projectRoot, '.agent/logs/preview.log'),
|
||
'utf8',
|
||
);
|
||
const gameHtml = await fs.readFile(
|
||
path.join(projectRoot, 'game/index.html'),
|
||
'utf8',
|
||
);
|
||
const agenda2 = await fs.readFile(
|
||
path.join(projectRoot, '.agent/passes/pass-2/agenda.md'),
|
||
'utf8',
|
||
);
|
||
|
||
assert(output.includes('agent.run.completed'), 'CLI did not complete');
|
||
assert(
|
||
previewUrl?.startsWith('http://127.0.0.1:'),
|
||
'CLI did not print local preview URL',
|
||
);
|
||
assert(
|
||
previewHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'),
|
||
'local preview did not serve generated game',
|
||
);
|
||
assert(
|
||
previewDom?.includes('data-smoke-frame=') &&
|
||
previewDom.includes('data-smoke-audio="ready"'),
|
||
'headless browser did not run generated preview frame',
|
||
);
|
||
const canvasPixels = extractDomNumber(previewDom, 'smoke-canvas-pixels');
|
||
const canvasColors = extractDomNumber(previewDom, 'smoke-canvas-colors');
|
||
assert(
|
||
canvasPixels > 40 && canvasColors > 2,
|
||
`headless browser preview canvas looks blank: pixels=${canvasPixels}, colors=${canvasColors}`,
|
||
);
|
||
assert(
|
||
previewAsset.includes(smokeAssetMarker),
|
||
'local preview did not serve referenced project asset',
|
||
);
|
||
assert(
|
||
previewAudio.includes(smokeAudioAssetBytes),
|
||
'local preview did not serve referenced audio asset',
|
||
);
|
||
assert(
|
||
previewAssetHead?.statusCode === 200 &&
|
||
previewAssetHead.contentLength === String(smokeAssetBytes.length) &&
|
||
previewAssetHead.contentType === 'image/png' &&
|
||
previewAssetHead.body === '',
|
||
'local preview HEAD did not preserve referenced asset content length',
|
||
);
|
||
assert(
|
||
previewAudioHead?.statusCode === 200 &&
|
||
previewAudioHead.contentLength ===
|
||
String(Buffer.byteLength(smokeAudioAssetBytes)) &&
|
||
previewAudioHead.contentType === 'audio/mpeg' &&
|
||
previewAudioHead.body === '',
|
||
'local preview HEAD did not preserve referenced audio asset metadata',
|
||
);
|
||
assert(
|
||
responseIndex === responses.length,
|
||
`provider calls ${responseIndex}/${responses.length}`,
|
||
);
|
||
assert(
|
||
requestBodies.every((body) => body.includes('"stream":true')),
|
||
'provider requests did not use streaming LLM mode',
|
||
);
|
||
assert(
|
||
requestBodies.some((body) =>
|
||
body.includes('"model":"planner-smoke-model"'),
|
||
),
|
||
'provider requests did not use planner agent LLM override',
|
||
);
|
||
assert(
|
||
requestBodies.some((body) =>
|
||
body.includes('"model":"generator-smoke-model"'),
|
||
),
|
||
'provider requests did not use generator agent LLM override',
|
||
);
|
||
assert(
|
||
requestBodies.every((body) => !body.includes('global-smoke-model-unused')),
|
||
'provider requests unexpectedly used global LLM config',
|
||
);
|
||
assert(
|
||
requestBodies.some(
|
||
(body) =>
|
||
body.includes('# 本地项目资产') &&
|
||
body.includes(smokeAssetPath) &&
|
||
body.includes(smokeAudioAssetPath),
|
||
),
|
||
'provider requests missing local asset prompt context',
|
||
);
|
||
assert(
|
||
requestBodies.some((body) =>
|
||
body.includes(smokeProjectConversationMarker),
|
||
) &&
|
||
requestBodies.some((body) => body.includes(smokeAgentConversationMarker)),
|
||
'provider requests missing recent conversation prompt context',
|
||
);
|
||
assert(
|
||
requestBodies.every((body) => !body.includes('sk-smoke-secret')),
|
||
'provider requests leaked sensitive conversation context',
|
||
);
|
||
assert(trace.status === 'preview-stopped', `trace status ${trace.status}`);
|
||
assert(trace.passes === 2, `trace passes ${trace.passes}`);
|
||
const expectedToolCallCount = trace.steps.reduce(
|
||
(total, step) => total + (step.toolCalls?.length ?? 0),
|
||
0,
|
||
);
|
||
assert(
|
||
trace.toolCallCount === expectedToolCallCount,
|
||
`trace toolCallCount ${trace.toolCallCount}/${expectedToolCallCount}`,
|
||
);
|
||
assert(
|
||
trace.toolCallCount <= trace.maxToolCalls,
|
||
`trace tool budget ${trace.toolCallCount}/${trace.maxToolCalls}`,
|
||
);
|
||
const tracedGroups = new Set(
|
||
trace.steps.map((step) => step.group).filter(Boolean),
|
||
);
|
||
for (const group of [
|
||
'design',
|
||
'balance',
|
||
'art',
|
||
'audio',
|
||
'code',
|
||
'publishing',
|
||
]) {
|
||
assert(
|
||
tracedGroups.has(group),
|
||
`trace missing professional group ${group}`,
|
||
);
|
||
}
|
||
assert(
|
||
trace.taskGraph?.repairRoutes?.some(
|
||
(route) =>
|
||
route.reason === 'code-runtime+dependency-impact' &&
|
||
route.taskIds.includes('code-prototype') &&
|
||
route.taskIds.includes('preview-readiness') &&
|
||
route.taskIds.includes('publish-package'),
|
||
),
|
||
'trace missing code-runtime dependency-impact repair route',
|
||
);
|
||
assert(
|
||
pass2TaskGraph.repairRoutes?.some(
|
||
(route) =>
|
||
route.reason === 'code-runtime+dependency-impact' &&
|
||
route.taskIds.includes('code-prototype') &&
|
||
route.taskIds.includes('preview-readiness') &&
|
||
route.taskIds.includes('publish-package'),
|
||
),
|
||
'pass task graph missing downstream-impact code repair route',
|
||
);
|
||
assert(
|
||
trace.artifacts?.some((artifact) => artifact.path === '.agent/agent.db'),
|
||
'trace artifacts missing .agent/agent.db',
|
||
);
|
||
assert(
|
||
trace.artifacts?.some(
|
||
(artifact) => artifact.path === '.agent/passes/pass-2/task-graph.json',
|
||
),
|
||
'trace artifacts missing pass-2 task graph',
|
||
);
|
||
assert(
|
||
trace.artifacts?.some(
|
||
(artifact) => artifact.path === '.agent/logs/preview.log',
|
||
),
|
||
'trace artifacts missing preview log',
|
||
);
|
||
assert(
|
||
trace.artifacts?.some(
|
||
(artifact) => artifact.path === '.agent/logs/agent.log',
|
||
) &&
|
||
trace.artifacts?.some(
|
||
(artifact) => artifact.path === '.agent/logs/command.log',
|
||
),
|
||
'trace artifacts missing agent or command logs',
|
||
);
|
||
assert(
|
||
previewLog.includes('preview.running') &&
|
||
previewLog.includes('preview.stopped'),
|
||
'preview log missing start or stop entries',
|
||
);
|
||
assert(
|
||
agentDbRecords.some((record) => record.recordType === 'project.init') &&
|
||
agentDbRecords.some(
|
||
(record) => record.recordType === 'game.generate_draft',
|
||
),
|
||
'agent.db missing init or generation records',
|
||
);
|
||
assert(
|
||
agenda2.includes('activeTasks: code-director'),
|
||
'repair agenda did not target code tasks',
|
||
);
|
||
assert(
|
||
agenda2.includes('publish-package'),
|
||
'repair agenda did not target downstream publishing tasks',
|
||
);
|
||
assert(
|
||
agenda2.includes('carriedTasks: design-director'),
|
||
'repair agenda did not carry design tasks',
|
||
);
|
||
assert(
|
||
trace.steps.some(
|
||
(step) =>
|
||
step.pass === 2 &&
|
||
step.agent === '策划组 / Director' &&
|
||
step.status === 'carried-over',
|
||
),
|
||
'trace missing carried-over design role',
|
||
);
|
||
assert(
|
||
trace.steps.some(
|
||
(step) =>
|
||
step.pass === 2 &&
|
||
step.agent === '程序组 / Director' &&
|
||
step.toolCalls?.[0]?.toolId === 'agent.role.brief.code.director',
|
||
),
|
||
'trace missing code repair role',
|
||
);
|
||
assert(
|
||
trace.steps.some(
|
||
(step) =>
|
||
step.pass === 2 &&
|
||
step.agent === '运营组 / Publish' &&
|
||
step.toolCalls?.[0]?.toolId === 'agent.role.brief.publishing.publish',
|
||
),
|
||
'trace missing downstream publishing repair role',
|
||
);
|
||
assert(
|
||
trace.steps.some((step) =>
|
||
step.toolCalls?.some(
|
||
(toolCall) =>
|
||
toolCall.toolId === 'agent.tool.suggest.canvas.project_sync' &&
|
||
toolCall.status === 'suggested',
|
||
),
|
||
),
|
||
'trace missing canvas project sync tool suggestion',
|
||
);
|
||
assert(
|
||
trace.steps.some(
|
||
(step) =>
|
||
step.agent === 'Planner' &&
|
||
step.inputPaths?.includes('memory/session.md') &&
|
||
step.inputPaths?.includes('memory/project.md') &&
|
||
step.inputPaths?.includes('memory/blackboard.md') &&
|
||
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
|
||
step.inputPaths?.includes('.agent/conversations/agents/') &&
|
||
step.inputPaths?.includes('.agent/manifest.json'),
|
||
),
|
||
'trace missing planner memory, conversation or manifest inputs',
|
||
);
|
||
assert(
|
||
trace.steps.some(
|
||
(step) =>
|
||
step.agent === '策划组 / Director' &&
|
||
step.pass === 1 &&
|
||
step.inputPaths?.includes('memory/blackboard.md') &&
|
||
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
|
||
step.inputPaths?.includes('.agent/conversations/agents/') &&
|
||
step.inputPaths?.includes('memory/agents/design/director.md') &&
|
||
step.inputPaths?.includes('.agent/manifest.json') &&
|
||
step.inputPaths?.includes('.agent/passes/pass-1/agenda.md'),
|
||
),
|
||
'trace missing role brief conversation, manifest or agenda inputs',
|
||
);
|
||
assert(
|
||
trace.steps.some(
|
||
(step) =>
|
||
step.agent === 'Generator' &&
|
||
step.pass === 2 &&
|
||
step.inputPaths?.includes('memory/blackboard.md') &&
|
||
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
|
||
step.inputPaths?.includes('.agent/conversations/agents/') &&
|
||
step.inputPaths?.includes('.agent/manifest.json') &&
|
||
step.inputPaths?.includes('.agent/passes/pass-2/agenda.md') &&
|
||
step.inputPaths?.includes('.agent/passes/pass-2/task-graph.json'),
|
||
),
|
||
'trace missing generator conversation, manifest, agenda or task graph inputs',
|
||
);
|
||
assert(
|
||
gameHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'),
|
||
'game html marker missing',
|
||
);
|
||
assert(
|
||
gameHtml.includes('addEventListener'),
|
||
'game html input listener missing',
|
||
);
|
||
assert(
|
||
gameHtml.includes(`/${smokeAssetPath}`),
|
||
'game html did not reference seeded local asset',
|
||
);
|
||
assert(
|
||
gameHtml.includes(`/${smokeAudioAssetPath}`),
|
||
'game html did not reference seeded local audio asset',
|
||
);
|
||
|
||
console.log('ai-game-creator-shell.agent-run.smoke=passed');
|
||
console.log(`projectPath=${projectRoot}`);
|
||
console.log(`tracePath=${tracePath}`);
|
||
} finally {
|
||
await restoreOptionalFile(localConfigPath, previousLocalConfig);
|
||
server.close();
|
||
}
|
||
|
||
async function readOptionalFile(filePath) {
|
||
try {
|
||
return await fs.readFile(filePath);
|
||
} catch (error) {
|
||
if (error?.code === 'ENOENT') {
|
||
return null;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function restoreOptionalFile(filePath, previous) {
|
||
if (previous === null) {
|
||
await fs.rm(filePath, { force: true });
|
||
return;
|
||
}
|
||
await fs.writeFile(filePath, previous);
|
||
}
|
||
|
||
async function writeSmokeLocalConfig(baseUrl) {
|
||
await fs.writeFile(
|
||
localConfigPath,
|
||
`${JSON.stringify(
|
||
{
|
||
llm: {
|
||
apiKey: 'global-smoke-key-unused',
|
||
baseUrl: 'http://127.0.0.1:1/v1',
|
||
model: 'global-smoke-model-unused',
|
||
apiKind: 'openai_chat',
|
||
stream: true,
|
||
},
|
||
agentLlm: {
|
||
planner: {
|
||
apiKey: 'planner-smoke-key',
|
||
baseUrl,
|
||
model: 'planner-smoke-model',
|
||
apiKind: 'openai_chat',
|
||
stream: true,
|
||
},
|
||
generator: {
|
||
apiKey: 'generator-smoke-key',
|
||
baseUrl,
|
||
model: 'generator-smoke-model',
|
||
apiKind: 'openai_chat',
|
||
stream: true,
|
||
},
|
||
},
|
||
},
|
||
null,
|
||
2,
|
||
)}\n`,
|
||
);
|
||
}
|
||
|
||
function runAgent() {
|
||
return new Promise((resolve, reject) => {
|
||
let previewReadStarted = false;
|
||
let previewUrl = '';
|
||
let previewHtml = '';
|
||
let previewAsset = '';
|
||
let previewAssetHead = null;
|
||
let previewAudio = '';
|
||
let previewAudioHead = null;
|
||
let previewDom = '';
|
||
const child = spawn(
|
||
'cargo',
|
||
[
|
||
'run',
|
||
'--manifest-path',
|
||
'src-tauri/Cargo.toml',
|
||
'--',
|
||
'--agent-run',
|
||
projectRoot,
|
||
prompt,
|
||
],
|
||
{
|
||
cwd: appRoot,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
env: {
|
||
...inheritedChildEnvironment,
|
||
// 该 smoke 只使用一次性 loopback Provider;生产路由仍保持锁定。
|
||
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
|
||
},
|
||
},
|
||
);
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let previewReadError = null;
|
||
child.stdout.on('data', (chunk) => {
|
||
stdout += chunk.toString();
|
||
if (previewReadStarted) {
|
||
return;
|
||
}
|
||
const line = stdout
|
||
.split('\n')
|
||
.find((entry) => entry.startsWith('previewUrl='));
|
||
if (!line) {
|
||
return;
|
||
}
|
||
previewReadStarted = true;
|
||
previewUrl = line.slice('previewUrl='.length).trim();
|
||
Promise.all([
|
||
readHttpText(previewUrl),
|
||
readHttpText(new URL(smokeAssetPath, previewUrl).toString()),
|
||
readHttpHead(new URL(smokeAssetPath, previewUrl).toString()),
|
||
readHttpText(new URL(smokeAudioAssetPath, previewUrl).toString()),
|
||
readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString()),
|
||
readBrowserDom(previewUrl),
|
||
])
|
||
.then(([html, asset, assetHead, audio, audioHead, dom]) => {
|
||
previewHtml = html;
|
||
previewAsset = asset;
|
||
previewAssetHead = assetHead;
|
||
previewAudio = audio;
|
||
previewAudioHead = audioHead;
|
||
previewDom = dom;
|
||
})
|
||
.catch((error) => {
|
||
previewReadError = error;
|
||
})
|
||
.finally(() => {
|
||
child.stdin.write('\n');
|
||
});
|
||
});
|
||
child.stderr.on('data', (chunk) => {
|
||
stderr += chunk.toString();
|
||
});
|
||
child.on('error', reject);
|
||
child.on('close', (code) => {
|
||
const output = `${stdout}${stderr}`;
|
||
if (previewReadError) {
|
||
reject(previewReadError);
|
||
return;
|
||
}
|
||
if (code === 0) {
|
||
resolve({
|
||
output,
|
||
previewHtml,
|
||
previewUrl,
|
||
previewAsset,
|
||
previewAssetHead,
|
||
previewAudio,
|
||
previewAudioHead,
|
||
previewDom,
|
||
});
|
||
} else {
|
||
reject(new Error(output || `agent run exited with ${code}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function seedLocalAsset() {
|
||
await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true });
|
||
await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true });
|
||
await seedConversationContext();
|
||
await fs.writeFile(path.join(projectRoot, smokeAssetPath), smokeAssetBytes);
|
||
await fs.writeFile(
|
||
path.join(projectRoot, smokeAudioAssetPath),
|
||
smokeAudioAssetBytes,
|
||
);
|
||
await fs.writeFile(
|
||
path.join(projectRoot, '.agent/manifest.json'),
|
||
JSON.stringify(
|
||
{
|
||
schemaVersion: 'game-creation-app.manifest.v1',
|
||
projectId: 'local-provider-smoke',
|
||
name: '本地 Provider Smoke',
|
||
assets: [
|
||
{
|
||
id: 'smoke-chef',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: smokeAssetPath,
|
||
source: { kind: 'uploaded' },
|
||
},
|
||
{
|
||
id: 'smoke-bounce',
|
||
kind: 'sound-effect',
|
||
mediaType: 'audio/mpeg',
|
||
localPath: smokeAudioAssetPath,
|
||
source: { kind: 'uploaded' },
|
||
},
|
||
],
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
}
|
||
|
||
async function seedConversationContext() {
|
||
const projectConversationPath = path.join(
|
||
projectRoot,
|
||
'.agent/conversations/project.jsonl',
|
||
);
|
||
const agentConversationPath = path.join(
|
||
projectRoot,
|
||
'.agent/conversations/agents/art-asset-plan.jsonl',
|
||
);
|
||
await fs.mkdir(path.dirname(projectConversationPath), { recursive: true });
|
||
await fs.mkdir(path.dirname(agentConversationPath), { recursive: true });
|
||
await fs.writeFile(
|
||
projectConversationPath,
|
||
`${JSON.stringify({
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: `玩家坚持使用 ${smokeProjectConversationMarker}`,
|
||
agentId: null,
|
||
updatedAt: 1,
|
||
})}\n`,
|
||
);
|
||
await fs.writeFile(
|
||
agentConversationPath,
|
||
`${JSON.stringify({
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: `API Key sk-smoke-secret\n美术方向 ${smokeAgentConversationMarker}`,
|
||
agentId: 'art-asset-plan',
|
||
updatedAt: 2,
|
||
})}\n`,
|
||
);
|
||
}
|
||
|
||
function readHttpText(url) {
|
||
return new Promise((resolve, reject) => {
|
||
http
|
||
.get(url, (response) => {
|
||
let body = '';
|
||
response.setEncoding('utf8');
|
||
response.on('data', (chunk) => {
|
||
body += chunk;
|
||
});
|
||
response.on('end', () => resolve(body));
|
||
})
|
||
.on('error', reject);
|
||
});
|
||
}
|
||
|
||
function readHttpHead(url) {
|
||
return new Promise((resolve, reject) => {
|
||
const request = http.request(url, { method: 'HEAD' }, (response) => {
|
||
let body = '';
|
||
response.setEncoding('utf8');
|
||
response.on('data', (chunk) => {
|
||
body += chunk;
|
||
});
|
||
response.on('end', () =>
|
||
resolve({
|
||
statusCode: response.statusCode,
|
||
contentLength: response.headers['content-length'],
|
||
contentType: response.headers['content-type'],
|
||
body,
|
||
}),
|
||
);
|
||
});
|
||
request.on('error', reject);
|
||
request.end();
|
||
});
|
||
}
|
||
|
||
function readBrowserDom(url) {
|
||
return new Promise((resolve, reject) => {
|
||
const chrome = resolveChromeBin();
|
||
const child = spawn(
|
||
chrome,
|
||
[
|
||
'--headless',
|
||
'--disable-gpu',
|
||
'--disable-dev-shm-usage',
|
||
'--no-sandbox',
|
||
'--virtual-time-budget=1000',
|
||
'--dump-dom',
|
||
url,
|
||
],
|
||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||
);
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.on('data', (chunk) => {
|
||
stdout += chunk.toString();
|
||
});
|
||
child.stderr.on('data', (chunk) => {
|
||
stderr += chunk.toString();
|
||
});
|
||
child.on('error', reject);
|
||
child.on('close', (code) => {
|
||
if (code === 0) {
|
||
resolve(stdout);
|
||
} else {
|
||
reject(new Error(stderr || `headless Chrome exited with ${code}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function resolveChromeBin() {
|
||
const windowsRoot = path.parse(os.homedir()).root;
|
||
for (const candidate of [
|
||
path.join(
|
||
windowsRoot,
|
||
'Program Files/Google/Chrome/Application/chrome.exe',
|
||
),
|
||
path.join(
|
||
windowsRoot,
|
||
'Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||
),
|
||
path.join(
|
||
os.homedir(),
|
||
'AppData/Local/Google/Chrome/Application/chrome.exe',
|
||
),
|
||
path.join(
|
||
windowsRoot,
|
||
'Program Files/Microsoft/Edge/Application/msedge.exe',
|
||
),
|
||
path.join(
|
||
windowsRoot,
|
||
'Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||
),
|
||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||
'/opt/google/chrome/chrome',
|
||
'/usr/bin/google-chrome',
|
||
'/usr/bin/google-chrome-stable',
|
||
'/usr/bin/chromium',
|
||
'/usr/bin/chromium-browser',
|
||
]) {
|
||
try {
|
||
accessSync(candidate);
|
||
return candidate;
|
||
} catch {
|
||
// Try the next supported system browser path.
|
||
}
|
||
}
|
||
return 'google-chrome';
|
||
}
|
||
|
||
function extractDomNumber(dom, kebabName) {
|
||
const match = dom?.match(new RegExp(`data-${kebabName}="(\\d+)"`));
|
||
return match ? Number(match[1]) : 0;
|
||
}
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) {
|
||
throw new Error(message);
|
||
}
|
||
}
|