Files
Genarrative/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs
T
AIGameCreator App 2ec05ba85d 修复自主构建验证收敛与浏览器取证
限制旧试玩失败下的重复委派并强制当前 revision 复验

收紧固定试玩控件唯一可见启用合同

为 Chrome 使用短临时目录并支持当前 run 截图别名

补齐确定性门禁与运行时决策记录
2026-07-22 14:43:55 +08:00

400 lines
13 KiB
JavaScript

import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createDeterministicLaneDefenseRouter,
deterministicLaneDefenseInitialHtml,
deterministicLaneDefenseModel,
hiddenCanvasCss,
startDeterministicLaneDefenseProvider,
visibleCanvasCss,
} from './deterministic-lane-defense-provider.mjs';
import { withLoopbackNoProxy } from './llm-transient-fault-proxy.mjs';
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const repoRoot = path.resolve(appRoot, '../..');
const realE2eScript = path.join(appRoot, 'scripts/agent-runtime-real-e2e.mjs');
const suite = 'supervisor-autonomous-playable-lane-defense';
const wrapperSuite =
'supervisor-autonomous-playable-lane-defense-deterministic';
const configFileName = 'game-creator.config.json';
const configSentinelName = '.deterministic-provider-e2e.json';
const configSentinelSchema = 'genarrative-deterministic-provider-e2e-config.v1';
const outputLimit = 32 * 1024 * 1024;
function hashValue(value) {
return createHash('sha256').update(value).digest('hex');
}
function assert(condition, code) {
if (condition) return;
const error = new Error(code);
error.code = code;
throw error;
}
function parseArguments(args) {
let keepProject = false;
let selfTest = false;
for (const arg of args) {
if (arg === '--keep-project') keepProject = true;
else if (arg === '--self-test') selfTest = true;
else throw new Error('unknown-argument');
}
assert(!(keepProject && selfTest), 'self-test-keep-project-conflict');
return { keepProject, selfTest };
}
function appendBounded(current, chunk) {
const combined = Buffer.concat([current, chunk]);
if (combined.length > outputLimit) throw new Error('child-output-too-large');
return combined;
}
function runChild(args, environment) {
return new Promise((resolve) => {
const child = spawn(process.execPath, args, {
cwd: appRoot,
env: environment,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let outputError = null;
child.stdout.on('data', (chunk) => {
try {
stdout = appendBounded(stdout, chunk);
} catch (error) {
outputError = error;
child.kill('SIGTERM');
}
});
child.stderr.on('data', (chunk) => {
try {
stderr = appendBounded(stderr, chunk);
} catch (error) {
outputError = error;
child.kill('SIGTERM');
}
});
child.once('error', (error) =>
resolve({ code: null, signal: null, error, stdout, stderr }),
);
child.once('close', (code, signal) =>
resolve({ code, signal, error: outputError, stdout, stderr }),
);
});
}
function safeChildDiagnostic(result) {
return {
exitCode: Number.isInteger(result.code) ? result.code : null,
signal: typeof result.signal === 'string' ? result.signal : null,
errorCode:
typeof result.error?.code === 'string' ? result.error.code : null,
stdoutBytes: result.stdout.length,
stdoutSha256: hashValue(result.stdout),
stderrBytes: result.stderr.length,
stderrSha256: hashValue(result.stderr),
};
}
function parseChildReport(result) {
try {
const report = JSON.parse(result.stdout.toString('utf8'));
assert(report && typeof report === 'object', 'child-report-root-invalid');
return report;
} catch (error) {
if (error?.code) throw error;
const wrapped = new Error('child-report-json-invalid');
wrapped.code = 'child-report-json-invalid';
throw wrapped;
}
}
function expectedProviderStats(stats) {
return (
stats.requestCount === 17 &&
stats.planningRequestCount === 17 &&
stats.finalReplyRequestCount === 0 &&
stats.initialDelegationCount === 2 &&
stats.followupDelegationCount === 1 &&
stats.runStatusCount === 2 &&
stats.sourceWriteCount === 1 &&
stats.sourcePatchCount === 1 &&
stats.staticSmokeCount === 4 &&
stats.previewValidationCount === 2 &&
stats.supervisorDirectMutationAttemptCount === 1 &&
stats.unexpectedRequestCount === 0 &&
Object.keys(stats.rejectionCodes ?? {}).length === 0 &&
stats.byAgent?.['project-supervisor']?.planning === 9 &&
stats.byAgent?.['project-supervisor']?.finalReply === 0 &&
stats.byAgent?.['code-prototype']?.planning === 6 &&
stats.byAgent?.['code-prototype']?.finalReply === 0 &&
stats.byAgent?.['quality-review']?.planning === 2 &&
stats.byAgent?.['quality-review']?.finalReply === 0
);
}
function syntheticPayload(agentId, runId, tools, extraContext = '') {
return {
model: deterministicLaneDefenseModel,
stream: false,
messages: [
{
role: 'user',
content: `- templateAgentId: ${agentId}\n- runId: ${runId}\n${extraContext}`,
},
],
tools: tools.map((name) => ({ type: 'function', function: { name } })),
};
}
function responseFunctionNames(response) {
return response.choices[0].message.tool_calls.map(
(call) => call.function.name,
);
}
async function runSelfTest() {
const html = deterministicLaneDefenseInitialHtml();
assert([...html].length <= 8_000, 'self-test-html-source-budget-invalid');
assert(
html.includes(hiddenCanvasCss) &&
!html.includes(visibleCanvasCss) &&
html.includes('playable-web-game-state.v1') &&
html.includes('data-playtest-id="next-level"') &&
html.includes('Goal: defend the garden and win every wave.') &&
html.includes('requestAnimationFrame'),
'self-test-html-contract-invalid',
);
const apiKey = `deterministic-self-test-${randomUUID()}`;
const router = createDeterministicLaneDefenseRouter({ apiKey });
const allTools = [
'update_agent_plan',
'respond_to_user',
'runtime_tool_agent_delegate',
'runtime_tool_agent_run_status',
'runtime_tool_command_run_limited',
'runtime_tool_file_patch',
'runtime_tool_file_write',
'runtime_tool_preview_validate',
];
const route = (agentId, runId, tools = allTools, extraContext = '') =>
router.route({
authorization: `Bearer ${apiKey}`,
payload: syntheticPayload(agentId, runId, tools, extraContext),
});
assert(
responseFunctionNames(route('project-supervisor', 'parent-run')).join(
',',
) === 'runtime_tool_agent_delegate,runtime_tool_agent_delegate',
'self-test-initial-delegation-invalid',
);
const qualityPlan =
'计划进度:\n- #1 [in_progress] 核对完整玩法\n- #2 [pending] 回传结论\n工具策略:auto=无';
const builderPlan =
'计划进度:\n- #1 [completed] 生成入口\n- #2 [completed] 静态验证\n- #3 [in_progress] 回传结论\n工具策略:auto=无';
route('quality-review', 'quality-run', allTools, qualityPlan);
route('code-prototype', 'initial-code-run');
route('code-prototype', 'initial-code-run');
assert(
responseFunctionNames(
route('code-prototype', 'initial-code-run', allTools, builderPlan),
).join(',') === 'update_agent_plan,respond_to_user',
'self-test-builder-plan-completion-invalid',
);
assert(
responseFunctionNames(
route('quality-review', 'quality-run', allTools, qualityPlan),
).join(',') === 'update_agent_plan,respond_to_user',
'self-test-quality-stale-replan-invalid',
);
route('project-supervisor', 'parent-run');
route('project-supervisor', 'parent-run');
route('project-supervisor', 'parent-run');
assert(
responseFunctionNames(route('project-supervisor', 'parent-run'))[0] ===
'runtime_tool_file_patch',
'self-test-forbidden-parent-mutation-invalid',
);
assert(
responseFunctionNames(
route(
'project-supervisor',
'parent-run',
['runtime_tool_agent_delegate'],
'当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate',
),
)[0] === 'runtime_tool_agent_delegate',
'self-test-followup-delegation-invalid',
);
route('code-prototype', 'repair-code-run');
route('code-prototype', 'repair-code-run');
route('code-prototype', 'repair-code-run', allTools, builderPlan);
assert(
responseFunctionNames(route('project-supervisor', 'parent-run'))[0] ===
'runtime_tool_agent_run_status',
'self-test-repair-claim-before-verification-invalid',
);
assert(
responseFunctionNames(route('project-supervisor', 'parent-run')).join(
',',
) === 'runtime_tool_command_run_limited,runtime_tool_preview_validate',
'self-test-repair-verification-batch-invalid',
);
route('project-supervisor', 'parent-run');
const stats = router.getStats();
assert(expectedProviderStats(stats), 'self-test-provider-stats-invalid');
const rootPackage = JSON.parse(
await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8'),
);
const shellPackage = JSON.parse(
await fs.readFile(path.join(appRoot, 'package.json'), 'utf8'),
);
assert(
shellPackage.scripts?.[
'agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e'
] === 'node scripts/agent-runtime-deterministic-playable-e2e.mjs' &&
rootPackage.scripts?.[
'ai-game-creator-shell:agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e'
] ===
'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --',
'self-test-package-command-invalid',
);
return {
status: 'PASS',
suite: `${wrapperSuite}-self-test`,
providerUsed: false,
htmlChars: [...html].length,
providerStats: stats,
packageCommandsRegistered: true,
};
}
async function runE2e(options) {
const token = randomUUID();
const apiKey = `deterministic-runtime-${randomUUID()}`;
const configDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'genarrative-deterministic-provider-config-'),
);
let provider = null;
let childResult = null;
let childReport = null;
let configRemoved = false;
let providerStats = null;
let failureCode = null;
try {
if (process.platform !== 'win32') await fs.chmod(configDir, 0o700);
await fs.writeFile(
path.join(configDir, configSentinelName),
`${JSON.stringify({ schemaVersion: configSentinelSchema, token })}\n`,
{ flag: 'wx', mode: 0o600 },
);
provider = await startDeterministicLaneDefenseProvider({ apiKey });
const config = {
llm: {
apiKey,
baseUrl: provider.baseUrl,
model: deterministicLaneDefenseModel,
apiKind: 'openai_chat',
reasoningEffort: 'low',
stream: false,
requestTimeoutMs: 30_000,
maxRetries: 0,
retryBackoffMs: 100,
},
};
await fs.writeFile(
path.join(configDir, configFileName),
`${JSON.stringify(config)}\n`,
{ flag: 'wx', mode: 0o600 },
);
const childArgs = [
realE2eScript,
'--suite',
suite,
'--config-dir',
configDir,
];
if (options.keepProject) childArgs.push('--keep-project');
childResult = await runChild(
childArgs,
withLoopbackNoProxy({ ...process.env, NO_COLOR: '1' }),
);
childReport = parseChildReport(childResult);
} catch (error) {
failureCode = error?.code ?? 'deterministic-e2e-unexpected-error';
} finally {
if (provider) {
try {
await provider.stop();
providerStats = provider.getStats();
} catch {
failureCode ??= 'deterministic-provider-stop-failed';
}
}
try {
const sentinel = JSON.parse(
await fs.readFile(path.join(configDir, configSentinelName), 'utf8'),
);
assert(
sentinel.schemaVersion === configSentinelSchema &&
sentinel.token === token,
'deterministic-config-sentinel-invalid',
);
await fs.rm(configDir, { recursive: true, force: false });
configRemoved = true;
} catch (error) {
failureCode ??= error?.code ?? 'deterministic-config-cleanup-failed';
}
}
const childPassed =
childResult?.code === 0 &&
childResult?.signal === null &&
!childResult?.error &&
childReport?.status === 'PASS' &&
childReport?.suite === suite &&
childReport?.cleanup?.performed === !options.keepProject &&
childReport?.cleanup?.kept === options.keepProject;
const providerPassed =
providerStats?.stopped === true && expectedProviderStats(providerStats);
const status =
!failureCode && childPassed && providerPassed && configRemoved
? 'PASS'
: 'FAIL';
if (status !== 'PASS' && !failureCode) {
failureCode = !childPassed
? 'deterministic-child-e2e-failed'
: !providerPassed
? 'deterministic-provider-contract-failed'
: 'deterministic-config-not-cleaned';
}
return {
status,
suite: wrapperSuite,
providerMode: 'deterministic-loopback-openai-chat',
delegatedSuite: suite,
child: childReport,
provider: providerStats,
cleanup: {
providerStopped: providerStats?.stopped === true,
configRemoved,
projectKept: options.keepProject,
},
childDiagnostic: childResult ? safeChildDiagnostic(childResult) : null,
failureCode,
};
}
const options = parseArguments(process.argv.slice(2));
const report = options.selfTest ? await runSelfTest() : await runE2e(options);
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
process.exitCode = report.status === 'PASS' ? 0 : 1;