3f5d3b43af
- 合入 origin/master 6 个提交(#313 项目写锁残留回收与启动诊断、#315 AGC Ctrl+C 残留后端修复) - 冲突解决:decision-log.md 保留 V3 两条 2026-09-09 决策与 master 的写锁回收条目 - 冲突解决:pitfalls.md 保留 V3 转场经验条目与 master 的 Ctrl+C 后端残留条目
1875 lines
57 KiB
JavaScript
1875 lines
57 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { EventEmitter } from 'node:events';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import ts from 'typescript';
|
|
|
|
import {
|
|
appIdentifier,
|
|
defaultRealSwarmTestTask,
|
|
} from './agent-swarm-test-chat.mjs';
|
|
import {
|
|
askHidden,
|
|
assertSafeGameCreatorConfigDestination,
|
|
buildGameCreatorWizardConfig,
|
|
readGameCreatorWizardConfigState,
|
|
writeGameCreatorConfigAtomically,
|
|
writeGameCreatorWizardConfig,
|
|
} from './game-creator-config-wizard.mjs';
|
|
|
|
const packageConfig = JSON.parse(
|
|
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
);
|
|
const tauriConfig = JSON.parse(
|
|
fs.readFileSync(
|
|
new URL('../src-tauri/tauri.conf.json', import.meta.url),
|
|
'utf8',
|
|
),
|
|
);
|
|
const windowsTauriConfig = JSON.parse(
|
|
fs.readFileSync(
|
|
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
|
|
'utf8',
|
|
),
|
|
);
|
|
const cargoManifestSource = fs.readFileSync(
|
|
new URL('../src-tauri/Cargo.toml', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const cargoPackageVersion = cargoManifestSource
|
|
.split(/\r?\n(?=\[)/u)
|
|
.find((section) => section.startsWith('[package]'))
|
|
?.match(/^version\s*=\s*"([^"]+)"\s*$/mu)?.[1];
|
|
const eventCapabilityPath = new URL(
|
|
'../src-tauri/capabilities/events.json',
|
|
import.meta.url,
|
|
);
|
|
const defaultAppConfig = JSON.parse(
|
|
fs.readFileSync(
|
|
new URL('../game-creator.config.json', import.meta.url),
|
|
'utf8',
|
|
),
|
|
);
|
|
const rootPackageConfig = JSON.parse(
|
|
fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
|
|
);
|
|
const swarmTestChatSource = fs.readFileSync(
|
|
new URL('../scripts/agent-swarm-test-chat.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const viteConfigSource = fs.readFileSync(
|
|
new URL('../vite.config.ts', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const devPortSource = fs.readFileSync(
|
|
new URL('../scripts/dev-port.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const startTauriDevSource = fs.readFileSync(
|
|
new URL('../scripts/start-tauri-dev.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const appSource = [
|
|
readSourceTree(new URL('../src/', import.meta.url), '.ts'),
|
|
readSourceTree(new URL('../src/', import.meta.url), '.tsx'),
|
|
].join('\n');
|
|
const appInvokeSources = readSourceFiles(
|
|
new URL('../src/', import.meta.url),
|
|
new Set(['.ts', '.tsx']),
|
|
);
|
|
const appEntrypointSource = fs.readFileSync(
|
|
new URL('../src/main.tsx', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const tauriHandlerSource = fs.readFileSync(
|
|
new URL('../src-tauri/src/main.rs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const tauriRustSource = readSourceTree(
|
|
new URL('../src-tauri/src/', import.meta.url),
|
|
'.rs',
|
|
);
|
|
const sharedContractSource = fs.readFileSync(
|
|
new URL(
|
|
'../../../packages/shared/src/contracts/gameCreationApp.ts',
|
|
import.meta.url,
|
|
),
|
|
'utf8',
|
|
);
|
|
const rustSharedContractSource = fs.readFileSync(
|
|
new URL(
|
|
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
|
|
import.meta.url,
|
|
),
|
|
'utf8',
|
|
);
|
|
const allowedUncalledTauriCommands = [
|
|
'append_direct_project_conversation_message',
|
|
'chat_with_game_creator_agent',
|
|
'check_ui_editor_font_glyph_coverage',
|
|
'create_ui_design_resource',
|
|
'normalize_local_project_raster_resource',
|
|
'open_game_creator_launcher_window',
|
|
'open_game_creator_workspace_window',
|
|
'read_direct_project_conversation',
|
|
'stop_local_game_preview_if_matches',
|
|
'start_game_creator_external_mcp',
|
|
'stop_game_creator_external_mcp',
|
|
];
|
|
const sourceExtensions = new Set([
|
|
'.json',
|
|
'.md',
|
|
'.mjs',
|
|
'.rs',
|
|
'.toml',
|
|
'.ts',
|
|
'.tsx',
|
|
]);
|
|
|
|
function collectFiles(path) {
|
|
const stat = fs.statSync(path);
|
|
if (stat.isDirectory()) {
|
|
return fs.readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
|
|
if (entry.name === 'node_modules' || entry.name === 'target') {
|
|
return [];
|
|
}
|
|
return collectFiles(
|
|
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
|
|
);
|
|
});
|
|
}
|
|
if (sourceExtensions.has(pathnameExtension(path.pathname))) {
|
|
return [path];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function readSourceTree(path, extension) {
|
|
const stat = fs.statSync(path);
|
|
if (stat.isDirectory()) {
|
|
return fs
|
|
.readdirSync(path, { withFileTypes: true })
|
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
.map((entry) =>
|
|
readSourceTree(
|
|
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
|
|
extension,
|
|
),
|
|
)
|
|
.join('\n');
|
|
}
|
|
if (pathnameExtension(path.pathname) !== extension) {
|
|
return '';
|
|
}
|
|
return fs.readFileSync(path, 'utf8');
|
|
}
|
|
|
|
function readSourceFiles(path, extensions) {
|
|
const stat = fs.statSync(path);
|
|
if (stat.isDirectory()) {
|
|
return fs
|
|
.readdirSync(path, { withFileTypes: true })
|
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
.flatMap((entry) =>
|
|
readSourceFiles(
|
|
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
|
|
extensions,
|
|
),
|
|
);
|
|
}
|
|
if (!extensions.has(pathnameExtension(path.pathname))) return [];
|
|
return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }];
|
|
}
|
|
|
|
function pathnameExtension(pathname) {
|
|
const index = pathname.lastIndexOf('.');
|
|
return index === -1 ? '' : pathname.slice(index);
|
|
}
|
|
|
|
function assertNoOpenAiApiKeys(paths) {
|
|
const secretPattern = /sk-[A-Za-z0-9_-]{20,}/;
|
|
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
|
|
const source = fs.readFileSync(path, 'utf8');
|
|
if (secretPattern.test(source)) {
|
|
throw new Error(
|
|
`AI game creator shell source must not contain API keys: ${path.pathname}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertNoEnvironmentConfigFallbacks(paths) {
|
|
const allowedDevCheck = 'import.meta.env.DEV';
|
|
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
|
|
const source = fs
|
|
.readFileSync(path, 'utf8')
|
|
.replaceAll(allowedDevCheck, '')
|
|
.replaceAll('game-creator.config.local.json', '');
|
|
if (/\bprocess\.env\b|\bdotenv\b/.test(source)) {
|
|
throw new Error(
|
|
`AI game creator shell must use runtime config, not environment config: ${path.pathname}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertNoNativeBrowserConfirm(paths) {
|
|
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
|
|
const source = fs.readFileSync(path, 'utf8');
|
|
if (/\bwindow\.confirm\b/.test(source)) {
|
|
throw new Error(
|
|
`AI game creator shell confirmations must use in-app UI: ${path.pathname}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertNoBlockingNativeFilePicker(source) {
|
|
if (/\.blocking_pick_(?:file|files|folder|folders)\s*\(/.test(source)) {
|
|
throw new Error(
|
|
'AI game creator shell native file pickers must not block the Tauri event loop',
|
|
);
|
|
}
|
|
}
|
|
|
|
function extractConstArrayBlock(source, name) {
|
|
const start = source.indexOf(`const ${name}`);
|
|
if (start === -1) {
|
|
throw new Error(`Missing contract array: ${name}`);
|
|
}
|
|
const end = source.indexOf('];', start);
|
|
if (end === -1) {
|
|
throw new Error(`Missing contract array end: ${name}`);
|
|
}
|
|
return source.slice(start, end + 2);
|
|
}
|
|
|
|
function parseTsCommands(source) {
|
|
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
|
|
return Array.from(
|
|
block.matchAll(/\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g),
|
|
([, id, permission]) => ({ id, permission }),
|
|
);
|
|
}
|
|
|
|
function parseRustCommands(source) {
|
|
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
|
|
const permissionNames = {
|
|
Auto: 'auto',
|
|
Confirm: 'confirm',
|
|
Deny: 'deny',
|
|
};
|
|
return Array.from(
|
|
block.matchAll(
|
|
/command\(\s*"([^"]+)",\s*GameCreationAppPermission::(Auto|Confirm|Deny)\s*\)/g,
|
|
),
|
|
([, id, permission]) => ({ id, permission: permissionNames[permission] }),
|
|
);
|
|
}
|
|
|
|
function parseTsCapabilities(source) {
|
|
const block = extractConstArrayBlock(
|
|
source,
|
|
'GAME_CREATION_AGENT_CAPABILITIES',
|
|
);
|
|
return Array.from(
|
|
block.matchAll(
|
|
/\{\s*id:\s*'([^']+)',\s*area:\s*'([^']+)',\s*title:\s*'([^']+)',?\s*\}/g,
|
|
),
|
|
([, id, area, title]) => ({ id, area, title }),
|
|
);
|
|
}
|
|
|
|
function parseRustCapabilities(source) {
|
|
const block = extractConstArrayBlock(
|
|
source,
|
|
'GAME_CREATION_AGENT_CAPABILITIES',
|
|
);
|
|
return Array.from(
|
|
block.matchAll(
|
|
/capability\(\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",?\s*\)/g,
|
|
),
|
|
([, id, area, title]) => ({ id, area, title }),
|
|
);
|
|
}
|
|
|
|
function assertContractRecordsMatch(label, leftRecords, rightRecords) {
|
|
const normalize = (records) =>
|
|
records
|
|
.map((record) => JSON.stringify(record))
|
|
.sort((left, right) => left.localeCompare(right));
|
|
const left = normalize(leftRecords);
|
|
const right = normalize(rightRecords);
|
|
if (left.length === 0 || right.length === 0) {
|
|
throw new Error(`${label} parser returned no records`);
|
|
}
|
|
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
throw new Error(
|
|
`${label} drifted between TypeScript and Rust contracts\nTS=${left.join(
|
|
'\n',
|
|
)}\nRust=${right.join('\n')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024;
|
|
const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024;
|
|
const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024;
|
|
const APP_INVOKE_COMMAND_MAX_LENGTH = 128;
|
|
const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024;
|
|
const APP_INVOKE_BARE_CALL_NAMES = new Set([
|
|
'invoke',
|
|
'directInvoke',
|
|
'invokeInput',
|
|
'invokeAuthenticatedInput',
|
|
'invokeDiagnostic',
|
|
]);
|
|
|
|
function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
|
|
const sourceByteLength = Buffer.byteLength(source, 'utf8');
|
|
if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) {
|
|
throw new Error(
|
|
`AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`,
|
|
);
|
|
}
|
|
const sourceFile = ts.createSourceFile(
|
|
fileName,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
);
|
|
const parseDiagnostic = sourceFile.parseDiagnostics[0];
|
|
if (parseDiagnostic !== undefined) {
|
|
throw new Error(
|
|
`AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`,
|
|
);
|
|
}
|
|
const commands = [];
|
|
const visit = (node) => {
|
|
if (ts.isCallExpression(node)) {
|
|
const expression = node.expression;
|
|
const isBareCall =
|
|
ts.isIdentifier(expression) &&
|
|
APP_INVOKE_BARE_CALL_NAMES.has(expression.text);
|
|
const isObjectInvoke =
|
|
ts.isPropertyAccessExpression(expression) &&
|
|
expression.name.text === 'invoke';
|
|
const commandArgument =
|
|
ts.isIdentifier(expression) && expression.text === 'invokeDiagnostic'
|
|
? node.arguments[1]
|
|
: node.arguments[0];
|
|
if (
|
|
(isBareCall || isObjectInvoke) &&
|
|
commandArgument !== undefined &&
|
|
ts.isStringLiteral(commandArgument) &&
|
|
commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH &&
|
|
/^[a-z0-9_]+$/u.test(commandArgument.text)
|
|
) {
|
|
commands.push(commandArgument.text);
|
|
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
|
|
throw new Error(
|
|
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sourceFile);
|
|
return commands;
|
|
}
|
|
|
|
function parseAppInvokeSourceFiles(files) {
|
|
if (files.length > APP_INVOKE_FILE_MAX_COUNT) {
|
|
throw new Error(
|
|
`AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`,
|
|
);
|
|
}
|
|
const totalLength = files.reduce(
|
|
(length, file) => length + Buffer.byteLength(file.source, 'utf8'),
|
|
0,
|
|
);
|
|
if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) {
|
|
throw new Error(
|
|
`AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`,
|
|
);
|
|
}
|
|
const commands = files.flatMap(({ fileName, source }) =>
|
|
parseAppInvokeCommandNames(source, fileName),
|
|
);
|
|
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
|
|
throw new Error(
|
|
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`,
|
|
);
|
|
}
|
|
return commands;
|
|
}
|
|
|
|
function parseTauriHandlerCommandNames(source) {
|
|
const match = source.match(/tauri::generate_handler!\[([\s\S]*?)\]/);
|
|
if (!match) {
|
|
throw new Error('AI game creator shell Tauri handler list is missing');
|
|
}
|
|
return Array.from(
|
|
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
|
|
([, command]) => command,
|
|
);
|
|
}
|
|
|
|
function parseRustFunctionNames(source) {
|
|
return Array.from(
|
|
source.matchAll(/\b(?:async\s+)?fn\s+([a-z][a-z0-9_]*)\s*\(/g),
|
|
([, name]) => name,
|
|
);
|
|
}
|
|
|
|
function assertCommandNamesSubset(label, leftNames, rightNames) {
|
|
const right = new Set(rightNames);
|
|
const missing = Array.from(new Set(leftNames))
|
|
.filter((name) => !right.has(name))
|
|
.sort((left, rightName) => left.localeCompare(rightName));
|
|
if (missing.length > 0) {
|
|
throw new Error(`${label} missing commands: ${missing.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
function assertCommandNamesDisjoint(label, leftNames, rightNames) {
|
|
const right = new Set(rightNames);
|
|
const overlapping = Array.from(new Set(leftNames))
|
|
.filter((name) => right.has(name))
|
|
.sort((left, rightName) => left.localeCompare(rightName));
|
|
if (overlapping.length > 0) {
|
|
throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
function runAppInvokeParserRegressionChecks() {
|
|
assert.deepEqual(
|
|
parseAppInvokeCommandNames(`
|
|
invoke('direct_command', {});
|
|
directInvoke<Result>('generic_direct_command', {});
|
|
invokeInput < Result > ('input_wrapper_command', {});
|
|
invokeAuthenticatedInput<Result>(
|
|
'authenticated_input_wrapper_command',
|
|
{},
|
|
);
|
|
input.invoke<Result>('object_field_command', {});
|
|
`),
|
|
[
|
|
'direct_command',
|
|
'generic_direct_command',
|
|
'input_wrapper_command',
|
|
'authenticated_input_wrapper_command',
|
|
'object_field_command',
|
|
],
|
|
);
|
|
|
|
assert.deepEqual(
|
|
parseAppInvokeCommandNames(`
|
|
// invoke('line_comment_decoy')
|
|
/* invokeInput('block_comment_decoy') */
|
|
const quoted = "directInvoke('string_decoy')";
|
|
const template = \`input.invoke('template_decoy')\`;
|
|
const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u;
|
|
invokeCommand('unrelated_name');
|
|
myinvoke('unrelated_suffix');
|
|
invoke(dynamicCommand, {});
|
|
invoke('UPPERCASE_COMMAND', {});
|
|
`),
|
|
[],
|
|
);
|
|
|
|
assert.throws(
|
|
() => parseAppInvokeCommandNames("invoke<Result>('malformed_generic', {"),
|
|
/source cannot be parsed/u,
|
|
);
|
|
|
|
assert.deepEqual(
|
|
parseAppInvokeCommandNames(
|
|
`invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`,
|
|
),
|
|
[],
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
parseAppInvokeCommandNames(' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1)),
|
|
/source exceeds/u,
|
|
);
|
|
|
|
const wrapperInvocations = parseAppInvokeCommandNames(
|
|
"invokeInput<Result>('wrapper_reachability_command', {})",
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
assertCommandNamesSubset(
|
|
'App invoke parser reachability fixture',
|
|
['wrapper_reachability_command'],
|
|
wrapperInvocations,
|
|
),
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCommandNamesDisjoint(
|
|
'App invoke parser false allowlist fixture',
|
|
wrapperInvocations,
|
|
['wrapper_reachability_command'],
|
|
),
|
|
/overlapping commands: wrapper_reachability_command/u,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertCommandNamesSubset(
|
|
'App invoke parser removed wrapper fixture',
|
|
['wrapper_reachability_command'],
|
|
parseAppInvokeCommandNames('const wrapperWasRemoved = true;'),
|
|
),
|
|
/missing commands: wrapper_reachability_command/u,
|
|
);
|
|
}
|
|
|
|
function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) {
|
|
return { code, signal, stdout, stderr };
|
|
}
|
|
|
|
class HiddenInputFixture extends EventEmitter {
|
|
constructor({ failRawRestore = false } = {}) {
|
|
super();
|
|
this.isTTY = true;
|
|
this.isRaw = false;
|
|
this.paused = true;
|
|
this.failRawRestore = failRawRestore;
|
|
this.rawModeChanges = [];
|
|
}
|
|
|
|
isPaused() {
|
|
return this.paused;
|
|
}
|
|
|
|
setRawMode(enabled) {
|
|
this.rawModeChanges.push(enabled);
|
|
this.isRaw = enabled;
|
|
if (!enabled && this.failRawRestore) {
|
|
throw new Error('fixture raw restore failure');
|
|
}
|
|
return this;
|
|
}
|
|
|
|
resume() {
|
|
this.paused = false;
|
|
return this;
|
|
}
|
|
|
|
pause() {
|
|
this.paused = true;
|
|
return this;
|
|
}
|
|
}
|
|
|
|
function hiddenOutputFixture() {
|
|
const writes = [];
|
|
return {
|
|
writes,
|
|
write(value) {
|
|
writes.push(value);
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function assertHiddenInputRestored(input) {
|
|
assert.equal(input.listenerCount('data'), 0);
|
|
assert.equal(input.listenerCount('end'), 0);
|
|
assert.equal(input.listenerCount('error'), 0);
|
|
assert.equal(input.paused, true);
|
|
}
|
|
|
|
async function runHiddenInputRegressionChecks() {
|
|
const endedInput = new HiddenInputFixture();
|
|
const endedPromise = askHidden('fixture', {
|
|
input: endedInput,
|
|
output: hiddenOutputFixture(),
|
|
});
|
|
endedInput.emit('end');
|
|
await assert.rejects(endedPromise, /隐藏输入在完成前已结束/u);
|
|
assert.deepEqual(endedInput.rawModeChanges, [true, false]);
|
|
assertHiddenInputRestored(endedInput);
|
|
|
|
const erroredInput = new HiddenInputFixture();
|
|
const erroredPromise = askHidden('fixture', {
|
|
input: erroredInput,
|
|
output: hiddenOutputFixture(),
|
|
});
|
|
erroredInput.emit('error', new Error('fixture stdin error'));
|
|
await assert.rejects(erroredPromise, /fixture stdin error/u);
|
|
assert.deepEqual(erroredInput.rawModeChanges, [true, false]);
|
|
assertHiddenInputRestored(erroredInput);
|
|
|
|
const restoreFailedInput = new HiddenInputFixture({ failRawRestore: true });
|
|
const restoreFailedPromise = askHidden('fixture', {
|
|
input: restoreFailedInput,
|
|
output: hiddenOutputFixture(),
|
|
});
|
|
restoreFailedInput.emit('data', Buffer.from('secret\n'));
|
|
await assert.rejects(restoreFailedPromise, /恢复终端 raw mode 失败/u);
|
|
assert.deepEqual(restoreFailedInput.rawModeChanges, [true, false]);
|
|
assertHiddenInputRestored(restoreFailedInput);
|
|
|
|
const signaledInput = new HiddenInputFixture();
|
|
const signalSource = new EventEmitter();
|
|
const forwardedSignals = [];
|
|
const signaledPromise = askHidden('fixture', {
|
|
input: signaledInput,
|
|
output: hiddenOutputFixture(),
|
|
signalSource,
|
|
terminateForSignal: (signal) => forwardedSignals.push(signal),
|
|
platform: 'win32',
|
|
});
|
|
signalSource.emit('SIGBREAK');
|
|
await assert.rejects(signaledPromise, /SIGBREAK/u);
|
|
assert.deepEqual(signaledInput.rawModeChanges, [true, false]);
|
|
assertHiddenInputRestored(signaledInput);
|
|
assert.deepEqual(forwardedSignals, ['SIGBREAK']);
|
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
|
|
assert.equal(signalSource.listenerCount(signal), 0);
|
|
}
|
|
}
|
|
|
|
async function runConfigWizardRegressionChecks() {
|
|
const testRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
|
|
);
|
|
try {
|
|
const canonicalTestRoot = fs.realpathSync.native(testRoot);
|
|
const realConfigAncestor = path.join(testRoot, 'real-config-ancestor');
|
|
const linkedConfigAncestor = path.join(testRoot, 'linked-config-ancestor');
|
|
fs.mkdirSync(realConfigAncestor);
|
|
fs.symlinkSync(
|
|
realConfigAncestor,
|
|
linkedConfigAncestor,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
const missingLinkedConfigDir = path.join(
|
|
linkedConfigAncestor,
|
|
'missing-appdata',
|
|
);
|
|
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
|
|
assert.equal(
|
|
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
|
|
path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'),
|
|
);
|
|
|
|
const gitRoot = path.join(testRoot, 'tracked-repository');
|
|
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
|
|
fs.mkdirSync(trackedConfigDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(trackedConfigDir, 'game-creator.config.json'),
|
|
'{}\n',
|
|
{ mode: 0o600 },
|
|
);
|
|
execFileSync('git', ['init', '--quiet', gitRoot], { stdio: 'ignore' });
|
|
execFileSync(
|
|
'git',
|
|
['-C', gitRoot, 'add', 'runtime-config/game-creator.config.json'],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
await assert.rejects(
|
|
assertSafeGameCreatorConfigDestination(trackedConfigDir),
|
|
/Git 已跟踪/u,
|
|
);
|
|
|
|
const untrackedConfigDir = path.join(gitRoot, 'untracked-runtime-config');
|
|
await assert.rejects(
|
|
assertSafeGameCreatorConfigDestination(untrackedConfigDir),
|
|
/Git 仓库之外/u,
|
|
);
|
|
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
|
|
assert.equal(
|
|
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
|
|
path.join(canonicalTestRoot, 'outside-appdata'),
|
|
);
|
|
await assert.rejects(
|
|
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
|
|
requireDedicatedLeaf: true,
|
|
}),
|
|
new RegExp(appIdentifier.replaceAll('.', '\\.')),
|
|
);
|
|
const dedicatedConfigDir = path.join(testRoot, appIdentifier);
|
|
assert.equal(
|
|
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
|
|
requireDedicatedLeaf: true,
|
|
}),
|
|
path.join(canonicalTestRoot, appIdentifier),
|
|
);
|
|
|
|
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
|
|
assert.equal(
|
|
await assertSafeGameCreatorConfigDestination(injectedNonGitConfigDir, {
|
|
runGit: async () =>
|
|
gitCheckResult({
|
|
code: 128,
|
|
stderr:
|
|
'fatal: not a git repository (or any of the parent directories): .git\n',
|
|
}),
|
|
}),
|
|
path.join(canonicalTestRoot, 'injected-non-git'),
|
|
);
|
|
await assert.rejects(
|
|
assertSafeGameCreatorConfigDestination(
|
|
path.join(testRoot, 'missing-git-command'),
|
|
{
|
|
runGit: async () => {
|
|
const error = new Error('spawn git ENOENT');
|
|
error.code = 'ENOENT';
|
|
throw error;
|
|
},
|
|
},
|
|
),
|
|
/Git 命令不可用或启动失败/u,
|
|
);
|
|
await assert.rejects(
|
|
assertSafeGameCreatorConfigDestination(
|
|
path.join(testRoot, 'abnormal-rev-parse'),
|
|
{
|
|
runGit: async () =>
|
|
gitCheckResult({
|
|
code: 129,
|
|
stderr: 'fixture rev-parse failure',
|
|
}),
|
|
},
|
|
),
|
|
/Git 检查异常.*fixture rev-parse failure/u,
|
|
);
|
|
|
|
const abnormalTrackedGitRoot = path.join(
|
|
testRoot,
|
|
'abnormal-tracked-repository',
|
|
);
|
|
const abnormalTrackedConfigDir = path.join(
|
|
abnormalTrackedGitRoot,
|
|
'runtime-config',
|
|
);
|
|
fs.mkdirSync(abnormalTrackedConfigDir, { recursive: true });
|
|
let gitCheckCount = 0;
|
|
await assert.rejects(
|
|
assertSafeGameCreatorConfigDestination(abnormalTrackedConfigDir, {
|
|
runGit: async () => {
|
|
gitCheckCount += 1;
|
|
if (gitCheckCount === 1) {
|
|
return gitCheckResult({ stdout: `${abnormalTrackedGitRoot}\n` });
|
|
}
|
|
return gitCheckResult({
|
|
code: 2,
|
|
stderr: 'fixture ls-files failure',
|
|
});
|
|
},
|
|
}),
|
|
/配置文件跟踪状态.*fixture ls-files failure/u,
|
|
);
|
|
assert.equal(gitCheckCount, 2);
|
|
|
|
const overlayConfigDir = path.join(testRoot, 'overlay-appdata');
|
|
fs.mkdirSync(overlayConfigDir, { recursive: true, mode: 0o700 });
|
|
const primaryConfigPath = path.join(
|
|
overlayConfigDir,
|
|
'game-creator.config.json',
|
|
);
|
|
const localConfigPath = path.join(
|
|
overlayConfigDir,
|
|
'game-creator.config.local.json',
|
|
);
|
|
const primaryConfig = {
|
|
llm: {
|
|
apiKey: 'fixture-primary-key',
|
|
baseUrl: 'https://primary.example.test/v1',
|
|
model: 'primary-model',
|
|
apiKind: 'openai_chat',
|
|
stream: false,
|
|
requestTimeoutMs: 12345,
|
|
},
|
|
editorApi: {
|
|
apiKey: 'fixture-editor-key',
|
|
baseUrl: 'http://127.0.0.1:8082',
|
|
},
|
|
};
|
|
const localConfig = {
|
|
llm: {
|
|
apiKey: 'fixture-old-overlay-key',
|
|
model: 'old-overlay-model',
|
|
stream: true,
|
|
},
|
|
agentLlm: {
|
|
planner: { model: 'planner-overlay-model' },
|
|
},
|
|
};
|
|
fs.writeFileSync(
|
|
primaryConfigPath,
|
|
`${JSON.stringify(primaryConfig, null, 2)}\n`,
|
|
{ mode: 0o644 },
|
|
);
|
|
fs.writeFileSync(
|
|
localConfigPath,
|
|
`${JSON.stringify(localConfig, null, 2)}\n`,
|
|
{ mode: 0o644 },
|
|
);
|
|
const overlayState =
|
|
await readGameCreatorWizardConfigState(overlayConfigDir);
|
|
if (process.platform !== 'win32') {
|
|
assert.equal(fs.statSync(primaryConfigPath).mode & 0o777, 0o600);
|
|
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
|
|
}
|
|
assert.equal(overlayState.configPath, primaryConfigPath);
|
|
assert.equal(overlayState.localConfigPath, localConfigPath);
|
|
assert.equal(overlayState.effectiveConfig.llm.model, 'old-overlay-model');
|
|
assert.equal(overlayState.effectiveConfig.llm.stream, true);
|
|
assert.equal(overlayState.effectiveConfig.llm.requestTimeoutMs, 12345);
|
|
assert.deepEqual(
|
|
overlayState.writeConfig.editorApi,
|
|
primaryConfig.editorApi,
|
|
);
|
|
|
|
const updatedPrimary = buildGameCreatorWizardConfig(
|
|
overlayState.writeConfig,
|
|
{
|
|
apiKey: 'fixture-new-key',
|
|
baseUrl: 'https://new.example.test/v1/',
|
|
model: 'new-model',
|
|
apiKind: 'openai_responses',
|
|
},
|
|
);
|
|
assert.equal(updatedPrimary.agentMode, 'provider');
|
|
await writeGameCreatorWizardConfig(overlayState, updatedPrimary);
|
|
const reloadedState =
|
|
await readGameCreatorWizardConfigState(overlayConfigDir);
|
|
assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key');
|
|
assert.equal(reloadedState.effectiveConfig.agentMode, 'provider');
|
|
assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model');
|
|
assert.equal(
|
|
reloadedState.effectiveConfig.llm.baseUrl,
|
|
'https://new.example.test/v1',
|
|
);
|
|
assert.equal(reloadedState.effectiveConfig.llm.requestTimeoutMs, 12345);
|
|
assert.equal(reloadedState.effectiveConfig.llm.stream, true);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(primaryConfigPath, 'utf8')).llm.apiKey,
|
|
'fixture-new-key',
|
|
);
|
|
const sanitizedLocalConfig = JSON.parse(
|
|
fs.readFileSync(localConfigPath, 'utf8'),
|
|
);
|
|
assert.equal(sanitizedLocalConfig.llm, undefined);
|
|
assert.deepEqual(sanitizedLocalConfig.agentLlm, localConfig.agentLlm);
|
|
if (process.platform !== 'win32') {
|
|
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
|
|
}
|
|
|
|
const unchangedLocalConfigDir = path.join(
|
|
testRoot,
|
|
'unchanged-local-appdata',
|
|
);
|
|
fs.mkdirSync(unchangedLocalConfigDir, { recursive: true, mode: 0o700 });
|
|
const unchangedPrimaryConfigPath = path.join(
|
|
unchangedLocalConfigDir,
|
|
'game-creator.config.json',
|
|
);
|
|
const unchangedLocalConfigPath = path.join(
|
|
unchangedLocalConfigDir,
|
|
'game-creator.config.local.json',
|
|
);
|
|
const unchangedLocalSource =
|
|
'{\n "editorApi": { "baseUrl": "http://127.0.0.1:8082" }\n}\n';
|
|
fs.writeFileSync(unchangedPrimaryConfigPath, '{}\n', { mode: 0o600 });
|
|
fs.writeFileSync(unchangedLocalConfigPath, unchangedLocalSource, {
|
|
mode: 0o644,
|
|
});
|
|
const unchangedLocalState = await readGameCreatorWizardConfigState(
|
|
unchangedLocalConfigDir,
|
|
);
|
|
await writeGameCreatorWizardConfig(
|
|
unchangedLocalState,
|
|
buildGameCreatorWizardConfig(unchangedLocalState.writeConfig, {
|
|
apiKey: 'fixture-private-local-key',
|
|
baseUrl: 'https://private.example.test/v1',
|
|
model: 'private-model',
|
|
apiKind: 'openai_responses',
|
|
}),
|
|
);
|
|
assert.equal(
|
|
fs.readFileSync(unchangedLocalConfigPath, 'utf8'),
|
|
unchangedLocalSource,
|
|
);
|
|
if (process.platform !== 'win32') {
|
|
assert.equal(fs.statSync(unchangedLocalConfigPath).mode & 0o777, 0o600);
|
|
}
|
|
|
|
const invalidLocalConfigDir = path.join(testRoot, 'invalid-local-appdata');
|
|
fs.mkdirSync(invalidLocalConfigDir, { recursive: true, mode: 0o700 });
|
|
const invalidPrimaryConfigPath = path.join(
|
|
invalidLocalConfigDir,
|
|
'game-creator.config.json',
|
|
);
|
|
const invalidLocalConfigPath = path.join(
|
|
invalidLocalConfigDir,
|
|
'game-creator.config.local.json',
|
|
);
|
|
fs.writeFileSync(invalidPrimaryConfigPath, '{}\n', { mode: 0o600 });
|
|
fs.writeFileSync(invalidLocalConfigPath, '{ invalid json\n', {
|
|
mode: 0o644,
|
|
});
|
|
await assert.rejects(
|
|
writeGameCreatorWizardConfig(
|
|
{
|
|
configPath: invalidPrimaryConfigPath,
|
|
localConfigPath: invalidLocalConfigPath,
|
|
},
|
|
{ llm: { apiKey: 'fixture-invalid-local-key' } },
|
|
),
|
|
/读取客户端配置失败/u,
|
|
);
|
|
if (process.platform !== 'win32') {
|
|
assert.equal(fs.statSync(invalidLocalConfigPath).mode & 0o777, 0o600);
|
|
}
|
|
|
|
const windowsLocalConfigDir = path.join(testRoot, 'windows-local-appdata');
|
|
fs.mkdirSync(windowsLocalConfigDir, { recursive: true });
|
|
const windowsLocalPrimaryPath = path.join(
|
|
windowsLocalConfigDir,
|
|
'game-creator.config.json',
|
|
);
|
|
const windowsLocalConfigPath = path.join(
|
|
windowsLocalConfigDir,
|
|
'game-creator.config.local.json',
|
|
);
|
|
fs.writeFileSync(windowsLocalPrimaryPath, '{}\n');
|
|
fs.writeFileSync(windowsLocalConfigPath, unchangedLocalSource);
|
|
const windowsReadAclPaths = [];
|
|
const windowsLocalState = await readGameCreatorWizardConfigState(
|
|
windowsLocalConfigDir,
|
|
{
|
|
platform: 'win32',
|
|
secureWindowsPath: async (targetPath, { isDirectory }) => {
|
|
windowsReadAclPaths.push({ targetPath, isDirectory });
|
|
},
|
|
},
|
|
);
|
|
assert.deepEqual(windowsReadAclPaths, [
|
|
{ targetPath: windowsLocalConfigDir, isDirectory: true },
|
|
{ targetPath: windowsLocalPrimaryPath, isDirectory: false },
|
|
{ targetPath: windowsLocalConfigPath, isDirectory: false },
|
|
]);
|
|
const windowsLocalAclPaths = [];
|
|
await writeGameCreatorWizardConfig(
|
|
windowsLocalState,
|
|
buildGameCreatorWizardConfig(windowsLocalState.writeConfig, {
|
|
apiKey: 'fixture-windows-local-key',
|
|
baseUrl: 'https://windows.example.test/v1',
|
|
model: 'windows-model',
|
|
apiKind: 'openai_responses',
|
|
}),
|
|
{
|
|
platform: 'win32',
|
|
secureWindowsPath: async (targetPath, { isDirectory }) => {
|
|
if (!isDirectory) windowsLocalAclPaths.push(targetPath);
|
|
},
|
|
},
|
|
);
|
|
assert.equal(
|
|
fs.readFileSync(windowsLocalConfigPath, 'utf8'),
|
|
unchangedLocalSource,
|
|
);
|
|
assert.equal(windowsLocalAclPaths.includes(windowsLocalConfigPath), true);
|
|
|
|
const guiSavedConfig = JSON.parse(
|
|
fs.readFileSync(primaryConfigPath, 'utf8'),
|
|
);
|
|
guiSavedConfig.llm = {
|
|
...guiSavedConfig.llm,
|
|
apiKey: 'fixture-gui-key',
|
|
model: 'gui-model',
|
|
};
|
|
await writeGameCreatorConfigAtomically(primaryConfigPath, guiSavedConfig);
|
|
const afterGuiSave =
|
|
await readGameCreatorWizardConfigState(overlayConfigDir);
|
|
assert.equal(afterGuiSave.effectiveConfig.llm.apiKey, 'fixture-gui-key');
|
|
assert.equal(afterGuiSave.effectiveConfig.llm.model, 'gui-model');
|
|
|
|
const windowsConfigDir = path.join(testRoot, 'windows-appdata');
|
|
const windowsConfigPath = path.join(
|
|
windowsConfigDir,
|
|
'game-creator.config.json',
|
|
);
|
|
const aclEvents = [];
|
|
await writeGameCreatorConfigAtomically(
|
|
windowsConfigPath,
|
|
{ llm: { apiKey: 'fixture-windows-key' } },
|
|
{
|
|
platform: 'win32',
|
|
secureWindowsPath: async (targetPath, { isDirectory }) => {
|
|
const metadata = fs.lstatSync(targetPath);
|
|
const temporaryFile =
|
|
!isDirectory && path.basename(targetPath).startsWith('.');
|
|
if (temporaryFile) {
|
|
assert.equal(metadata.size, 0);
|
|
assert.equal(fs.readFileSync(targetPath, 'utf8'), '');
|
|
}
|
|
aclEvents.push({ isDirectory, temporaryFile });
|
|
},
|
|
},
|
|
);
|
|
assert.deepEqual(aclEvents, [
|
|
{ isDirectory: true, temporaryFile: false },
|
|
{ isDirectory: false, temporaryFile: true },
|
|
{ isDirectory: false, temporaryFile: false },
|
|
]);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(windowsConfigPath, 'utf8')).llm.apiKey,
|
|
'fixture-windows-key',
|
|
);
|
|
|
|
const failedWindowsConfigDir = path.join(
|
|
testRoot,
|
|
'failed-windows-appdata',
|
|
);
|
|
const failedWindowsConfigPath = path.join(
|
|
failedWindowsConfigDir,
|
|
'game-creator.config.json',
|
|
);
|
|
await assert.rejects(
|
|
writeGameCreatorConfigAtomically(
|
|
failedWindowsConfigPath,
|
|
{ llm: { apiKey: 'fixture-must-not-reach-disk' } },
|
|
{
|
|
platform: 'win32',
|
|
secureWindowsPath: async (_targetPath, { isDirectory }) => {
|
|
if (!isDirectory) throw new Error('fixture DACL failure');
|
|
},
|
|
},
|
|
),
|
|
/fixture DACL failure/u,
|
|
);
|
|
assert.equal(fs.existsSync(failedWindowsConfigPath), false);
|
|
assert.deepEqual(fs.readdirSync(failedWindowsConfigDir), []);
|
|
} finally {
|
|
fs.rmSync(testRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
assertNoOpenAiApiKeys([
|
|
new URL('../src/', import.meta.url),
|
|
new URL('../scripts/', import.meta.url),
|
|
new URL('../game-creator.config.json', import.meta.url),
|
|
new URL('../src-tauri/src/', import.meta.url),
|
|
new URL('../package.json', import.meta.url),
|
|
new URL('../vite.config.ts', import.meta.url),
|
|
new URL('../src-tauri/Cargo.toml', import.meta.url),
|
|
new URL('../src-tauri/tauri.conf.json', import.meta.url),
|
|
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
|
|
new URL(
|
|
'../../../packages/shared/src/contracts/gameCreationApp.ts',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../packages/shared/src/contracts/gameCreationApp.test.ts',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../server-rs/crates/platform-agent/src/game_creation.rs',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
|
|
import.meta.url,
|
|
),
|
|
new URL(
|
|
'../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md',
|
|
import.meta.url,
|
|
),
|
|
]);
|
|
|
|
assertNoEnvironmentConfigFallbacks([
|
|
new URL('../src/', import.meta.url),
|
|
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
|
|
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
|
|
new URL('../scripts/start-dev-server.mjs', import.meta.url),
|
|
new URL('../src-tauri/src/', import.meta.url),
|
|
new URL('../package.json', import.meta.url),
|
|
new URL('../vite.config.ts', import.meta.url),
|
|
new URL('../src-tauri/Cargo.toml', import.meta.url),
|
|
new URL('../src-tauri/tauri.conf.json', import.meta.url),
|
|
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
|
|
]);
|
|
|
|
assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]);
|
|
assertNoBlockingNativeFilePicker(tauriRustSource);
|
|
|
|
runAppInvokeParserRegressionChecks();
|
|
|
|
const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources);
|
|
|
|
assertContractRecordsMatch(
|
|
'AI game creator shell command contract',
|
|
parseTsCommands(sharedContractSource),
|
|
parseRustCommands(rustSharedContractSource),
|
|
);
|
|
|
|
assertContractRecordsMatch(
|
|
'AI game creator shell capability contract',
|
|
parseTsCapabilities(sharedContractSource),
|
|
parseRustCapabilities(rustSharedContractSource),
|
|
);
|
|
|
|
assertCommandNamesSubset(
|
|
'AI game creator shell Tauri handler',
|
|
appInvokeCommandNames,
|
|
parseTauriHandlerCommandNames(tauriHandlerSource),
|
|
);
|
|
|
|
assertCommandNamesSubset(
|
|
'AI game creator shell Tauri command implementation',
|
|
parseTauriHandlerCommandNames(tauriHandlerSource),
|
|
parseRustFunctionNames(tauriRustSource),
|
|
);
|
|
|
|
assertCommandNamesSubset(
|
|
'AI game creator shell App invoke or explicit native-only allowlist',
|
|
parseTauriHandlerCommandNames(tauriHandlerSource),
|
|
[...appInvokeCommandNames, ...allowedUncalledTauriCommands],
|
|
);
|
|
|
|
assertCommandNamesSubset(
|
|
'AI game creator shell explicit native-only allowlist',
|
|
allowedUncalledTauriCommands,
|
|
parseTauriHandlerCommandNames(tauriHandlerSource),
|
|
);
|
|
|
|
assertCommandNamesDisjoint(
|
|
'AI game creator shell App invoke and explicit native-only allowlist',
|
|
appInvokeCommandNames,
|
|
allowedUncalledTauriCommands,
|
|
);
|
|
|
|
const tauriHandlerCommandNames =
|
|
parseTauriHandlerCommandNames(tauriHandlerSource);
|
|
if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) {
|
|
throw new Error(
|
|
'AI game creator shell home creation must expose automatic project creation',
|
|
);
|
|
}
|
|
if (
|
|
tauriHandlerCommandNames.includes('chat_with_game_creator_home_direct_codex')
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell home surface must not expose a projectless Codex conversation',
|
|
);
|
|
}
|
|
|
|
if (packageConfig.name !== '@genarrative/ai-game-creator-shell') {
|
|
throw new Error('AI game creator shell package name drifted');
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.['llm-status'] !==
|
|
'node scripts/run-cli-with-config.mjs --llm-status'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell llm-status must use client config before checking LLM config',
|
|
);
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.['agent-run'] !==
|
|
'node scripts/run-cli-with-config.mjs --agent-run'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell agent-run must use client config before running the provider path',
|
|
);
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.['agent-task'] !==
|
|
'node scripts/run-cli-with-config.mjs --agent-task'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell agent-task must use client config before starting the single Agent runtime',
|
|
);
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.swarm !==
|
|
'node scripts/run-cli-with-config.mjs --swarm-chat'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell swarm must use client config before starting the interactive Agent runtime',
|
|
);
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.config !==
|
|
'node scripts/game-creator-config-wizard.mjs'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell config must use the AppData configuration wizard',
|
|
);
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.['test:chat'] !==
|
|
`node scripts/agent-swarm-test-chat.mjs --task ${JSON.stringify(defaultRealSwarmTestTask)} --no-open`
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell test:chat must use the one-click Swarm test entry',
|
|
);
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.['test:chat:manual'] !==
|
|
'node scripts/agent-swarm-test-chat.mjs'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell test:chat:manual must keep the interactive Swarm test entry',
|
|
);
|
|
}
|
|
|
|
if (
|
|
rootPackageConfig.scripts?.['agc:test'] !==
|
|
'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --'
|
|
) {
|
|
throw new Error('agc:test must delegate to the deterministic playable E2E');
|
|
}
|
|
|
|
if (
|
|
rootPackageConfig.scripts?.['agc:config'] !==
|
|
'npm --prefix apps/ai-game-creator-shell run config --'
|
|
) {
|
|
throw new Error(
|
|
'agc:config must delegate to the AppData configuration wizard',
|
|
);
|
|
}
|
|
|
|
if (
|
|
rootPackageConfig.scripts?.['agc:test:chat'] !==
|
|
'npm --prefix apps/ai-game-creator-shell run test:chat --'
|
|
) {
|
|
throw new Error(
|
|
'agc:test:chat must delegate to the one-click Swarm test entry',
|
|
);
|
|
}
|
|
|
|
if (
|
|
rootPackageConfig.scripts?.['agc:test:chat:manual'] !==
|
|
'npm --prefix apps/ai-game-creator-shell run test:chat:manual --'
|
|
) {
|
|
throw new Error(
|
|
'agc:test:chat:manual must delegate to the interactive Swarm test entry',
|
|
);
|
|
}
|
|
|
|
for (const requiredSource of [
|
|
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
|
|
"'--swarm-chat'",
|
|
"'--autonomous-game-build'",
|
|
"'--preview-serve'",
|
|
'cleanupSwarmTestProject(project)',
|
|
]) {
|
|
if (!swarmTestChatSource.includes(requiredSource)) {
|
|
throw new Error(`Swarm test entry contract drifted: ${requiredSource}`);
|
|
}
|
|
}
|
|
|
|
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
|
|
throw new Error('AI game creator shell productName drifted');
|
|
}
|
|
|
|
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
|
throw new Error('AI game creator shell identifier drifted');
|
|
}
|
|
|
|
const expectedBundledCodexResources = {
|
|
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
|
|
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
|
|
'codex/win-x64/bin/codex-code-mode-host.exe',
|
|
'resources/codex/win-x64/codex-path/rg.exe':
|
|
'codex/win-x64/codex-path/rg.exe',
|
|
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
|
|
'codex/win-x64/codex-resources/codex-command-runner.exe',
|
|
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
|
|
'codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
|
'resources/codex/win-x64/codex-package.json':
|
|
'codex/win-x64/codex-package.json',
|
|
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
|
|
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
|
|
};
|
|
if (tauriConfig.bundle?.resources !== undefined) {
|
|
throw new Error(
|
|
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
|
|
);
|
|
}
|
|
assert.deepEqual(
|
|
windowsTauriConfig.bundle?.resources,
|
|
expectedBundledCodexResources,
|
|
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
|
|
);
|
|
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
|
|
throw new Error(
|
|
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
|
|
);
|
|
}
|
|
|
|
if (tauriConfig.app?.withGlobalTauri !== true) {
|
|
throw new Error(
|
|
'AI game creator shell must expose window.__TAURI__ for local commands',
|
|
);
|
|
}
|
|
|
|
if (!fs.existsSync(eventCapabilityPath)) {
|
|
throw new Error(
|
|
'AI game creator shell must declare a Tauri event-listener capability',
|
|
);
|
|
}
|
|
const eventCapability = JSON.parse(
|
|
fs.readFileSync(eventCapabilityPath, 'utf8'),
|
|
);
|
|
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
|
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
|
for (const windowLabel of [
|
|
'client',
|
|
'developer',
|
|
'main',
|
|
'launcher',
|
|
'supervisor-chat',
|
|
]) {
|
|
if (!eventCapabilityWindows.has(windowLabel)) {
|
|
throw new Error(
|
|
`AI game creator shell event capability missing window: ${windowLabel}`,
|
|
);
|
|
}
|
|
}
|
|
for (const permission of [
|
|
'core:event:allow-listen',
|
|
'core:event:allow-unlisten',
|
|
]) {
|
|
if (!eventCapabilityPermissions.has(permission)) {
|
|
throw new Error(
|
|
`AI game creator shell event capability missing permission: ${permission}`,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
eventCapabilityPermissions.has('core:event:allow-emit') ||
|
|
eventCapabilityPermissions.has('core:event:allow-emit-to') ||
|
|
eventCapabilityPermissions.has('core:event:default')
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell frontend event capability must stay listen-only',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!Array.isArray(tauriConfig.app?.windows) ||
|
|
tauriConfig.app.windows.length !== 1 ||
|
|
tauriConfig.app.windows[0]?.label !== 'client' ||
|
|
tauriConfig.app.windows[0]?.title !== '陶泥儿' ||
|
|
tauriConfig.app.windows[0]?.url !== 'index.html'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell must start with only the client window',
|
|
);
|
|
}
|
|
|
|
if (defaultAppConfig.llm?.apiKey !== '') {
|
|
throw new Error('AI game creator shell default llm.apiKey must stay empty');
|
|
}
|
|
|
|
if (defaultAppConfig.agentMode !== 'codex_app_server') {
|
|
throw new Error(
|
|
'AI game creator shell default agentMode must be codex_app_server',
|
|
);
|
|
}
|
|
|
|
const allowedLlmReasoningEfforts = new Set([
|
|
'default',
|
|
'low',
|
|
'medium',
|
|
'high',
|
|
'max',
|
|
]);
|
|
|
|
if (defaultAppConfig.llm?.reasoningEffort !== 'max') {
|
|
throw new Error(
|
|
'AI game creator shell default llm.reasoningEffort must stay max',
|
|
);
|
|
}
|
|
|
|
for (const [agentId, agentConfig] of Object.entries(
|
|
defaultAppConfig.agentLlm ?? {},
|
|
)) {
|
|
if (
|
|
agentConfig?.reasoningEffort !== undefined &&
|
|
!allowedLlmReasoningEfforts.has(agentConfig.reasoningEffort)
|
|
) {
|
|
throw new Error(
|
|
`AI game creator shell agentLlm.${agentId}.reasoningEffort is invalid`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (defaultAppConfig.editorApi !== undefined) {
|
|
throw new Error(
|
|
'AI game creator shell ordinary default config must not contain editorApi',
|
|
);
|
|
}
|
|
|
|
if (
|
|
defaultAppConfig.llm?.requestTimeoutMs < 1000 ||
|
|
defaultAppConfig.llm?.maxRetries < 0 ||
|
|
defaultAppConfig.llm?.retryBackoffMs < 1
|
|
) {
|
|
throw new Error('AI game creator shell default LLM timing config is invalid');
|
|
}
|
|
|
|
const windows = tauriConfig.app?.windows ?? [];
|
|
if (
|
|
windows.length !== 1 ||
|
|
windows[0]?.label !== 'client' ||
|
|
windows[0]?.url !== 'index.html'
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell release config must expose only the client window',
|
|
);
|
|
}
|
|
|
|
const clientWindow = windows[0];
|
|
if (
|
|
clientWindow.width !== 1280 ||
|
|
clientWindow.height !== 800 ||
|
|
clientWindow.minWidth !== 1280 ||
|
|
clientWindow.minHeight !== 720
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell client window must default to 1280x800 and stay at least 1280x720',
|
|
);
|
|
}
|
|
|
|
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
|
|
throw new Error(
|
|
'AI game creator shell Tauri config must retain the non-launcher fallback devUrl',
|
|
);
|
|
}
|
|
|
|
if (!viteConfigSource.includes("host: '127.0.0.1'")) {
|
|
throw new Error(
|
|
'AI game creator shell Vite dev server must bind to localhost',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!viteConfigSource.includes('port: 3080') ||
|
|
!viteConfigSource.includes('port: server.config.server.port')
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell Vite config must retain its fallback and report the actual CLI-selected port',
|
|
);
|
|
}
|
|
|
|
for (const snippet of [
|
|
'mapDevPortsToPortRange',
|
|
'agcVitePort',
|
|
'resolveAgcDevEndpoint',
|
|
'GENARRATIVE_AGC_VITE_PORT',
|
|
]) {
|
|
if (!devPortSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell dev port resolver drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'resolveAgcDevEndpoint',
|
|
'withAgcDevEndpointEnv',
|
|
"'--config'",
|
|
'configOverride',
|
|
]) {
|
|
if (!startTauriDevSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell Tauri dev port injection drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!viteConfigSource.includes('strictPort: true')) {
|
|
throw new Error(
|
|
'AI game creator shell Vite dev server must not drift away from Tauri devUrl',
|
|
);
|
|
}
|
|
|
|
if (!viteConfigSource.includes('allow: [repoRoot]')) {
|
|
throw new Error(
|
|
'AI game creator shell Vite dev server must allow shared repository sources',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!(
|
|
tauriConfig.build?.beforeDevCommand?.includes(
|
|
'run ai-game-creator-shell:dev-server',
|
|
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
|
|
);
|
|
}
|
|
|
|
if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
|
|
throw new Error(
|
|
'AI game creator shell dev must run through the managed Tauri dev launcher',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!tauriHandlerSource.includes('.build(tauri_context)') ||
|
|
!tauriHandlerSource.includes('handle_game_creator_gui_run_event(&event)')
|
|
) {
|
|
throw new Error(
|
|
'AI game creator Tauri runtime must build from the prepared Context and preserve the generic GUI exit hook',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell beforeBuildCommand must resolve vite config from app root',
|
|
);
|
|
}
|
|
|
|
const releaseVersions = [
|
|
packageConfig.version,
|
|
tauriConfig.version,
|
|
cargoPackageVersion,
|
|
];
|
|
if (
|
|
releaseVersions.some((version) => !/^\d+\.\d+\.\d+$/u.test(version ?? '')) ||
|
|
new Set(releaseVersions).size !== 1
|
|
) {
|
|
throw new Error(
|
|
'AI game creator release versions must be valid three-part semver and synchronized',
|
|
);
|
|
}
|
|
|
|
const devServerSource = fs.readFileSync(
|
|
new URL('../scripts/start-dev-server.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const runCliWithConfigSource = fs.readFileSync(
|
|
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
if (!runCliWithConfigSource.includes('resolveGameCreatorAppConfigDir')) {
|
|
throw new Error(
|
|
'AI game creator shell CLI wrapper must resolve the GUI AppData directory',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!runCliWithConfigSource.includes(
|
|
"'--config-dir', resolveGameCreatorAppConfigDir()",
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'AI game creator shell CLI wrapper must pass the GUI AppData directory to native commands',
|
|
);
|
|
}
|
|
|
|
for (const snippet of [
|
|
'resolveAgcDevEndpoint',
|
|
'withAgcDevEndpointEnv',
|
|
"response.body.includes('<title>陶泥儿</title>')",
|
|
'function isPortListening()',
|
|
'cannot be safely reused',
|
|
'non-HTTP or unrecognized server',
|
|
"'--config'",
|
|
"'vite.config.ts'",
|
|
"'--port'",
|
|
]) {
|
|
if (!devServerSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell dev server wrapper drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const configWizardSource = fs.readFileSync(
|
|
new URL('./game-creator-config-wizard.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
for (const snippet of [
|
|
'assertSafeGameCreatorConfigDestination',
|
|
'readGameCreatorWizardConfigState',
|
|
'$security.SetAccessRuleProtection($true, $false)',
|
|
'$targetItem = Get-Item -LiteralPath $target -Force',
|
|
'$targetItem.SetAccessControl($security)',
|
|
'$verified = $targetItem.GetAccessControl()',
|
|
'$rules.Count -ne 1',
|
|
'[System.Security.AccessControl.FileSystemRights]::FullControl',
|
|
"'-NoProfile'",
|
|
"'-Command'",
|
|
'windowsPrivateAclScript',
|
|
'await secureWindowsPath(temporaryPath, { isDirectory: false })',
|
|
'await temporaryFile.writeFile',
|
|
]) {
|
|
if (!configWizardSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator config wizard guardrail drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) {
|
|
throw new Error(
|
|
'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)',
|
|
);
|
|
}
|
|
if (/\bGet-Acl\b/u.test(configWizardSource)) {
|
|
throw new Error(
|
|
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
|
|
);
|
|
}
|
|
|
|
await runConfigWizardRegressionChecks();
|
|
await runHiddenInputRegressionChecks();
|
|
|
|
for (const snippet of [
|
|
"new URL('..', import.meta.url)",
|
|
"'--manifest-path'",
|
|
"'src-tauri/Cargo.toml'",
|
|
]) {
|
|
if (!runCliWithConfigSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell config CLI wrapper drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"',
|
|
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
|
|
'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")',
|
|
'fn configure_game_creator_runtime_config_dir(',
|
|
'game_creator_runtime_config_dir()',
|
|
'.unwrap_or_else(|| app.path().app_config_dir())?',
|
|
'fn load_game_creator_app_config()',
|
|
'fn read_game_creator_app_config()',
|
|
'fn write_game_creator_app_config(',
|
|
'fn writable_game_creator_config_path()',
|
|
'fn normalize_game_creator_app_config(',
|
|
'fn merge_game_creator_config_file(',
|
|
'.join("apps")',
|
|
'.join("ai-game-creator-shell")',
|
|
'read_game_creator_app_config,',
|
|
'write_game_creator_app_config,',
|
|
'resolve_game_creator_llm_config_for_agent(app_config, "planner")',
|
|
'resolve_game_creator_llm_config_for_agent(app_config, "generator")',
|
|
'build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?',
|
|
'build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?',
|
|
'agentLlm.{agent_id}',
|
|
'let app_config = match load_game_creator_app_config()',
|
|
'fn append_local_permission_log_at(',
|
|
'"command.auto"',
|
|
'GameCreationAppPermission::Auto',
|
|
'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH',
|
|
'validate_game_creator_bundled_codex_cli',
|
|
'内置 Codex CLI 完整性校验失败',
|
|
]) {
|
|
if (!tauriRustSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const runtimeConfigSetupStart = tauriHandlerSource.indexOf(
|
|
'configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {',
|
|
);
|
|
const runtimeConfigSetupEnd = tauriHandlerSource.indexOf(
|
|
'})?;',
|
|
runtimeConfigSetupStart,
|
|
);
|
|
const runtimeConfigSetupSource = tauriHandlerSource.slice(
|
|
runtimeConfigSetupStart,
|
|
runtimeConfigSetupEnd,
|
|
);
|
|
if (
|
|
runtimeConfigSetupStart === -1 ||
|
|
runtimeConfigSetupEnd === -1 ||
|
|
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
|
|
!runtimeConfigSetupSource.includes('setup_log.fail(') ||
|
|
!runtimeConfigSetupSource.includes(
|
|
'startup.appdata.configure.failed details={details}',
|
|
) ||
|
|
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
|
|
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
|
|
!tauriHandlerSource.includes(
|
|
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
|
|
) ||
|
|
!tauriHandlerSource.includes('early_startup_log_path(')
|
|
) {
|
|
throw new Error(
|
|
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
|
|
);
|
|
}
|
|
|
|
for (const snippet of [
|
|
'import.meta.env.DEV',
|
|
'supervisorChatMode',
|
|
'supervisorChatOnly',
|
|
'open_project_supervisor_chat_window',
|
|
'index.html?supervisor-chat&projectPath=',
|
|
]) {
|
|
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
|
throw new Error(
|
|
'AI game creator normal startup must not automatically open the developer window',
|
|
);
|
|
}
|
|
|
|
const smokeAgentRunSource = fs.readFileSync(
|
|
new URL('./smoke-agent-run-local-provider.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
for (const snippet of [
|
|
'agentLlm',
|
|
'planner-smoke-model',
|
|
'generator-smoke-model',
|
|
'global-smoke-model-unused',
|
|
]) {
|
|
if (!smokeAgentRunSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator local-provider smoke lost per-agent LLM coverage: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
"'game.run_local'",
|
|
"'read_game_creator_app_config'",
|
|
"'write_game_creator_app_config'",
|
|
'aria-label="运行时配置"',
|
|
'陶泥儿智能创作(固定)',
|
|
'官方账号服务(固定)',
|
|
'runtime_config.save',
|
|
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
|
"'activate_local_game_preview'",
|
|
'已切换到客户端运行视图',
|
|
'async function executeRunLocal',
|
|
'function needsInitializedChatProject',
|
|
'function resolvePendingCommandProjectPath',
|
|
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
|
'`permission.cancel ${command.id} missing-project`',
|
|
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
|
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
|
'function parseRememberInput',
|
|
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
|
'async function executeAgentTraceChat',
|
|
"relativePath: '.agent/logs/command.log'",
|
|
"'permission.pending'",
|
|
"'permission.confirm'",
|
|
"'permission.cancel'",
|
|
"'command.auto'",
|
|
"'agent.run_status'",
|
|
'function summarizeAgentRunTrace',
|
|
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
|
'agentRunTrace.error ?',
|
|
'className="trace-error"',
|
|
'agentRunTrace.taskGraph.repairRoutes.map',
|
|
"in: ${step.inputPaths.join(', ') || 'none'}",
|
|
"out: ${step.outputPaths.join(', ') || 'none'}",
|
|
]) {
|
|
if (!appSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell trace panel guardrail drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const script of [
|
|
'ai-game-creator-shell:dev',
|
|
'ai-game-creator-shell:dev-server',
|
|
'ai-game-creator-shell:build',
|
|
'ai-game-creator-shell:agent-task',
|
|
'agc:swarm',
|
|
'ai-game-creator-shell:typecheck',
|
|
'ai-game-creator-shell:agent-run:smoke',
|
|
'ai-game-creator-shell:check',
|
|
]) {
|
|
if (!rootPackageConfig.scripts?.[script]) {
|
|
throw new Error(`root package missing ${script}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
rootPackageConfig.scripts?.['agc:swarm'] !==
|
|
'npm --prefix apps/ai-game-creator-shell run swarm --'
|
|
) {
|
|
throw new Error('root agc:swarm script must forward CLI args');
|
|
}
|
|
|
|
if (
|
|
rootPackageConfig.scripts?.['ai-game-creator-shell:build'] !==
|
|
'npm --prefix apps/ai-game-creator-shell run build --'
|
|
) {
|
|
throw new Error(
|
|
'root ai-game-creator-shell:build script must forward build args',
|
|
);
|
|
}
|
|
|
|
const agentRunSmokeSource = fs.readFileSync(
|
|
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
for (const snippet of [
|
|
"const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef'",
|
|
"const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'",
|
|
"mediaType: 'audio/mpeg'",
|
|
'document.body.dataset.smokeFrame = String(frameCount)',
|
|
"ctx.fillStyle = '#ff00ff'",
|
|
'ctx.fillRect(4, 4, 8, 24)',
|
|
'const sample = ctx.getImageData(4, 4, 24, 24).data',
|
|
'chef.complete && chef.naturalWidth > 0',
|
|
'document.body.dataset.smokeCanvasPixels = String(litPixels)',
|
|
'document.body.dataset.smokeCanvasColors = String(colors.size)',
|
|
'readBrowserDom(previewUrl)',
|
|
"extractDomNumber(previewDom, 'smoke-canvas-pixels')",
|
|
'--dump-dom',
|
|
'readHttpHead(new URL(smokeAssetPath, previewUrl).toString())',
|
|
'readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString())',
|
|
'function writeStreamingChatCompletion',
|
|
'requestJson?.stream === true',
|
|
`requestBodies.every((body) => body.includes('"stream":true'))`,
|
|
"const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')",
|
|
"apiKind: 'openai_chat'",
|
|
'stream: true',
|
|
'await restoreOptionalFile(localConfigPath, previousLocalConfig)',
|
|
"method: 'HEAD'",
|
|
'previewAssetHead.contentLength === String(smokeAssetBytes.length)',
|
|
"previewAudioHead.contentType === 'audio/mpeg'",
|
|
"previewAssetHead.body === ''",
|
|
'trace missing professional group ${group}',
|
|
]) {
|
|
if (!agentRunSmokeSource.includes(snippet)) {
|
|
throw new Error(
|
|
`AI game creator shell agent-run smoke drifted: ${snippet}`,
|
|
);
|
|
}
|
|
}
|