1a7abfd059
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
## 交付内容
- 客户端新增 `project_snapshot` 模块:项目扫描与排除口径、增量索引、差异对比、上传编排、状态查询。
- 触发:工作区窗口存活期间的周期定时器 + 工作区窗口关闭(`CloseRequested`);应用退出只在有界预算内等待在途同步收尾,不重复发起。
- 服务端新增两条登录态路由 `POST /api/agc/project-snapshots/files` 与 `/manifest`,由 api-server 用服务端凭据写入私有前缀 `agc/project-snapshots/v1/{user}/{project}/`;客户端不持有 OSS 凭据、不直连 OSS。
- `platform-oss` 新增内部对象精确写入与探测、项目快照对象键构造;修复 `head_internal_object` 读 HEAD 响应长度恒为 0(会导致服务端"已存在即跳过"永不生效)。
- `shared-contracts` 新增 `agc_project_snapshots` DTO 与项目 ID、相对路径、摘要校验。
- 真实 OSS 存储层冒烟示例 + 客户端真实链路冒烟用例(`#[ignore]`,env 驱动)。
- 登记 `check-config.mjs` 的 native-only 命令白名单,恢复 `npm run agc` 可启动。
## 验证证据
- 存储层:`cargo run -p platform-oss --example agc_project_snapshot_live_smoke` 对真实 `agc-dev` 完成写入 → 读回(contentLength 32)→ 清单写入 → 探针清理。
- HTTP 层:登录态下 7 项校验(401 无 token、400 路径穿越/长度不一致/非法摘要/空正文/重复路径清单)全部符合预期且不写对象。
- 客户端链路(真实项目 gameagent-033b6cf3…):第一次 `synced uploaded=6 uploadedBytes=36694 remoteSkipped=6`,紧接着第二次 `no-op uploaded=0`。
- GUI 触发:`trigger=periodic ... uploaded=6`;把周期设为 600 秒排除干扰、改一个文件后关窗得到 `trigger=project-close ... revision=2 uploaded=1`,索引摘要由 `28d837cd84f8ab62` 推进到 `bff34e2e336d901f`(同步在进程退出前完成)。
- 门禁:AGC `project_snapshot` 14 passed(+1 ignored live)、api-server `project_snapshots` 3 passed、platform-oss 39 passed、shared-contracts 87 passed、`cargo fmt --check`(两处)、`check:encoding`、`check:doc-index`、`git diff --check`、`check-config.mjs` 全部通过。
## 已知未决(不阻塞本里程碑)
- 远端对象只增不减:没有删除路径,也没有 bucket 生命周期规则;要收口需要先定保留语义(清单引用 GC 还是 OSS 生命周期)。
- 部署环境需确认 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*` 或回退的 `ALIYUN_OSS_*` 具备目标 bucket 私有前缀的 `PutObject` 权限;未配置时接口返回 503、客户端失败关闭(不推进索引)。
- 缺少按用户/项目的配额与限流(error_reports 有每小时提交上限的先例,本功能没有)。
- 没有界面入口与状态可见性:`read_local_project_snapshot_state` 已注册但未接 UI,失败只写本机日志。
## 不做
- 不做云端下载/恢复、跨设备合并、版本回滚。
- 不改 `/api/external/v1` 与 External OpenAPI,不新增 SpacetimeDB 表。
---------
Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/400
1917 lines
59 KiB
JavaScript
1917 lines
59 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',
|
||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||
'bind_components',
|
||
'chat_with_game_creator_agent',
|
||
'check_ui_editor_font_glyph_coverage',
|
||
'create_ui_design_resource',
|
||
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
|
||
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
|
||
// (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
|
||
// `start_local_project_asset_generation`。
|
||
'generate_local_project_asset',
|
||
'open_game_creator_launcher_window',
|
||
'open_game_creator_workspace_window',
|
||
'read_direct_project_conversation',
|
||
// 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
|
||
// 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
|
||
'read_local_project_snapshot_state',
|
||
'sync_local_project_snapshot',
|
||
'reset_design_agent_session',
|
||
'stop_local_game_preview_if_matches',
|
||
'start_game_creator_external_mcp',
|
||
'stop_game_creator_external_mcp',
|
||
'list_agc_plugins',
|
||
'list_agc_extensions',
|
||
'refresh_agc_plugins',
|
||
'start_agc_plugin',
|
||
'stop_agc_plugin',
|
||
'reload_agc_plugin',
|
||
'call_agc_plugin',
|
||
'read_agc_plugin_panel',
|
||
'set_agc_plugin_project_path',
|
||
'set_agc_plugin_enabled',
|
||
];
|
||
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 !== '陶泥儿') {
|
||
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 expectedBundledDesignAgentResources = {
|
||
'design-agent': 'design-agent',
|
||
};
|
||
const expectedBundledWindowsResources = {
|
||
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
|
||
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
|
||
'coding-agent/win-x64/bin/codex-code-mode-host.exe',
|
||
'resources/codex/win-x64/codex-path/rg.exe':
|
||
'coding-agent/win-x64/codex-path/rg.exe',
|
||
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
|
||
'coding-agent/win-x64/codex-resources/codex-command-runner.exe',
|
||
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
|
||
'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
||
'resources/codex/win-x64/codex-package.json':
|
||
'coding-agent/win-x64/codex-package.json',
|
||
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
|
||
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
|
||
'resources/plugins': 'plugins',
|
||
};
|
||
assert.deepEqual(
|
||
tauriConfig.bundle?.resources,
|
||
expectedBundledDesignAgentResources,
|
||
'AI game creator shell base Tauri config must bundle the design-agent resource pack',
|
||
);
|
||
for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
|
||
if (String(key).includes('codex')) {
|
||
throw new Error(
|
||
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
|
||
);
|
||
}
|
||
}
|
||
assert.deepEqual(
|
||
windowsTauriConfig.bundle?.resources,
|
||
expectedBundledWindowsResources,
|
||
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
|
||
);
|
||
if (
|
||
Object.prototype.hasOwnProperty.call(
|
||
windowsTauriConfig.bundle?.resources ?? {},
|
||
'design-agent',
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'design-agent resource pack must not be mixed into the Windows Codex sidecar bundle',
|
||
);
|
||
}
|
||
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}`,
|
||
);
|
||
}
|
||
}
|